From cca131d83abee545d7c790f1611f90d9e92496c9 Mon Sep 17 00:00:00 2001 From: seb Date: Tue, 22 Sep 2026 07:25:16 +0200 Subject: [PATCH] sqlite --- TODO.md | 40 +- package-lock.json | 24 ++ package.json | 1 + scripts/test-sqlite-storage.js | 220 +++++++++++ src/index.js | 220 +++++------ src/providers/mssql/index.js | 29 +- src/providers/mssql/queries/numbers.js | 11 + src/providers/provider.js | 16 +- src/providers/sqlite3/index.js | 347 +++++++++++++++- src/providers/sqlite3/queries/category.js | 54 ++- src/providers/sqlite3/queries/delivery.js | 33 +- .../sqlite3/queries/manifestattachment.js | 60 ++- src/providers/sqlite3/queries/pic.js | 24 +- src/providers/sqlite3/schema.js | 6 +- src/store/article/addImage.js | 104 +++-- src/store/article/createUnderParent.js | 111 +++--- src/store/article/delete.js | 83 ++-- src/store/article/deleteImage.js | 33 +- src/store/article/getBySku.js | 21 +- src/store/article/getHistoryBySku.js | 24 +- src/store/article/importRows.js | 374 +++++++++--------- src/store/article/listExport.js | 49 +-- src/store/article/listSearchIndex.js | 27 +- src/store/article/orderImages.js | 83 ++-- src/store/article/queryBySku.js | 43 +- src/store/article/resolveArticleBySku.js | 21 +- src/store/article/setBrandIdBySku.js | 49 ++- src/store/article/setCategoryIdsBySku.js | 79 ++-- src/store/article/setEansBySku.js | 65 +-- src/store/article/setFatherBySku.js | 130 +++--- src/store/article/setPricesBySku.js | 95 ++--- src/store/article/setWeightBySku.js | 31 +- src/store/article/updateBySku.js | 223 ++++++----- src/store/articlesupplier/create.js | 133 +++---- src/store/articlesupplier/delete.js | 76 ++-- src/store/articlesupplier/helpers.js | 70 +++- src/store/articlesupplier/listByArticleSku.js | 27 +- .../articlesupplier/listByEntityAndSkus.js | 33 +- src/store/articlesupplier/order.js | 86 ++-- src/store/articlesupplier/update.js | 165 ++++---- src/store/brand/delete.js | 29 +- src/store/brand/list.js | 24 +- src/store/brand/upsert.js | 69 ++-- src/store/category/addImage.js | 103 +++-- src/store/category/createUnderParent.js | 84 ++-- src/store/category/delete.js | 16 +- src/store/category/deleteImage.js | 22 +- src/store/category/ensureByPath.js | 98 ++--- src/store/category/getCategoryArticles.js | 12 +- src/store/category/getTreeForCompany.js | 35 +- src/store/category/move.js | 99 ++--- src/store/category/order.js | 83 ++-- src/store/category/queryCategoryArticles.js | 23 +- src/store/category/rename.js | 29 +- src/store/category/resolveCategoryAccess.js | 22 +- src/store/company/create.js | 128 +++--- src/store/company/delete.js | 17 +- src/store/company/getById.js | 16 +- src/store/company/getByName.js | 16 +- src/store/company/list.js | 18 +- src/store/company/update.js | 66 ++-- src/store/companysetting/get.js | 27 +- src/store/companysetting/list.js | 32 +- src/store/companysetting/set.js | 57 +-- src/store/companyuser/add.js | 22 +- src/store/companyuser/delete.js | 16 +- src/store/companyuser/getByEmail.js | 21 +- src/store/companyuser/resolveCompanyAccess.js | 24 +- .../resolveSelectedCompanyAccess.js | 22 +- src/store/customergroup/create.js | 44 ++- src/store/customergroup/delete.js | 31 +- src/store/customergroup/list.js | 24 +- src/store/customergroup/order.js | 89 +++-- .../resolveCustomerGroupAccess.js | 22 +- src/store/customergroup/update.js | 49 ++- src/store/delivery/attachments.js | 125 ++++-- src/store/delivery/create.js | 92 ++--- src/store/delivery/delete.js | 36 +- src/store/delivery/getById.js | 18 +- src/store/delivery/helpers.js | 39 +- src/store/delivery/list.js | 44 +-- src/store/delivery/update.js | 108 ++--- src/store/delivery/writeLines.js | 23 +- src/store/entity/create.js | 97 +++-- src/store/entity/delete.js | 25 +- src/store/entity/getById.js | 28 +- src/store/entity/isNumberFree.js | 30 +- src/store/entity/list.js | 69 ++-- src/store/entity/peekNextNumber.js | 13 +- src/store/entity/resolveEntityAccess.js | 22 +- src/store/entity/update.js | 205 +++++----- src/store/entityaddress/create.js | 75 ++-- src/store/entityaddress/delete.js | 30 +- src/store/entityaddress/list.js | 29 +- src/store/entityaddress/update.js | 82 ++-- src/store/entitycontact/create.js | 57 ++- src/store/entitycontact/delete.js | 30 +- src/store/entitycontact/list.js | 29 +- src/store/entitycontact/update.js | 64 +-- src/store/initialize.js | 12 +- src/store/manifestDoc/helpers.js | 94 +++-- src/store/manifestLines.js | 59 +-- src/store/manifestattachment/add.js | 119 +++--- src/store/manifestattachment/getById.js | 43 +- .../manifestattachment/listByManifestId.js | 43 +- src/store/manifestattachment/order.js | 97 ++--- src/store/manifestattachment/remove.js | 53 ++- .../resolveManifestAccess.js | 21 +- src/store/numbers/list.js | 24 +- src/store/numbers/nextNumber.js | 56 ++- src/store/numbers/nextSku.js | 4 +- src/store/numbers/peekNumber.js | 51 ++- src/store/numbers/update.js | 51 ++- src/store/order/create.js | 75 ++-- src/store/order/delete.js | 37 +- src/store/order/getById.js | 19 +- src/store/order/list.js | 35 +- src/store/order/update.js | 100 ++--- src/store/pic/getImageByHash.js | 37 +- src/store/pic/getThumbByHash.js | 37 +- src/store/pic/resolvePicAccess.js | 20 +- src/store/posterminal/delete.js | 25 +- .../posterminal/getCategoryPosterminalIds.js | 23 +- src/store/posterminal/list.js | 21 +- src/store/posterminal/setOff.js | 52 ++- src/store/posterminal/setOffAll.js | 48 ++- src/store/posterminal/setOn.js | 52 ++- src/store/posterminal/setOnAll.js | 63 +-- src/store/posterminal/upsert.js | 77 ++-- src/store/purge.js | 13 +- src/store/quote/create.js | 75 ++-- src/store/quote/delete.js | 37 +- src/store/quote/getById.js | 19 +- src/store/quote/list.js | 35 +- src/store/quote/update.js | 100 ++--- src/store/request.js | 3 - src/store/runner.js | 42 +- src/store/supplier/create.js | 76 ++-- src/store/supplier/delete.js | 31 +- src/store/supplier/ensureLieferantenGroup.js | 53 ++- src/store/supplier/list.js | 28 +- src/store/supplier/update.js | 126 +++--- src/store/unit/delete.js | 29 +- src/store/unit/ensureDefault.js | 80 ++-- src/store/unit/list.js | 29 +- src/store/unit/upsert.js | 169 ++++---- src/util/generateDocumentThumb.js | 47 +++ vfs.md | 190 +++++++++ 148 files changed, 5343 insertions(+), 3466 deletions(-) create mode 100644 scripts/test-sqlite-storage.js delete mode 100644 src/store/request.js create mode 100644 vfs.md diff --git a/TODO.md b/TODO.md index 829cae6..bcf568a 100644 --- a/TODO.md +++ b/TODO.md @@ -9,35 +9,35 @@ Replace raw `pool` usage in `src/store/**` with `provider.query()` / `provider.transaction()` / `provider.exec()` and the normalized result shape (`{ rows, rowsAffected, insertId }` instead of `recordset` / `rowsAffected[]`). For every module: -- [ ] `pool.request().input(...).query(SQL)` → `provider.query(SQL, params)` -- [ ] `pool.transaction(); begin; commit; catch rollback` → `provider.transaction(async (tx) => { ... })` -- [ ] `result.recordset` → `result.rows`; `result.rowsAffected[0]` → `result.rowsAffected` -- [ ] `request.js` helper (`(tx ?? pool).request()`) becomes obsolete — delete when unused +- [x] `pool.request().input(...).query(SQL)` → `provider.query(SQL, params)` +- [x] `pool.transaction(); begin; commit; catch rollback` → `provider.transaction(async (tx) => { ... })` +- [x] `result.recordset` → `result.rows`; `result.rowsAffected[0]` → `result.rowsAffected` +- [x] `request.js` helper (`(tx ?? pool).request()`) becomes obsolete — deleted - [x] `src/store/runner.js` bridges mixed call sites: converted modules accept a provider runner *or* a legacy mssql Transaction/`pool` (wrapped on the fly) so partially converted callers keep working -- [ ] `recordset[0].id` after `OUTPUT INSERTED.id` → keep, but prefer `insertId` where trivial +- [x] `recordset[0].id` after `OUTPUT INSERTED.id` → keep, but prefer `insertId` where trivial Conversion order (bottom-up by dependency): - [x] `lang`, `tax` (leaf helpers, used by company create) - [x] `google_auth` -- [ ] `companyuser` (resolveCompanyAccess / resolveSelectedCompanyAccess first) -- [ ] `company`, `companysetting` -- [ ] `customergroup`, `entity`, `entityaddress`, `entitycontact`, `entityhistory` -- [ ] `unit` (incl. `ensureDefault`), `brand`, `supplier` -- [ ] `numbers` (see §2 — stored procs!) -- [ ] `category` (recursive CTEs!) -- [ ] `article` + articletext/articlepic/articleean/price/articlehistory -- [ ] `articlesupplier` -- [ ] `quote`, `order`, `delivery` (shared manifest helpers: `manifestLines`, `manifestDoc`, `manifestattachment`) -- [ ] `posterminal`, `pic` -- [ ] `initialize.js`, `purge.js` final wiring (drop `provider.pool` fallback from `index.js`) -- [ ] Delete `src/store/request.js` once no module uses it +- [x] `companyuser` (resolveCompanyAccess / resolveSelectedCompanyAccess first) +- [x] `company`, `companysetting` +- [x] `customergroup`, `entity`, `entityaddress`, `entitycontact`, `entityhistory` +- [x] `unit` (incl. `ensureDefault`), `brand`, `supplier` +- [x] `numbers` (see §2 — stored procs!) +- [x] `category` (recursive CTEs!) +- [x] `article` + articletext/articlepic/articleean/price/articlehistory +- [x] `articlesupplier` +- [x] `quote`, `order`, `delivery` (shared manifest helpers: `manifestLines`, `manifestDoc`, `manifestattachment`) +- [x] `posterminal`, `pic` +- [x] `initialize.js`, `purge.js` final wiring (drop `provider.pool` fallback from `index.js`) +- [x] Delete `src/store/request.js` once no module uses it ## 2. Replace MSSQL stored procedures (blocker for SQLite) -- [ ] `numbers.nextNumber` uses proc `getnextnumberwithprefixandpadding` (output param) -- [ ] `numbers.peekNumber` uses proc `peeknextnumberwithprefixandpadding` (output param) -- [ ] Implement both in plain JS inside the store layer: `SELECT next/prefix/padding`, +- [x] `numbers.nextNumber` uses proc `getnextnumberwithprefixandpadding` (output param) +- [x] `numbers.peekNumber` uses proc `peeknextnumberwithprefixandpadding` (output param) +- [x] Implement both in plain JS inside the store layer: `SELECT next/prefix/padding`, compute `prefix + padded next`, `UPDATE numbers SET next = next + 1 WHERE ...`. Must stay atomic: use `provider.transaction()` with row locking semantics (MSSQL: `WITH (UPDLOCK, ROWLOCK)` in the SELECT; SQLite: within its write tx). diff --git a/package-lock.json b/package-lock.json index c5c6341..2adb353 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "thumbhash": "^0.1.1" }, "devDependencies": { + "better-sqlite3": "^13.0.3", "mssql": "^12.7.2" }, "peerDependencies": { @@ -984,6 +985,19 @@ ], "license": "MIT" }, + "node_modules/better-sqlite3": { + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz", + "integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.0.0" + }, + "engines": { + "node": ">=22" + } + }, "node_modules/bl": { "version": "6.1.6", "resolved": "https://registry.npmjs.org/bl/-/bl-6.1.6.tgz", @@ -1466,6 +1480,16 @@ "dev": true, "license": "MIT" }, + "node_modules/node-addon-api": { + "version": "8.9.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz", + "integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, "node_modules/open": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", diff --git a/package.json b/package.json index c3a006d..4e089fc 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ } }, "devDependencies": { + "better-sqlite3": "^13.0.3", "mssql": "^12.7.2" } } diff --git a/scripts/test-sqlite-storage.js b/scripts/test-sqlite-storage.js new file mode 100644 index 0000000..f8bad7a --- /dev/null +++ b/scripts/test-sqlite-storage.js @@ -0,0 +1,220 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, existsSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import sharp from 'sharp'; +import LibDashStore from '../src/index.js'; +import { BetterSqlite3Provider } from '../src/providers/sqlite3/index.js'; + +async function runTests() { + console.log('--- Starting SQLite file and pic directory storage tests ---'); + + const tempDir = mkdtempSync(join(tmpdir(), 'test-sqlite-store-')); + const dbFile = join(tempDir, 'test.db'); + const storageDir = join(tempDir, 'custom-storage'); + + const provider = new BetterSqlite3Provider({ + filename: dbFile, + storageDir, + }); + + await provider.initialize(); + const store = await new LibDashStore(provider); + + try { + // Verify storage subdirectories exist + assert(existsSync(join(storageDir, 'content')), 'content dir should exist'); + assert(existsSync(join(storageDir, 'thumbavif')), 'thumbavif dir should exist'); + assert(existsSync(join(storageDir, 'fullbinary')), 'fullbinary dir should exist'); + assert(existsSync(join(storageDir, 'thumbbinary')), 'thumbbinary dir should exist'); + + // Verify sqlite tables do NOT contain blob columns + const fileTableInfo = provider.db.pragma('table_info("file")'); + const fileColNames = fileTableInfo.map((c) => c.name); + console.log('file table columns:', fileColNames); + assert(!fileColNames.includes('content'), 'file table should NOT have content column'); + assert(!fileColNames.includes('thumbavif'), 'file table should NOT have thumbavif column'); + + const picTableInfo = provider.db.pragma('table_info("pic")'); + const picColNames = picTableInfo.map((c) => c.name); + console.log('pic table columns:', picColNames); + assert(!picColNames.includes('fullbinary'), 'pic table should NOT have fullbinary column'); + assert(!picColNames.includes('thumbbinary'), 'pic table should NOT have thumbbinary column'); + + // Setup base data: google_auth, company, unit, article, manifest + const googleAuthId = await store.google_auth.add('test@example.com'); + const company = await store.company.create({ + name: 'Test Corp', + iso639: 'de', + taxrate: 19, + reducedtaxrate: 7, + googleAuthId, + }); + await store.google_auth.setSelectedCompany(googleAuthId, company.id); + + // 1. Test Manifest Attachment (file table) + console.log('1. Testing manifestattachment with file table...'); + const delivery = await store.delivery.create(googleAuthId, { + externalNumber: 'DEL-1', + date: new Date().toISOString(), + lines: [ + { + name: 'Item A', + amount: 5, + net: 10, + taxrate: 19, + }, + ], + }); + + const manifestId = delivery.id; + const testContent = Buffer.from('Hello SQLite Storage Engine! PDF or text content test.'); + const testThumb = await sharp({ + create: { + width: 64, + height: 64, + channels: 4, + background: { r: 255, g: 0, b: 0, alpha: 1 }, + }, + }) + .avif() + .toBuffer(); + + const attachment = await store.manifestattachment.add(googleAuthId, manifestId, { + name: 'test-doc.txt', + mimetype: 'text/plain', + content: testContent, + thumbavif: testThumb, + thumbw: 64, + thumbh: 64, + }); + + console.log('Attachment created with hash:', attachment.hash); + + // Verify files on disk have correct extensions according to mimetype + const contentPath = join(storageDir, 'content', attachment.hash + '.txt'); + const thumbPath = join(storageDir, 'thumbavif', attachment.hash + '.avif'); + assert(existsSync(contentPath), 'Content file must exist on disk at ' + contentPath); + assert(existsSync(thumbPath), 'Thumb file must exist on disk at ' + thumbPath); + assert.deepEqual(readFileSync(contentPath), testContent, 'Content on disk must match'); + assert.deepEqual(readFileSync(thumbPath), testThumb, 'Thumb on disk must match'); + + // Verify getById + const fetched = await store.manifestattachment.getById(googleAuthId, manifestId, attachment.id); + assert.equal(fetched.name, 'test-doc.txt'); + assert(Buffer.isBuffer(fetched.content), 'Fetched content must be a Buffer'); + assert.deepEqual(fetched.content, testContent, 'Fetched content must match original'); + assert(Buffer.isBuffer(fetched.thumbavif), 'Fetched thumbavif must be a Buffer'); + assert.deepEqual(fetched.thumbavif, testThumb, 'Fetched thumbavif must match original'); + + // Verify listByManifestId + const list = await store.manifestattachment.listByManifestId(googleAuthId, manifestId); + assert.equal(list.length, 1); + assert(Buffer.isBuffer(list[0].thumbavif), 'List thumbavif must be a Buffer'); + assert.deepEqual(list[0].thumbavif, testThumb); + + // Verify remove + await store.manifestattachment.remove(googleAuthId, manifestId, attachment.id); + assert(!existsSync(contentPath), 'Content file should be deleted on remove'); + assert(!existsSync(thumbPath), 'Thumb file should be deleted on remove'); + + // Test delivery creation with inline attached files + console.log('1b. Testing delivery.create with inline attached file...'); + const deliveryWithFile = await store.delivery.create(googleAuthId, { + externalNumber: 'DEL-WITH-FILE', + date: new Date().toISOString(), + lines: [{ name: 'Item B', amount: 1, net: 5, taxrate: 19 }], + files: [{ + name: 'inline.txt', + mimetype: 'text/plain', + content: Buffer.from('Inline delivery file content'), + }], + }); + assert(deliveryWithFile.files && deliveryWithFile.files.length === 1, 'Delivery should have 1 attached file'); + const inlineHash = deliveryWithFile.files[0].hash; + const inlineContentPath = join(storageDir, 'content', inlineHash + '.txt'); + const inlineThumbPath = join(storageDir, 'thumbavif', inlineHash + '.avif'); + assert(existsSync(inlineContentPath), 'Inline file content must exist on disk with .txt extension'); + assert(existsSync(inlineThumbPath), 'Inline file thumb must exist on disk with .avif extension'); + + const fetchedDelivery = await store.delivery.getById(googleAuthId, deliveryWithFile.id); + assert(fetchedDelivery.files && fetchedDelivery.files.length === 1); + assert(fetchedDelivery.files[0].thumbavif, 'Fetched delivery file should include thumbavif'); + console.log('Manifest attachment tests passed!'); + + // 2. Test Article Image (pic table) + console.log('2. Testing article.addImage with pic table...'); + const cat = await store.category.createUnderParent({ + googleAuthId, + parentId: company.rootCategoryId, + name: 'Sub Category', + }); + const article = await store.article.createUnderParent({ + googleAuthId, + parentId: cat.id, + name: 'Test Article', + }); + + const sampleImage = await sharp({ + create: { + width: 300, + height: 200, + channels: 4, + background: { r: 0, g: 128, b: 255, alpha: 1 }, + }, + }) + .png() + .toBuffer(); + + const picResult = await store.article.addImage(googleAuthId, article.sku, sampleImage); + const fullHash = picResult.fullHash; + console.log('Article pic added with hash:', fullHash); + + const fullPath = join(storageDir, 'fullbinary', fullHash + '.avif'); + const picThumbPath = join(storageDir, 'thumbbinary', fullHash + '.avif'); + assert(existsSync(fullPath), 'Full picture must exist on disk at ' + fullPath); + assert(existsSync(picThumbPath), 'Thumb picture must exist on disk at ' + picThumbPath); + + // Verify getImageByHash + const fullPic = await store.pic.getImageByHash(googleAuthId, fullHash); + assert(Buffer.isBuffer(fullPic.buffer), 'Image buffer must be returned'); + assert.deepEqual(fullPic.buffer, readFileSync(fullPath)); + + // Verify getThumbByHash + const thumbPic = await store.pic.getThumbByHash(googleAuthId, fullHash); + assert(Buffer.isBuffer(thumbPic.buffer), 'Thumbnail buffer must be returned'); + assert.deepEqual(thumbPic.buffer, readFileSync(picThumbPath)); + + // Verify deleteImage + await store.article.deleteImage(googleAuthId, article.sku, fullHash); + assert(!existsSync(fullPath), 'Full pic file should be deleted on deleteImage'); + assert(!existsSync(picThumbPath), 'Thumb pic file should be deleted on deleteImage'); + console.log('Article pic tests passed!'); + + // 3. Test purge + console.log('3. Testing purge...'); + // Add one more file before purge + await store.manifestattachment.add(googleAuthId, manifestId, { + name: 'purge-test.txt', + mimetype: 'text/plain', + content: Buffer.from('To be purged'), + }); + await provider.purge(); + assert(!existsSync(contentPath), 'Files should be purged'); + console.log('Purge tests passed!'); + + console.log('\nAll tests PASSED successfully!'); + } finally { + provider.close(); + try { + rmSync(tempDir, { recursive: true, force: true }); + } catch { + // ignore + } + } +} + +runTests().catch((err) => { + console.error('Test failed with error:', err); + process.exit(1); +}); diff --git a/src/index.js b/src/index.js index 32098a4..0c066b6 100644 --- a/src/index.js +++ b/src/index.js @@ -138,55 +138,52 @@ export default class LibDashStore { } this.provider = provider; - // Transitional: store modules still use the raw mssql pool. - // They are converted to provider.query() area by area (see TODO.md). - const pool = provider.pool; - this.pool = pool; + this.pool = provider.pool; this.companyuser = { - getByEmail: companyuserGetByEmail(pool), - add: companyuserAdd(pool), - delete: companyuserDelete(pool), + getByEmail: companyuserGetByEmail(provider), + add: companyuserAdd(provider), + delete: companyuserDelete(provider), }; this.company = { - create: companyCreate(pool), - update: companyUpdate(pool), - delete: companyDelete(pool), - list: companyList(pool), + create: companyCreate(provider), + update: companyUpdate(provider), + delete: companyDelete(provider), + list: companyList(provider), }; this.category = { - getTreeForCompany: categoryGetTreeForCompany(pool), - getCategoryArticles: categoryGetCategoryArticles(pool), - createUnderParent: categoryCreateUnderParent(pool), - ensureByPath: categoryEnsureByPath(pool), - rename: categoryRename(pool), - move: categoryMove(pool), - order: categoryOrder(pool), - delete: categoryDelete(pool), - addImage: categoryAddImage(pool), - deleteImage: categoryDeleteImage(pool), + getTreeForCompany: categoryGetTreeForCompany(provider), + getCategoryArticles: categoryGetCategoryArticles(provider), + createUnderParent: categoryCreateUnderParent(provider), + ensureByPath: categoryEnsureByPath(provider), + rename: categoryRename(provider), + move: categoryMove(provider), + order: categoryOrder(provider), + delete: categoryDelete(provider), + addImage: categoryAddImage(provider), + deleteImage: categoryDeleteImage(provider), }; this.article = { - createUnderParent: articleCreateUnderParent(pool), - getBySku: articleGetBySku(pool), - getHistoryBySku: articleGetHistoryBySku(pool), - listSearchIndex: articleListSearchIndex(pool), - listExport: articleListExport(pool), - updateBySku: articleUpdateBySku(pool), - setPricesBySku: articleSetPricesBySku(pool), - setEansBySku: articleSetEansBySku(pool), - setBrandIdBySku: articleSetBrandIdBySku(pool), - setWeightBySku: articleSetWeightBySku(pool), - setCategoryIdsBySku: articleSetCategoryIdsBySku(pool), - setFatherBySku: articleSetFatherBySku(pool), - importRows: articleImportRows(pool), - addImage: articleAddImage(pool), - deleteImage: articleDeleteImage(pool), - orderImages: articleOrderImages(pool), - delete: articleDelete(pool), + createUnderParent: articleCreateUnderParent(provider), + getBySku: articleGetBySku(provider), + getHistoryBySku: articleGetHistoryBySku(provider), + listSearchIndex: articleListSearchIndex(provider), + listExport: articleListExport(provider), + updateBySku: articleUpdateBySku(provider), + setPricesBySku: articleSetPricesBySku(provider), + setEansBySku: articleSetEansBySku(provider), + setBrandIdBySku: articleSetBrandIdBySku(provider), + setWeightBySku: articleSetWeightBySku(provider), + setCategoryIdsBySku: articleSetCategoryIdsBySku(provider), + setFatherBySku: articleSetFatherBySku(provider), + importRows: articleImportRows(provider), + addImage: articleAddImage(provider), + deleteImage: articleDeleteImage(provider), + orderImages: articleOrderImages(provider), + delete: articleDelete(provider), }; this.lang = { @@ -205,105 +202,105 @@ export default class LibDashStore { }; this.unit = { - list: unitList(pool), - upsert: unitUpsert(pool), - delete: unitDelete(pool), + list: unitList(provider), + upsert: unitUpsert(provider), + delete: unitDelete(provider), }; this.companysetting = { - list: companysettingList(pool), - get: companysettingGet(pool), - set: companysettingSet(pool), + list: companysettingList(provider), + get: companysettingGet(provider), + set: companysettingSet(provider), }; this.brand = { - list: brandList(pool), - upsert: brandUpsert(pool), - delete: brandDelete(pool), + list: brandList(provider), + upsert: brandUpsert(provider), + delete: brandDelete(provider), }; this.customergroup = { - list: customergroupList(pool), - create: customergroupCreate(pool), - update: customergroupUpdate(pool), - order: customergroupOrder(pool), - delete: customergroupDelete(pool), + list: customergroupList(provider), + create: customergroupCreate(provider), + update: customergroupUpdate(provider), + order: customergroupOrder(provider), + delete: customergroupDelete(provider), }; this.entity = { - list: entityList(pool), - getById: entityGetById(pool), - create: entityCreate(pool), - update: entityUpdate(pool), - delete: entityDelete(pool), - peekNextNumber: entityPeekNextNumber(pool), - isNumberFree: entityIsNumberFree(pool), + list: entityList(provider), + getById: entityGetById(provider), + create: entityCreate(provider), + update: entityUpdate(provider), + delete: entityDelete(provider), + peekNextNumber: entityPeekNextNumber(provider), + isNumberFree: entityIsNumberFree(provider), }; this.entityaddress = { - list: entityaddressList(pool), - create: entityaddressCreate(pool), - update: entityaddressUpdate(pool), - delete: entityaddressDelete(pool), + list: entityaddressList(provider), + create: entityaddressCreate(provider), + update: entityaddressUpdate(provider), + delete: entityaddressDelete(provider), }; this.entitycontact = { - list: entitycontactList(pool), - create: entitycontactCreate(pool), - update: entitycontactUpdate(pool), - delete: entitycontactDelete(pool), + list: entitycontactList(provider), + create: entitycontactCreate(provider), + update: entitycontactUpdate(provider), + delete: entitycontactDelete(provider), }; this.supplier = { - list: supplierList(pool), - create: supplierCreate(pool), - update: supplierUpdate(pool), - delete: supplierDelete(pool), + list: supplierList(provider), + create: supplierCreate(provider), + update: supplierUpdate(provider), + delete: supplierDelete(provider), }; this.articlesupplier = { - listByArticleSku: articlesupplierListByArticleSku(pool), - listByEntityAndSkus: articlesupplierListByEntityAndSkus(pool), - listBySupplierAndSkus: articlesupplierListByEntityAndSkus(pool), - create: articlesupplierCreate(pool), - update: articlesupplierUpdate(pool), - order: articlesupplierOrder(pool), - delete: articlesupplierDelete(pool), + listByArticleSku: articlesupplierListByArticleSku(provider), + listByEntityAndSkus: articlesupplierListByEntityAndSkus(provider), + listBySupplierAndSkus: articlesupplierListByEntityAndSkus(provider), + create: articlesupplierCreate(provider), + update: articlesupplierUpdate(provider), + order: articlesupplierOrder(provider), + delete: articlesupplierDelete(provider), }; this.delivery = { - list: deliveryList(pool), - getById: deliveryGetById(pool), - create: deliveryCreate(pool), - update: deliveryUpdate(pool), - delete: deliveryDelete(pool), + list: deliveryList(provider), + getById: deliveryGetById(provider), + create: deliveryCreate(provider), + update: deliveryUpdate(provider), + delete: deliveryDelete(provider), }; this.quote = { - list: quoteList(pool), - getById: quoteGetById(pool), - create: quoteCreate(pool), - update: quoteUpdate(pool), - delete: quoteDelete(pool), + list: quoteList(provider), + getById: quoteGetById(provider), + create: quoteCreate(provider), + update: quoteUpdate(provider), + delete: quoteDelete(provider), }; this.order = { - list: orderList(pool), - getById: orderGetById(pool), - create: orderCreate(pool), - update: orderUpdate(pool), - delete: orderDelete(pool), + list: orderList(provider), + getById: orderGetById(provider), + create: orderCreate(provider), + update: orderUpdate(provider), + delete: orderDelete(provider), }; this.posterminal = { - list: posterminalList(pool), - upsert: posterminalUpsert(pool), - delete: posterminalDelete(pool), - setOn: posterminalSetOn(pool), - setOff: posterminalSetOff(pool), - setOnAll: posterminalSetOnAll(pool), - setOffAll: posterminalSetOffAll(pool), - getCategoryPosterminalIds: posterminalGetCategoryPosterminalIds(pool), + list: posterminalList(provider), + upsert: posterminalUpsert(provider), + delete: posterminalDelete(provider), + setOn: posterminalSetOn(provider), + setOff: posterminalSetOff(provider), + setOnAll: posterminalSetOnAll(provider), + setOffAll: posterminalSetOffAll(provider), + getCategoryPosterminalIds: posterminalGetCategoryPosterminalIds(provider), }; this.google_auth = { @@ -317,21 +314,21 @@ export default class LibDashStore { }; this.pic = { - getImageByHash: picGetImageByHash(pool), - getThumbByHash: picGetThumbByHash(pool), + getImageByHash: picGetImageByHash(provider), + getThumbByHash: picGetThumbByHash(provider), }; this.numbers = { - list: numbersList(pool), - update: numbersUpdate(pool), + list: numbersList(provider), + update: numbersUpdate(provider), }; this.manifestattachment = { - add: manifestattachmentAdd(pool), - remove: manifestattachmentRemove(pool), - listByManifestId: manifestattachmentListByManifestId(pool), - getById: manifestattachmentGetById(pool), - order: manifestattachmentOrder(pool), + add: manifestattachmentAdd(provider), + remove: manifestattachmentRemove(provider), + listByManifestId: manifestattachmentListByManifestId(provider), + getById: manifestattachmentGetById(provider), + order: manifestattachmentOrder(provider), }; this.purge = storePurge(provider); @@ -347,3 +344,6 @@ export default class LibDashStore { } export { SCHEMA_SCRIPTS } from './providers/mssql/schema.js'; +export { BetterSqlite3Provider } from './providers/sqlite3/index.js'; +export { MssqlProvider } from './providers/mssql/index.js'; + diff --git a/src/providers/mssql/index.js b/src/providers/mssql/index.js index 25886da..9437184 100644 --- a/src/providers/mssql/index.js +++ b/src/providers/mssql/index.js @@ -133,8 +133,12 @@ export class MssqlProvider { const tx = pool.transaction(); await tx.begin(); try { - const runner = this.makeExecutor(() => tx.request()); - const result = await fn(runner); + const executor = this.makeExecutor(() => tx.request()); + const runner = async (sql, params) => executor.query(sql, params); + runner.query = runner; + runner.exec = (proc, opts) => executor.exec(proc, opts); + runner.dialect = 'mssql'; + const result = await fn(runner, tx); await tx.commit(); return result; } catch (err) { @@ -148,17 +152,32 @@ export class MssqlProvider { } async initialize() { - // Implemented by store/initialize.js — no-op here (keeps parity with - // other providers, which may run migrations internally instead). + const pool = await this.ensurePool(); + const { SCHEMA_SCRIPTS } = await import('./schema.js'); + for (const script of SCHEMA_SCRIPTS) { + if (script && script.trim()) { + await pool.request().query(script); + } + } } async purge() { - // Implemented by store/purge.js — no-op here. + const pool = await this.ensurePool(); + const { buildPurgeSql } = await import('./purgeSql.js'); + await pool.request().query(buildPurgeSql()); } close() { return this.pool ? this.pool.close() : Promise.resolve(); } + + isUniqueConstraintError(err) { + return isUniqueConstraintError(err); + } + + isForeignKeyConstraintError(err) { + return isForeignKeyConstraintError(err); + } } /** mssql: 2627 = PK/unique violation, 2601 = unique index violation */ diff --git a/src/providers/mssql/queries/numbers.js b/src/providers/mssql/queries/numbers.js index f40e976..adf709c 100644 --- a/src/providers/mssql/queries/numbers.js +++ b/src/providers/mssql/queries/numbers.js @@ -44,3 +44,14 @@ OUTPUT INSERTED.id, INSERTED.[type], INSERTED.prefix, INSERTED.padding, INSERTED WHERE id = @id AND company_id = @companyId `; + +export const GET_FOR_UPDATE = ` +SELECT id, [type], prefix, padding, [next] AS [next] +FROM numbers WITH (UPDLOCK, ROWLOCK) +WHERE company_id = @companyId AND [type] = @type; +`; + +export const INCREMENT = ` +UPDATE numbers SET [next] = [next] + 1 WHERE id = @id; +`; + diff --git a/src/providers/provider.js b/src/providers/provider.js index 3eeec41..c4eb69d 100644 --- a/src/providers/provider.js +++ b/src/providers/provider.js @@ -23,18 +23,26 @@ /** * Checks whether an error is a unique-constraint violation. - * @param {unknown} err + * @param {any} err * @returns {boolean} */ export function isUniqueConstraintError(err) { - throw new Error('Not implemented: isUniqueConstraintError'); + return ( + err?.number === 2627 || + err?.number === 2601 || + err?.code === 'SQLITE_CONSTRAINT_UNIQUE' || + err?.code === 'SQLITE_CONSTRAINT_PRIMARYKEY' + ); } /** * Checks whether an error is a foreign-key-constraint violation. - * @param {unknown} err + * @param {any} err * @returns {boolean} */ export function isForeignKeyConstraintError(err) { - throw new Error('Not implemented: isForeignKeyConstraintError'); + return ( + err?.number === 547 || + err?.code === 'SQLITE_CONSTRAINT_FOREIGNKEY' + ); } diff --git a/src/providers/sqlite3/index.js b/src/providers/sqlite3/index.js index 013312c..bb485a2 100644 --- a/src/providers/sqlite3/index.js +++ b/src/providers/sqlite3/index.js @@ -1,15 +1,29 @@ /** - * Provider for a caller-owned `better-sqlite3` Database instance. + * Provider for `better-sqlite3` Database instances or connection options. * * The driver is synchronous, but this adapter exposes the asynchronous * provider contract used by LibDashStore. */ +import Database from 'better-sqlite3'; +import { SCHEMA_SCRIPTS } from './schema.js'; + +function normalizeParamValue(value) { + if (value === undefined || value === null) return null; + if (value instanceof Date) { + return isNaN(value.getTime()) ? null : value.toISOString(); + } + if (typeof value === 'boolean') { + return value ? 1 : 0; + } + return value; +} + function bindParams(params) { return Object.fromEntries( Object.entries(params).map(([key, value]) => [ key.replace(/^[@:$]/, ''), - value === undefined ? null : value, + normalizeParamValue(value), ]), ); } @@ -20,10 +34,169 @@ function normalizeInsertId(value) { return value <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(value) : value; } +import { dirname, join } from 'node:path'; +import { mkdirSync, mkdtempSync, promises as fsPromises } from 'node:fs'; +import { tmpdir } from 'node:os'; + +export function extFromMimetype(mimetype) { + if (!mimetype) return ''; + const type = mimetype.trim().toLowerCase().split(';')[0]; + const map = { + 'application/pdf': '.pdf', + 'text/plain': '.txt', + 'text/csv': '.csv', + 'text/html': '.html', + 'text/markdown': '.md', + 'image/avif': '.avif', + 'image/png': '.png', + 'image/jpeg': '.jpg', + 'image/jpg': '.jpg', + 'image/webp': '.webp', + 'image/gif': '.gif', + 'image/svg+xml': '.svg', + 'application/json': '.json', + 'application/zip': '.zip', + 'application/xml': '.xml', + 'application/octet-stream': '.bin', + }; + if (map[type]) return map[type]; + if (type.startsWith('image/')) return '.' + type.slice(6).replace('+xml', ''); + if (type.startsWith('text/')) return '.' + type.slice(5); + return ''; +} + +async function safeWrite(filePath, buffer) { + if (buffer == null) return; + const buf = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer); + await fsPromises.writeFile(filePath, buf); +} + +async function findFileByHash(dir, hash, ext = '') { + if (!dir || !hash) return null; + if (ext) { + const candidate = join(dir, hash + ext); + try { + await fsPromises.access(candidate); + return candidate; + } catch { + // fallback + } + } + const candidateNoExt = join(dir, hash); + try { + await fsPromises.access(candidateNoExt); + return candidateNoExt; + } catch { + // fallback + } + try { + const files = await fsPromises.readdir(dir); + const match = files.find((f) => f === hash || f.startsWith(hash + '.')); + if (match) return join(dir, match); + } catch { + // ignore + } + return null; +} + +async function safeReadByHash(dir, hash, ext = '') { + const filePath = await findFileByHash(dir, hash, ext); + if (!filePath) return null; + try { + return await fsPromises.readFile(filePath); + } catch (err) { + if (err.code === 'ENOENT') return null; + throw err; + } +} + +async function safeDeleteByHash(dir, hash) { + if (!dir || !hash) return; + try { + const files = await fsPromises.readdir(dir); + const matches = files.filter((f) => f === hash || f.startsWith(hash + '.')); + await Promise.all(matches.map((f) => fsPromises.unlink(join(dir, f)))); + } catch (err) { + if (err.code !== 'ENOENT') throw err; + } +} + export class BetterSqlite3Executor { - /** @param {import('better-sqlite3').Database} db */ - constructor(db) { + /** + * @param {import('better-sqlite3').Database} db + * @param {string|null} [storageDir=null] + */ + constructor(db, storageDir = null) { this.db = db; + this.dialect = 'sqlite3'; + this.storageDir = storageDir; + } + + async writeFileContent(hash, buffer, mimetype = null) { + if (!this.storageDir || !hash) return; + const ext = extFromMimetype(mimetype); + await safeWrite(join(this.storageDir, 'content', hash + ext), buffer); + } + + async readFileContent(hash, mimetype = null) { + if (!this.storageDir || !hash) return null; + const ext = extFromMimetype(mimetype); + return safeReadByHash(join(this.storageDir, 'content'), hash, ext); + } + + async writeThumbAvif(hash, buffer) { + if (!this.storageDir || !hash) return; + await safeWrite(join(this.storageDir, 'thumbavif', hash + '.avif'), buffer); + } + + async readThumbAvif(hash) { + if (!this.storageDir || !hash) return null; + return safeReadByHash(join(this.storageDir, 'thumbavif'), hash, '.avif'); + } + + async writePicFull(hash, buffer) { + if (!this.storageDir || !hash) return; + await safeWrite(join(this.storageDir, 'fullbinary', hash + '.avif'), buffer); + } + + async readPicFull(hash) { + if (!this.storageDir || !hash) return null; + return safeReadByHash(join(this.storageDir, 'fullbinary'), hash, '.avif'); + } + + async writePicThumb(hash, buffer) { + if (!this.storageDir || !hash) return; + await safeWrite(join(this.storageDir, 'thumbbinary', hash + '.avif'), buffer); + } + + async readPicThumb(hash) { + if (!this.storageDir || !hash) return null; + return safeReadByHash(join(this.storageDir, 'thumbbinary'), hash, '.avif'); + } + + async deleteFile(hash) { + if (!this.storageDir || !hash) return; + await safeDeleteByHash(join(this.storageDir, 'content'), hash); + await safeDeleteByHash(join(this.storageDir, 'thumbavif'), hash); + } + + async deletePic(hash) { + if (!this.storageDir || !hash) return; + await safeDeleteByHash(join(this.storageDir, 'fullbinary'), hash); + await safeDeleteByHash(join(this.storageDir, 'thumbbinary'), hash); + } + + async purgeFiles() { + if (!this.storageDir) return; + for (const sub of ['content', 'thumbavif', 'fullbinary', 'thumbbinary']) { + const dir = join(this.storageDir, sub); + try { + const files = await fsPromises.readdir(dir); + await Promise.all(files.map((f) => fsPromises.unlink(join(dir, f)))); + } catch { + // ignore + } + } } /** @returns {Promise} */ @@ -49,11 +222,47 @@ export class BetterSqlite3Executor { } } +function ensureDirectory(filename) { + if (typeof filename === 'string' && filename !== ':memory:' && !filename.startsWith(':memory:')) { + const dir = dirname(filename); + if (dir && dir !== '.') { + mkdirSync(dir, { recursive: true }); + } + } +} + export class BetterSqlite3Provider extends BetterSqlite3Executor { /** - * @param {import('better-sqlite3').Database} db An open, caller-owned database. + * @param {import('better-sqlite3').Database | string | { filename: string, storageDir?: string, filesDir?: string, [key: string]: any }} [dbOrConfig] + * An open better-sqlite3 Database, a file path (or ':memory:'), or a config object. */ - constructor(db) { + constructor(dbOrConfig = ':memory:') { + let db = dbOrConfig; + let storageDir = null; + + if (typeof dbOrConfig === 'string') { + ensureDirectory(dbOrConfig); + db = new Database(dbOrConfig); + storageDir = (dbOrConfig === ':memory:' || dbOrConfig.startsWith(':memory:')) + ? mkdtempSync(join(tmpdir(), 'dash-sqlite-storage-')) + : join(dirname(dbOrConfig), 'storage'); + } else if ( + dbOrConfig && + typeof dbOrConfig === 'object' && + typeof dbOrConfig.prepare !== 'function' + ) { + const { filename, storageDir: customStorageDir, filesDir, ...options } = dbOrConfig; + if (filename) ensureDirectory(filename); + db = new Database(filename || ':memory:', options); + storageDir = customStorageDir || filesDir || ( + (!filename || filename === ':memory:' || filename.startsWith(':memory:')) + ? mkdtempSync(join(tmpdir(), 'dash-sqlite-storage-')) + : join(dirname(filename), 'storage') + ); + } else if (db && typeof db.prepare === 'function') { + storageDir = mkdtempSync(join(tmpdir(), 'dash-sqlite-storage-')); + } + if ( !db || typeof db.prepare !== 'function' || @@ -61,22 +270,29 @@ export class BetterSqlite3Provider extends BetterSqlite3Executor { typeof db.pragma !== 'function' ) { throw new TypeError( - 'BetterSqlite3Provider requires an open better-sqlite3 Database', + 'BetterSqlite3Provider requires an open better-sqlite3 Database or a filename string', ); } - super(db); - this.dialect = 'sqlite3'; + super(db, storageDir); + if (this.storageDir) { + for (const sub of ['content', 'thumbavif', 'fullbinary', 'thumbbinary']) { + mkdirSync(join(this.storageDir, sub), { recursive: true }); + } + } + this.pool = this; db.pragma('foreign_keys = ON'); db.pragma('journal_mode = WAL'); db.pragma('busy_timeout = 5000'); } #transactionTail = Promise.resolve(); + #transactionDepth = 0; /** * Runs `fn(runner)` in an immediate write transaction. Transactions are * serialized because better-sqlite3 has one synchronous connection. + * Nested calls use SAVEPOINTs. * * @template T * @param {(runner: BetterSqlite3Executor) => Promise | T} fn @@ -87,15 +303,66 @@ export class BetterSqlite3Provider extends BetterSqlite3Executor { return Promise.reject(new TypeError('transaction requires a callback')); } + const bindRunner = (executor) => { + const runner = async (sql, params) => executor.query(sql, params); + runner.query = runner; + runner.dialect = 'sqlite3'; + runner.storageDir = this.storageDir; + runner.writeFileContent = executor.writeFileContent.bind(executor); + runner.readFileContent = executor.readFileContent.bind(executor); + runner.writeThumbAvif = executor.writeThumbAvif.bind(executor); + runner.readThumbAvif = executor.readThumbAvif.bind(executor); + runner.writePicFull = executor.writePicFull.bind(executor); + runner.readPicFull = executor.readPicFull.bind(executor); + runner.writePicThumb = executor.writePicThumb.bind(executor); + runner.readPicThumb = executor.readPicThumb.bind(executor); + runner.deleteFile = executor.deleteFile.bind(executor); + runner.deletePic = executor.deletePic.bind(executor); + runner.purgeFiles = executor.purgeFiles.bind(executor); + return runner; + }; + + if (this.#transactionDepth > 0 || this.db.inTransaction) { + const savepoint = `sp_${++this.#transactionDepth}`; + this.db.exec(`SAVEPOINT ${savepoint}`); + const executor = new BetterSqlite3Executor(this.db, this.storageDir); + const runner = bindRunner(executor); + + return Promise.resolve() + .then(() => fn(runner, executor)) + .then( + (result) => { + this.db.exec(`RELEASE SAVEPOINT ${savepoint}`); + this.#transactionDepth--; + return result; + }, + (err) => { + try { + this.db.exec(`ROLLBACK TO SAVEPOINT ${savepoint}`); + this.db.exec(`RELEASE SAVEPOINT ${savepoint}`); + } catch { + // ignore + } + this.#transactionDepth--; + throw err; + }, + ); + } + const run = async () => { + this.#transactionDepth = 1; this.db.exec('BEGIN IMMEDIATE'); try { - const result = await fn(new BetterSqlite3Executor(this.db)); + const executor = new BetterSqlite3Executor(this.db, this.storageDir); + const runner = bindRunner(executor); + const result = await fn(runner, executor); this.db.exec('COMMIT'); return result; } catch (err) { if (this.db.inTransaction) this.db.exec('ROLLBACK'); throw err; + } finally { + this.#transactionDepth = 0; } }; @@ -108,18 +375,67 @@ export class BetterSqlite3Provider extends BetterSqlite3Executor { throw new Error('Stored procedures are not supported by better-sqlite3'); } + /** + * Initializes the native SQLite schema. + * @returns {Promise} + */ async initialize() { - // SQLite migrations are provided by the SQLite schema layer. + for (const script of SCHEMA_SCRIPTS) { + if (script && script.trim()) { + this.db.exec(script); + } + } } + /** + * Purges all tables, views, and triggers from the SQLite database. + * @returns {Promise} + */ async purge() { - // SQLite schema teardown is provided by the SQLite schema layer. + await this.purgeFiles(); + this.db.pragma('foreign_keys = OFF'); + try { + const tables = this.db + .prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'", + ) + .all() + .map((r) => r.name); + const views = this.db + .prepare("SELECT name FROM sqlite_master WHERE type = 'view'") + .all() + .map((r) => r.name); + const triggers = this.db + .prepare("SELECT name FROM sqlite_master WHERE type = 'trigger'") + .all() + .map((r) => r.name); + + for (const trigger of triggers) { + this.db.exec(`DROP TRIGGER IF EXISTS "${trigger}"`); + } + for (const view of views) { + this.db.exec(`DROP VIEW IF EXISTS "${view}"`); + } + for (const table of tables) { + this.db.exec(`DROP TABLE IF EXISTS "${table}"`); + } + } finally { + this.db.pragma('foreign_keys = ON'); + } } - /** Explicitly closes the caller-owned database. It is never closed implicitly. */ + /** Explicitly closes the database. */ close() { this.db.close(); } + + isUniqueConstraintError(err) { + return isUniqueConstraintError(err); + } + + isForeignKeyConstraintError(err) { + return isForeignKeyConstraintError(err); + } } export function isUniqueConstraintError(err) { @@ -131,4 +447,7 @@ export function isUniqueConstraintError(err) { export function isForeignKeyConstraintError(err) { return err?.code === 'SQLITE_CONSTRAINT_FOREIGNKEY'; -} \ No newline at end of file +} + +export { BetterSqlite3Provider as SqliteProvider }; +export { BetterSqlite3Executor as SqliteExecutor }; \ No newline at end of file diff --git a/src/providers/sqlite3/queries/category.js b/src/providers/sqlite3/queries/category.js index 8f291ec..59d1349 100644 --- a/src/providers/sqlite3/queries/category.js +++ b/src/providers/sqlite3/queries/category.js @@ -5,10 +5,60 @@ import { ARTICLE_LIST_COLUMNS, ARTICLE_LIST_JOINS } from './article.js'; const queries = translateQueries(source, 'category'); export const { CREATE, DELETE, GET_BY_PARENT_AND_NAME, - GET_NEXT_SIBLING_SORT, IS_ANCESTOR, LIST_CHILD_IDS, - MOVE, RESOLVE_CATEGORY_ACCESS, UPDATE_SORT, + GET_NEXT_SIBLING_SORT, LIST_CHILD_IDS, + MOVE, UPDATE_SORT, } = queries; +export const RESOLVE_CATEGORY_ACCESS = ` +WITH RECURSIVE ancestors AS ( + SELECT id, parent_id + FROM category + WHERE id = @categoryId + + UNION ALL + + SELECT c.id, c.parent_id + FROM category c + INNER JOIN ancestors a ON c.id = a.parent_id +), +root AS ( + SELECT id AS rootCategoryId + FROM ancestors + WHERE parent_id IS NULL + LIMIT 1 +) +SELECT + p.id AS categoryId, + r.rootCategoryId, + co.id AS companyId, + co.default_language_id AS languageId, + co.poscustomergroup_id AS posCustomerGroupId, + cu.id AS companyuserId +FROM category p +LEFT JOIN root r ON 1 = 1 +LEFT JOIN company co ON co.root_category_id = r.rootCategoryId +LEFT JOIN companyuser cu + ON cu.company_id = co.id AND cu.google_auth_id = @googleAuthId +WHERE p.id = @categoryId +`; + +export const IS_ANCESTOR = ` +WITH RECURSIVE ancestors AS ( + SELECT id, parent_id + FROM category + WHERE id = @parentId + + UNION ALL + + SELECT c.id, c.parent_id + FROM category c + INNER JOIN ancestors a ON c.id = a.parent_id +) +SELECT id +FROM ancestors +WHERE id = @id +`; + export const GET_CATEGORY_ARTICLES = ` SELECT ${ARTICLE_LIST_COLUMNS} FROM categoryarticle ca diff --git a/src/providers/sqlite3/queries/delivery.js b/src/providers/sqlite3/queries/delivery.js index 0a539ce..452d11a 100644 --- a/src/providers/sqlite3/queries/delivery.js +++ b/src/providers/sqlite3/queries/delivery.js @@ -5,18 +5,41 @@ const queries = translateQueries(source, 'delivery'); export const { CREATE_ATTACHMENT, CREATE_MANIFEST, DELETE_LINES_BY_MANIFEST, DELETE_MANIFEST, DELIVERY_TYPE, GET_DELIVERY_HEADER, GET_DELIVERY_LINES, - GET_NEXT_ATTACHMENT_SORT, LIST_DELIVERIES, LIST_DELIVERY_FILES, + GET_NEXT_ATTACHMENT_SORT, LIST_DELIVERIES, UPDATE_MANIFEST, } = queries; export const CREATE_FILE = ` INSERT INTO "file" ( - hash, mimetype, name, size, created, modified, content, - thumbavif, thumbx, thumby, meta + hash, mimetype, name, size, created, modified, + thumbx, thumby, meta ) VALUES ( - @hash, @mimetype, @name, @size, @created, @modified, @content, - @thumbavif, @thumbx, @thumby, @meta + @hash, @mimetype, @name, @size, @created, @modified, + @thumbx, @thumby, @meta ) RETURNING id +`; + +export const LIST_DELIVERY_FILES = ` +SELECT + ma.id, + ma.manifest_id AS manifestId, + ma.file_id AS fileId, + ma.sort, + f.hash, + f.mimetype, + f.name, + f.size, + f.created, + f.modified, + f.thumbx, + f.thumby, + f.meta +FROM manifestattachment ma +INNER JOIN "file" f ON f.id = ma.file_id +INNER JOIN manifest m ON m.id = ma.manifest_id +WHERE m.company_id = @companyId + AND m."type" = @type +ORDER BY ma.manifest_id, ma.sort ASC, ma.id ASC `; \ No newline at end of file diff --git a/src/providers/sqlite3/queries/manifestattachment.js b/src/providers/sqlite3/queries/manifestattachment.js index dac399d..bd09b25 100644 --- a/src/providers/sqlite3/queries/manifestattachment.js +++ b/src/providers/sqlite3/queries/manifestattachment.js @@ -4,18 +4,68 @@ import { translateQueries } from './translate.js'; const queries = translateQueries(source, 'manifestattachment'); export const { CHECK_MANIFEST_ACCESS, CREATE_ATTACHMENT, DELETE_ATTACHMENT, - DELETE_FILE, FIND_ATTACHMENT, GET_BY_ID, GET_NEXT_SORT, LIST_BY_MANIFEST, + DELETE_FILE, GET_NEXT_SORT, LIST_IDS_BY_MANIFEST, SET_TEMP_SORTS, UPDATE_SORT, } = queries; +export const FIND_ATTACHMENT = ` +SELECT ma.id, ma.file_id AS fileId, f.hash +FROM manifestattachment ma +INNER JOIN "file" f ON f.id = ma.file_id +WHERE ma.manifest_id = @manifestId + AND (ma.id = @id OR ma.file_id = @id) +`; + export const CREATE_FILE = ` INSERT INTO "file" ( - hash, mimetype, name, size, created, modified, content, - thumbavif, thumbx, thumby, meta + hash, mimetype, name, size, created, modified, + thumbx, thumby, meta ) VALUES ( - @hash, @mimetype, @name, @size, @created, @modified, @content, - @thumbavif, @thumbx, @thumby, @meta + @hash, @mimetype, @name, @size, @created, @modified, + @thumbx, @thumby, @meta ) RETURNING id +`; + +export const LIST_BY_MANIFEST = ` +SELECT + ma.id, + ma.manifest_id AS manifestId, + ma.file_id AS fileId, + ma.sort, + f.hash, + f.mimetype, + f.name, + f.size, + f.created, + f.modified, + f.thumbx, + f.thumby, + f.meta +FROM manifestattachment ma +INNER JOIN "file" f ON f.id = ma.file_id +WHERE ma.manifest_id = @manifestId +ORDER BY ma.sort ASC, ma.id ASC +`; + +export const GET_BY_ID = ` +SELECT + ma.id, + ma.manifest_id AS manifestId, + ma.file_id AS fileId, + ma.sort, + f.hash, + f.mimetype, + f.name, + f.size, + f.created, + f.modified, + f.thumbx, + f.thumby, + f.meta +FROM manifestattachment ma +INNER JOIN "file" f ON f.id = ma.file_id +WHERE ma.manifest_id = @manifestId + AND (ma.id = @id OR ma.file_id = @id) `; \ No newline at end of file diff --git a/src/providers/sqlite3/queries/pic.js b/src/providers/sqlite3/queries/pic.js index f99d10e..a3cade7 100644 --- a/src/providers/sqlite3/queries/pic.js +++ b/src/providers/sqlite3/queries/pic.js @@ -3,9 +3,31 @@ import { translateQueries } from './translate.js'; const queries = translateQueries(source, 'pic'); export const { - CREATE, GET_CATEGORY_ID_BY_HASH, GET_FULL_BY_HASH, GET_THUMB_BY_HASH, + GET_CATEGORY_ID_BY_HASH, } = queries; +export const CREATE = ` +INSERT INTO pic ( + w, h, size, hash, thumbw, thumbh, thumbhash, source +) +VALUES ( + @w, @h, @size, @hash, @thumbw, @thumbh, @thumbhash, @source +) +RETURNING id +`; + +export const GET_FULL_BY_HASH = ` +SELECT w, h, size, hash +FROM pic +WHERE hash = @hash +`; + +export const GET_THUMB_BY_HASH = ` +SELECT thumbw AS w, thumbh AS h, hash +FROM pic +WHERE hash = @hash +`; + export const DELETE_BY_ARTICLE_AND_HASH = ` DELETE FROM pic WHERE hash = @fullHash diff --git a/src/providers/sqlite3/schema.js b/src/providers/sqlite3/schema.js index fc19a81..81b8ff6 100644 --- a/src/providers/sqlite3/schema.js +++ b/src/providers/sqlite3/schema.js @@ -42,9 +42,7 @@ CREATE TABLE IF NOT EXISTS pic ( thumbw INTEGER NOT NULL, thumbh INTEGER NOT NULL, thumbhash TEXT NOT NULL, - source TEXT NOT NULL, - fullbinary BLOB NOT NULL, - thumbbinary BLOB NOT NULL + source TEXT NOT NULL ); `; @@ -542,8 +540,6 @@ CREATE TABLE IF NOT EXISTS "file" ( size INTEGER NOT NULL, created TEXT NOT NULL DEFAULT (${UTC_NOW}), modified TEXT NOT NULL DEFAULT (${UTC_NOW}), - content BLOB NOT NULL, - thumbavif BLOB, thumbx INTEGER, thumby INTEGER, meta TEXT diff --git a/src/store/article/addImage.js b/src/store/article/addImage.js index 832f1e3..ade2626 100644 --- a/src/store/article/addImage.js +++ b/src/store/article/addImage.js @@ -1,12 +1,25 @@ -import { ADD as ADD_ARTICLEPIC, GET_NEXT_SORT } from '../../providers/mssql/queries/articlepic.js'; -import { CREATE as CREATE_PIC } from '../../providers/mssql/queries/pic.js'; +import { + ADD as MSSQL_ADD_ARTICLEPIC, + GET_NEXT_SORT as MSSQL_GET_NEXT_SORT, +} from '../../providers/mssql/queries/articlepic.js'; +import { + ADD as SQLITE_ADD_ARTICLEPIC, + GET_NEXT_SORT as SQLITE_GET_NEXT_SORT, +} from '../../providers/sqlite3/queries/articlepic.js'; +import { CREATE as MSSQL_CREATE_PIC } from '../../providers/mssql/queries/pic.js'; +import { CREATE as SQLITE_CREATE_PIC } from '../../providers/sqlite3/queries/pic.js'; import processAvifImage from '../../util/processAvifImage.js'; import newFullHash from '../../util/newFullHash.js'; -import request from '../request.js'; import resolveArticleBySku from './resolveArticleBySku.js'; +import makeRunner from '../runner.js'; -export default function addImage(pool) { - const resolve = resolveArticleBySku(pool); +export default function addImage(provider) { + const resolve = resolveArticleBySku(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + + const getSortSql = isSqlite ? SQLITE_GET_NEXT_SORT : MSSQL_GET_NEXT_SORT; + const createPicSql = isSqlite ? SQLITE_CREATE_PIC : MSSQL_CREATE_PIC; + const addArticlePicSql = isSqlite ? SQLITE_ADD_ARTICLEPIC : MSSQL_ADD_ARTICLEPIC; /** * Adds an image to an article by its SKU, processing it into AVIF variants. @@ -14,50 +27,53 @@ export default function addImage(pool) { * @param {number} googleAuthId - The id of the Google auth user. * @param {string} sku - The article SKU. * @param {Buffer} image - The source image buffer. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise<{picId: number, sort: number, full: {w: number, h: number, size: number}, thumb: {w: number, h: number}, fullHash: string, thumbHash: string}>} The added image info. * * @example * const result = await store.article.addImage(42, 'SKU-1', imageBuffer); * // => { picId, sort, full: { w, h, size }, thumb: { w, h }, fullHash, thumbHash } */ - return async (googleAuthId, sku, image) => { - const { articleId } = await resolve(googleAuthId, sku); + return async (googleAuthId, sku, image, tx) => { + const runAdd = async (runner) => { + const { articleId } = await resolve(googleAuthId, sku, runner); - const processed = await processAvifImage(image); + const processed = await processAvifImage(image); - const tx = pool.transaction(); - await tx.begin(); - - try { - const sortResult = await request(pool, tx) - .input('articleId', articleId) - .query(GET_NEXT_SORT); - - const sort = sortResult.recordset[0].sort; + const sortResult = await runner.query(getSortSql, { articleId }); + const sort = sortResult.rows[0].sort; const fullHash = newFullHash(); - const picResult = await request(pool, tx) - .input('w', processed.full.w) - .input('h', processed.full.h) - .input('size', processed.full.size) - .input('hash', fullHash) - .input('thumbw', processed.thumb.w) - .input('thumbh', processed.thumb.h) - .input('thumbhash', processed.thumb.hash) - .input('source', '') - .input('fullbinary', processed.full.buffer) - .input('thumbbinary', processed.thumb.buffer) - .query(CREATE_PIC); + if (isSqlite) { + if (runner.writePicFull) { + await runner.writePicFull(fullHash, processed.full.buffer); + await runner.writePicThumb(fullHash, processed.thumb.buffer); + } else if (provider?.writePicFull) { + await provider.writePicFull(fullHash, processed.full.buffer); + await provider.writePicThumb(fullHash, processed.thumb.buffer); + } + } - const picId = picResult.recordset[0].id; + const picResult = await runner.query(createPicSql, { + w: processed.full.w, + h: processed.full.h, + size: processed.full.size, + hash: fullHash, + thumbw: processed.thumb.w, + thumbh: processed.thumb.h, + thumbhash: processed.thumb.hash, + source: '', + fullbinary: processed.full.buffer, + thumbbinary: processed.thumb.buffer, + }); - await request(pool, tx) - .input('articleId', articleId) - .input('picId', picId) - .input('sort', sort) - .query(ADD_ARTICLEPIC); + const picId = picResult.rows[0]?.id ?? picResult.insertId; - await tx.commit(); + await runner.query(addArticlePicSql, { + articleId, + picId, + sort, + }); return { picId, @@ -74,13 +90,15 @@ export default function addImage(pool) { fullHash, thumbHash: processed.thumb.hash, }; - } catch (err) { - try { - await tx.rollback(); - } catch { - // ignore - } - throw err; + }; + + if (tx) { + return runAdd(makeRunner(provider, tx)); } + if (typeof provider?.transaction === 'function') { + return provider.transaction(runAdd); + } + return runAdd(makeRunner(provider)); }; } + diff --git a/src/store/article/createUnderParent.js b/src/store/article/createUnderParent.js index 515d58c..192aa7a 100644 --- a/src/store/article/createUnderParent.js +++ b/src/store/article/createUnderParent.js @@ -1,17 +1,27 @@ -import { CREATE } from '../../providers/mssql/queries/article.js'; -import { ADD as ADD_CATEGORYARTICLE } from '../../providers/mssql/queries/categoryarticle.js'; -import { CREATE as CREATE_ARTICLETEXT } from '../../providers/mssql/queries/articletext.js'; -import { INSERT as INSERT_HISTORY } from '../../providers/mssql/queries/articlehistory.js'; +import { CREATE as MSSQL_CREATE_ARTICLE } from '../../providers/mssql/queries/article.js'; +import { CREATE as SQLITE_CREATE_ARTICLE } from '../../providers/sqlite3/queries/article.js'; +import { ADD as MSSQL_ADD_CATEGORYARTICLE } from '../../providers/mssql/queries/categoryarticle.js'; +import { ADD as SQLITE_ADD_CATEGORYARTICLE } from '../../providers/sqlite3/queries/categoryarticle.js'; +import { CREATE as MSSQL_CREATE_ARTICLETEXT } from '../../providers/mssql/queries/articletext.js'; +import { CREATE as SQLITE_CREATE_ARTICLETEXT } from '../../providers/sqlite3/queries/articletext.js'; +import { INSERT as MSSQL_INSERT_HISTORY } from '../../providers/mssql/queries/articlehistory.js'; +import { INSERT as SQLITE_INSERT_HISTORY } from '../../providers/sqlite3/queries/articlehistory.js'; import resolveCategoryAccess from '../category/resolveCategoryAccess.js'; import ensureDefaultUnit from '../unit/ensureDefault.js'; import nextSku from '../numbers/nextSku.js'; -import request from '../request.js'; +import makeRunner from '../runner.js'; import slugify from '../slugify.js'; -export default function createUnderParent(pool) { - const access = resolveCategoryAccess(pool); - const ensureUnit = ensureDefaultUnit(pool); - const allocateSku = nextSku(pool); +export default function createUnderParent(provider) { + const access = resolveCategoryAccess(provider); + const ensureUnit = ensureDefaultUnit(provider); + const allocateSku = nextSku(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + + const createArticleSql = isSqlite ? SQLITE_CREATE_ARTICLE : MSSQL_CREATE_ARTICLE; + const addCategoryArticleSql = isSqlite ? SQLITE_ADD_CATEGORYARTICLE : MSSQL_ADD_CATEGORYARTICLE; + const createArticleTextSql = isSqlite ? SQLITE_CREATE_ARTICLETEXT : MSSQL_CREATE_ARTICLETEXT; + const insertHistorySql = isSqlite ? SQLITE_INSERT_HISTORY : MSSQL_INSERT_HISTORY; /** * Creates a new article under a given parent category, allocating a SKU and @@ -21,54 +31,51 @@ export default function createUnderParent(pool) { * @param {number} input.googleAuthId - The id of the Google auth user. * @param {number} input.parentId - The id of the parent category. * @param {string} input.name - The article name. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise<{id: number, parentId: number, name: string, sku: string}>} The created article. * * @example * const article = await store.article.createUnderParent({ googleAuthId: 42, parentId: 5, name: 'Cola' }); * // => { id, parentId, name, sku } */ - return async ({ googleAuthId, parentId, name }) => { - const { companyId, languageId, companyuserId } = await access( - googleAuthId, - parentId - ); + return async ({ googleAuthId, parentId, name }, tx) => { + const runCreate = async (runner) => { + const { companyId, languageId, companyuserId } = await access( + googleAuthId, + parentId, + runner, + ); - const tx = pool.transaction(); - await tx.begin(); - - try { - const unitId = await ensureUnit(tx, languageId, companyId); - const sku = await allocateSku(companyId, tx); + const unitId = await ensureUnit(runner, languageId, companyId); + const sku = await allocateSku(companyId, runner); const slug = slugify(name); - const article = await request(pool, tx) - .input('sku', sku) - .input('unitId', unitId) - .input('unitamount', 1) - .input('weight', 0) - .query(CREATE); + const article = await runner.query(createArticleSql, { + sku, + unitId, + unitamount: 1, + weight: 0, + }); - const id = article.recordset[0].id; + const id = article.rows[0]?.id ?? article.insertId; - await request(pool, tx) - .input('categoryId', parentId) - .input('articleId', id) - .query(ADD_CATEGORYARTICLE); + await runner.query(addCategoryArticleSql, { + categoryId: parentId, + articleId: id, + }); - await request(pool, tx) - .input('articleId', id) - .input('languageId', languageId) - .input('name', name) - .input('slug', slug) - .query(CREATE_ARTICLETEXT); + await runner.query(createArticleTextSql, { + articleId: id, + languageId, + name, + slug, + }); - await request(pool, tx) - .input('articleId', id) - .input('companyuserId', companyuserId) - .input('text', `Artikel "${name}" angelegt`) - .query(INSERT_HISTORY); - - await tx.commit(); + await runner.query(insertHistorySql, { + articleId: id, + companyuserId, + text: `Artikel "${name}" angelegt`, + }); return { id, @@ -76,13 +83,15 @@ export default function createUnderParent(pool) { name, sku, }; - } catch (err) { - try { - await tx.rollback(); - } catch { - // ignore - } - throw err; + }; + + if (tx) { + return runCreate(makeRunner(provider, tx)); } + if (typeof provider?.transaction === 'function') { + return provider.transaction(runCreate); + } + return runCreate(makeRunner(provider)); }; } + diff --git a/src/store/article/delete.js b/src/store/article/delete.js index 1c30d94..e1fe4ed 100644 --- a/src/store/article/delete.js +++ b/src/store/article/delete.js @@ -1,83 +1,60 @@ -import { - DELETE_ARTICLELIST, - DELETE_BY_ID, - DELETE_OPTIONSLIST, - DELETE_PICS_BY_ARTICLE, - GET_CHILD_COUNT, - GET_LIST_IDS, -} from '../../providers/mssql/queries/articleDelete.js'; +import * as mssqlQueries from '../../providers/mssql/queries/articleDelete.js'; +import * as sqliteQueries from '../../providers/sqlite3/queries/articleDelete.js'; import resolveArticleBySku from './resolveArticleBySku.js'; -import request from '../request.js'; +import makeRunner from '../runner.js'; -export default function del(pool) { - const resolve = resolveArticleBySku(pool); +export default function del(provider) { + const resolve = resolveArticleBySku(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + const queries = isSqlite ? sqliteQueries : mssqlQueries; /** * Deletes an article by its SKU, refusing when it still has children. * * @param {number} googleAuthId - The id of the Google auth user. * @param {string} sku - The article SKU. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise} * @throws {Error} If the article has children or is not found. * * @example * await store.article.delete(42, 'SKU-1'); */ - return async (googleAuthId, sku) => { - const { articleId } = await resolve(googleAuthId, sku); + return async (googleAuthId, sku, tx) => { + const runDelete = async (runner) => { + const { articleId } = await resolve(googleAuthId, sku, runner); - const childCount = await pool - .request() - .input('articleId', articleId) - .query(GET_CHILD_COUNT); + const childCount = await runner.query(queries.GET_CHILD_COUNT, { articleId }); + if (childCount.rows[0].count > 0) { + throw new Error('Article has children'); + } - if (childCount.recordset[0].count > 0) { - throw new Error('Article has children'); - } + const listIds = await runner.query(queries.GET_LIST_IDS, { articleId }); + const { articlelistId, optionslistId } = listIds.rows[0] ?? {}; - const listIds = await pool - .request() - .input('articleId', articleId) - .query(GET_LIST_IDS); + await runner.query(queries.DELETE_PICS_BY_ARTICLE, { articleId }); - const { articlelistId, optionslistId } = listIds.recordset[0] ?? {}; - - const tx = pool.transaction(); - await tx.begin(); - - try { - await request(pool, tx) - .input('articleId', articleId) - .query(DELETE_PICS_BY_ARTICLE); - - const deleted = await request(pool, tx) - .input('articleId', articleId) - .query(DELETE_BY_ID); - - if (!deleted.rowsAffected[0]) { + const deleted = await runner.query(queries.DELETE_BY_ID, { articleId }); + if (!deleted.rowsAffected) { throw new Error('Article not found'); } if (articlelistId != null) { - await request(pool, tx) - .input('id', articlelistId) - .query(DELETE_ARTICLELIST); + await runner.query(queries.DELETE_ARTICLELIST, { id: articlelistId }); } if (optionslistId != null) { - await request(pool, tx) - .input('id', optionslistId) - .query(DELETE_OPTIONSLIST); + await runner.query(queries.DELETE_OPTIONSLIST, { id: optionslistId }); } + }; - await tx.commit(); - } catch (err) { - try { - await tx.rollback(); - } catch { - // ignore - } - throw err; + if (tx) { + return runDelete(makeRunner(provider, tx)); } + if (typeof provider?.transaction === 'function') { + return provider.transaction(runDelete); + } + return runDelete(makeRunner(provider)); }; } + diff --git a/src/store/article/deleteImage.js b/src/store/article/deleteImage.js index b0bc2f8..b1c2387 100644 --- a/src/store/article/deleteImage.js +++ b/src/store/article/deleteImage.js @@ -1,8 +1,11 @@ -import { DELETE_BY_ARTICLE_AND_HASH } from '../../providers/mssql/queries/pic.js'; +import { DELETE_BY_ARTICLE_AND_HASH as MSSQL_QUERY } from '../../providers/mssql/queries/pic.js'; +import { DELETE_BY_ARTICLE_AND_HASH as SQLITE_QUERY } from '../../providers/sqlite3/queries/pic.js'; import resolveArticleBySku from './resolveArticleBySku.js'; +import makeRunner from '../runner.js'; -export default function deleteImage(pool) { - const resolve = resolveArticleBySku(pool); +export default function deleteImage(provider) { + const resolve = resolveArticleBySku(provider); + const sql = provider?.dialect === 'sqlite3' ? SQLITE_QUERY : MSSQL_QUERY; /** * Deletes an image from an article by its SKU and full image hash. @@ -10,23 +13,29 @@ export default function deleteImage(pool) { * @param {number} googleAuthId - The id of the Google auth user. * @param {string} sku - The article SKU. * @param {string} fullHash - The full image hash to delete. + * @param {object} [tx] - Optional transaction. * @returns {Promise} * @throws {Error} If the image is not found. * * @example * await store.article.deleteImage(42, 'SKU-1', 'abc123'); */ - return async (googleAuthId, sku, fullHash) => { - const { articleId } = await resolve(googleAuthId, sku); + return async (googleAuthId, sku, fullHash, tx) => { + const { articleId } = await resolve(googleAuthId, sku, tx); + const runner = makeRunner(provider, tx); + const result = await runner.query(sql, { articleId, fullHash }); - const result = await pool - .request() - .input('articleId', articleId) - .input('fullHash', fullHash) - .query(DELETE_BY_ARTICLE_AND_HASH); - - if (!result.rowsAffected[0]) { + if (!result.rowsAffected) { throw new Error('Image not found'); } + + if (provider?.dialect === 'sqlite3') { + if (runner.deletePic) { + await runner.deletePic(fullHash); + } else if (provider?.deletePic) { + await provider.deletePic(fullHash); + } + } }; } + diff --git a/src/store/article/getBySku.js b/src/store/article/getBySku.js index 01c86c7..915ae64 100644 --- a/src/store/article/getBySku.js +++ b/src/store/article/getBySku.js @@ -1,4 +1,6 @@ -import { GET_ARTICLE_BY_SKU_FULL } from '../../providers/mssql/queries/article.js'; +import { GET_ARTICLE_BY_SKU_FULL as MSSQL_QUERY } from '../../providers/mssql/queries/article.js'; +import { GET_ARTICLE_BY_SKU_FULL as SQLITE_QUERY } from '../../providers/sqlite3/queries/article.js'; +import makeRunner from '../runner.js'; function parseJsonArray(raw) { if (!raw) return []; @@ -6,13 +8,16 @@ function parseJsonArray(raw) { return Array.isArray(arr) ? arr : []; } -export default function getBySku(pool) { +export default function getBySku(provider) { + const sql = provider?.dialect === 'sqlite3' ? SQLITE_QUERY : MSSQL_QUERY; + /** * Returns the full detail of an article by its SKU, including unit, brand, * categories, photos, EANs and prices. * * @param {number} googleAuthId - The id of the Google auth user. * @param {string} sku - The article SKU. + * @param {object} [tx] - Optional transaction. * @returns {Promise<{article: {id: number, sku: string, name: string, slug: string, unitamount: number|null, weight: number|null, unit: {si: string|null, basesi: string|null, basesifactor: number|null, symbol: string|null, name: string|null}, brand: {name: string, thumbW: number, thumbH: number, thumbHash: string}|null, categoryIds: number[], photos: Array<{sort: number, fullHash: string, fullW: number, fullH: number, fullSize: number, thumbW: number, thumbH: number, thumbHash: string}>, eans: string[], prices: Array<{minmultiplier: number, net: number, taxRate: number, customergroupId: number}>, suppliers: Array<{id: number, articleId: number, entityId: number, supplierName: string|null, sku: string|null, pu: number, stock: number|null, sort: number, priceId: number|null, price: {net: number, taxId: number, taxRate?: number}|null}>, optionsCount: number, articleListCount: number, childCount: number, fatherArticleId: number|null, fatherSku: string|null}}>} The full article detail. * @throws {Error} If the article is not found or the user is not authorized. * @@ -20,16 +25,13 @@ export default function getBySku(pool) { * const article = await store.article.getBySku(42, 'SKU-1'); * // => { article: { id, sku, name, unit, brand, categoryIds, photos, eans, prices, ... } } */ - return async (googleAuthId, sku) => { + return async (googleAuthId, sku, tx) => { const t0 = performance.now(); - const result = await pool - .request() - .input('sku', sku) - .input('googleAuthId', googleAuthId) - .query(GET_ARTICLE_BY_SKU_FULL); + const runner = makeRunner(provider, tx); + const result = await runner.query(sql, { sku, googleAuthId }); console.log(`[getBySku] single query ${(performance.now() - t0).toFixed(1)}ms`); - const row = result.recordset[0]; + const row = result.rows[0]; if (!row) { throw new Error('Article not found'); } @@ -43,6 +45,7 @@ export default function getBySku(pool) { throw new Error('Not authorized'); } + const a = typeof row.articleJson === 'string' ? JSON.parse(row.articleJson) : row.articleJson; return { diff --git a/src/store/article/getHistoryBySku.js b/src/store/article/getHistoryBySku.js index a8c7ddd..caed888 100644 --- a/src/store/article/getHistoryBySku.js +++ b/src/store/article/getHistoryBySku.js @@ -1,29 +1,30 @@ -import { LIST_BY_ARTICLE_ID } from '../../providers/mssql/queries/articlehistory.js'; +import { LIST_BY_ARTICLE_ID as MSSQL_QUERY } from '../../providers/mssql/queries/articlehistory.js'; +import { LIST_BY_ARTICLE_ID as SQLITE_QUERY } from '../../providers/sqlite3/queries/articlehistory.js'; import resolveArticleBySku from './resolveArticleBySku.js'; +import makeRunner from '../runner.js'; -export default function getHistoryBySku(pool) { - const resolve = resolveArticleBySku(pool); +export default function getHistoryBySku(provider) { + const resolve = resolveArticleBySku(provider); + const sql = provider?.dialect === 'sqlite3' ? SQLITE_QUERY : MSSQL_QUERY; /** * Returns the change history of an article by its SKU. * * @param {number} googleAuthId - The id of the Google auth user. * @param {string} sku - The article SKU. + * @param {object} [tx] - Optional transaction. * @returns {Promise>} The article history entries. * * @example * const history = await store.article.getHistoryBySku(42, 'SKU-1'); * // => [{ id, companyuserId, date, text }] */ - return async (googleAuthId, sku) => { - const { articleId } = await resolve(googleAuthId, sku); + return async (googleAuthId, sku, tx) => { + const { articleId } = await resolve(googleAuthId, sku, tx); + const runner = makeRunner(provider, tx); + const result = await runner.query(sql, { articleId }); - const result = await pool - .request() - .input('articleId', articleId) - .query(LIST_BY_ARTICLE_ID); - - return result.recordset.map((row) => ({ + return result.rows.map((row) => ({ id: row.id, companyuserId: row.companyuserId ?? null, date: row.date, @@ -31,3 +32,4 @@ export default function getHistoryBySku(pool) { })); }; } + diff --git a/src/store/article/importRows.js b/src/store/article/importRows.js index 283e376..43ee66b 100644 --- a/src/store/article/importRows.js +++ b/src/store/article/importRows.js @@ -1,24 +1,35 @@ +import * as mssqlArticle from '../../providers/mssql/queries/article.js'; +import * as sqliteArticle from '../../providers/sqlite3/queries/article.js'; +import { ADD as MSSQL_ADD_CATEGORYARTICLE } from '../../providers/mssql/queries/categoryarticle.js'; +import { ADD as SQLITE_ADD_CATEGORYARTICLE } from '../../providers/sqlite3/queries/categoryarticle.js'; +import { CREATE as MSSQL_CREATE_ARTICLETEXT } from '../../providers/mssql/queries/articletext.js'; +import { CREATE as SQLITE_CREATE_ARTICLETEXT } from '../../providers/sqlite3/queries/articletext.js'; +import { INSERT as MSSQL_INSERT_HISTORY } from '../../providers/mssql/queries/articlehistory.js'; +import { INSERT as SQLITE_INSERT_HISTORY } from '../../providers/sqlite3/queries/articlehistory.js'; +import { GET_CHILD_COUNT as MSSQL_GET_CHILD_COUNT } from '../../providers/mssql/queries/articleDelete.js'; +import { GET_CHILD_COUNT as SQLITE_GET_CHILD_COUNT } from '../../providers/sqlite3/queries/articleDelete.js'; import { - ADD_EAN, - CREATE as CREATE_ARTICLE, - EAN_EXISTS, - GET_ID_BY_SKU, - SET_FATHER_SORT, - SET_IS_FATHER, - UPDATE_BRAND, - UPDATE_FATHER_BY_SKU, -} from '../../providers/mssql/queries/article.js'; -import { ADD as ADD_CATEGORYARTICLE } from '../../providers/mssql/queries/categoryarticle.js'; -import { CREATE as CREATE_ARTICLETEXT } from '../../providers/mssql/queries/articletext.js'; -import { INSERT as INSERT_HISTORY } from '../../providers/mssql/queries/articlehistory.js'; -import { GET_CHILD_COUNT } from '../../providers/mssql/queries/articleDelete.js'; -import { CREATE as CREATE_BRAND, GET_BY_COMPANY_AND_NAME } from '../../providers/mssql/queries/brand.js'; -import { CREATE as CREATE_PRICE } from '../../providers/mssql/queries/price.js'; -import { CREATE as CREATE_TAX, GET_BY_RATE } from '../../providers/mssql/queries/tax.js'; -import request from '../request.js'; + CREATE as MSSQL_CREATE_BRAND, + GET_BY_COMPANY_AND_NAME as MSSQL_GET_BRAND_BY_NAME, +} from '../../providers/mssql/queries/brand.js'; +import { + CREATE as SQLITE_CREATE_BRAND, + GET_BY_COMPANY_AND_NAME as SQLITE_GET_BRAND_BY_NAME, +} from '../../providers/sqlite3/queries/brand.js'; +import { CREATE as MSSQL_CREATE_PRICE } from '../../providers/mssql/queries/price.js'; +import { CREATE as SQLITE_CREATE_PRICE } from '../../providers/sqlite3/queries/price.js'; +import { + CREATE as MSSQL_CREATE_TAX, + GET_BY_RATE as MSSQL_GET_TAX_BY_RATE, +} from '../../providers/mssql/queries/tax.js'; +import { + CREATE as SQLITE_CREATE_TAX, + GET_BY_RATE as SQLITE_GET_TAX_BY_RATE, +} from '../../providers/sqlite3/queries/tax.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import ensureByPath from '../category/ensureByPath.js'; import ensureDefaultUnit from '../unit/ensureDefault.js'; +import makeRunner from '../runner.js'; import slugify from '../slugify.js'; // Normalizes a numeric value that may arrive as a string using German decimal @@ -34,29 +45,41 @@ function toNumber(value) { return Number.isFinite(n) ? n : null; } -export default function importRows(pool) { - const companyAccess = resolveSelectedCompanyAccess(pool); - const ensureUnit = ensureDefaultUnit(pool); - const ensureCategoryByPath = ensureByPath(pool); +export default function importRows(provider) { + const companyAccess = resolveSelectedCompanyAccess(provider); + const ensureUnit = ensureDefaultUnit(provider); + const ensureCategoryByPath = ensureByPath(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + + const articleQueries = isSqlite ? sqliteArticle : mssqlArticle; + const addCategoryArticleSql = isSqlite ? SQLITE_ADD_CATEGORYARTICLE : MSSQL_ADD_CATEGORYARTICLE; + const createArticleTextSql = isSqlite ? SQLITE_CREATE_ARTICLETEXT : MSSQL_CREATE_ARTICLETEXT; + const insertHistorySql = isSqlite ? SQLITE_INSERT_HISTORY : MSSQL_INSERT_HISTORY; + const getChildCountSql = isSqlite ? SQLITE_GET_CHILD_COUNT : MSSQL_GET_CHILD_COUNT; + const getBrandByNameSql = isSqlite ? SQLITE_GET_BRAND_BY_NAME : MSSQL_GET_BRAND_BY_NAME; + const createBrandSql = isSqlite ? SQLITE_CREATE_BRAND : MSSQL_CREATE_BRAND; + const createPriceSql = isSqlite ? SQLITE_CREATE_PRICE : MSSQL_CREATE_PRICE; + const getTaxByRateSql = isSqlite ? SQLITE_GET_TAX_BY_RATE : MSSQL_GET_TAX_BY_RATE; + const createTaxSql = isSqlite ? SQLITE_CREATE_TAX : MSSQL_CREATE_TAX; // Find or create a brand for a company by name (within the current tx). - async function ensureBrand(tx, companyId, name) { - const existing = await request(pool, tx) - .input('companyId', companyId) - .input('name', name) - .query(GET_BY_COMPANY_AND_NAME); + async function ensureBrand(runner, companyId, name) { + const existing = await runner.query(getBrandByNameSql, { + companyId, + name, + }); - if (existing.recordset[0]) { - return existing.recordset[0].id; + if (existing.rows[0]) { + return existing.rows[0].id; } - const created = await request(pool, tx) - .input('companyId', companyId) - .input('name', name) - .input('gsprpinfo', null) - .query(CREATE_BRAND); + const created = await runner.query(createBrandSql, { + companyId, + name, + gsprpinfo: null, + }); - return created.recordset[0].id; + return created.rows[0]?.id ?? created.insertId; } /** @@ -82,6 +105,7 @@ export default function importRows(pool) { * @param {Array|string} [input.rows[].eans] - The EANs to set. * @param {string} [input.rows[].fatherSku] - The father article SKU for a V/K compound link. * @param {number|string} [input.rows[].deposit] - The deposit (Pfand) value; currently ignored. + * @param {object} [tx] - Optional transaction. * @returns {Promise<{imported: Array, skipped: Array, errors: Array}>} The import outcome. * * @example @@ -94,14 +118,15 @@ export default function importRows(pool) { * }); * // => { imported: [...], skipped: [...], errors: [...] } */ - return async ({ googleAuthId, rows }) => { + return async ({ googleAuthId, rows }, tx) => { + const baseRunner = makeRunner(provider, tx); const { companyId, languageId, rootCategoryId, posCustomerGroupId, companyuserId, - } = await companyAccess(googleAuthId); + } = await companyAccess(googleAuthId, baseRunner); if (!Array.isArray(rows)) { throw new Error('Invalid rows'); @@ -112,6 +137,16 @@ export default function importRows(pool) { const errors = []; const usedEans = new Set(); + const runInTx = async (fn) => { + if (tx) { + return fn(baseRunner); + } + if (typeof provider?.transaction === 'function') { + return provider.transaction(fn); + } + return fn(baseRunner); + }; + // Pass 1: create articles (skip existing SKUs). for (const row of rows) { const sku = String(row?.sku ?? '').trim(); @@ -122,123 +157,113 @@ export default function importRows(pool) { continue; } - const existing = await pool.request().input('sku', sku).query(GET_ID_BY_SKU); - if (existing.recordset[0]) { + const existing = await baseRunner.query(articleQueries.GET_ID_BY_SKU, { sku }); + if (existing.rows[0]) { skipped.push({ sku, name, reason: 'duplicate sku' }); continue; } - let tx; try { - tx = pool.transaction(); - await tx.begin(); + await runInTx(async (runner) => { + const unitId = await ensureUnit(runner, companyId, languageId); + const slug = slugify(name); - const unitId = await ensureUnit(tx, companyId, languageId); - const slug = slugify(name); + const article = await runner.query(articleQueries.CREATE, { + sku, + unitId, + unitamount: 1, + weight: 0, + }); - const article = await request(pool, tx) - .input('sku', sku) - .input('unitId', unitId) - .input('unitamount', 1) - .input('weight', 0) - .query(CREATE_ARTICLE); + const articleId = article.rows[0]?.id ?? article.insertId; - const articleId = article.recordset[0].id; - - // Resolve the parent category id (explicit id or breadcrumb path). - let parentId = row.parentId; - if (parentId == null && row.parentCategory) { - parentId = await ensureCategoryByPath( - { googleAuthId, parentId: rootCategoryId, path: row.parentCategory }, - tx, - ); - } - if (parentId == null) { - throw new Error('Parent category required'); - } - - await request(pool, tx) - .input('categoryId', parentId) - .input('articleId', articleId) - .query(ADD_CATEGORYARTICLE); - - await request(pool, tx) - .input('articleId', articleId) - .input('languageId', languageId) - .input('name', name) - .input('slug', slug) - .query(CREATE_ARTICLETEXT); - - await request(pool, tx) - .input('articleId', articleId) - .input('companyuserId', companyuserId) - .input('text', `Artikel "${name}" importiert`) - .query(INSERT_HISTORY); - - // Brand (upsert by name). - const brandName = row.brand != null ? String(row.brand).trim() : ''; - if (brandName) { - const brandId = await ensureBrand(tx, companyId, brandName); - await request(pool, tx) - .input('articleId', articleId) - .input('brandId', brandId) - .query(UPDATE_BRAND); - } - - // POS price (net/tax). - const net = toNumber(row.net); - const taxRate = toNumber(row.tax) ?? 0; - if (net != null) { - const taxResult = await request(pool, tx) - .input('rate', taxRate) - .query(GET_BY_RATE); - let taxId = taxResult.recordset[0]?.id; - if (!taxId) { - const created = await request(pool, tx) - .input('rate', taxRate) - .query(CREATE_TAX); - taxId = created.recordset[0].id; + // Resolve the parent category id (explicit id or breadcrumb path). + let parentId = row.parentId; + if (parentId == null && row.parentCategory) { + parentId = await ensureCategoryByPath( + { googleAuthId, parentId: rootCategoryId, path: row.parentCategory }, + runner, + ); + } + if (parentId == null) { + throw new Error('Parent category required'); } - await request(pool, tx) - .input('taxId', taxId) - .input('articleId', articleId) - .input('minmultiplier', 1) - .input('customerGroupId', posCustomerGroupId) - .input('net', net) - .query(CREATE_PRICE); - } - // EANs (array, or a single value; skip duplicates within the import). - const rawEans = row.eans; - const eans = Array.isArray(rawEans) - ? rawEans - : rawEans == null || rawEans === '' - ? [] - : [rawEans]; - for (const ean of eans) { - const clean = String(ean ?? '').trim(); - if (!clean || usedEans.has(clean)) continue; - const used = await request(pool, tx) - .input('ean', clean) - .query(EAN_EXISTS); - if (used.recordset[0]) continue; - await request(pool, tx) - .input('articleId', articleId) - .input('ean', clean) - .query(ADD_EAN); - usedEans.add(clean); - } + await runner.query(addCategoryArticleSql, { + categoryId: parentId, + articleId, + }); - await tx.commit(); - imported.push({ id: articleId, sku, name }); + await runner.query(createArticleTextSql, { + articleId, + languageId, + name, + slug, + }); + + await runner.query(insertHistorySql, { + articleId, + companyuserId, + text: `Artikel "${name}" importiert`, + }); + + // Brand (upsert by name). + const brandName = row.brand != null ? String(row.brand).trim() : ''; + if (brandName) { + const brandId = await ensureBrand(runner, companyId, brandName); + await runner.query(articleQueries.UPDATE_BRAND, { + articleId, + brandId, + }); + } + + // POS price (net/tax). + const net = toNumber(row.net); + const taxRate = toNumber(row.tax) ?? 0; + if (net != null) { + const taxResult = await runner.query(getTaxByRateSql, { + rate: taxRate, + }); + let taxId = taxResult.rows[0]?.id; + if (!taxId) { + const created = await runner.query(createTaxSql, { + rate: taxRate, + }); + taxId = created.rows[0]?.id ?? created.insertId; + } + await runner.query(createPriceSql, { + taxId, + articleId, + minmultiplier: 1, + customerGroupId: posCustomerGroupId, + net, + }); + } + + // EANs (array, or a single value; skip duplicates within the import). + const rawEans = row.eans; + const eans = Array.isArray(rawEans) + ? rawEans + : rawEans == null || rawEans === '' + ? [] + : [rawEans]; + for (const ean of eans) { + const clean = String(ean ?? '').trim(); + if (!clean || usedEans.has(clean)) continue; + const used = await runner.query(articleQueries.EAN_EXISTS, { + ean: clean, + }); + if (used.rows[0]) continue; + await runner.query(articleQueries.ADD_EAN, { + articleId, + ean: clean, + }); + usedEans.add(clean); + } + + imported.push({ id: articleId, sku, name }); + }); } catch (err) { - if (tx) { - try { - await tx.rollback(); - } catch { - // ignore - } - } errors.push({ sku, name, error: err.message ?? String(err) }); } } @@ -250,50 +275,35 @@ export default function importRows(pool) { row?.fatherSku != null ? String(row.fatherSku).trim() : ''; if (!sku || !fatherSku) continue; - let tx; try { - tx = pool.transaction(); - await tx.begin(); - - const child = await request(pool, tx) - .input('sku', sku) - .query(GET_ID_BY_SKU); - const childId = child.recordset[0]?.articleId; - if (childId) { - const father = await request(pool, tx) - .input('sku', fatherSku) - .query(GET_ID_BY_SKU); - const fatherId = father.recordset[0]?.articleId; - if (fatherId && fatherId !== childId) { - await request(pool, tx) - .input('sku', sku) - .input('fatherArticleId', fatherId) - .query(UPDATE_FATHER_BY_SKU); - await request(pool, tx) - .input('articleId', fatherId) - .query(SET_IS_FATHER); - const childCount = await request(pool, tx) - .input('articleId', fatherId) - .query(GET_CHILD_COUNT); - const count = childCount.recordset[0].count; - // The child's sort is its 0-based position among the father's children. - await request(pool, tx) - .input('articleId', childId) - .input('fatherArticleId', fatherId) - .input('fathersort', count - 1) - .query(SET_FATHER_SORT); + await runInTx(async (runner) => { + const child = await runner.query(articleQueries.GET_ID_BY_SKU, { sku }); + const childId = child.rows[0]?.articleId; + if (childId) { + 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, + fatherArticleId: fatherId, + fathersort: count - 1, + }); + } } - } - - await tx.commit(); + }); } catch { - if (tx) { - try { - await tx.rollback(); - } catch { - // ignore - } - } // Non-fatal: article was already imported in pass 1. } } diff --git a/src/store/article/listExport.js b/src/store/article/listExport.js index e99b110..3e968f2 100644 --- a/src/store/article/listExport.js +++ b/src/store/article/listExport.js @@ -1,6 +1,8 @@ -import { GET_ARTICLE_EXPORT, GET_CATEGORY_TREE_FLAT } from '../../providers/mssql/queries/article.js'; +import * as mssqlArticle from '../../providers/mssql/queries/article.js'; +import * as sqliteArticle from '../../providers/sqlite3/queries/article.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import resolveCategoryAccess from '../category/resolveCategoryAccess.js'; +import makeRunner from '../runner.js'; const ROOT_CATEGORY_NAME = 'Home'; @@ -10,9 +12,11 @@ function parseJsonArray(raw) { return Array.isArray(arr) ? arr : []; } -export default function listExport(pool) { - const companyAccess = resolveSelectedCompanyAccess(pool); - const categoryAccess = resolveCategoryAccess(pool); +export default function listExport(provider) { + const companyAccess = resolveSelectedCompanyAccess(provider); + const categoryAccess = resolveCategoryAccess(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + const queries = isSqlite ? sqliteArticle : mssqlArticle; /** * Returns articles for export, optionally scoped to a category, including @@ -20,6 +24,7 @@ export default function listExport(pool) { * * @param {number} googleAuthId - The id of the Google auth user. * @param {number|null} [categoryId] - Optional category id to scope the export to; defaults to the company root. + * @param {object} [tx] - Optional transaction. * @returns {Promise>} The articles for export. * @throws {Error} If the category is not reachable from the selected company. * @@ -27,16 +32,17 @@ export default function listExport(pool) { * const articles = await store.article.listExport(42, null); * // => [{ id, sku, name, slug, unitSi, net, taxRate, eans, categoryBreadcrumbs, fullHashes, ... }] */ - return async (googleAuthId, categoryId) => { + return async (googleAuthId, categoryId, tx) => { + const runner = makeRunner(provider, tx); const { languageId, posCustomerGroupId, rootCategoryId } = - await companyAccess(googleAuthId); + await companyAccess(googleAuthId, runner); // Default to the company's root category when no specific category is given. let exportCategoryId = rootCategoryId; if (categoryId != null) { // The requested category must be reachable via the selected company's root. - const access = await categoryAccess(googleAuthId, categoryId); + const access = await categoryAccess(googleAuthId, categoryId, runner); if (access.rootCategoryId !== rootCategoryId) { throw new Error('Not authorized'); } @@ -44,22 +50,20 @@ export default function listExport(pool) { } const [result, treeResult] = await Promise.all([ - pool - .request() - .input('categoryId', exportCategoryId) - .input('languageId', languageId) - .input('posCustomerGroupId', posCustomerGroupId) - .query(GET_ARTICLE_EXPORT), - pool - .request() - .input('rootId', rootCategoryId) - .input('languageId', languageId) - .query(GET_CATEGORY_TREE_FLAT), + runner.query(queries.GET_ARTICLE_EXPORT, { + categoryId: exportCategoryId, + languageId, + posCustomerGroupId, + }), + runner.query(queries.GET_CATEGORY_TREE_FLAT, { + rootId: rootCategoryId, + languageId, + }), ]); // id -> { id, parentId, name } for breadcrumb resolution. const catById = new Map(); - for (const node of treeResult.recordset) { + for (const node of treeResult.rows) { catById.set(String(node.id), node); } @@ -67,10 +71,10 @@ export default function listExport(pool) { const displayName = (node) => String(node.id) === String(rootCategoryId) ? ROOT_CATEGORY_NAME : (node.name ?? '').trim(); - const breadcrumb = (categoryId) => { + const breadcrumb = (cId) => { const crumbs = []; const seen = new Set(); - let id = categoryId; + let id = cId; while (id != null && !seen.has(String(id))) { seen.add(String(id)); const node = catById.get(String(id)); @@ -82,7 +86,7 @@ export default function listExport(pool) { return crumbs.join('·'); }; - return result.recordset.map((row) => ({ + return result.rows.map((row) => ({ id: row.id, sku: row.sku, name: row.name, @@ -102,3 +106,4 @@ export default function listExport(pool) { })); }; } + diff --git a/src/store/article/listSearchIndex.js b/src/store/article/listSearchIndex.js index 0d09fb1..32daec6 100644 --- a/src/store/article/listSearchIndex.js +++ b/src/store/article/listSearchIndex.js @@ -1,29 +1,33 @@ -import { SEARCH_ARTICLES } from '../../providers/mssql/queries/article.js'; +import { SEARCH_ARTICLES as MSSQL_SEARCH_ARTICLES } from '../../providers/mssql/queries/article.js'; +import { SEARCH_ARTICLES as SQLITE_SEARCH_ARTICLES } from '../../providers/sqlite3/queries/article.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; +import makeRunner from '../runner.js'; -export default function listSearchIndex(pool) { - const access = resolveSelectedCompanyAccess(pool); +export default function listSearchIndex(provider) { + const access = resolveSelectedCompanyAccess(provider); + const sql = provider?.dialect === 'sqlite3' ? SQLITE_SEARCH_ARTICLES : MSSQL_SEARCH_ARTICLES; /** * Returns a lightweight search index of all articles for the user's selected company. * * @param {number} googleAuthId - The id of the Google auth user. + * @param {object} [tx] - Optional transaction. * @returns {Promise, categoryIds: Array}>>} The article search index. * * @example * const articles = await store.article.listSearchIndex(42); * // => [{ id, sku, name, eans, categoryIds }] */ - return async (googleAuthId) => { - const { companyId, rootCategoryId, languageId } = await access(googleAuthId); + return async (googleAuthId, tx) => { + const runner = makeRunner(provider, tx); + const { rootCategoryId, languageId } = await access(googleAuthId, runner); - const result = await pool - .request() - .input('rootCategoryId', rootCategoryId) - .input('languageId', languageId) - .query(SEARCH_ARTICLES); + const result = await runner.query(sql, { + rootCategoryId, + languageId, + }); - return result.recordset.map((row) => ({ + return result.rows.map((row) => ({ id: row.id, sku: row.sku, name: row.name, @@ -34,3 +38,4 @@ export default function listSearchIndex(pool) { })); }; } + diff --git a/src/store/article/orderImages.js b/src/store/article/orderImages.js index 980b784..68751c3 100644 --- a/src/store/article/orderImages.js +++ b/src/store/article/orderImages.js @@ -1,13 +1,12 @@ -import { - LIST_HASHES_BY_ARTICLE, - SET_TEMP_SORTS, - UPDATE_SORT_BY_HASH, -} from '../../providers/mssql/queries/articlepic.js'; +import * as mssqlPic from '../../providers/mssql/queries/articlepic.js'; +import * as sqlitePic from '../../providers/sqlite3/queries/articlepic.js'; import resolveArticleBySku from './resolveArticleBySku.js'; -import request from '../request.js'; +import makeRunner from '../runner.js'; -export default function orderImages(pool) { - const resolve = resolveArticleBySku(pool); +export default function orderImages(provider) { + const resolve = resolveArticleBySku(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + const queries = isSqlite ? sqlitePic : mssqlPic; /** * Reorders the images of an article by its SKU. @@ -16,6 +15,7 @@ export default function orderImages(pool) { * @param {string} sku - The article SKU. * @param {object} input - The reorder input. * @param {Array} input.fullHashes - The full image hashes in their new order. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise<{fullHashes: Array}>} The applied order. * @throws {Error} If the order is invalid. * @@ -23,62 +23,53 @@ export default function orderImages(pool) { * const result = await store.article.orderImages(42, 'SKU-1', { fullHashes: ['a', 'b'] }); * // => { fullHashes: ['a', 'b'] } */ - return async (googleAuthId, sku, { fullHashes }) => { + return async (googleAuthId, sku, { fullHashes }, tx) => { if (!Array.isArray(fullHashes)) { throw new Error('Invalid image order'); } - const { articleId } = await resolve(googleAuthId, sku); + const runOrder = async (runner) => { + const { articleId } = await resolve(googleAuthId, sku, runner); - const current = await pool - .request() - .input('articleId', articleId) - .query(LIST_HASHES_BY_ARTICLE); + const current = await runner.query(queries.LIST_HASHES_BY_ARTICLE, { articleId }); + const existing = new Set(current.rows.map((r) => r.fullHash)); - const existing = new Set(current.recordset.map((r) => r.fullHash)); - - if (fullHashes.length !== existing.size) { - throw new Error('Invalid image order'); - } - - const seen = new Set(); - for (const fullHash of fullHashes) { - if (!existing.has(fullHash) || seen.has(fullHash)) { + if (fullHashes.length !== existing.size) { throw new Error('Invalid image order'); } - seen.add(fullHash); - } - const tx = pool.transaction(); - await tx.begin(); + const seen = new Set(); + for (const fullHash of fullHashes) { + if (!existing.has(fullHash) || seen.has(fullHash)) { + throw new Error('Invalid image order'); + } + seen.add(fullHash); + } - try { - await request(pool, tx) - .input('articleId', articleId) - .query(SET_TEMP_SORTS); + await runner.query(queries.SET_TEMP_SORTS, { articleId }); for (let sort = 0; sort < fullHashes.length; sort++) { - const update = await request(pool, tx) - .input('articleId', articleId) - .input('fullHash', fullHashes[sort]) - .input('sort', sort) - .query(UPDATE_SORT_BY_HASH); + const update = await runner.query(queries.UPDATE_SORT_BY_HASH, { + articleId, + fullHash: fullHashes[sort], + sort, + }); - if (!update.rowsAffected[0]) { + if (!update.rowsAffected) { throw new Error('Invalid image order'); } } - await tx.commit(); - return { fullHashes }; - } catch (err) { - try { - await tx.rollback(); - } catch { - // ignore - } - throw err; + }; + + if (tx) { + return runOrder(makeRunner(provider, tx)); } + if (typeof provider?.transaction === 'function') { + return provider.transaction(runOrder); + } + return runOrder(makeRunner(provider)); }; } + diff --git a/src/store/article/queryBySku.js b/src/store/article/queryBySku.js index d260f26..0eb59f0 100644 --- a/src/store/article/queryBySku.js +++ b/src/store/article/queryBySku.js @@ -1,10 +1,6 @@ -import { - GET_CATEGORY_IDS_BY_ARTICLE_ID, - GET_DETAIL_BY_ARTICLE_ID, - GET_EANS_BY_ARTICLE_ID, - GET_PHOTOS_BY_ARTICLE_ID, - GET_PRICES_BY_ARTICLE_ID, -} from '../../providers/mssql/queries/articleDetail.js'; +import * as mssqlDetail from '../../providers/mssql/queries/articleDetail.js'; +import * as sqliteDetail from '../../providers/sqlite3/queries/articleDetail.js'; +import makeRunner from '../runner.js'; function shapeArticleDetail(row, categoryIds, photos, eans, prices) { return { @@ -41,31 +37,32 @@ function shapeArticleDetail(row, categoryIds, photos, eans, prices) { }; } -export default function queryBySku(pool) { - return async (articleId, languageId) => { +export default function queryBySku(provider) { + const isSqlite = provider?.dialect === 'sqlite3'; + const queries = isSqlite ? sqliteDetail : mssqlDetail; + + return async (articleId, languageId, tx) => { + const runner = makeRunner(provider, tx); const [detail, categories, photos, eans, prices] = await Promise.all([ - pool - .request() - .input('articleId', articleId) - .input('languageId', languageId) - .query(GET_DETAIL_BY_ARTICLE_ID), - pool.request().input('articleId', articleId).query(GET_CATEGORY_IDS_BY_ARTICLE_ID), - pool.request().input('articleId', articleId).query(GET_PHOTOS_BY_ARTICLE_ID), - pool.request().input('articleId', articleId).query(GET_EANS_BY_ARTICLE_ID), - pool.request().input('articleId', articleId).query(GET_PRICES_BY_ARTICLE_ID), + runner.query(queries.GET_DETAIL_BY_ARTICLE_ID, { articleId, languageId }), + runner.query(queries.GET_CATEGORY_IDS_BY_ARTICLE_ID, { articleId }), + runner.query(queries.GET_PHOTOS_BY_ARTICLE_ID, { articleId }), + runner.query(queries.GET_EANS_BY_ARTICLE_ID, { articleId }), + runner.query(queries.GET_PRICES_BY_ARTICLE_ID, { articleId }), ]); - const row = detail.recordset[0]; + const row = detail.rows[0]; if (!row) { return null; } return shapeArticleDetail( row, - categories.recordset, - photos.recordset, - eans.recordset, - prices.recordset, + categories.rows, + photos.rows, + eans.rows, + prices.rows, ); }; } + diff --git a/src/store/article/resolveArticleBySku.js b/src/store/article/resolveArticleBySku.js index 9db1676..a83ee6b 100644 --- a/src/store/article/resolveArticleBySku.js +++ b/src/store/article/resolveArticleBySku.js @@ -1,14 +1,16 @@ -import { RESOLVE_ARTICLE_ACCESS_BY_SKU } from '../../providers/mssql/queries/article.js'; +import { RESOLVE_ARTICLE_ACCESS_BY_SKU as MSSQL_RESOLVE } from '../../providers/mssql/queries/article.js'; +import { RESOLVE_ARTICLE_ACCESS_BY_SKU as SQLITE_RESOLVE } from '../../providers/sqlite3/queries/article.js'; +import makeRunner from '../runner.js'; -export default function resolveArticleBySku(pool) { - return async (googleAuthId, sku) => { - const result = await pool - .request() - .input('sku', sku) - .input('googleAuthId', googleAuthId) - .query(RESOLVE_ARTICLE_ACCESS_BY_SKU); +export default function resolveArticleBySku(provider) { + const sql = + provider?.dialect === 'sqlite3' ? SQLITE_RESOLVE : MSSQL_RESOLVE; - const row = result.recordset[0]; + return async (googleAuthId, sku, tx) => { + const runner = makeRunner(provider, tx); + const result = await runner.query(sql, { sku, googleAuthId }); + + const row = result.rows[0]; if (!row?.articleId || !row?.categoryId) { throw new Error('Article not found'); } @@ -30,3 +32,4 @@ export default function resolveArticleBySku(pool) { }; }; } + diff --git a/src/store/article/setBrandIdBySku.js b/src/store/article/setBrandIdBySku.js index 7396f9d..1a21bb6 100644 --- a/src/store/article/setBrandIdBySku.js +++ b/src/store/article/setBrandIdBySku.js @@ -1,13 +1,22 @@ import { - GET_BRAND_BY_COMPANY_AND_ID, - UPDATE_BRAND, + GET_BRAND_BY_COMPANY_AND_ID as MSSQL_GET_BRAND, + UPDATE_BRAND as MSSQL_UPDATE_BRAND, } from '../../providers/mssql/queries/article.js'; +import { + GET_BRAND_BY_COMPANY_AND_ID as SQLITE_GET_BRAND, + UPDATE_BRAND as SQLITE_UPDATE_BRAND, +} from '../../providers/sqlite3/queries/article.js'; import queryBySku from './queryBySku.js'; import resolveArticleBySku from './resolveArticleBySku.js'; +import makeRunner from '../runner.js'; -export default function setBrandIdBySku(pool) { - const resolve = resolveArticleBySku(pool); - const load = queryBySku(pool); +export default function setBrandIdBySku(provider) { + const resolve = resolveArticleBySku(provider); + const load = queryBySku(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + + const getBrandSql = isSqlite ? SQLITE_GET_BRAND : MSSQL_GET_BRAND; + const updateBrandSql = isSqlite ? SQLITE_UPDATE_BRAND : MSSQL_UPDATE_BRAND; /** * Sets (or clears) the brand of an article by its SKU. @@ -15,6 +24,7 @@ export default function setBrandIdBySku(pool) { * @param {number} googleAuthId - The id of the Google auth user. * @param {string} sku - The article SKU. * @param {number|null} brandId - The brand id, or `null` to clear the brand. + * @param {object} [tx] - Optional transaction. * @returns {Promise} The updated article. * @throws {Error} If the brand or article is not found. * @@ -22,10 +32,12 @@ export default function setBrandIdBySku(pool) { * const article = await store.article.setBrandIdBySku(42, 'SKU-1', 3); * // => { id, sku, name, ... } */ - return async (googleAuthId, sku, brandId) => { + return async (googleAuthId, sku, brandId, tx) => { + const runner = makeRunner(provider, tx); const { articleId, languageId, companyId } = await resolve( googleAuthId, sku, + runner, ); if (brandId !== null && !Number.isFinite(brandId)) { @@ -34,28 +46,26 @@ export default function setBrandIdBySku(pool) { let resolvedBrandId = null; if (brandId !== null) { - const brand = await pool - .request() - .input('brandId', brandId) - .input('companyId', companyId) - .query(GET_BRAND_BY_COMPANY_AND_ID); - if (!brand.recordset[0]) { + const brand = await runner.query(getBrandSql, { + brandId, + companyId, + }); + if (!brand.rows[0]) { throw new Error('Brand not found'); } resolvedBrandId = brandId; } - const result = await pool - .request() - .input('articleId', articleId) - .input('brandId', resolvedBrandId) - .query(UPDATE_BRAND); + const result = await runner.query(updateBrandSql, { + articleId, + brandId: resolvedBrandId, + }); - if (!result.rowsAffected[0]) { + if (!result.rowsAffected) { throw new Error('Article not found'); } - const article = await load(articleId, languageId); + const article = await load(articleId, languageId, runner); if (!article) { throw new Error('Article not found'); } @@ -63,3 +73,4 @@ export default function setBrandIdBySku(pool) { return article; }; } + diff --git a/src/store/article/setCategoryIdsBySku.js b/src/store/article/setCategoryIdsBySku.js index 155e724..4d6b5d8 100644 --- a/src/store/article/setCategoryIdsBySku.js +++ b/src/store/article/setCategoryIdsBySku.js @@ -1,16 +1,24 @@ import { - ADD_CATEGORY, - DELETE_CATEGORIES_BY_ARTICLE, + ADD_CATEGORY as MSSQL_ADD_CATEGORY, + DELETE_CATEGORIES_BY_ARTICLE as MSSQL_DELETE_CATEGORIES, } from '../../providers/mssql/queries/article.js'; -import request from '../request.js'; +import { + ADD_CATEGORY as SQLITE_ADD_CATEGORY, + DELETE_CATEGORIES_BY_ARTICLE as SQLITE_DELETE_CATEGORIES, +} from '../../providers/sqlite3/queries/article.js'; import resolveCategoryAccess from '../category/resolveCategoryAccess.js'; import queryBySku from './queryBySku.js'; import resolveArticleBySku from './resolveArticleBySku.js'; +import makeRunner from '../runner.js'; -export default function setCategoryIdsBySku(pool) { - const resolve = resolveArticleBySku(pool); - const categoryAccess = resolveCategoryAccess(pool); - const load = queryBySku(pool); +export default function setCategoryIdsBySku(provider) { + const resolve = resolveArticleBySku(provider); + const categoryAccess = resolveCategoryAccess(provider); + const load = queryBySku(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + + const addCategorySql = isSqlite ? SQLITE_ADD_CATEGORY : MSSQL_ADD_CATEGORY; + const deleteCategoriesSql = isSqlite ? SQLITE_DELETE_CATEGORIES : MSSQL_DELETE_CATEGORIES; /** * Replaces the category membership of an article by its SKU. @@ -18,6 +26,7 @@ export default function setCategoryIdsBySku(pool) { * @param {number} googleAuthId - The id of the Google auth user. * @param {string} sku - The article SKU. * @param {Array} categoryIds - The category ids the article should belong to (at least one). + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise} The updated article. * @throws {Error} If the category ids are invalid or the article is not found. * @@ -25,7 +34,7 @@ export default function setCategoryIdsBySku(pool) { * const article = await store.article.setCategoryIdsBySku(42, 'SKU-1', [5, 9]); * // => { id, sku, name, ... } */ - return async (googleAuthId, sku, categoryIds) => { + return async (googleAuthId, sku, categoryIds, tx) => { if (!Array.isArray(categoryIds)) { throw new Error('Invalid categoryIds'); } @@ -42,49 +51,45 @@ export default function setCategoryIdsBySku(pool) { throw new Error('Article must have at least one category'); } - const { articleId, languageId, companyId } = await resolve( - googleAuthId, - sku, - ); + const runSet = async (runner) => { + const { articleId, languageId, companyId } = await resolve( + googleAuthId, + sku, + runner, + ); - const tx = pool.transaction(); - await tx.begin(); - - try { for (const categoryId of normalized) { // Ensures the category belongs to the same company as the article. - const access = await categoryAccess(googleAuthId, categoryId); + const access = await categoryAccess(googleAuthId, categoryId, runner); if (access.companyId !== companyId) { throw new Error('Invalid categoryIds'); } } - await request(pool, tx) - .input('articleId', articleId) - .query(DELETE_CATEGORIES_BY_ARTICLE); + await runner.query(deleteCategoriesSql, { articleId }); for (const categoryId of normalized) { - await request(pool, tx) - .input('categoryId', categoryId) - .input('articleId', articleId) - .query(ADD_CATEGORY); + await runner.query(addCategorySql, { + categoryId, + articleId, + }); } - await tx.commit(); - } catch (err) { - try { - await tx.rollback(); - } catch { - // ignore + const article = await load(articleId, languageId, runner); + if (!article) { + throw new Error('Article not found'); } - throw err; - } - const article = await load(articleId, languageId); - if (!article) { - throw new Error('Article not found'); - } + return article; + }; - return article; + if (tx) { + return runSet(makeRunner(provider, tx)); + } + if (typeof provider?.transaction === 'function') { + return provider.transaction(runSet); + } + return runSet(makeRunner(provider)); }; } + diff --git a/src/store/article/setEansBySku.js b/src/store/article/setEansBySku.js index 8290371..ec21c1c 100644 --- a/src/store/article/setEansBySku.js +++ b/src/store/article/setEansBySku.js @@ -1,14 +1,22 @@ import { - ADD_EAN, - DELETE_EANS_BY_ARTICLE, + ADD_EAN as MSSQL_ADD_EAN, + DELETE_EANS_BY_ARTICLE as MSSQL_DELETE_EANS, } from '../../providers/mssql/queries/article.js'; -import request from '../request.js'; +import { + ADD_EAN as SQLITE_ADD_EAN, + DELETE_EANS_BY_ARTICLE as SQLITE_DELETE_EANS, +} from '../../providers/sqlite3/queries/article.js'; import queryBySku from './queryBySku.js'; import resolveArticleBySku from './resolveArticleBySku.js'; +import makeRunner from '../runner.js'; -export default function setEansBySku(pool) { - const resolve = resolveArticleBySku(pool); - const load = queryBySku(pool); +export default function setEansBySku(provider) { + const resolve = resolveArticleBySku(provider); + const load = queryBySku(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + + const addEanSql = isSqlite ? SQLITE_ADD_EAN : MSSQL_ADD_EAN; + const deleteEansSql = isSqlite ? SQLITE_DELETE_EANS : MSSQL_DELETE_EANS; /** * Replaces the EAN list of an article by its SKU. @@ -16,6 +24,7 @@ export default function setEansBySku(pool) { * @param {number} googleAuthId - The id of the Google auth user. * @param {string} sku - The article SKU. * @param {Array} eans - The EANs to set (must be unique, non-empty strings). + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise} The updated article. * @throws {Error} If the EANs are invalid or the article is not found. * @@ -23,7 +32,7 @@ export default function setEansBySku(pool) { * const article = await store.article.setEansBySku(42, 'SKU-1', ['4001234567890']); * // => { id, sku, name, ... } */ - return async (googleAuthId, sku, eans) => { + return async (googleAuthId, sku, eans, tx) => { if (!Array.isArray(eans)) { throw new Error('Invalid eans'); } @@ -41,38 +50,30 @@ export default function setEansBySku(pool) { normalized.push(clean); } - const { articleId, languageId } = await resolve(googleAuthId, sku); + const runSet = async (runner) => { + const { articleId, languageId } = await resolve(googleAuthId, sku, runner); - const tx = pool.transaction(); - await tx.begin(); - - try { - await request(pool, tx) - .input('articleId', articleId) - .query(DELETE_EANS_BY_ARTICLE); + await runner.query(deleteEansSql, { articleId }); for (const ean of normalized) { - await request(pool, tx) - .input('articleId', articleId) - .input('ean', ean) - .query(ADD_EAN); + await runner.query(addEanSql, { articleId, ean }); } - await tx.commit(); - } catch (err) { - try { - await tx.rollback(); - } catch { - // ignore + const article = await load(articleId, languageId, runner); + if (!article) { + throw new Error('Article not found'); } - throw err; - } - const article = await load(articleId, languageId); - if (!article) { - throw new Error('Article not found'); - } + return article; + }; - return article; + if (tx) { + return runSet(makeRunner(provider, tx)); + } + if (typeof provider?.transaction === 'function') { + return provider.transaction(runSet); + } + return runSet(makeRunner(provider)); }; } + diff --git a/src/store/article/setFatherBySku.js b/src/store/article/setFatherBySku.js index 5aa5a86..7fcd011 100644 --- a/src/store/article/setFatherBySku.js +++ b/src/store/article/setFatherBySku.js @@ -1,17 +1,18 @@ -import { - GET_ID_BY_SKU, - SET_FATHER_SORT, - SET_IS_FATHER, - UPDATE_FATHER_BY_SKU, -} from '../../providers/mssql/queries/article.js'; -import { GET_CHILD_COUNT } from '../../providers/mssql/queries/articleDelete.js'; -import request from '../request.js'; +import * as mssqlArticle from '../../providers/mssql/queries/article.js'; +import * as sqliteArticle from '../../providers/sqlite3/queries/article.js'; +import { GET_CHILD_COUNT as MSSQL_GET_CHILD_COUNT } from '../../providers/mssql/queries/articleDelete.js'; +import { GET_CHILD_COUNT as SQLITE_GET_CHILD_COUNT } from '../../providers/sqlite3/queries/articleDelete.js'; import queryBySku from './queryBySku.js'; import resolveArticleBySku from './resolveArticleBySku.js'; +import makeRunner from '../runner.js'; -export default function setFatherBySku(pool) { - const resolve = resolveArticleBySku(pool); - const load = queryBySku(pool); +export default function setFatherBySku(provider) { + const resolve = resolveArticleBySku(provider); + const load = queryBySku(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + + const queries = isSqlite ? sqliteArticle : mssqlArticle; + const getChildCountSql = isSqlite ? SQLITE_GET_CHILD_COUNT : MSSQL_GET_CHILD_COUNT; /** * Links an article to a father article (V/K compound) by their SKUs. @@ -19,6 +20,7 @@ export default function setFatherBySku(pool) { * @param {number} googleAuthId - The id of the Google auth user. * @param {string} sku - The child article SKU. * @param {string|null} fatherSku - The father article SKU, or `null` to unlink. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise} The updated child article. * @throws {Error} If the article or father is not found, or the link is invalid. * @@ -26,73 +28,67 @@ export default function setFatherBySku(pool) { * const article = await store.article.setFatherBySku(42, '42001190-1', '42001190'); * // => { id, sku, name, fatherSku: '42001190', ... } */ - return async (googleAuthId, sku, fatherSku) => { - const { articleId, languageId } = await resolve(googleAuthId, sku); + return async (googleAuthId, sku, fatherSku, tx) => { + const runSet = async (runner) => { + const { articleId, languageId } = await resolve(googleAuthId, sku, runner); - if (fatherSku == null || fatherSku === '') { - await request(pool) - .input('sku', sku) - .input('fatherArticleId', null) - .query(UPDATE_FATHER_BY_SKU); - const unlinked = await load(articleId, languageId); - if (!unlinked) { - throw new Error('Article not found'); + if (fatherSku == null || fatherSku === '') { + await runner.query(queries.UPDATE_FATHER_BY_SKU, { + sku, + fatherArticleId: null, + }); + const unlinked = await load(articleId, languageId, runner); + if (!unlinked) { + throw new Error('Article not found'); + } + return unlinked; } - return unlinked; - } - const father = await pool - .request() - .input('sku', fatherSku) - .query(GET_ID_BY_SKU); - const fatherId = father.recordset[0]?.articleId; - if (!fatherId) { - throw new Error('Father not found'); - } - if (fatherId === articleId) { - throw new Error('Invalid father'); - } + const father = await runner.query(queries.GET_ID_BY_SKU, { sku: fatherSku }); + const fatherId = father.rows[0]?.articleId; + if (!fatherId) { + throw new Error('Father not found'); + } + if (fatherId === articleId) { + throw new Error('Invalid father'); + } - const tx = pool.transaction(); - await tx.begin(); + await runner.query(queries.UPDATE_FATHER_BY_SKU, { + sku, + fatherArticleId: fatherId, + }); - try { - await request(pool, tx) - .input('sku', sku) - .input('fatherArticleId', fatherId) - .query(UPDATE_FATHER_BY_SKU); + await runner.query(queries.SET_IS_FATHER, { + articleId: fatherId, + }); - await request(pool, tx) - .input('articleId', fatherId) - .query(SET_IS_FATHER); - - const childCount = await request(pool, tx) - .input('articleId', fatherId) - .query(GET_CHILD_COUNT); - const count = childCount.recordset[0].count; + 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 request(pool, tx) - .input('articleId', articleId) - .input('fatherArticleId', fatherId) - .input('fathersort', count - 1) - .query(SET_FATHER_SORT); + await runner.query(queries.SET_FATHER_SORT, { + articleId, + fatherArticleId: fatherId, + fathersort: count - 1, + }); - await tx.commit(); - } catch (err) { - try { - await tx.rollback(); - } catch { - // ignore + const article = await load(articleId, languageId, runner); + if (!article) { + throw new Error('Article not found'); } - throw err; - } - const article = await load(articleId, languageId); - if (!article) { - throw new Error('Article not found'); - } + return article; + }; - return article; + if (tx) { + return runSet(makeRunner(provider, tx)); + } + if (typeof provider?.transaction === 'function') { + return provider.transaction(runSet); + } + return runSet(makeRunner(provider)); }; } + diff --git a/src/store/article/setPricesBySku.js b/src/store/article/setPricesBySku.js index 8407827..211d0c9 100644 --- a/src/store/article/setPricesBySku.js +++ b/src/store/article/setPricesBySku.js @@ -1,11 +1,15 @@ import { - CREATE as CREATE_PRICE, - DELETE_BY_ARTICLE_AND_CUSTOMERGROUP, + CREATE as MSSQL_CREATE_PRICE, + DELETE_BY_ARTICLE_AND_CUSTOMERGROUP as MSSQL_DELETE_PRICE, } from '../../providers/mssql/queries/price.js'; -import request from '../request.js'; +import { + CREATE as SQLITE_CREATE_PRICE, + DELETE_BY_ARTICLE_AND_CUSTOMERGROUP as SQLITE_DELETE_PRICE, +} from '../../providers/sqlite3/queries/price.js'; import taxEnsure from '../tax/ensure.js'; import queryBySku from './queryBySku.js'; import resolveArticleBySku from './resolveArticleBySku.js'; +import makeRunner from '../runner.js'; function normalizePrices(prices) { if (!Array.isArray(prices)) { @@ -42,10 +46,14 @@ function normalizePrices(prices) { return normalized; } -export default function setPricesBySku(pool) { - const resolve = resolveArticleBySku(pool); - const ensureTax = taxEnsure(pool); - const load = queryBySku(pool); +export default function setPricesBySku(provider) { + const resolve = resolveArticleBySku(provider); + const ensureTax = taxEnsure(provider); + const load = queryBySku(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + + const createPriceSql = isSqlite ? SQLITE_CREATE_PRICE : MSSQL_CREATE_PRICE; + const deletePriceSql = isSqlite ? SQLITE_DELETE_PRICE : MSSQL_DELETE_PRICE; /** * Replaces the price list of an article by its SKU. @@ -53,6 +61,7 @@ export default function setPricesBySku(pool) { * @param {number} googleAuthId - The id of the Google auth user. * @param {string} sku - The article SKU. * @param {Array<{price: number, tax: number, minmultiplier?: number}>} prices - The price entries (minmultiplier defaults to 1). + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise} The updated article. * @throws {Error} If the prices are invalid or the article is not found. * @@ -60,58 +69,56 @@ export default function setPricesBySku(pool) { * const article = await store.article.setPricesBySku(42, 'SKU-1', [{ price: 1.5, tax: 19, minmultiplier: 1 }]); * // => { id, sku, name, ... } */ - return async (googleAuthId, sku, prices) => { + return async (googleAuthId, sku, prices, tx) => { const normalized = normalizePrices(prices); - const { articleId, languageId, posCustomerGroupId } = await resolve( - googleAuthId, - sku, - ); - if (!posCustomerGroupId) { - throw new Error('POS customer group required'); - } + const runSet = async (runner) => { + const { articleId, languageId, posCustomerGroupId } = await resolve( + googleAuthId, + sku, + runner, + ); + if (!posCustomerGroupId) { + throw new Error('POS customer group required'); + } - const tx = pool.transaction(); - await tx.begin(); - - try { const taxIds = new Map(); for (const entry of normalized) { if (!taxIds.has(entry.tax)) { - taxIds.set(entry.tax, await ensureTax(entry.tax, tx)); + taxIds.set(entry.tax, await ensureTax(entry.tax, runner)); } } - await request(pool, tx) - .input('articleId', articleId) - .input('customerGroupId', posCustomerGroupId) - .query(DELETE_BY_ARTICLE_AND_CUSTOMERGROUP); + await runner.query(deletePriceSql, { + articleId, + customerGroupId: posCustomerGroupId, + }); for (const entry of normalized) { - await request(pool, tx) - .input('taxId', taxIds.get(entry.tax)) - .input('articleId', articleId) - .input('minmultiplier', entry.minmultiplier) - .input('customerGroupId', posCustomerGroupId) - .input('net', entry.price) - .query(CREATE_PRICE); + await runner.query(createPriceSql, { + taxId: taxIds.get(entry.tax), + articleId, + minmultiplier: entry.minmultiplier, + customerGroupId: posCustomerGroupId, + net: entry.price, + }); } - await tx.commit(); - } catch (err) { - try { - await tx.rollback(); - } catch { - // ignore + const article = await load(articleId, languageId, runner); + if (!article) { + throw new Error('Article not found'); } - throw err; - } - const article = await load(articleId, languageId); - if (!article) { - throw new Error('Article not found'); - } + return article; + }; - return article; + if (tx) { + return runSet(makeRunner(provider, tx)); + } + if (typeof provider?.transaction === 'function') { + return provider.transaction(runSet); + } + return runSet(makeRunner(provider)); }; } + diff --git a/src/store/article/setWeightBySku.js b/src/store/article/setWeightBySku.js index e31c066..ed47d5a 100644 --- a/src/store/article/setWeightBySku.js +++ b/src/store/article/setWeightBySku.js @@ -1,10 +1,13 @@ -import { UPDATE_WEIGHT } from '../../providers/mssql/queries/article.js'; +import { UPDATE_WEIGHT as MSSQL_UPDATE_WEIGHT } from '../../providers/mssql/queries/article.js'; +import { UPDATE_WEIGHT as SQLITE_UPDATE_WEIGHT } from '../../providers/sqlite3/queries/article.js'; import queryBySku from './queryBySku.js'; import resolveArticleBySku from './resolveArticleBySku.js'; +import makeRunner from '../runner.js'; -export default function setWeightBySku(pool) { - const resolve = resolveArticleBySku(pool); - const load = queryBySku(pool); +export default function setWeightBySku(provider) { + const resolve = resolveArticleBySku(provider); + const load = queryBySku(provider); + const sql = provider?.dialect === 'sqlite3' ? SQLITE_UPDATE_WEIGHT : MSSQL_UPDATE_WEIGHT; /** * Sets the weight of an article by its SKU. @@ -12,6 +15,7 @@ export default function setWeightBySku(pool) { * @param {number} googleAuthId - The id of the Google auth user. * @param {string} sku - The article SKU. * @param {number} weight - The weight (must be >= 0). + * @param {object} [tx] - Optional transaction. * @returns {Promise} The updated article. * @throws {Error} If the weight is invalid or the article is not found. * @@ -19,24 +23,24 @@ export default function setWeightBySku(pool) { * const article = await store.article.setWeightBySku(42, 'SKU-1', 1.5); * // => { id, sku, name, ... } */ - return async (googleAuthId, sku, weight) => { + return async (googleAuthId, sku, weight, tx) => { if (!Number.isFinite(weight) || weight < 0) { throw new Error('Invalid weight'); } - const { articleId, languageId } = await resolve(googleAuthId, sku); + const runner = makeRunner(provider, tx); + const { articleId, languageId } = await resolve(googleAuthId, sku, runner); - const result = await pool - .request() - .input('articleId', articleId) - .input('weight', weight) - .query(UPDATE_WEIGHT); + const result = await runner.query(sql, { + articleId, + weight, + }); - if (!result.rowsAffected[0]) { + if (!result.rowsAffected) { throw new Error('Article not found'); } - const article = await load(articleId, languageId); + const article = await load(articleId, languageId, runner); if (!article) { throw new Error('Article not found'); } @@ -44,3 +48,4 @@ export default function setWeightBySku(pool) { return article; }; } + diff --git a/src/store/article/updateBySku.js b/src/store/article/updateBySku.js index 697b7ad..79021af 100644 --- a/src/store/article/updateBySku.js +++ b/src/store/article/updateBySku.js @@ -1,15 +1,16 @@ -import { - UPDATE_UNIT, - UPDATE_UNITAMOUNT, - UPDATE_UNIT_AND_AMOUNT, -} from '../../providers/mssql/queries/article.js'; -import { INSERT as INSERT_HISTORY } from '../../providers/mssql/queries/articlehistory.js'; -import { UPDATE_BY_ARTICLE_AND_LANGUAGE } from '../../providers/mssql/queries/articletext.js'; -import { GET_DETAIL_BY_ARTICLE_ID } from '../../providers/mssql/queries/articleDetail.js'; -import { GET_FULL_BY_SI } from '../../providers/mssql/queries/unit.js'; -import request from '../request.js'; +import * as mssqlArticle from '../../providers/mssql/queries/article.js'; +import * as sqliteArticle from '../../providers/sqlite3/queries/article.js'; +import { INSERT as MSSQL_INSERT_HISTORY } from '../../providers/mssql/queries/articlehistory.js'; +import { INSERT as SQLITE_INSERT_HISTORY } from '../../providers/sqlite3/queries/articlehistory.js'; +import { UPDATE_BY_ARTICLE_AND_LANGUAGE as MSSQL_UPDATE_TEXT } from '../../providers/mssql/queries/articletext.js'; +import { UPDATE_BY_ARTICLE_AND_LANGUAGE as SQLITE_UPDATE_TEXT } from '../../providers/sqlite3/queries/articletext.js'; +import { GET_DETAIL_BY_ARTICLE_ID as MSSQL_GET_DETAIL } from '../../providers/mssql/queries/articleDetail.js'; +import { GET_DETAIL_BY_ARTICLE_ID as SQLITE_GET_DETAIL } from '../../providers/sqlite3/queries/articleDetail.js'; +import { GET_FULL_BY_SI as MSSQL_GET_UNIT } from '../../providers/mssql/queries/unit.js'; +import { GET_FULL_BY_SI as SQLITE_GET_UNIT } from '../../providers/sqlite3/queries/unit.js'; import queryBySku from './queryBySku.js'; import resolveArticleBySku from './resolveArticleBySku.js'; +import makeRunner from '../runner.js'; import slugify from '../slugify.js'; function formatUnitLabel(amount, symbol) { @@ -20,9 +21,16 @@ function formatUnitLabel(amount, symbol) { return `${Number(amount)}${sym}`; } -export default function updateBySku(pool) { - const resolve = resolveArticleBySku(pool); - const load = queryBySku(pool); +export default function updateBySku(provider) { + const resolve = resolveArticleBySku(provider); + const load = queryBySku(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + + const articleQueries = isSqlite ? sqliteArticle : mssqlArticle; + const insertHistorySql = isSqlite ? SQLITE_INSERT_HISTORY : MSSQL_INSERT_HISTORY; + const updateTextSql = isSqlite ? SQLITE_UPDATE_TEXT : MSSQL_UPDATE_TEXT; + const getDetailSql = isSqlite ? SQLITE_GET_DETAIL : MSSQL_GET_DETAIL; + const getUnitSql = isSqlite ? SQLITE_GET_UNIT : MSSQL_GET_UNIT; /** * Updates an article's name and/or unit (SI and amount), recording history. @@ -33,6 +41,7 @@ export default function updateBySku(pool) { * @param {string} [patch.name] - The new article name. * @param {string} [patch.unitSi] - The new unit SI code. * @param {number} [patch.unitamount] - The new unit amount (must be > 0). + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise} The updated article. * @throws {Error} If no valid field is provided or the article is not found. * @@ -40,7 +49,7 @@ export default function updateBySku(pool) { * const article = await store.article.updateBySku(42, 'SKU-1', { name: 'Cola 1L', unitSi: 'l', unitamount: 1 }); * // => { id, sku, name, ... } */ - return async (googleAuthId, sku, patch = {}) => { + return async (googleAuthId, sku, patch = {}, tx) => { const hasName = Object.prototype.hasOwnProperty.call(patch, 'name'); const hasUnitamount = Object.prototype.hasOwnProperty.call(patch, 'unitamount'); const hasUnitSi = Object.prototype.hasOwnProperty.call(patch, 'unitSi'); @@ -57,122 +66,118 @@ export default function updateBySku(pool) { throw new Error('Invalid unitamount'); } - const { articleId, languageId, companyId, companyuserId } = await resolve( - googleAuthId, - sku, - ); - - const currentResult = await pool - .request() - .input('articleId', articleId) - .input('languageId', languageId) - .query(GET_DETAIL_BY_ARTICLE_ID); - const current = currentResult.recordset[0]; - if (!current) { - throw new Error('Article not found'); - } - - let unitId; - let newUnitSymbol = current.unitSymbol ?? current.unitSi ?? ''; - if (hasUnitSi) { - if (!patch.unitSi?.trim()) { - throw new Error('Unit not found'); - } - const unit = await pool - .request() - .input('companyId', companyId) - .input('si', patch.unitSi.trim()) - .input('languageId', languageId) - .query(GET_FULL_BY_SI); - const unitRow = unit.recordset[0]; - unitId = unitRow?.id; - if (!unitId) { - throw new Error('Unit not found'); - } - newUnitSymbol = unitRow.symbol ?? unitRow.si ?? ''; - } - - const historyLines = []; - if (hasName) { - const oldName = current.name ?? ''; - const newName = patch.name.trim(); - if (oldName !== newName) { - historyLines.push(`Name geändert von "${oldName}" auf "${newName}"`); - } - } - if (hasUnitSi || hasUnitamount) { - const oldLabel = formatUnitLabel( - current.unitamount, - current.unitSymbol ?? current.unitSi ?? '', + const runUpdate = async (runner) => { + const { articleId, languageId, companyId, companyuserId } = await resolve( + googleAuthId, + sku, + runner, ); - const newLabel = formatUnitLabel( - hasUnitamount ? patch.unitamount : current.unitamount, - newUnitSymbol, - ); - if (oldLabel !== newLabel) { - historyLines.push(`Einheit geändert von "${oldLabel}" auf "${newLabel}"`); + + const currentResult = await runner.query(getDetailSql, { + articleId, + languageId, + }); + const current = currentResult.rows[0]; + if (!current) { + throw new Error('Article not found'); } - } - const tx = pool.transaction(); - await tx.begin(); + let unitId; + let newUnitSymbol = current.unitSymbol ?? current.unitSi ?? ''; + if (hasUnitSi) { + if (!patch.unitSi?.trim()) { + throw new Error('Unit not found'); + } + const unit = await runner.query(getUnitSql, { + companyId, + si: patch.unitSi.trim(), + languageId, + }); + const unitRow = unit.rows[0]; + unitId = unitRow?.id; + if (!unitId) { + throw new Error('Unit not found'); + } + newUnitSymbol = unitRow.symbol ?? unitRow.si ?? ''; + } + + const historyLines = []; + if (hasName) { + const oldName = current.name ?? ''; + const newName = patch.name.trim(); + if (oldName !== newName) { + historyLines.push(`Name geändert von "${oldName}" auf "${newName}"`); + } + } + if (hasUnitSi || hasUnitamount) { + const oldLabel = formatUnitLabel( + current.unitamount, + current.unitSymbol ?? current.unitSi ?? '', + ); + const newLabel = formatUnitLabel( + hasUnitamount ? patch.unitamount : current.unitamount, + newUnitSymbol, + ); + if (oldLabel !== newLabel) { + historyLines.push(`Einheit geändert von "${oldLabel}" auf "${newLabel}"`); + } + } - try { if (hasName) { const slug = slugify(patch.name); - const update = await request(pool, tx) - .input('articleId', articleId) - .input('languageId', languageId) - .input('name', patch.name.trim()) - .input('slug', slug) - .query(UPDATE_BY_ARTICLE_AND_LANGUAGE); + const update = await runner.query(updateTextSql, { + articleId, + languageId, + name: patch.name.trim(), + slug, + }); - if (!update.rowsAffected[0]) { + if (!update.rowsAffected) { throw new Error('Article not found'); } } if (hasUnitSi && hasUnitamount) { - await request(pool, tx) - .input('articleId', articleId) - .input('unitId', unitId) - .input('unitamount', patch.unitamount) - .query(UPDATE_UNIT_AND_AMOUNT); + await runner.query(articleQueries.UPDATE_UNIT_AND_AMOUNT, { + articleId, + unitId, + unitamount: patch.unitamount, + }); } else if (hasUnitSi) { - await request(pool, tx) - .input('articleId', articleId) - .input('unitId', unitId) - .query(UPDATE_UNIT); + await runner.query(articleQueries.UPDATE_UNIT, { + articleId, + unitId, + }); } else if (hasUnitamount) { - await request(pool, tx) - .input('articleId', articleId) - .input('unitamount', patch.unitamount) - .query(UPDATE_UNITAMOUNT); + await runner.query(articleQueries.UPDATE_UNITAMOUNT, { + articleId, + unitamount: patch.unitamount, + }); } if (historyLines.length) { - await request(pool, tx) - .input('articleId', articleId) - .input('companyuserId', companyuserId) - .input('text', historyLines.join('\n')) - .query(INSERT_HISTORY); + await runner.query(insertHistorySql, { + articleId, + companyuserId, + text: historyLines.join('\n'), + }); } - await tx.commit(); - } catch (err) { - try { - await tx.rollback(); - } catch { - // ignore + const article = await load(articleId, languageId, runner); + if (!article) { + throw new Error('Article not found'); } - throw err; - } - const article = await load(articleId, languageId); - if (!article) { - throw new Error('Article not found'); - } + return article; + }; - return article; + if (tx) { + return runUpdate(makeRunner(provider, tx)); + } + if (typeof provider?.transaction === 'function') { + return provider.transaction(runUpdate); + } + return runUpdate(makeRunner(provider)); }; } + diff --git a/src/store/articlesupplier/create.js b/src/store/articlesupplier/create.js index ce6bdcb..644c2e5 100644 --- a/src/store/articlesupplier/create.js +++ b/src/store/articlesupplier/create.js @@ -1,11 +1,7 @@ -import { - CHECK_EXISTS, - CREATE as CREATE_ARTICLE_SUPPLIER, - GET_BY_ID_AND_ARTICLE_ID, - GET_NEXT_SORT, -} from '../../providers/mssql/queries/articlesupplier.js'; -import { CREATE as CREATE_PRICE } from '../../providers/mssql/queries/price.js'; -import request from '../request.js'; +import * as mssqlQueries from '../../providers/mssql/queries/articlesupplier.js'; +import * as sqliteQueries from '../../providers/sqlite3/queries/articlesupplier.js'; +import { CREATE as MSSQL_CREATE_PRICE } from '../../providers/mssql/queries/price.js'; +import { CREATE as SQLITE_CREATE_PRICE } from '../../providers/sqlite3/queries/price.js'; import resolveArticleBySku from '../article/resolveArticleBySku.js'; import ensureLieferantenGroup from '../supplier/ensureLieferantenGroup.js'; import taxEnsure from '../tax/ensure.js'; @@ -15,10 +11,15 @@ import { resolveCompanyDefaultTax, shapeArticleSupplier, } from './helpers.js'; +import makeRunner from '../runner.js'; -export default function create(pool) { - const resolve = resolveArticleBySku(pool); - const ensureTax = taxEnsure(pool); +export default function create(provider) { + const resolve = resolveArticleBySku(provider); + const ensureTax = taxEnsure(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + + const queries = isSqlite ? sqliteQueries : mssqlQueries; + const createPriceSql = isSqlite ? SQLITE_CREATE_PRICE : MSSQL_CREATE_PRICE; /** * Assigns a supplier to an article, optionally creating a purchase price. @@ -34,6 +35,7 @@ export default function create(pool) { * @param {number|null} [input.netPrice] - Net purchase price. * @param {number} [input.taxId] - The tax id for the purchase price. * @param {number} [input.taxRate] - The tax rate in percent (used if taxId is not provided). + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise<{id: number, articleId: number, entityId: number, supplierName: string|null, sku: string|null, pu: number, stock: number|null, sort: number, priceId: number|null, price: {net: number, taxId: number, taxRate?: number}|null}>} The created assignment. * @throws {Error} If input is invalid, article or supplier is not found, or supplier is already assigned. * @@ -46,7 +48,7 @@ export default function create(pool) { * }); * // => { id, articleId, entityId, supplierName, sku, pu, stock, sort, priceId, price } */ - return async (googleAuthId, sku, input = {}) => { + return async (googleAuthId, sku, input = {}, tx) => { const entityId = input.entityId; if (entityId == null || !Number.isFinite(entityId)) { throw new Error('Supplier required'); @@ -79,30 +81,26 @@ export default function create(pool) { throw new Error('Invalid price'); } - const { articleId, companyId } = await resolve(googleAuthId, sku); + const runCreate = async (runner) => { + const { articleId, companyId } = await resolve(googleAuthId, sku, runner); - await assertSupplier(pool, null, companyId, entityId); + await assertSupplier(runner, companyId, entityId); - const existingCheck = await pool - .request() - .input('articleId', articleId) - .input('entityId', entityId) - .query(CHECK_EXISTS); + const existingCheck = await runner.query(queries.CHECK_EXISTS, { + articleId, + entityId, + }); - if (existingCheck.recordset[0]) { - throw new Error('Supplier already assigned'); - } + if (existingCheck.rows[0]) { + throw new Error('Supplier already assigned'); + } - const tx = pool.transaction(); - await tx.begin(); - - try { let sort = input.sort; if (sort === undefined || sort === null) { - const sortRes = await request(pool, tx) - .input('articleId', articleId) - .query(GET_NEXT_SORT); - sort = sortRes.recordset[0].sort; + const sortRes = await runner.query(queries.GET_NEXT_SORT, { + articleId, + }); + sort = sortRes.rows[0].sort; } let priceId = null; @@ -111,63 +109,60 @@ export default function create(pool) { let taxId = input.taxId; if (taxId == null || !Number.isFinite(taxId)) { if (input.taxRate != null && Number.isFinite(input.taxRate)) { - taxId = await ensureTax(input.taxRate, tx); + taxId = await ensureTax(input.taxRate, runner); } else { const defaultTaxId = await resolveCompanyDefaultTax( - pool, - tx, + runner, companyId, ); - taxId = defaultTaxId != null ? defaultTaxId : await ensureTax(0, tx); + taxId = defaultTaxId != null ? defaultTaxId : await ensureTax(0, runner); } } const customerGroupId = await ensureLieferantenGroup( - pool, + runner, companyId, - tx, ); - const priceRes = await request(pool, tx) - .input('taxId', taxId) - .input('articleId', articleId) - .input('minmultiplier', 1) - .input('customerGroupId', customerGroupId) - .input('net', netPrice) - .query(CREATE_PRICE); + const priceRes = await runner.query(createPriceSql, { + taxId, + articleId, + minmultiplier: 1, + customerGroupId, + net: netPrice, + }); - priceId = priceRes.recordset[0].id; + priceId = priceRes.rows[0]?.id ?? priceRes.insertId; } - const createdRes = await request(pool, tx) - .input('articleId', articleId) - .input('entityId', entityId) - .input('priceId', priceId) - .input('sku', supplierSku) - .input('stock', stock) - .input('pu', pu) - .input('sort', sort) - .query(CREATE_ARTICLE_SUPPLIER); + const createdRes = await runner.query(queries.CREATE, { + articleId, + entityId, + priceId, + sku: supplierSku, + stock, + pu, + sort, + }); - const createdId = createdRes.recordset[0].id; + const createdId = createdRes.rows[0]?.id ?? createdRes.insertId; - await tx.commit(); + const loadRes = await runner.query(queries.GET_BY_ID_AND_ARTICLE_ID, { + id: createdId, + articleId, + companyId, + }); - const loadRes = await pool - .request() - .input('id', createdId) - .input('articleId', articleId) - .input('companyId', companyId) - .query(GET_BY_ID_AND_ARTICLE_ID); + return shapeArticleSupplier(loadRes.rows[0]); + }; - return shapeArticleSupplier(loadRes.recordset[0]); - } catch (err) { - try { - await tx.rollback(); - } catch { - // ignore - } - throw err; + if (tx) { + return runCreate(makeRunner(provider, tx)); } + if (typeof provider?.transaction === 'function') { + return provider.transaction(runCreate); + } + return runCreate(makeRunner(provider)); }; } + diff --git a/src/store/articlesupplier/delete.js b/src/store/articlesupplier/delete.js index 6044fa1..87ab469 100644 --- a/src/store/articlesupplier/delete.js +++ b/src/store/articlesupplier/delete.js @@ -1,13 +1,12 @@ -import { - DELETE_BY_ID_AND_ARTICLE_ID, - DELETE_PRICE, - GET_BY_ID_AND_ARTICLE_ID, -} from '../../providers/mssql/queries/articlesupplier.js'; -import request from '../request.js'; +import * as mssqlQueries from '../../providers/mssql/queries/articlesupplier.js'; +import * as sqliteQueries from '../../providers/sqlite3/queries/articlesupplier.js'; import resolveArticleBySku from '../article/resolveArticleBySku.js'; +import makeRunner from '../runner.js'; -export default function del(pool) { - const resolve = resolveArticleBySku(pool); +export default function del(provider) { + const resolve = resolveArticleBySku(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + const queries = isSqlite ? sqliteQueries : mssqlQueries; /** * Deletes a supplier assignment and cleans up its associated purchase price. @@ -15,55 +14,52 @@ export default function del(pool) { * @param {number} googleAuthId - The id of the Google auth user. * @param {string} sku - The article SKU. * @param {number} id - The id of the articlesupplier record. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise} * @throws {Error} If the supplier assignment is not found. * * @example * await store.articlesupplier.delete(42, 'SKU-1', 12); */ - return async (googleAuthId, sku, id) => { + return async (googleAuthId, sku, id, tx) => { if (id == null || !Number.isFinite(id)) { throw new Error('Supplier assignment not found'); } - const { articleId, companyId } = await resolve(googleAuthId, sku); + const runDelete = async (runner) => { + const { articleId, companyId } = await resolve(googleAuthId, sku, runner); - const existingRes = await pool - .request() - .input('id', id) - .input('articleId', articleId) - .input('companyId', companyId) - .query(GET_BY_ID_AND_ARTICLE_ID); + const existingRes = await runner.query(queries.GET_BY_ID_AND_ARTICLE_ID, { + id, + articleId, + companyId, + }); - const existing = existingRes.recordset[0]; - if (!existing) { - throw new Error('Supplier assignment not found'); - } + const existing = existingRes.rows[0]; + if (!existing) { + throw new Error('Supplier assignment not found'); + } - const tx = pool.transaction(); - await tx.begin(); - - try { - await request(pool, tx) - .input('id', id) - .input('articleId', articleId) - .query(DELETE_BY_ID_AND_ARTICLE_ID); + await runner.query(queries.DELETE_BY_ID_AND_ARTICLE_ID, { + id, + articleId, + }); if (existing.priceId != null) { - await request(pool, tx) - .input('id', existing.priceId) - .input('articleId', articleId) - .query(DELETE_PRICE); + await runner.query(queries.DELETE_PRICE, { + id: existing.priceId, + articleId, + }); } + }; - await tx.commit(); - } catch (err) { - try { - await tx.rollback(); - } catch { - // ignore - } - throw err; + if (tx) { + return runDelete(makeRunner(provider, tx)); } + if (typeof provider?.transaction === 'function') { + return provider.transaction(runDelete); + } + return runDelete(makeRunner(provider)); }; } + diff --git a/src/store/articlesupplier/helpers.js b/src/store/articlesupplier/helpers.js index 09f2fb4..628d042 100644 --- a/src/store/articlesupplier/helpers.js +++ b/src/store/articlesupplier/helpers.js @@ -1,6 +1,14 @@ -import { GET_SUPPLIER_BY_ID, SUPPLIER_GROUP_NAME } from '../../providers/mssql/queries/entity.js'; -import { GET_COMPANY_DEFAULT_TAX } from '../../providers/mssql/queries/articlesupplier.js'; -import request from '../request.js'; +import { + GET_SUPPLIER_BY_ID as MSSQL_GET_SUPPLIER_BY_ID, + SUPPLIER_GROUP_NAME as MSSQL_SUPPLIER_GROUP_NAME, +} from '../../providers/mssql/queries/entity.js'; +import { + GET_SUPPLIER_BY_ID as SQLITE_GET_SUPPLIER_BY_ID, + SUPPLIER_GROUP_NAME as SQLITE_SUPPLIER_GROUP_NAME, +} from '../../providers/sqlite3/queries/entity.js'; +import { GET_COMPANY_DEFAULT_TAX as MSSQL_DEFAULT_TAX } from '../../providers/mssql/queries/articlesupplier.js'; +import { GET_COMPANY_DEFAULT_TAX as SQLITE_DEFAULT_TAX } from '../../providers/sqlite3/queries/articlesupplier.js'; +import makeRunner from '../runner.js'; export function optionalString(value) { if (value == null || value === '') return null; @@ -30,26 +38,58 @@ export function shapeArticleSupplier(row) { }; } -export async function assertSupplier(pool, tx, companyId, supplierId) { +export async function assertSupplier(runnerOrPool, txOrCompanyId, maybeCompanyId, maybeSupplierId) { + let runner; + let companyId; + let supplierId; + + if (maybeSupplierId !== undefined) { + runner = makeRunner(runnerOrPool, txOrCompanyId); + companyId = maybeCompanyId; + supplierId = maybeSupplierId; + } else { + runner = makeRunner(runnerOrPool); + companyId = txOrCompanyId; + supplierId = maybeCompanyId; + } + if (supplierId == null || !Number.isFinite(supplierId)) { throw new Error('Supplier not found'); } - const result = await request(pool, tx) - .input('id', supplierId) - .input('companyId', companyId) - .input('groupName', SUPPLIER_GROUP_NAME) - .query(GET_SUPPLIER_BY_ID); + const isSqlite = runner?.dialect === 'sqlite3'; + const getSupplierSql = isSqlite ? SQLITE_GET_SUPPLIER_BY_ID : MSSQL_GET_SUPPLIER_BY_ID; + const groupName = isSqlite ? SQLITE_SUPPLIER_GROUP_NAME : MSSQL_SUPPLIER_GROUP_NAME; - if (!result.recordset[0]) { + const result = await runner.query(getSupplierSql, { + id: supplierId, + companyId, + groupName, + }); + + if (!result.rows[0]) { throw new Error('Supplier not found'); } return supplierId; } -export async function resolveCompanyDefaultTax(pool, tx, companyId) { - const result = await request(pool, tx) - .input('companyId', companyId) - .query(GET_COMPANY_DEFAULT_TAX); - return result.recordset[0]?.taxrate ?? null; +export async function resolveCompanyDefaultTax(runnerOrPool, txOrCompanyId, maybeCompanyId) { + let runner; + let companyId; + + if (maybeCompanyId !== undefined) { + runner = makeRunner(runnerOrPool, txOrCompanyId); + companyId = maybeCompanyId; + } else { + runner = makeRunner(runnerOrPool); + companyId = txOrCompanyId; + } + + const isSqlite = runner?.dialect === 'sqlite3'; + const defaultTaxSql = isSqlite ? SQLITE_DEFAULT_TAX : MSSQL_DEFAULT_TAX; + + const result = await runner.query(defaultTaxSql, { + companyId, + }); + return result.rows[0]?.taxrate ?? null; } diff --git a/src/store/articlesupplier/listByArticleSku.js b/src/store/articlesupplier/listByArticleSku.js index 76fe44b..3065ec8 100644 --- a/src/store/articlesupplier/listByArticleSku.js +++ b/src/store/articlesupplier/listByArticleSku.js @@ -1,15 +1,19 @@ -import { LIST_BY_ARTICLE_ID } from '../../providers/mssql/queries/articlesupplier.js'; +import { LIST_BY_ARTICLE_ID as MSSQL_QUERY } from '../../providers/mssql/queries/articlesupplier.js'; +import { LIST_BY_ARTICLE_ID as SQLITE_QUERY } from '../../providers/sqlite3/queries/articlesupplier.js'; import resolveArticleBySku from '../article/resolveArticleBySku.js'; import { shapeArticleSupplier } from './helpers.js'; +import makeRunner from '../runner.js'; -export default function listByArticleSku(pool) { - const resolve = resolveArticleBySku(pool); +export default function listByArticleSku(provider) { + const resolve = resolveArticleBySku(provider); + const sql = provider?.dialect === 'sqlite3' ? SQLITE_QUERY : MSSQL_QUERY; /** * Lists the supplier assignments for an article by its SKU. * * @param {number} googleAuthId - The id of the Google auth user. * @param {string} sku - The article SKU. + * @param {object} [tx] - Optional transaction. * @returns {Promise>} The supplier assignments. * @throws {Error} If the article is not found or the user is not authorized. * @@ -17,15 +21,16 @@ export default function listByArticleSku(pool) { * const suppliers = await store.articlesupplier.listByArticleSku(42, 'SKU-1'); * // => [{ id, articleId, entityId, supplierName, sku, pu, stock, priceId, price }] */ - return async (googleAuthId, sku) => { - const { articleId, companyId } = await resolve(googleAuthId, sku); + return async (googleAuthId, sku, tx) => { + const runner = makeRunner(provider, tx); + const { articleId, companyId } = await resolve(googleAuthId, sku, runner); - const result = await pool - .request() - .input('articleId', articleId) - .input('companyId', companyId) - .query(LIST_BY_ARTICLE_ID); + const result = await runner.query(sql, { + articleId, + companyId, + }); - return result.recordset.map(shapeArticleSupplier); + return result.rows.map(shapeArticleSupplier); }; } + diff --git a/src/store/articlesupplier/listByEntityAndSkus.js b/src/store/articlesupplier/listByEntityAndSkus.js index 993e0d2..9bc51a9 100644 --- a/src/store/articlesupplier/listByEntityAndSkus.js +++ b/src/store/articlesupplier/listByEntityAndSkus.js @@ -1,6 +1,8 @@ -import { LIST_BY_ENTITY_AND_SKUS } from '../../providers/mssql/queries/articlesupplier.js'; +import { LIST_BY_ENTITY_AND_SKUS as MSSQL_QUERY } from '../../providers/mssql/queries/articlesupplier.js'; +import { LIST_BY_ENTITY_AND_SKUS as SQLITE_QUERY } from '../../providers/sqlite3/queries/articlesupplier.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import { assertSupplier } from './helpers.js'; +import makeRunner from '../runner.js'; function parseJsonArray(raw) { if (!raw) return []; @@ -12,8 +14,9 @@ function parseJsonArray(raw) { } } -export default function listByEntityAndSkus(pool) { - const resolveAccess = resolveSelectedCompanyAccess(pool); +export default function listByEntityAndSkus(provider) { + const resolveAccess = resolveSelectedCompanyAccess(provider); + const sql = provider?.dialect === 'sqlite3' ? SQLITE_QUERY : MSSQL_QUERY; /** * Lists article-supplier assignments for a given supplier (entityId) and SKU list, @@ -22,6 +25,7 @@ export default function listByEntityAndSkus(pool) { * @param {number} googleAuthId - The id of the Google auth user. * @param {number|{entityId?: number, supplierId?: number, skus?: string[]|string}} entityIdOrOptions - The supplier entity id or an options object. * @param {string[]|string} [maybeSkus] - The list of SKUs to filter by (or single SKU string). + * @param {object} [tx] - Optional transaction. * @returns {Promise, articlepic: Array<{sort: number, thumbhash: string, hash: string, thumbw: number, thumbh: number}>, eans: string[], articleean: string[]}}>>} The supplier assignments with article details. * @throws {Error} If user is not authorized, entityId is missing, or supplier does not exist. * @@ -29,7 +33,7 @@ export default function listByEntityAndSkus(pool) { * const items = await store.articlesupplier.listByEntityAndSkus(42, 5, ['SKU-1', 'SKU-2']); * // => [{ id, articleId, entityId, supplierName, sku, pu, stock, sort, price, article: { id, sku, name, slug, photos, eans } }] */ - return async (googleAuthId, entityIdOrOptions, maybeSkus) => { + return async (googleAuthId, entityIdOrOptions, maybeSkus, tx) => { let entityId; let skus; @@ -68,19 +72,19 @@ export default function listByEntityAndSkus(pool) { skusJson = JSON.stringify(Array.from(new Set(cleanList))); } - const { companyId, languageId } = await resolveAccess(googleAuthId); + const runner = makeRunner(provider, tx); + const { companyId, languageId } = await resolveAccess(googleAuthId, runner); - await assertSupplier(pool, null, companyId, parsedEntityId); + await assertSupplier(runner, companyId, parsedEntityId); - const result = await pool - .request() - .input('entityId', parsedEntityId) - .input('companyId', companyId) - .input('languageId', languageId) - .input('skusJson', skusJson) - .query(LIST_BY_ENTITY_AND_SKUS); + const result = await runner.query(sql, { + entityId: parsedEntityId, + companyId, + languageId, + skusJson, + }); - return result.recordset.map((row) => { + return result.rows.map((row) => { const rawPhotos = parseJsonArray(row.photosJson); const photos = rawPhotos.map((p) => ({ sort: p.sort, @@ -128,3 +132,4 @@ export default function listByEntityAndSkus(pool) { }); }; } + diff --git a/src/store/articlesupplier/order.js b/src/store/articlesupplier/order.js index 2efea5a..9b02b2f 100644 --- a/src/store/articlesupplier/order.js +++ b/src/store/articlesupplier/order.js @@ -1,13 +1,12 @@ -import { - LIST_IDS_BY_ARTICLE, - SET_TEMP_SORTS, - UPDATE_SORT, -} from '../../providers/mssql/queries/articlesupplier.js'; -import request from '../request.js'; +import * as mssqlQueries from '../../providers/mssql/queries/articlesupplier.js'; +import * as sqliteQueries from '../../providers/sqlite3/queries/articlesupplier.js'; import resolveArticleBySku from '../article/resolveArticleBySku.js'; +import makeRunner from '../runner.js'; -export default function order(pool) { - const resolve = resolveArticleBySku(pool); +export default function order(provider) { + const resolve = resolveArticleBySku(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + const queries = isSqlite ? sqliteQueries : mssqlQueries; /** * Reorders the supplier assignments of an article by its SKU. @@ -16,6 +15,7 @@ export default function order(pool) { * @param {string} sku - The article SKU. * @param {object} input - The reorder input. * @param {Array} input.ids - The articlesupplier ids in their new order. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise<{ids: Array}>} The applied order. * @throws {Error} If the order is invalid. * @@ -23,62 +23,58 @@ export default function order(pool) { * const result = await store.articlesupplier.order(42, 'SKU-1', { ids: [12, 10, 15] }); * // => { ids: [12, 10, 15] } */ - return async (googleAuthId, sku, { ids } = {}) => { + return async (googleAuthId, sku, { ids } = {}, tx) => { if (!Array.isArray(ids)) { throw new Error('Invalid supplier order'); } - const { articleId } = await resolve(googleAuthId, sku); + const runOrder = async (runner) => { + const { articleId } = await resolve(googleAuthId, sku, runner); - const current = await pool - .request() - .input('articleId', articleId) - .query(LIST_IDS_BY_ARTICLE); + const current = await runner.query(queries.LIST_IDS_BY_ARTICLE, { + articleId, + }); - const existing = new Set(current.recordset.map((r) => r.id)); + const existing = new Set(current.rows.map((r) => r.id)); - if (ids.length !== existing.size) { - throw new Error('Invalid supplier order'); - } - - const seen = new Set(); - for (const id of ids) { - if (!Number.isFinite(id) || !existing.has(id) || seen.has(id)) { + if (ids.length !== existing.size) { throw new Error('Invalid supplier order'); } - seen.add(id); - } - const tx = pool.transaction(); - await tx.begin(); + const seen = new Set(); + for (const id of ids) { + if (!Number.isFinite(id) || !existing.has(id) || seen.has(id)) { + throw new Error('Invalid supplier order'); + } + seen.add(id); + } - try { - await request(pool, tx) - .input('articleId', articleId) - .query(SET_TEMP_SORTS); + await runner.query(queries.SET_TEMP_SORTS, { + articleId, + }); for (let sort = 0; sort < ids.length; sort++) { - const updateRes = await request(pool, tx) - .input('id', ids[sort]) - .input('articleId', articleId) - .input('sort', sort) - .query(UPDATE_SORT); + const updateRes = await runner.query(queries.UPDATE_SORT, { + id: ids[sort], + articleId, + sort, + }); - if (!updateRes.rowsAffected[0]) { + if (!updateRes.rowsAffected) { throw new Error('Invalid supplier order'); } } - await tx.commit(); - return { ids }; - } catch (err) { - try { - await tx.rollback(); - } catch { - // ignore - } - throw err; + }; + + if (tx) { + return runOrder(makeRunner(provider, tx)); } + if (typeof provider?.transaction === 'function') { + return provider.transaction(runOrder); + } + return runOrder(makeRunner(provider)); }; } + diff --git a/src/store/articlesupplier/update.js b/src/store/articlesupplier/update.js index 41d19e0..c6c069e 100644 --- a/src/store/articlesupplier/update.js +++ b/src/store/articlesupplier/update.js @@ -1,11 +1,7 @@ -import { - DELETE_PRICE, - GET_BY_ID_AND_ARTICLE_ID, - UPDATE as UPDATE_ARTICLE_SUPPLIER, - UPDATE_PRICE, -} from '../../providers/mssql/queries/articlesupplier.js'; -import { CREATE as CREATE_PRICE } from '../../providers/mssql/queries/price.js'; -import request from '../request.js'; +import * as mssqlQueries from '../../providers/mssql/queries/articlesupplier.js'; +import * as sqliteQueries from '../../providers/sqlite3/queries/articlesupplier.js'; +import { CREATE as MSSQL_CREATE_PRICE } from '../../providers/mssql/queries/price.js'; +import { CREATE as SQLITE_CREATE_PRICE } from '../../providers/sqlite3/queries/price.js'; import resolveArticleBySku from '../article/resolveArticleBySku.js'; import ensureLieferantenGroup from '../supplier/ensureLieferantenGroup.js'; import taxEnsure from '../tax/ensure.js'; @@ -14,10 +10,15 @@ import { resolveCompanyDefaultTax, shapeArticleSupplier, } from './helpers.js'; +import makeRunner from '../runner.js'; -export default function update(pool) { - const resolve = resolveArticleBySku(pool); - const ensureTax = taxEnsure(pool); +export default function update(provider) { + const resolve = resolveArticleBySku(provider); + const ensureTax = taxEnsure(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + + const queries = isSqlite ? sqliteQueries : mssqlQueries; + const createPriceSql = isSqlite ? SQLITE_CREATE_PRICE : MSSQL_CREATE_PRICE; /** * Updates an existing supplier assignment and its associated purchase price. @@ -33,6 +34,7 @@ export default function update(pool) { * @param {number|null} [patch.netPrice] - Net purchase price. * @param {number} [patch.taxId] - The tax id for the purchase price. * @param {number} [patch.taxRate] - The tax rate in percent. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise<{id: number, articleId: number, entityId: number, supplierName: string|null, sku: string|null, pu: number, stock: number|null, sort: number, priceId: number|null, price: {net: number, taxId: number, taxRate?: number}|null}>} The updated assignment. * @throws {Error} If input is invalid or supplier assignment is not found. * @@ -44,7 +46,7 @@ export default function update(pool) { * }); * // => { id, articleId, entityId, supplierName, sku, pu, stock, sort, priceId, price } */ - return async (googleAuthId, sku, id, patch = {}) => { + return async (googleAuthId, sku, id, patch = {}, tx) => { if (id == null || !Number.isFinite(id)) { throw new Error('Supplier assignment not found'); } @@ -73,24 +75,20 @@ export default function update(pool) { } } - const { articleId, companyId } = await resolve(googleAuthId, sku); + const runUpdate = async (runner) => { + const { articleId, companyId } = await resolve(googleAuthId, sku, runner); - const existingRes = await pool - .request() - .input('id', id) - .input('articleId', articleId) - .input('companyId', companyId) - .query(GET_BY_ID_AND_ARTICLE_ID); + const existingRes = await runner.query(queries.GET_BY_ID_AND_ARTICLE_ID, { + id, + articleId, + companyId, + }); - const existing = existingRes.recordset[0]; - if (!existing) { - throw new Error('Supplier assignment not found'); - } + const existing = existingRes.rows[0]; + if (!existing) { + throw new Error('Supplier assignment not found'); + } - const tx = pool.transaction(); - await tx.begin(); - - try { let priceId = existing.priceId; if (patch.netPrice !== undefined) { @@ -99,20 +97,20 @@ export default function update(pool) { const oldPriceId = priceId; priceId = null; - await request(pool, tx) - .input('id', id) - .input('articleId', articleId) - .input('sku', existing.sku) - .input('stock', existing.stock) - .input('pu', existing.pu) - .input('sort', existing.sort) - .input('priceId', null) - .query(UPDATE_ARTICLE_SUPPLIER); + await runner.query(queries.UPDATE, { + id, + articleId, + sku: existing.sku, + stock: existing.stock, + pu: existing.pu, + sort: existing.sort, + priceId: null, + }); - await request(pool, tx) - .input('id', oldPriceId) - .input('articleId', articleId) - .query(DELETE_PRICE); + await runner.query(queries.DELETE_PRICE, { + id: oldPriceId, + articleId, + }); } } else { // netPrice is a valid number @@ -120,49 +118,47 @@ export default function update(pool) { let taxId = patch.taxId; if (taxId == null || !Number.isFinite(taxId)) { if (patch.taxRate != null && Number.isFinite(patch.taxRate)) { - taxId = await ensureTax(patch.taxRate, tx); + taxId = await ensureTax(patch.taxRate, runner); } else { taxId = existing.priceTaxId; } } - await request(pool, tx) - .input('id', priceId) - .input('articleId', articleId) - .input('net', patch.netPrice) - .input('taxId', taxId) - .query(UPDATE_PRICE); + await runner.query(queries.UPDATE_PRICE, { + id: priceId, + articleId, + net: patch.netPrice, + taxId, + }); } else { // create new price row let taxId = patch.taxId; if (taxId == null || !Number.isFinite(taxId)) { if (patch.taxRate != null && Number.isFinite(patch.taxRate)) { - taxId = await ensureTax(patch.taxRate, tx); + taxId = await ensureTax(patch.taxRate, runner); } else { const defaultTaxId = await resolveCompanyDefaultTax( - pool, - tx, + runner, companyId, ); - taxId = defaultTaxId != null ? defaultTaxId : await ensureTax(0, tx); + taxId = defaultTaxId != null ? defaultTaxId : await ensureTax(0, runner); } } const customerGroupId = await ensureLieferantenGroup( - pool, + runner, companyId, - tx, ); - const priceRes = await request(pool, tx) - .input('taxId', taxId) - .input('articleId', articleId) - .input('minmultiplier', 1) - .input('customerGroupId', customerGroupId) - .input('net', patch.netPrice) - .query(CREATE_PRICE); + const priceRes = await runner.query(createPriceSql, { + taxId, + articleId, + minmultiplier: 1, + customerGroupId, + net: patch.netPrice, + }); - priceId = priceRes.recordset[0].id; + priceId = priceRes.rows[0]?.id ?? priceRes.insertId; } } } @@ -178,33 +174,32 @@ export default function update(pool) { const nextPu = patch.pu !== undefined ? patch.pu : existing.pu; const nextSort = patch.sort !== undefined ? patch.sort : existing.sort; - await request(pool, tx) - .input('id', id) - .input('articleId', articleId) - .input('sku', nextSku) - .input('stock', nextStock) - .input('pu', nextPu) - .input('sort', nextSort) - .input('priceId', priceId) - .query(UPDATE_ARTICLE_SUPPLIER); + await runner.query(queries.UPDATE, { + id, + articleId, + sku: nextSku, + stock: nextStock, + pu: nextPu, + sort: nextSort, + priceId, + }); - await tx.commit(); + const loadRes = await runner.query(queries.GET_BY_ID_AND_ARTICLE_ID, { + id, + articleId, + companyId, + }); - const loadRes = await pool - .request() - .input('id', id) - .input('articleId', articleId) - .input('companyId', companyId) - .query(GET_BY_ID_AND_ARTICLE_ID); + return shapeArticleSupplier(loadRes.rows[0]); + }; - return shapeArticleSupplier(loadRes.recordset[0]); - } catch (err) { - try { - await tx.rollback(); - } catch { - // ignore - } - throw err; + if (tx) { + return runUpdate(makeRunner(provider, tx)); } + if (typeof provider?.transaction === 'function') { + return provider.transaction(runUpdate); + } + return runUpdate(makeRunner(provider)); }; } + diff --git a/src/store/brand/delete.js b/src/store/brand/delete.js index 0a07aa6..692a8ba 100644 --- a/src/store/brand/delete.js +++ b/src/store/brand/delete.js @@ -1,39 +1,44 @@ -import { DELETE_BY_COMPANY_AND_ID } from '../../providers/mssql/queries/brand.js'; +import { DELETE_BY_COMPANY_AND_ID as MSSQL_DELETE } from '../../providers/mssql/queries/brand.js'; +import { DELETE_BY_COMPANY_AND_ID as SQLITE_DELETE } from '../../providers/sqlite3/queries/brand.js'; +import { isForeignKeyConstraintError } from '../../providers/provider.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; +import makeRunner from '../runner.js'; -export default function del(pool) { - const access = resolveSelectedCompanyAccess(pool); +export default function del(provider) { + const access = resolveSelectedCompanyAccess(provider); + const sql = provider?.dialect === 'sqlite3' ? SQLITE_DELETE : MSSQL_DELETE; /** * Deletes a brand for the user's selected company by its id. * * @param {number} googleAuthId - The id of the Google auth user. * @param {number} id - The id of the brand to delete. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise} * @throws {Error} If the brand is not found. * * @example * await store.brand.delete(42, 3); */ - return async (googleAuthId, id) => { + return async (googleAuthId, id, tx) => { if (!Number.isFinite(id)) { throw new Error('Brand not found'); } - const { companyId } = await access(googleAuthId); + const runner = makeRunner(provider, tx); + const { companyId } = await access(googleAuthId, tx); try { - const result = await pool - .request() - .input('id', id) - .input('companyId', companyId) - .query(DELETE_BY_COMPANY_AND_ID); + const result = await runner.query(sql, { + id, + companyId, + }); - if (!result.rowsAffected[0]) { + if (!result.rowsAffected) { throw new Error('Brand not found'); } } catch (err) { - if (err.number === 547) { + if (isForeignKeyConstraintError(err)) { throw new Error('Brand in use'); } throw err; diff --git a/src/store/brand/list.js b/src/store/brand/list.js index fe0a60e..1f0f244 100644 --- a/src/store/brand/list.js +++ b/src/store/brand/list.js @@ -1,27 +1,31 @@ -import { LIST_BY_COMPANY } from '../../providers/mssql/queries/brand.js'; +import { LIST_BY_COMPANY as MSSQL_LIST_BY_COMPANY } from '../../providers/mssql/queries/brand.js'; +import { LIST_BY_COMPANY as SQLITE_LIST_BY_COMPANY } from '../../providers/sqlite3/queries/brand.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; +import makeRunner from '../runner.js'; -export default function list(pool) { - const access = resolveSelectedCompanyAccess(pool); +export default function list(provider) { + const access = resolveSelectedCompanyAccess(provider); + const sql = + provider?.dialect === 'sqlite3' + ? SQLITE_LIST_BY_COMPANY + : MSSQL_LIST_BY_COMPANY; /** * Lists the brands of the user's selected company. * * @param {number} googleAuthId - The id of the Google auth user. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise>} The brands. * * @example * const brands = await store.brand.list(42); * // => [{ id, name, gsprpinfo }] */ - return async (googleAuthId) => { - const { companyId } = await access(googleAuthId); + return async (googleAuthId, tx) => { + const { companyId } = await access(googleAuthId, tx); - const result = await pool - .request() - .input('companyId', companyId) - .query(LIST_BY_COMPANY); + const result = await makeRunner(provider, tx).query(sql, { companyId }); - return result.recordset; + return result.rows; }; } diff --git a/src/store/brand/upsert.js b/src/store/brand/upsert.js index de7a47f..5893271 100644 --- a/src/store/brand/upsert.js +++ b/src/store/brand/upsert.js @@ -1,12 +1,24 @@ import { - CREATE, - GET_BY_COMPANY_AND_NAME, - UPDATE_BY_ID, + CREATE as MSSQL_CREATE, + GET_BY_COMPANY_AND_NAME as MSSQL_GET_BY_COMPANY_AND_NAME, + UPDATE_BY_ID as MSSQL_UPDATE_BY_ID, } from '../../providers/mssql/queries/brand.js'; +import { + CREATE as SQLITE_CREATE, + GET_BY_COMPANY_AND_NAME as SQLITE_GET_BY_COMPANY_AND_NAME, + UPDATE_BY_ID as SQLITE_UPDATE_BY_ID, +} from '../../providers/sqlite3/queries/brand.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; +import makeRunner from '../runner.js'; -export default function upsert(pool) { - const access = resolveSelectedCompanyAccess(pool); +export default function upsert(provider) { + const access = resolveSelectedCompanyAccess(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + const getSql = isSqlite + ? SQLITE_GET_BY_COMPANY_AND_NAME + : MSSQL_GET_BY_COMPANY_AND_NAME; + const updateSql = isSqlite ? SQLITE_UPDATE_BY_ID : MSSQL_UPDATE_BY_ID; + const createSql = isSqlite ? SQLITE_CREATE : MSSQL_CREATE; /** * Creates or updates a brand for the user's selected company, keyed by name. @@ -15,6 +27,7 @@ export default function upsert(pool) { * @param {object} input - The brand input. * @param {string} input.name - The brand name. * @param {string} [input.gsprpinfo] - Optional GSPRP info. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise<{id: number, name: string, gsprpinfo: string|null}>} The upserted brand. * @throws {Error} If the brand is invalid. * @@ -22,42 +35,42 @@ export default function upsert(pool) { * const brand = await store.brand.upsert(42, { name: 'Coca-Cola', gsprpinfo: 'x' }); * // => { id, name, gsprpinfo } */ - return async (googleAuthId, input = {}) => { + return async (googleAuthId, input = {}, tx) => { const name = input.name?.trim(); if (!name) { throw new Error('Invalid brand'); } const gsprpinfo = input.gsprpinfo ?? null; - const { companyId } = await access(googleAuthId); + const runner = makeRunner(provider, tx); + const { companyId } = await access(googleAuthId, tx); - const existing = await pool - .request() - .input('companyId', companyId) - .input('name', name) - .query(GET_BY_COMPANY_AND_NAME); + const existing = await runner.query(getSql, { + companyId, + name, + }); - if (existing.recordset[0]) { - const { id } = existing.recordset[0]; - await pool - .request() - .input('id', id) - .input('companyId', companyId) - .input('name', name) - .input('gsprpinfo', gsprpinfo) - .query(UPDATE_BY_ID); + if (existing.rows[0]) { + const { id } = existing.rows[0]; + await runner.query(updateSql, { + id, + companyId, + name, + gsprpinfo, + }); return { id, name, gsprpinfo }; } - const created = await pool - .request() - .input('companyId', companyId) - .input('name', name) - .input('gsprpinfo', gsprpinfo) - .query(CREATE); + const created = await runner.query(createSql, { + companyId, + name, + gsprpinfo, + }); + + const id = created.rows[0]?.id ?? created.insertId; return { - id: created.recordset[0].id, + id, name, gsprpinfo, }; diff --git a/src/store/category/addImage.js b/src/store/category/addImage.js index c5b4d71..eaab9e3 100644 --- a/src/store/category/addImage.js +++ b/src/store/category/addImage.js @@ -1,12 +1,25 @@ -import { ADD as ADD_CATEGORYPIC, GET_NEXT_SORT } from '../../providers/mssql/queries/categorypic.js'; -import { CREATE as CREATE_PIC } from '../../providers/mssql/queries/pic.js'; +import { + ADD as MSSQL_ADD_CATEGORYPIC, + GET_NEXT_SORT as MSSQL_GET_NEXT_SORT, +} from '../../providers/mssql/queries/categorypic.js'; +import { + ADD as SQLITE_ADD_CATEGORYPIC, + GET_NEXT_SORT as SQLITE_GET_NEXT_SORT, +} from '../../providers/sqlite3/queries/categorypic.js'; +import { CREATE as MSSQL_CREATE_PIC } from '../../providers/mssql/queries/pic.js'; +import { CREATE as SQLITE_CREATE_PIC } from '../../providers/sqlite3/queries/pic.js'; import processAvifImage from '../../util/processAvifImage.js'; import newFullHash from '../../util/newFullHash.js'; -import request from '../request.js'; import resolveCategoryAccess from './resolveCategoryAccess.js'; +import makeRunner from '../runner.js'; -export default function addImage(pool) { - const access = resolveCategoryAccess(pool); +export default function addImage(provider) { + const access = resolveCategoryAccess(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + + const getSortSql = isSqlite ? SQLITE_GET_NEXT_SORT : MSSQL_GET_NEXT_SORT; + const createPicSql = isSqlite ? SQLITE_CREATE_PIC : MSSQL_CREATE_PIC; + const addCategoryPicSql = isSqlite ? SQLITE_ADD_CATEGORYPIC : MSSQL_ADD_CATEGORYPIC; /** * Adds an image to a category, processing it into AVIF full and thumb variants. @@ -14,50 +27,53 @@ export default function addImage(pool) { * @param {number} googleAuthId - The id of the Google auth user. * @param {number} categoryId - The id of the category. * @param {Buffer} image - The source image buffer. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise<{id: number, fullHash: string, thumbW: number, thumbH: number, thumbHash: string}>} The added image info. * * @example * const result = await store.category.addImage(42, 5, imageBuffer); * // => { id, fullHash, thumbW, thumbH, thumbHash } */ - return async (googleAuthId, categoryId, image) => { - await access(googleAuthId, categoryId); + return async (googleAuthId, categoryId, image, tx) => { + const runAdd = async (runner) => { + await access(googleAuthId, categoryId, runner); - const processed = await processAvifImage(image); + const processed = await processAvifImage(image); - const tx = pool.transaction(); - await tx.begin(); - - try { - const sortResult = await request(pool, tx) - .input('categoryId', categoryId) - .query(GET_NEXT_SORT); - - const sort = sortResult.recordset[0].sort; + const sortResult = await runner.query(getSortSql, { categoryId }); + const sort = sortResult.rows[0].sort; const fullHash = newFullHash(); - const picResult = await request(pool, tx) - .input('w', processed.full.w) - .input('h', processed.full.h) - .input('size', processed.full.size) - .input('hash', fullHash) - .input('thumbw', processed.thumb.w) - .input('thumbh', processed.thumb.h) - .input('thumbhash', processed.thumb.hash) - .input('source', '') - .input('fullbinary', processed.full.buffer) - .input('thumbbinary', processed.thumb.buffer) - .query(CREATE_PIC); + if (isSqlite) { + if (runner.writePicFull) { + await runner.writePicFull(fullHash, processed.full.buffer); + await runner.writePicThumb(fullHash, processed.thumb.buffer); + } else if (provider?.writePicFull) { + await provider.writePicFull(fullHash, processed.full.buffer); + await provider.writePicThumb(fullHash, processed.thumb.buffer); + } + } - const picId = picResult.recordset[0].id; + const picResult = await runner.query(createPicSql, { + w: processed.full.w, + h: processed.full.h, + size: processed.full.size, + hash: fullHash, + thumbw: processed.thumb.w, + thumbh: processed.thumb.h, + thumbhash: processed.thumb.hash, + source: '', + fullbinary: processed.full.buffer, + thumbbinary: processed.thumb.buffer, + }); - await request(pool, tx) - .input('categoryId', categoryId) - .input('picId', picId) - .input('sort', sort) - .query(ADD_CATEGORYPIC); + const picId = picResult.rows[0]?.id ?? picResult.insertId; - await tx.commit(); + await runner.query(addCategoryPicSql, { + categoryId, + picId, + sort, + }); return { id: categoryId, @@ -66,13 +82,14 @@ export default function addImage(pool) { thumbH: processed.thumb.h, thumbHash: processed.thumb.hash, }; - } catch (err) { - try { - await tx.rollback(); - } catch { - // ignore - } - throw err; + }; + + if (tx) { + return runAdd(makeRunner(provider, tx)); } + if (typeof provider?.transaction === 'function') { + return provider.transaction(runAdd); + } + return runAdd(makeRunner(provider)); }; } diff --git a/src/store/category/createUnderParent.js b/src/store/category/createUnderParent.js index c4efb18..b6a7963 100644 --- a/src/store/category/createUnderParent.js +++ b/src/store/category/createUnderParent.js @@ -1,14 +1,28 @@ import { - CREATE, - GET_NEXT_SIBLING_SORT, + CREATE as MSSQL_CREATE, + GET_NEXT_SIBLING_SORT as MSSQL_GET_NEXT_SIBLING_SORT, } from '../../providers/mssql/queries/category.js'; -import { CREATE as CREATE_CATEGORYTEXT } from '../../providers/mssql/queries/categorytext.js'; +import { + CREATE as SQLITE_CREATE, + GET_NEXT_SIBLING_SORT as SQLITE_GET_NEXT_SIBLING_SORT, +} from '../../providers/sqlite3/queries/category.js'; +import { CREATE as MSSQL_CREATE_CATEGORYTEXT } from '../../providers/mssql/queries/categorytext.js'; +import { CREATE as SQLITE_CREATE_CATEGORYTEXT } from '../../providers/sqlite3/queries/categorytext.js'; import resolveCategoryAccess from './resolveCategoryAccess.js'; -import request from '../request.js'; import slugify from '../slugify.js'; +import makeRunner from '../runner.js'; -export default function createUnderParent(pool) { - const access = resolveCategoryAccess(pool); +export default function createUnderParent(provider) { + const access = resolveCategoryAccess(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + + const getSortSql = isSqlite + ? SQLITE_GET_NEXT_SIBLING_SORT + : MSSQL_GET_NEXT_SIBLING_SORT; + const createSql = isSqlite ? SQLITE_CREATE : MSSQL_CREATE; + const createTextSql = isSqlite + ? SQLITE_CREATE_CATEGORYTEXT + : MSSQL_CREATE_CATEGORYTEXT; /** * Creates a new category under a given parent category. @@ -17,56 +31,50 @@ export default function createUnderParent(pool) { * @param {number} input.googleAuthId - The id of the Google auth user. * @param {number} input.parentId - The id of the parent category. * @param {string} input.name - The category name. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise<{id: number, parentId: number, sort: number, name: string, slug: string}>} The created category. * * @example * const category = await store.category.createUnderParent({ googleAuthId: 42, parentId: 5, name: 'Drinks' }); * // => { id, parentId, sort, name, slug } */ - return async ({ googleAuthId, parentId, name }) => { - const { languageId } = await access(googleAuthId, parentId); + return async ({ googleAuthId, parentId, name }, tx) => { + const runCreate = async (runner) => { + const { languageId } = await access(googleAuthId, parentId, runner); - const tx = pool.transaction(); - await tx.begin(); - - try { - const sortResult = await request(pool, tx) - .input('parentId', parentId) - .query(GET_NEXT_SIBLING_SORT); - - const sort = sortResult.recordset[0].sort; + const sortResult = await runner.query(getSortSql, { parentId }); + const sort = sortResult.rows[0].sort; const slug = slugify(name); - const category = await request(pool, tx) - .input('parentId', parentId) - .input('sort', sort) - .query(CREATE); + const category = await runner.query(createSql, { + parentId, + sort, + }); - const { id, parentId: resolvedParentId } = category.recordset[0]; + const id = category.rows[0]?.id ?? category.insertId; - await request(pool, tx) - .input('categoryId', id) - .input('languageId', languageId) - .input('name', name) - .input('slug', slug) - .query(CREATE_CATEGORYTEXT); - - await tx.commit(); + await runner.query(createTextSql, { + categoryId: id, + languageId, + name, + slug, + }); return { id, - parentId: resolvedParentId, + parentId, sort, name, slug, }; - } catch (err) { - try { - await tx.rollback(); - } catch { - // ignore - } - throw err; + }; + + if (tx) { + return runCreate(makeRunner(provider, tx)); } + if (typeof provider?.transaction === 'function') { + return provider.transaction(runCreate); + } + return runCreate(makeRunner(provider)); }; } diff --git a/src/store/category/delete.js b/src/store/category/delete.js index 5bf2088..2d17907 100644 --- a/src/store/category/delete.js +++ b/src/store/category/delete.js @@ -1,8 +1,11 @@ -import { DELETE } from '../../providers/mssql/queries/category.js'; +import { DELETE as MSSQL_DELETE } from '../../providers/mssql/queries/category.js'; +import { DELETE as SQLITE_DELETE } from '../../providers/sqlite3/queries/category.js'; import resolveCategoryAccess from './resolveCategoryAccess.js'; +import makeRunner from '../runner.js'; -export default function del(pool) { - const access = resolveCategoryAccess(pool); +export default function del(provider) { + const access = resolveCategoryAccess(provider); + const sql = provider?.dialect === 'sqlite3' ? SQLITE_DELETE : MSSQL_DELETE; /** * Deletes a category, refusing to delete the company's root category. @@ -10,19 +13,20 @@ export default function del(pool) { * @param {object} input - The delete input. * @param {number} input.googleAuthId - The id of the Google auth user. * @param {number} input.id - The id of the category to delete. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise} * @throws {Error} If the category is the root category. * * @example * await store.category.delete({ googleAuthId: 42, id: 5 }); */ - return async ({ googleAuthId, id }) => { - const { rootCategoryId } = await access(googleAuthId, id); + return async ({ googleAuthId, id }, tx) => { + const { rootCategoryId } = await access(googleAuthId, id, tx); if (id === rootCategoryId) { throw new Error('Cannot delete root category'); } - await pool.request().input('id', id).query(DELETE); + await makeRunner(provider, tx).query(sql, { id }); }; } diff --git a/src/store/category/deleteImage.js b/src/store/category/deleteImage.js index d96010d..68c577e 100644 --- a/src/store/category/deleteImage.js +++ b/src/store/category/deleteImage.js @@ -1,14 +1,18 @@ -import { DELETE_BY_CATEGORY } from '../../providers/mssql/queries/categorypic.js'; +import { DELETE_BY_CATEGORY as MSSQL_DELETE } from '../../providers/mssql/queries/categorypic.js'; +import { DELETE_BY_CATEGORY as SQLITE_DELETE } from '../../providers/sqlite3/queries/categorypic.js'; import resolveCategoryAccess from './resolveCategoryAccess.js'; +import makeRunner from '../runner.js'; -export default function deleteImage(pool) { - const access = resolveCategoryAccess(pool); +export default function deleteImage(provider) { + const access = resolveCategoryAccess(provider); + const sql = provider?.dialect === 'sqlite3' ? SQLITE_DELETE : MSSQL_DELETE; /** * Deletes the image of a category. * * @param {number} googleAuthId - The id of the Google auth user. * @param {number} categoryId - The id of the category. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise<{id: number, thumbW: null, thumbH: null, thumbHash: null}>} The cleared image info. * @throws {Error} If the image is not found. * @@ -16,15 +20,13 @@ export default function deleteImage(pool) { * const result = await store.category.deleteImage(42, 5); * // => { id: 5, thumbW: null, thumbH: null, thumbHash: null } */ - return async (googleAuthId, categoryId) => { - await access(googleAuthId, categoryId); + return async (googleAuthId, categoryId, tx) => { + const runner = makeRunner(provider, tx); + await access(googleAuthId, categoryId, runner); - const result = await pool - .request() - .input('categoryId', categoryId) - .query(DELETE_BY_CATEGORY); + const result = await runner.query(sql, { categoryId }); - if (!result.rowsAffected[0]) { + if (!result.rowsAffected) { throw new Error('Image not found'); } diff --git a/src/store/category/ensureByPath.js b/src/store/category/ensureByPath.js index a7824ce..ad1c3b9 100644 --- a/src/store/category/ensureByPath.js +++ b/src/store/category/ensureByPath.js @@ -1,12 +1,18 @@ import { - CREATE, - GET_BY_PARENT_AND_NAME, - GET_NEXT_SIBLING_SORT, + CREATE as MSSQL_CREATE, + GET_BY_PARENT_AND_NAME as MSSQL_GET_BY_PARENT_AND_NAME, + GET_NEXT_SIBLING_SORT as MSSQL_GET_NEXT_SIBLING_SORT, } from '../../providers/mssql/queries/category.js'; -import { CREATE as CREATE_CATEGORYTEXT } from '../../providers/mssql/queries/categorytext.js'; +import { + CREATE as SQLITE_CREATE, + GET_BY_PARENT_AND_NAME as SQLITE_GET_BY_PARENT_AND_NAME, + GET_NEXT_SIBLING_SORT as SQLITE_GET_NEXT_SIBLING_SORT, +} from '../../providers/sqlite3/queries/category.js'; +import { CREATE as MSSQL_CREATE_CATEGORYTEXT } from '../../providers/mssql/queries/categorytext.js'; +import { CREATE as SQLITE_CREATE_CATEGORYTEXT } from '../../providers/sqlite3/queries/categorytext.js'; import resolveCategoryAccess from './resolveCategoryAccess.js'; -import request from '../request.js'; import slugify from '../slugify.js'; +import makeRunner from '../runner.js'; // Splits a "A | B | C" breadcrumb string into trimmed, non-empty name parts, // ordered root-first. @@ -17,8 +23,20 @@ export function splitPath(path) { .filter(Boolean); } -export default function ensureByPath(pool) { - const access = resolveCategoryAccess(pool); +export default function ensureByPath(provider) { + const access = resolveCategoryAccess(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + + const getSortSql = isSqlite + ? SQLITE_GET_NEXT_SIBLING_SORT + : MSSQL_GET_NEXT_SIBLING_SORT; + const createCategorySql = isSqlite ? SQLITE_CREATE : MSSQL_CREATE; + const createTextSql = isSqlite + ? SQLITE_CREATE_CATEGORYTEXT + : MSSQL_CREATE_CATEGORYTEXT; + const getByNameSql = isSqlite + ? SQLITE_GET_BY_PARENT_AND_NAME + : MSSQL_GET_BY_PARENT_AND_NAME; /** * Resolves a category by breadcrumb path under a given parent, creating any @@ -43,62 +61,54 @@ export default function ensureByPath(pool) { let currentId = parentId; - const createCategory = async (tx, parentId, name, languageId) => { - const sortResult = await request(pool, tx) - .input('parentId', parentId) - .query(GET_NEXT_SIBLING_SORT); - const sort = sortResult.recordset[0].sort; + const createCategory = async (runner, parentId, name, languageId) => { + const sortResult = await runner.query(getSortSql, { parentId }); + const sort = sortResult.rows[0].sort; const slug = slugify(name); - const category = await request(pool, tx) - .input('parentId', parentId) - .input('sort', sort) - .query(CREATE); - const id = category.recordset[0].id; + const category = await runner.query(createCategorySql, { + parentId, + sort, + }); + const id = category.rows[0]?.id ?? category.insertId; - await request(pool, tx) - .input('categoryId', id) - .input('languageId', languageId) - .input('name', name) - .input('slug', slug) - .query(CREATE_CATEGORYTEXT); + await runner.query(createTextSql, { + categoryId: id, + languageId, + name, + slug, + }); return id; }; for (const name of names) { - const { languageId } = await access(googleAuthId, currentId); + const { languageId } = await access(googleAuthId, currentId, tx); - const existing = await request(pool, tx) - .input('parentId', currentId) - .input('languageId', languageId) - .input('name', name) - .query(GET_BY_PARENT_AND_NAME); + const existing = await makeRunner(provider, tx).query(getByNameSql, { + parentId: currentId, + languageId, + name, + }); - if (existing.recordset[0]) { - currentId = existing.recordset[0].id; + if (existing.rows[0]) { + currentId = existing.rows[0].id; continue; } if (tx) { // Run within the caller's transaction (e.g. article import row). - currentId = await createCategory(tx, currentId, name, languageId); + currentId = await createCategory(makeRunner(provider, tx), currentId, name, languageId); continue; } // Standalone use: own transaction per created category. - const ownTx = pool.transaction(); - await ownTx.begin(); - try { - currentId = await createCategory(ownTx, currentId, name, languageId); - await ownTx.commit(); - } catch (err) { - try { - await ownTx.rollback(); - } catch { - // ignore - } - throw err; + if (typeof provider?.transaction === 'function') { + currentId = await provider.transaction((runner) => + createCategory(runner, currentId, name, languageId), + ); + } else { + currentId = await createCategory(makeRunner(provider), currentId, name, languageId); } } diff --git a/src/store/category/getCategoryArticles.js b/src/store/category/getCategoryArticles.js index 8645eb3..9a50283 100644 --- a/src/store/category/getCategoryArticles.js +++ b/src/store/category/getCategoryArticles.js @@ -1,27 +1,29 @@ import resolveCategoryAccess from './resolveCategoryAccess.js'; import queryCategoryArticles from './queryCategoryArticles.js'; -export default function getCategoryArticles(pool) { - const access = resolveCategoryAccess(pool); - const articles = queryCategoryArticles(pool); +export default function getCategoryArticles(provider) { + const access = resolveCategoryAccess(provider); + const articles = queryCategoryArticles(provider); /** * Returns the articles belonging to a single category. * * @param {number} googleAuthId - The id of the Google auth user. * @param {number} categoryId - The id of the category. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise>} The articles in the category. * * @example * const articles = await store.category.getCategoryArticles(42, 5); * // => [{ id, sku, name, ... }] */ - return async (googleAuthId, categoryId) => { + return async (googleAuthId, categoryId, tx) => { const { languageId, posCustomerGroupId } = await access( googleAuthId, categoryId, + tx, ); - return articles({ categoryId, languageId, posCustomerGroupId }); + return articles({ categoryId, languageId, posCustomerGroupId }, tx); }; } diff --git a/src/store/category/getTreeForCompany.js b/src/store/category/getTreeForCompany.js index 2ffaeb9..26c5abc 100644 --- a/src/store/category/getTreeForCompany.js +++ b/src/store/category/getTreeForCompany.js @@ -1,39 +1,44 @@ -import { GET_TREE_FOR_COMPANY } from '../../providers/mssql/queries/category.js'; +import { GET_TREE_FOR_COMPANY as MSSQL_GET_TREE } from '../../providers/mssql/queries/category.js'; +import { GET_TREE_FOR_COMPANY as SQLITE_GET_TREE } from '../../providers/sqlite3/queries/category.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import queryCategoryArticles from './queryCategoryArticles.js'; import getCategoryPosterminalIds from '../posterminal/getCategoryPosterminalIds.js'; +import makeRunner from '../runner.js'; -export default function getTreeForCompany(pool) { - const access = resolveSelectedCompanyAccess(pool); - const articles = queryCategoryArticles(pool); - const posterminalIds = getCategoryPosterminalIds(pool); +export default function getTreeForCompany(provider) { + const access = resolveSelectedCompanyAccess(provider); + const articles = queryCategoryArticles(provider); + const posterminalIds = getCategoryPosterminalIds(provider); + const sql = + provider?.dialect === 'sqlite3' ? SQLITE_GET_TREE : MSSQL_GET_TREE; /** * Returns the full category tree for the user's selected company together * with its articles and the posterminal ids assigned to each category. * * @param {number} googleAuthId - The id of the Google auth user. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise<{languageId: number, tree: Array, articles: Array}>} The category tree, articles and language. * * @example * const tree = await store.category.getTreeForCompany(42); * // => { languageId, tree: [{ id, posterminalIds, ... }], articles: [...] } */ - return async (googleAuthId) => { + return async (googleAuthId, tx) => { const { companyId, rootCategoryId, languageId, posCustomerGroupId } = - await access(googleAuthId); + await access(googleAuthId, tx); + const runner = makeRunner(provider, tx); const [treeResult, articleRows, ptMap] = await Promise.all([ - pool - .request() - .input('rootId', rootCategoryId) - .input('languageId', languageId) - .query(GET_TREE_FOR_COMPANY), - articles({ categoryId: rootCategoryId, languageId, posCustomerGroupId }), - posterminalIds(companyId), + runner.query(sql, { + rootId: rootCategoryId, + languageId, + }), + articles({ categoryId: rootCategoryId, languageId, posCustomerGroupId }, tx), + posterminalIds(companyId, tx), ]); - const tree = treeResult.recordset.map((row) => ({ + const tree = treeResult.rows.map((row) => ({ ...row, posterminalIds: ptMap[row.id] ?? [], })); diff --git a/src/store/category/move.js b/src/store/category/move.js index 7b2a4d6..29663f0 100644 --- a/src/store/category/move.js +++ b/src/store/category/move.js @@ -1,13 +1,25 @@ import { - GET_NEXT_SIBLING_SORT, - IS_ANCESTOR, - MOVE, + GET_NEXT_SIBLING_SORT as MSSQL_GET_NEXT_SIBLING_SORT, + IS_ANCESTOR as MSSQL_IS_ANCESTOR, + MOVE as MSSQL_MOVE, } from '../../providers/mssql/queries/category.js'; -import request from '../request.js'; +import { + GET_NEXT_SIBLING_SORT as SQLITE_GET_NEXT_SIBLING_SORT, + IS_ANCESTOR as SQLITE_IS_ANCESTOR, + MOVE as SQLITE_MOVE, +} from '../../providers/sqlite3/queries/category.js'; import resolveCategoryAccess from './resolveCategoryAccess.js'; +import makeRunner from '../runner.js'; -export default function move(pool) { - const access = resolveCategoryAccess(pool); +export default function move(provider) { + const access = resolveCategoryAccess(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + + const isAncestorSql = isSqlite ? SQLITE_IS_ANCESTOR : MSSQL_IS_ANCESTOR; + const getSortSql = isSqlite + ? SQLITE_GET_NEXT_SIBLING_SORT + : MSSQL_GET_NEXT_SIBLING_SORT; + const moveSql = isSqlite ? SQLITE_MOVE : MSSQL_MOVE; /** * Moves a category to a new parent, preventing cycles and cross-company moves. @@ -16,6 +28,7 @@ export default function move(pool) { * @param {number} input.googleAuthId - The id of the Google auth user. * @param {number} input.id - The id of the category to move. * @param {number} input.parentId - The id of the new parent category. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise<{id: number, parentId: number, sort: number}>} The moved category. * @throws {Error} If the move would create a cycle or cross companies. * @@ -23,59 +36,49 @@ export default function move(pool) { * const result = await store.category.move({ googleAuthId: 42, id: 5, parentId: 9 }); * // => { id, parentId, sort } */ - return async ({ googleAuthId, id, parentId }) => { - // Source category auth (throws if missing / not authorized). - const source = await access(googleAuthId, id); - // Target must be a valid category for the same authorized user/company. - const target = await access(googleAuthId, parentId); + return async ({ googleAuthId, id, parentId }, tx) => { + const runMove = async (runner) => { + // Source category auth (throws if missing / not authorized). + const source = await access(googleAuthId, id, runner); + // Target must be a valid category for the same authorized user/company. + const target = await access(googleAuthId, parentId, runner); - if (target.companyId !== source.companyId) { - throw new Error('Parent not found'); - } + if (target.companyId !== source.companyId) { + throw new Error('Parent not found'); + } - if (id === parentId) { - throw new Error('Invalid parent'); - } + if (id === parentId) { + throw new Error('Invalid parent'); + } - const cycle = await pool - .request() - .input('id', id) - .input('parentId', parentId) - .query(IS_ANCESTOR); + const cycle = await runner.query(isAncestorSql, { id, parentId }); - if (cycle.recordset[0]) { - throw new Error('Invalid parent'); - } + if (cycle.rows[0]) { + throw new Error('Invalid parent'); + } - const tx = pool.transaction(); - await tx.begin(); + const sortResult = await runner.query(getSortSql, { parentId }); + const sort = sortResult.rows[0].sort; - try { - const sortResult = await request(pool, tx) - .input('parentId', parentId) - .query(GET_NEXT_SIBLING_SORT); - const sort = sortResult.recordset[0].sort; + const result = await runner.query(moveSql, { + id, + parentId, + sort, + }); - const result = await request(pool, tx) - .input('id', id) - .input('parentId', parentId) - .input('sort', sort) - .query(MOVE); - - if (!result.rowsAffected[0]) { + if (!result.rowsAffected) { throw new Error('Category not found'); } - await tx.commit(); - return { id, parentId, sort }; - } catch (err) { - try { - await tx.rollback(); - } catch { - // ignore - } - throw err; + }; + + if (tx) { + return runMove(makeRunner(provider, tx)); } + if (typeof provider?.transaction === 'function') { + return provider.transaction(runMove); + } + return runMove(makeRunner(provider)); }; } diff --git a/src/store/category/order.js b/src/store/category/order.js index 1f6a53d..68d5c6e 100644 --- a/src/store/category/order.js +++ b/src/store/category/order.js @@ -1,12 +1,21 @@ import { - LIST_CHILD_IDS, - UPDATE_SORT, + LIST_CHILD_IDS as MSSQL_LIST_CHILD_IDS, + UPDATE_SORT as MSSQL_UPDATE_SORT, } from '../../providers/mssql/queries/category.js'; -import request from '../request.js'; +import { + LIST_CHILD_IDS as SQLITE_LIST_CHILD_IDS, + UPDATE_SORT as SQLITE_UPDATE_SORT, +} from '../../providers/sqlite3/queries/category.js'; import resolveCategoryAccess from './resolveCategoryAccess.js'; +import makeRunner from '../runner.js'; -export default function order(pool) { - const access = resolveCategoryAccess(pool); +export default function order(provider) { + const access = resolveCategoryAccess(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + const listChildIdsSql = isSqlite + ? SQLITE_LIST_CHILD_IDS + : MSSQL_LIST_CHILD_IDS; + const updateSortSql = isSqlite ? SQLITE_UPDATE_SORT : MSSQL_UPDATE_SORT; /** * Reorders the child categories of a given parent. @@ -15,6 +24,7 @@ export default function order(pool) { * @param {number} input.googleAuthId - The id of the Google auth user. * @param {number} input.parentId - The id of the parent category. * @param {Array} input.categoryIds - The child category ids in their new order. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise<{categoryIds: Array}>} The applied order. * @throws {Error} If the order is invalid. * @@ -22,55 +32,48 @@ export default function order(pool) { * const result = await store.category.order({ googleAuthId: 42, parentId: 5, categoryIds: [3, 1, 2] }); * // => { categoryIds: [3, 1, 2] } */ - return async ({ googleAuthId, parentId, categoryIds }) => { + return async ({ googleAuthId, parentId, categoryIds }, tx) => { if (!Array.isArray(categoryIds)) { throw new Error('Invalid category order'); } - // Auth + ensures parentId is a real category of the user's company. - await access(googleAuthId, parentId); + const runOrder = async (runner) => { + // Auth + ensures parentId is a real category of the user's company. + await access(googleAuthId, parentId, runner); - const children = await pool - .request() - .input('parentId', parentId) - .query(LIST_CHILD_IDS); + const children = await runner.query(listChildIdsSql, { parentId }); - const existing = new Set(children.recordset.map((r) => r.id)); + const existing = new Set(children.rows.map((r) => r.id)); - if (categoryIds.length !== existing.size) { - throw new Error('Invalid category order'); - } - - const seen = new Set(); - for (const categoryId of categoryIds) { - if (!Number.isFinite(categoryId) || !existing.has(categoryId) || seen.has(categoryId)) { + if (categoryIds.length !== existing.size) { throw new Error('Invalid category order'); } - seen.add(categoryId); - } - const tx = pool.transaction(); - await tx.begin(); - - try { - for (let sort = 0; sort < categoryIds.length; sort++) { - await request(pool, tx) - .input('id', categoryIds[sort]) - .input('parentId', parentId) - .input('sort', sort) - .query(UPDATE_SORT); + const seen = new Set(); + for (const categoryId of categoryIds) { + if (!Number.isFinite(categoryId) || !existing.has(categoryId) || seen.has(categoryId)) { + throw new Error('Invalid category order'); + } + seen.add(categoryId); } - await tx.commit(); + for (let sort = 0; sort < categoryIds.length; sort++) { + await runner.query(updateSortSql, { + id: categoryIds[sort], + parentId, + sort, + }); + } return { categoryIds }; - } catch (err) { - try { - await tx.rollback(); - } catch { - // ignore - } - throw err; + }; + + if (tx) { + return runOrder(makeRunner(provider, tx)); } + if (typeof provider?.transaction === 'function') { + return provider.transaction(runOrder); + } + return runOrder(makeRunner(provider)); }; } diff --git a/src/store/category/queryCategoryArticles.js b/src/store/category/queryCategoryArticles.js index 23588a4..12fa91f 100644 --- a/src/store/category/queryCategoryArticles.js +++ b/src/store/category/queryCategoryArticles.js @@ -1,14 +1,17 @@ -import { GET_CATEGORY_ARTICLES } from '../../providers/mssql/queries/category.js'; +import { GET_CATEGORY_ARTICLES as MSSQL_GET } from '../../providers/mssql/queries/category.js'; +import { GET_CATEGORY_ARTICLES as SQLITE_GET } from '../../providers/sqlite3/queries/category.js'; +import makeRunner from '../runner.js'; -export default function queryCategoryArticles(pool) { - return async ({ categoryId, languageId, posCustomerGroupId }) => { - const result = await pool - .request() - .input('categoryId', categoryId) - .input('languageId', languageId) - .input('posCustomerGroupId', posCustomerGroupId) - .query(GET_CATEGORY_ARTICLES); +export default function queryCategoryArticles(provider) { + const sql = provider?.dialect === 'sqlite3' ? SQLITE_GET : MSSQL_GET; - return result.recordset; + return async ({ categoryId, languageId, posCustomerGroupId }, tx) => { + const result = await makeRunner(provider, tx).query(sql, { + categoryId, + languageId, + posCustomerGroupId, + }); + + return result.rows; }; } diff --git a/src/store/category/rename.js b/src/store/category/rename.js index 322e47c..169a5b6 100644 --- a/src/store/category/rename.js +++ b/src/store/category/rename.js @@ -1,9 +1,12 @@ -import { UPDATE_BY_CATEGORY_AND_LANGUAGE } from '../../providers/mssql/queries/categorytext.js'; +import { UPDATE_BY_CATEGORY_AND_LANGUAGE as MSSQL_UPDATE } from '../../providers/mssql/queries/categorytext.js'; +import { UPDATE_BY_CATEGORY_AND_LANGUAGE as SQLITE_UPDATE } from '../../providers/sqlite3/queries/categorytext.js'; import resolveCategoryAccess from './resolveCategoryAccess.js'; import slugify from '../slugify.js'; +import makeRunner from '../runner.js'; -export default function rename(pool) { - const access = resolveCategoryAccess(pool); +export default function rename(provider) { + const access = resolveCategoryAccess(provider); + const sql = provider?.dialect === 'sqlite3' ? SQLITE_UPDATE : MSSQL_UPDATE; /** * Renames a category and updates its slug. @@ -12,6 +15,7 @@ export default function rename(pool) { * @param {number} input.googleAuthId - The id of the Google auth user. * @param {number} input.id - The id of the category to rename. * @param {string} input.name - The new category name. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise<{id: number, name: string, slug: string}>} The renamed category. * @throws {Error} If the name is empty or the category is not found. * @@ -19,25 +23,24 @@ export default function rename(pool) { * const result = await store.category.rename({ googleAuthId: 42, id: 5, name: 'Soft Drinks' }); * // => { id, name, slug } */ - return async ({ googleAuthId, id, name }) => { + return async ({ googleAuthId, id, name }, tx) => { if (!name?.trim()) { throw new Error('Name required'); } const cleaned = name.trim(); - const { languageId } = await access(googleAuthId, id); + const { languageId } = await access(googleAuthId, id, tx); const slug = slugify(cleaned); - const result = await pool - .request() - .input('categoryId', id) - .input('languageId', languageId) - .input('name', cleaned) - .input('slug', slug) - .query(UPDATE_BY_CATEGORY_AND_LANGUAGE); + const result = await makeRunner(provider, tx).query(sql, { + categoryId: id, + languageId, + name: cleaned, + slug, + }); - if (!result.rowsAffected[0]) { + if (!result.rowsAffected) { throw new Error('Category not found'); } diff --git a/src/store/category/resolveCategoryAccess.js b/src/store/category/resolveCategoryAccess.js index 5b85a02..49c2439 100644 --- a/src/store/category/resolveCategoryAccess.js +++ b/src/store/category/resolveCategoryAccess.js @@ -1,14 +1,18 @@ -import { RESOLVE_CATEGORY_ACCESS } from '../../providers/mssql/queries/category.js'; +import { RESOLVE_CATEGORY_ACCESS as MSSQL_RESOLVE } from '../../providers/mssql/queries/category.js'; +import { RESOLVE_CATEGORY_ACCESS as SQLITE_RESOLVE } from '../../providers/sqlite3/queries/category.js'; +import makeRunner from '../runner.js'; -export default function resolveCategoryAccess(pool) { - return async (googleAuthId, categoryId) => { - const result = await pool - .request() - .input('categoryId', categoryId) - .input('googleAuthId', googleAuthId) - .query(RESOLVE_CATEGORY_ACCESS); +export default function resolveCategoryAccess(provider) { + const sql = + provider?.dialect === 'sqlite3' ? SQLITE_RESOLVE : MSSQL_RESOLVE; - const row = result.recordset[0]; + return async (googleAuthId, categoryId, tx) => { + const result = await makeRunner(provider, tx).query(sql, { + categoryId, + googleAuthId, + }); + + const row = result.rows[0]; if (!row) { throw new Error('Parent not found'); } diff --git a/src/store/company/create.js b/src/store/company/create.js index eacc3ca..1b829ec 100644 --- a/src/store/company/create.js +++ b/src/store/company/create.js @@ -1,20 +1,34 @@ import { POS_GROUP_NAME } from '../../providers/mssql/queries/entity.js'; -import { CREATE } from '../../providers/mssql/queries/company.js'; -import { CREATE as CREATE_CATEGORY } from '../../providers/mssql/queries/category.js'; +import { CREATE as MSSQL_CREATE } from '../../providers/mssql/queries/company.js'; +import { CREATE as SQLITE_CREATE } from '../../providers/sqlite3/queries/company.js'; +import { CREATE as MSSQL_CREATE_CATEGORY } from '../../providers/mssql/queries/category.js'; +import { CREATE as SQLITE_CREATE_CATEGORY } from '../../providers/sqlite3/queries/category.js'; import { - CREATE_WITHOUT_COMPANY as CREATE_CUSTOMERGROUP, - SET_COMPANY as SET_CUSTOMERGROUP_COMPANY, + CREATE_WITHOUT_COMPANY as MSSQL_CREATE_CUSTOMERGROUP, + SET_COMPANY as MSSQL_SET_CUSTOMERGROUP_COMPANY, } from '../../providers/mssql/queries/customergroup.js'; -import { ADD as ADD_COMPANYUSER } from '../../providers/mssql/queries/companyuser.js'; +import { + CREATE_WITHOUT_COMPANY as SQLITE_CREATE_CUSTOMERGROUP, + SET_COMPANY as SQLITE_SET_CUSTOMERGROUP_COMPANY, +} from '../../providers/sqlite3/queries/customergroup.js'; +import { ADD as MSSQL_ADD_COMPANYUSER } from '../../providers/mssql/queries/companyuser.js'; +import { ADD as SQLITE_ADD_COMPANYUSER } from '../../providers/sqlite3/queries/companyuser.js'; import getByName from './getByName.js'; import langEnsure from '../lang/ensure.js'; import taxEnsure from '../tax/ensure.js'; -import request from '../request.js'; +import makeRunner from '../runner.js'; -export default function create(pool) { - const nameTaken = getByName(pool); - const ensureLang = langEnsure(pool); - const ensureTax = taxEnsure(pool); +export default function create(provider) { + const nameTaken = getByName(provider); + const ensureLang = langEnsure(provider); + const ensureTax = taxEnsure(provider); + + const isSqlite = provider?.dialect === 'sqlite3'; + const createCategorySql = isSqlite ? SQLITE_CREATE_CATEGORY : MSSQL_CREATE_CATEGORY; + const createCustGroupSql = isSqlite ? SQLITE_CREATE_CUSTOMERGROUP : MSSQL_CREATE_CUSTOMERGROUP; + const createCompanySql = isSqlite ? SQLITE_CREATE : MSSQL_CREATE; + const setCustGroupCompanySql = isSqlite ? SQLITE_SET_CUSTOMERGROUP_COMPANY : MSSQL_SET_CUSTOMERGROUP_COMPANY; + const addCompanyUserSql = isSqlite ? SQLITE_ADD_COMPANYUSER : MSSQL_ADD_COMPANYUSER; /** * Creates a new company together with its root category, POS customer group, @@ -26,6 +40,7 @@ export default function create(pool) { * @param {number} input.taxrate - The standard tax rate in percent. * @param {number} input.reducedtaxrate - The reduced tax rate in percent. * @param {number} input.googleAuthId - The id of the Google auth user to link. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise<{id: number, rootCategoryId: number, languageId: number, taxrateId: number, reducedtaxrateId: number, taxfreerateId: number, posCustomerGroupId: number}>} The created company ids. * @throws {Error} If the company name is already taken. * @@ -39,60 +54,52 @@ export default function create(pool) { * }); * // => { id, rootCategoryId, languageId, taxrateId, reducedtaxrateId, taxfreerateId, posCustomerGroupId } */ - return async ({ iso639, name, taxrate, reducedtaxrate, googleAuthId }) => { - const tx = pool.transaction(); - await tx.begin(); - - try { - const existing = await nameTaken(name, tx); + return async ({ iso639, name, taxrate, reducedtaxrate, googleAuthId }, tx) => { + const runCreate = async (runner) => { + const existing = await nameTaken(name, runner); if (existing) { throw new Error('Company name not available'); } - const languageId = await ensureLang(iso639, tx); - const taxrateId = await ensureTax(taxrate, tx); - const reducedtaxrateId = await ensureTax(reducedtaxrate, tx); - const taxfreerateId = await ensureTax(0, tx); + const languageId = await ensureLang(iso639, runner); + const taxrateId = await ensureTax(taxrate, runner); + const reducedtaxrateId = await ensureTax(reducedtaxrate, runner); + const taxfreerateId = await ensureTax(0, runner); - const category = await request(pool, tx) - .input('parentId', null) - .input('sort', 0) - .query(CREATE_CATEGORY); - - const rootCategoryId = category.recordset[0].id; + const category = await runner.query(createCategorySql, { + parentId: null, + sort: 0, + }); + const rootCategoryId = category.rows[0]?.id ?? category.insertId; // Circular FK: create POS group first (company_id null), company with // poscustomergroup_id NOT NULL, then link the group back to the company. - const posGroup = await request(pool, tx) - .input('name', POS_GROUP_NAME) - .input('sort', 0) - .query(CREATE_CUSTOMERGROUP); + const posGroup = await runner.query(createCustGroupSql, { + name: POS_GROUP_NAME, + sort: 0, + }); + const posCustomerGroupId = posGroup.rows[0]?.id ?? posGroup.insertId; - const posCustomerGroupId = posGroup.recordset[0].id; + const result = await runner.query(createCompanySql, { + name, + rootCategoryId, + languageId, + taxrate: taxrateId, + reducedtaxrate: reducedtaxrateId, + taxfreerate: taxfreerateId, + posCustomerGroupId, + }); + const companyId = result.rows[0]?.id ?? result.insertId; - const result = await request(pool, tx) - .input('name', name) - .input('rootCategoryId', rootCategoryId) - .input('languageId', languageId) - .input('taxrate', taxrateId) - .input('reducedtaxrate', reducedtaxrateId) - .input('taxfreerate', taxfreerateId) - .input('posCustomerGroupId', posCustomerGroupId) - .query(CREATE); + await runner.query(setCustGroupCompanySql, { + id: posCustomerGroupId, + companyId, + }); - const companyId = result.recordset[0].id; - - await request(pool, tx) - .input('id', posCustomerGroupId) - .input('companyId', companyId) - .query(SET_CUSTOMERGROUP_COMPANY); - - await request(pool, tx) - .input('companyId', companyId) - .input('googleAuthId', googleAuthId) - .query(ADD_COMPANYUSER); - - await tx.commit(); + await runner.query(addCompanyUserSql, { + companyId, + googleAuthId, + }); return { id: companyId, @@ -103,13 +110,14 @@ export default function create(pool) { taxfreerateId, posCustomerGroupId, }; - } catch (err) { - try { - await tx.rollback(); - } catch { - // ignore - } - throw err; + }; + + if (tx) { + return runCreate(makeRunner(provider, tx)); } + if (typeof provider?.transaction === 'function') { + return provider.transaction(runCreate); + } + return runCreate(makeRunner(provider)); }; } diff --git a/src/store/company/delete.js b/src/store/company/delete.js index 5d1a8c2..4d62e6b 100644 --- a/src/store/company/delete.js +++ b/src/store/company/delete.js @@ -1,19 +1,22 @@ -import { DELETE } from '../../providers/mssql/queries/company.js'; +import { DELETE as MSSQL_DELETE } from '../../providers/mssql/queries/company.js'; +import { DELETE as SQLITE_DELETE } from '../../providers/sqlite3/queries/company.js'; +import makeRunner from '../runner.js'; + +export default function del(provider) { + const sql = + provider?.dialect === 'sqlite3' ? SQLITE_DELETE : MSSQL_DELETE; -export default function del(pool) { /** * Deletes a company by its id. * * @param {number} companyId - The id of the company to delete. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise} * * @example * await store.company.delete(1); */ - return async (companyId) => { - await pool - .request() - .input('companyId', companyId) - .query(DELETE); + return async (companyId, tx) => { + await makeRunner(provider, tx).query(sql, { companyId }); }; } diff --git a/src/store/company/getById.js b/src/store/company/getById.js index 11211e0..862d008 100644 --- a/src/store/company/getById.js +++ b/src/store/company/getById.js @@ -1,7 +1,11 @@ -import { GET_BY_ID } from '../../providers/mssql/queries/company.js'; -import request from '../request.js'; +import { GET_BY_ID as MSSQL_GET_BY_ID } from '../../providers/mssql/queries/company.js'; +import { GET_BY_ID as SQLITE_GET_BY_ID } from '../../providers/sqlite3/queries/company.js'; +import makeRunner from '../runner.js'; + +export default function getById(provider) { + const sql = + provider?.dialect === 'sqlite3' ? SQLITE_GET_BY_ID : MSSQL_GET_BY_ID; -export default function getById(pool) { /** * Returns a single company by its id. * @@ -14,10 +18,8 @@ export default function getById(pool) { * // => { id, name, ... } */ return async (id, tx) => { - const result = await request(pool, tx) - .input('id', id) - .query(GET_BY_ID); + const result = await makeRunner(provider, tx).query(sql, { id }); - return result.recordset[0]; + return result.rows[0]; }; } diff --git a/src/store/company/getByName.js b/src/store/company/getByName.js index fcdb5e5..6971115 100644 --- a/src/store/company/getByName.js +++ b/src/store/company/getByName.js @@ -1,7 +1,11 @@ -import { GET_BY_NAME } from '../../providers/mssql/queries/company.js'; -import request from '../request.js'; +import { GET_BY_NAME as MSSQL_GET_BY_NAME } from '../../providers/mssql/queries/company.js'; +import { GET_BY_NAME as SQLITE_GET_BY_NAME } from '../../providers/sqlite3/queries/company.js'; +import makeRunner from '../runner.js'; + +export default function getByName(provider) { + const sql = + provider?.dialect === 'sqlite3' ? SQLITE_GET_BY_NAME : MSSQL_GET_BY_NAME; -export default function getByName(pool) { /** * Returns a single company by its name. * @@ -14,10 +18,8 @@ export default function getByName(pool) { * // => { id, name, ... } */ return async (name, tx) => { - const result = await request(pool, tx) - .input('name', name) - .query(GET_BY_NAME); + const result = await makeRunner(provider, tx).query(sql, { name }); - return result.recordset[0]; + return result.rows[0]; }; } diff --git a/src/store/company/list.js b/src/store/company/list.js index c37e78e..71eb9b7 100644 --- a/src/store/company/list.js +++ b/src/store/company/list.js @@ -1,21 +1,23 @@ -import { LIST } from '../../providers/mssql/queries/company.js'; +import { LIST as MSSQL_LIST } from '../../providers/mssql/queries/company.js'; +import { LIST as SQLITE_LIST } from '../../providers/sqlite3/queries/company.js'; +import makeRunner from '../runner.js'; + +export default function list(provider) { + const sql = provider?.dialect === 'sqlite3' ? SQLITE_LIST : MSSQL_LIST; -export default function list(pool) { /** * Lists the companies accessible to the given Google auth user. * * @param {number} googleAuthId - The id of the Google auth user. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise>} The accessible company rows. * * @example * const companies = await store.company.list(42); * // => [{ id, name, ... }] */ - return async (googleAuthId) => { - const result = await pool - .request() - .input('googleAuthId', googleAuthId) - .query(LIST); - return result.recordset; + return async (googleAuthId, tx) => { + const result = await makeRunner(provider, tx).query(sql, { googleAuthId }); + return result.rows; }; } diff --git a/src/store/company/update.js b/src/store/company/update.js index 6246c79..78cf4fb 100644 --- a/src/store/company/update.js +++ b/src/store/company/update.js @@ -1,15 +1,18 @@ -import { UPDATE } from '../../providers/mssql/queries/company.js'; +import { UPDATE as MSSQL_UPDATE } from '../../providers/mssql/queries/company.js'; +import { UPDATE as SQLITE_UPDATE } from '../../providers/sqlite3/queries/company.js'; import getById from './getById.js'; import getByName from './getByName.js'; import langEnsure from '../lang/ensure.js'; import taxEnsure from '../tax/ensure.js'; -import request from '../request.js'; +import makeRunner from '../runner.js'; -export default function update(pool) { - const byId = getById(pool); - const byName = getByName(pool); - const ensureLang = langEnsure(pool); - const ensureTax = taxEnsure(pool); +export default function update(provider) { + const byId = getById(provider); + const byName = getByName(provider); + const ensureLang = langEnsure(provider); + const ensureTax = taxEnsure(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + const updateSql = isSqlite ? SQLITE_UPDATE : MSSQL_UPDATE; /** * Updates an existing company's name, language and tax rates. @@ -20,6 +23,7 @@ export default function update(pool) { * @param {string} input.name - The new company name (must be unique). * @param {number} input.taxrate - The standard tax rate in percent. * @param {number} input.reducedtaxrate - The reduced tax rate in percent. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise<{id: number, rootCategoryId: number, languageId: number, taxrateId: number, reducedtaxrateId: number, taxfreerateId: number}>} The updated company ids. * @throws {Error} If the company is not found or the name is taken. * @@ -27,36 +31,31 @@ export default function update(pool) { * const result = await store.company.update(1, { iso639: 'de', name: 'Acme', taxrate: 19, reducedtaxrate: 7 }); * // => { id, rootCategoryId, languageId, taxrateId, reducedtaxrateId, taxfreerateId } */ - return async (id, { iso639, name, taxrate, reducedtaxrate }) => { - const tx = pool.transaction(); - await tx.begin(); - - try { - const company = await byId(id, tx); + return async (id, { iso639, name, taxrate, reducedtaxrate }, tx) => { + const runUpdate = async (runner) => { + const company = await byId(id, runner); if (!company) { throw new Error('Company not found'); } if (name !== company.name) { - const taken = await byName(name, tx); + const taken = await byName(name, runner); if (taken) { throw new Error('Company name not available'); } } - const languageId = await ensureLang(iso639, tx); - const taxrateId = await ensureTax(taxrate, tx); - const reducedtaxrateId = await ensureTax(reducedtaxrate, tx); + const languageId = await ensureLang(iso639, runner); + const taxrateId = await ensureTax(taxrate, runner); + const reducedtaxrateId = await ensureTax(reducedtaxrate, runner); - await request(pool, tx) - .input('id', id) - .input('name', name) - .input('languageId', languageId) - .input('taxrate', taxrateId) - .input('reducedtaxrate', reducedtaxrateId) - .query(UPDATE); - - await tx.commit(); + await runner.query(updateSql, { + id, + name, + languageId, + taxrate: taxrateId, + reducedtaxrate: reducedtaxrateId, + }); return { id, @@ -66,13 +65,14 @@ export default function update(pool) { reducedtaxrateId, taxfreerateId: company.taxfreerate, }; - } catch (err) { - try { - await tx.rollback(); - } catch { - // ignore - } - throw err; + }; + + if (tx) { + return runUpdate(makeRunner(provider, tx)); } + if (typeof provider?.transaction === 'function') { + return provider.transaction(runUpdate); + } + return runUpdate(makeRunner(provider)); }; } diff --git a/src/store/companysetting/get.js b/src/store/companysetting/get.js index 0370b13..1e3af57 100644 --- a/src/store/companysetting/get.js +++ b/src/store/companysetting/get.js @@ -1,9 +1,15 @@ -import { GET_BY_COMPANY_AND_KEY } from '../../providers/mssql/queries/companysetting.js'; +import { GET_BY_COMPANY_AND_KEY as MSSQL_GET_BY_COMPANY_AND_KEY } from '../../providers/mssql/queries/companysetting.js'; +import { GET_BY_COMPANY_AND_KEY as SQLITE_GET_BY_COMPANY_AND_KEY } from '../../providers/sqlite3/queries/companysetting.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import shapeSetting from './shapeSetting.js'; +import makeRunner from '../runner.js'; -export default function get(pool) { - const access = resolveSelectedCompanyAccess(pool); +export default function get(provider) { + const access = resolveSelectedCompanyAccess(provider); + const sql = + provider?.dialect === 'sqlite3' + ? SQLITE_GET_BY_COMPANY_AND_KEY + : MSSQL_GET_BY_COMPANY_AND_KEY; /** * Returns a single company setting by key for the user's selected company. @@ -13,6 +19,7 @@ export default function get(pool) { * @param {object} [options] - Optional options. * @param {boolean} [options.frontendOnly] - Only return the setting if it is frontend-visible. * @param {boolean} [options.maskWriteonly] - Mask writeonly settings as '***'. Defaults to `frontendOnly` (usergui gets '***', backend gets full value). + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise<{key: string, value: string, frontend: boolean, writeonly: boolean}|undefined>} The setting, or `undefined` if not found. * * @example @@ -23,20 +30,20 @@ export default function get(pool) { googleAuthId, key, { frontendOnly = false, maskWriteonly } = {}, + tx, ) => { if (!key?.trim()) { return undefined; } - const { companyId } = await access(googleAuthId); + const { companyId } = await access(googleAuthId, tx); - const result = await pool - .request() - .input('companyId', companyId) - .input('key', key.trim()) - .query(GET_BY_COMPANY_AND_KEY); + const result = await makeRunner(provider, tx).query(sql, { + companyId, + key: key.trim(), + }); - const row = result.recordset[0]; + const row = result.rows[0]; if (!row) { return undefined; } diff --git a/src/store/companysetting/list.js b/src/store/companysetting/list.js index c7a9d4d..f74bd1b 100644 --- a/src/store/companysetting/list.js +++ b/src/store/companysetting/list.js @@ -1,12 +1,22 @@ import { - LIST_BY_COMPANY, - LIST_BY_COMPANY_FRONTEND, + LIST_BY_COMPANY as MSSQL_LIST_BY_COMPANY, + LIST_BY_COMPANY_FRONTEND as MSSQL_LIST_BY_COMPANY_FRONTEND, } from '../../providers/mssql/queries/companysetting.js'; +import { + LIST_BY_COMPANY as SQLITE_LIST_BY_COMPANY, + LIST_BY_COMPANY_FRONTEND as SQLITE_LIST_BY_COMPANY_FRONTEND, +} from '../../providers/sqlite3/queries/companysetting.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import shapeSetting from './shapeSetting.js'; +import makeRunner from '../runner.js'; -export default function list(pool) { - const access = resolveSelectedCompanyAccess(pool); +export default function list(provider) { + const access = resolveSelectedCompanyAccess(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + const listSql = isSqlite ? SQLITE_LIST_BY_COMPANY : MSSQL_LIST_BY_COMPANY; + const listFrontendSql = isSqlite + ? SQLITE_LIST_BY_COMPANY_FRONTEND + : MSSQL_LIST_BY_COMPANY_FRONTEND; /** * Lists the settings of the user's selected company, optionally only frontend-visible ones. @@ -18,6 +28,7 @@ export default function list(pool) { * @param {object} [options] - Optional options. * @param {boolean} [options.frontendOnly] - Only return frontend-visible settings. * @param {boolean} [options.maskWriteonly] - Whether to mask writeonly settings with '***'. Defaults to `true`. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise>} The settings. * * @example @@ -27,15 +38,16 @@ export default function list(pool) { return async ( googleAuthId, { frontendOnly = false, maskWriteonly = true } = {}, + tx, ) => { - const { companyId } = await access(googleAuthId); + const { companyId } = await access(googleAuthId, tx); - const result = await pool - .request() - .input('companyId', companyId) - .query(frontendOnly ? LIST_BY_COMPANY_FRONTEND : LIST_BY_COMPANY); + const result = await makeRunner(provider, tx).query( + frontendOnly ? listFrontendSql : listSql, + { companyId }, + ); - return result.recordset.map((row) => + return result.rows.map((row) => shapeSetting(row, { exposeValue: !maskWriteonly }), ); }; diff --git a/src/store/companysetting/set.js b/src/store/companysetting/set.js index 7e7fdf8..072e71c 100644 --- a/src/store/companysetting/set.js +++ b/src/store/companysetting/set.js @@ -1,6 +1,14 @@ -import { GET_BY_COMPANY_AND_KEY, UPSERT } from '../../providers/mssql/queries/companysetting.js'; +import { + GET_BY_COMPANY_AND_KEY as MSSQL_GET_BY_COMPANY_AND_KEY, + UPSERT as MSSQL_UPSERT, +} from '../../providers/mssql/queries/companysetting.js'; +import { + GET_BY_COMPANY_AND_KEY as SQLITE_GET_BY_COMPANY_AND_KEY, + UPSERT as SQLITE_UPSERT, +} from '../../providers/sqlite3/queries/companysetting.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import shapeSetting from './shapeSetting.js'; +import makeRunner from '../runner.js'; const ALLOWED_KEYS = new Set([ 'ui.theme', @@ -10,32 +18,29 @@ const ALLOWED_KEYS = new Set([ 'labelprinter.type', ]); -// Keys that are write-only by default: the GUI receives a scrambled "***" -// placeholder when reading, but the backend always gets the real value. const WRITEONLY_KEYS = new Set([ 'keys.tavily', 'keys.openrouter', ]); -export default function set(pool) { - const access = resolveSelectedCompanyAccess(pool); +export default function set(provider) { + const access = resolveSelectedCompanyAccess(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + const getSql = isSqlite + ? SQLITE_GET_BY_COMPANY_AND_KEY + : MSSQL_GET_BY_COMPANY_AND_KEY; + const upsertSql = isSqlite ? SQLITE_UPSERT : MSSQL_UPSERT; /** * Sets (upserts) a company setting by key for the user's selected company. * - * Only a fixed allow-list of keys can be set: `ui.theme`, `keys.tavily`, - * `keys.openrouter`, `labelprinter.addr`, `labelprinter.type`. - * - * Keys in the write-only set (`keys.tavily`, `keys.openrouter`) are stored - * with `writeonly = 1`; when retrieved by the GUI the value is returned as - * `"***"`. The backend always receives the real value. - * * @param {number} googleAuthId - The id of the Google auth user. * @param {object} input - The setting input. * @param {string} input.key - The setting key (must be in the allow-list). * @param {string} input.value - The setting value. * @param {boolean} [input.frontend] - Whether the setting is frontend-visible. * @param {boolean} [input.writeonly] - Whether the setting is write-only. Defaults to true for `keys.tavily` and `keys.openrouter`, false otherwise. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise<{key: string, value: string, frontend: boolean, writeonly: boolean}>} The upserted setting. * @throws {Error} If the input is invalid. * @@ -43,7 +48,7 @@ export default function set(pool) { * const setting = await store.companysetting.set(42, { key: 'ui.theme', value: 'dark' }); * // => { key, value, frontend, writeonly } */ - return async (googleAuthId, input = {}) => { + return async (googleAuthId, input = {}, tx) => { const key = input.key?.trim(); if (!ALLOWED_KEYS.has(key)) { throw new Error('Invalid setting'); @@ -52,29 +57,25 @@ export default function set(pool) { throw new Error('Invalid setting'); } + const runner = makeRunner(provider, tx); const frontend = Boolean(input.frontend); const writeonly = input.writeonly !== undefined ? Boolean(input.writeonly) : WRITEONLY_KEYS.has(key); - const { companyId } = await access(googleAuthId); + const { companyId } = await access(googleAuthId, tx); - await pool - .request() - .input('companyId', companyId) - .input('key', key) - .input('value', input.value) - .input('frontend', frontend) - .input('writeonly', writeonly) - .query(UPSERT); + await runner.query(upsertSql, { + companyId, + key, + value: input.value, + frontend: isSqlite ? (frontend ? 1 : 0) : frontend, + writeonly: isSqlite ? (writeonly ? 1 : 0) : writeonly, + }); - const result = await pool - .request() - .input('companyId', companyId) - .input('key', key) - .query(GET_BY_COMPANY_AND_KEY); + const result = await runner.query(getSql, { companyId, key }); // Return the masked value to the GUI; the DB holds the real value. - return shapeSetting(result.recordset[0]); + return shapeSetting(result.rows[0]); }; } diff --git a/src/store/companyuser/add.js b/src/store/companyuser/add.js index a879d13..b755af2 100644 --- a/src/store/companyuser/add.js +++ b/src/store/companyuser/add.js @@ -1,24 +1,28 @@ -import { ADD } from '../../providers/mssql/queries/companyuser.js'; +import { ADD as MSSQL_ADD } from '../../providers/mssql/queries/companyuser.js'; +import { ADD as SQLITE_ADD } from '../../providers/sqlite3/queries/companyuser.js'; +import makeRunner from '../runner.js'; + +export default function add(provider) { + const sql = provider?.dialect === 'sqlite3' ? SQLITE_ADD : MSSQL_ADD; -export default function add(pool) { /** * Adds a company user linking a company to a Google auth account. * * @param {number} companyId - The id of the company. * @param {number} googleAuthId - The id of the Google auth account. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise} The id of the created company user link. * * @example * const id = await store.companyuser.add(1, 42); * // => 7 */ - return async (companyId, googleAuthId) => { - const result = await pool - .request() - .input('companyId', companyId) - .input('googleAuthId', googleAuthId) - .query(ADD); + return async (companyId, googleAuthId, tx) => { + const result = await makeRunner(provider, tx).query(sql, { + companyId, + googleAuthId, + }); - return result.recordset[0].id; + return result.rows[0]?.id ?? result.insertId; }; } diff --git a/src/store/companyuser/delete.js b/src/store/companyuser/delete.js index 693c3cf..06541b4 100644 --- a/src/store/companyuser/delete.js +++ b/src/store/companyuser/delete.js @@ -1,19 +1,21 @@ -import { DELETE } from '../../providers/mssql/queries/companyuser.js'; +import { DELETE as MSSQL_DELETE } from '../../providers/mssql/queries/companyuser.js'; +import { DELETE as SQLITE_DELETE } from '../../providers/sqlite3/queries/companyuser.js'; +import makeRunner from '../runner.js'; + +export default function del(provider) { + const sql = provider?.dialect === 'sqlite3' ? SQLITE_DELETE : MSSQL_DELETE; -export default function del(pool) { /** * Deletes a company user link by its id. * * @param {number} id - The id of the company user link to delete. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise} * * @example * await store.companyuser.delete(7); */ - return async (id) => { - await pool - .request() - .input('id', id) - .query(DELETE); + return async (id, tx) => { + await makeRunner(provider, tx).query(sql, { id }); }; } diff --git a/src/store/companyuser/getByEmail.js b/src/store/companyuser/getByEmail.js index 9378250..f4b76d3 100644 --- a/src/store/companyuser/getByEmail.js +++ b/src/store/companyuser/getByEmail.js @@ -1,22 +1,27 @@ -import { GET_BY_EMAIL } from '../../providers/mssql/queries/companyuser.js'; +import { GET_BY_EMAIL as MSSQL_GET_BY_EMAIL } from '../../providers/mssql/queries/companyuser.js'; +import { GET_BY_EMAIL as SQLITE_GET_BY_EMAIL } from '../../providers/sqlite3/queries/companyuser.js'; +import makeRunner from '../runner.js'; + +export default function getByEmail(provider) { + const sql = + provider?.dialect === 'sqlite3' + ? SQLITE_GET_BY_EMAIL + : MSSQL_GET_BY_EMAIL; -export default function getByEmail(pool) { /** * Returns the company user ids associated with the given email address. * * @param {string} email - The email address. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise>} The company user ids. * * @example * const ids = await store.companyuser.getByEmail('user@example.com'); * // => [1, 2, 3] */ - return async (email) => { - const result = await pool - .request() - .input('email', email) - .query(GET_BY_EMAIL); + return async (email, tx) => { + const result = await makeRunner(provider, tx).query(sql, { email }); - return result.recordset.map((row) => row.id); + return result.rows.map((row) => row.id); }; } diff --git a/src/store/companyuser/resolveCompanyAccess.js b/src/store/companyuser/resolveCompanyAccess.js index bcacb18..073de9e 100644 --- a/src/store/companyuser/resolveCompanyAccess.js +++ b/src/store/companyuser/resolveCompanyAccess.js @@ -1,14 +1,20 @@ -import { GET_COMPANY_ACCESS } from '../../providers/mssql/queries/companyuser.js'; +import { GET_COMPANY_ACCESS as MSSQL_GET_COMPANY_ACCESS } from '../../providers/mssql/queries/companyuser.js'; +import { GET_COMPANY_ACCESS as SQLITE_GET_COMPANY_ACCESS } from '../../providers/sqlite3/queries/companyuser.js'; +import makeRunner from '../runner.js'; -export default function resolveCompanyAccess(pool) { - return async (googleAuthId, companyId) => { - const result = await pool - .request() - .input('googleAuthId', googleAuthId) - .input('companyId', companyId) - .query(GET_COMPANY_ACCESS); +export default function resolveCompanyAccess(provider) { + const sql = + provider?.dialect === 'sqlite3' + ? SQLITE_GET_COMPANY_ACCESS + : MSSQL_GET_COMPANY_ACCESS; - const row = result.recordset[0]; + return async (googleAuthId, companyId, tx) => { + const result = await makeRunner(provider, tx).query(sql, { + googleAuthId, + companyId, + }); + + const row = result.rows[0]; if (!row) { throw new Error('Company not found'); } diff --git a/src/store/companyuser/resolveSelectedCompanyAccess.js b/src/store/companyuser/resolveSelectedCompanyAccess.js index 4c466ce..511fdb2 100644 --- a/src/store/companyuser/resolveSelectedCompanyAccess.js +++ b/src/store/companyuser/resolveSelectedCompanyAccess.js @@ -1,27 +1,29 @@ -import { GET_BY_ID } from '../../providers/mssql/queries/google_auth.js'; +import { GET_BY_ID as MSSQL_GET_BY_ID } from '../../providers/mssql/queries/google_auth.js'; +import { GET_BY_ID as SQLITE_GET_BY_ID } from '../../providers/sqlite3/queries/google_auth.js'; +import makeRunner from '../runner.js'; import resolveCompanyAccess from './resolveCompanyAccess.js'; -export default function resolveSelectedCompanyAccess(pool) { - const access = resolveCompanyAccess(pool); +export default function resolveSelectedCompanyAccess(provider) { + const access = resolveCompanyAccess(provider); + const sql = + provider?.dialect === 'sqlite3' ? SQLITE_GET_BY_ID : MSSQL_GET_BY_ID; /** * Resolves the user's selected company and checks company membership. * * @param {number} googleAuthId - The id of the Google auth user. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise<{companyId: number, rootCategoryId: number, languageId: number, posCustomerGroupId: number|null, companyuserId: number}>} The resolved company access context. * @throws {Error} If the user is not authorized or has no company selected. */ - return async (googleAuthId) => { + return async (googleAuthId, tx) => { if (!Number.isFinite(googleAuthId)) { throw new Error('Not authorized'); } - const result = await pool - .request() - .input('googleAuthId', googleAuthId) - .query(GET_BY_ID); + const result = await makeRunner(provider, tx).query(sql, { googleAuthId }); - const row = result.recordset[0]; + const row = result.rows[0]; if (!row) { throw new Error('Not authorized'); } @@ -31,7 +33,7 @@ export default function resolveSelectedCompanyAccess(pool) { throw new Error('No company selected'); } - const ctx = await access(googleAuthId, companyId); + const ctx = await access(googleAuthId, companyId, tx); return { companyId, ...ctx }; }; } diff --git a/src/store/customergroup/create.js b/src/store/customergroup/create.js index e72ffb5..1fb7c3a 100644 --- a/src/store/customergroup/create.js +++ b/src/store/customergroup/create.js @@ -1,6 +1,14 @@ -import { CREATE, GET_NEXT_SORT } from '../../providers/mssql/queries/customergroup.js'; +import { + CREATE as MSSQL_CREATE, + GET_NEXT_SORT as MSSQL_GET_NEXT_SORT, +} from '../../providers/mssql/queries/customergroup.js'; +import { + CREATE as SQLITE_CREATE, + GET_NEXT_SORT as SQLITE_GET_NEXT_SORT, +} from '../../providers/sqlite3/queries/customergroup.js'; import { POS_GROUP_NAME, SUPPLIER_GROUP_NAME } from '../../providers/mssql/queries/entity.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; +import makeRunner from '../runner.js'; const RESERVED_NAMES = new Set([POS_GROUP_NAME, SUPPLIER_GROUP_NAME]); @@ -8,8 +16,11 @@ function shape(row) { return { id: row.id, name: row.name, sort: row.sort }; } -export default function create(pool) { - const access = resolveSelectedCompanyAccess(pool); +export default function create(provider) { + const access = resolveSelectedCompanyAccess(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + const getSortSql = isSqlite ? SQLITE_GET_NEXT_SORT : MSSQL_GET_NEXT_SORT; + const createSql = isSqlite ? SQLITE_CREATE : MSSQL_CREATE; /** * Creates a new customer group for the user's selected company. @@ -17,6 +28,7 @@ export default function create(pool) { * @param {number} googleAuthId - The id of the Google auth user. * @param {object} input - The customer group input. * @param {string} input.name - The customer group name (must not be a reserved name). + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise<{id: number, name: string, sort: number}>} The created customer group. * @throws {Error} If the name is empty or reserved. * @@ -24,7 +36,7 @@ export default function create(pool) { * const group = await store.customergroup.create(42, { name: 'Wholesale' }); * // => { id, name, sort } */ - return async (googleAuthId, { name } = {}) => { + return async (googleAuthId, { name } = {}, tx) => { const cleaned = name?.trim(); if (!cleaned) { throw new Error('Name required'); @@ -33,21 +45,19 @@ export default function create(pool) { throw new Error('Name not allowed'); } - const { companyId } = await access(googleAuthId); + const runner = makeRunner(provider, tx); + const { companyId } = await access(googleAuthId, tx); - const next = await pool - .request() - .input('companyId', companyId) - .query(GET_NEXT_SORT); - const sort = next.recordset[0].sort; + const next = await runner.query(getSortSql, { companyId }); + const sort = next.rows[0].sort; - const result = await pool - .request() - .input('companyId', companyId) - .input('name', cleaned) - .input('sort', sort) - .query(CREATE); + const result = await runner.query(createSql, { + companyId, + name: cleaned, + sort, + }); - return shape(result.recordset[0]); + const row = result.rows[0] ?? { id: result.insertId, name: cleaned, sort }; + return shape(row); }; } diff --git a/src/store/customergroup/delete.js b/src/store/customergroup/delete.js index ce1e1ba..017ad54 100644 --- a/src/store/customergroup/delete.js +++ b/src/store/customergroup/delete.js @@ -1,39 +1,46 @@ -import { DELETE_BY_COMPANY_AND_ID } from '../../providers/mssql/queries/customergroup.js'; +import { DELETE_BY_COMPANY_AND_ID as MSSQL_DELETE_BY_COMPANY_AND_ID } from '../../providers/mssql/queries/customergroup.js'; +import { DELETE_BY_COMPANY_AND_ID as SQLITE_DELETE_BY_COMPANY_AND_ID } from '../../providers/sqlite3/queries/customergroup.js'; +import { isForeignKeyConstraintError } from '../../providers/provider.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; +import makeRunner from '../runner.js'; -export default function del(pool) { - const access = resolveSelectedCompanyAccess(pool); +export default function del(provider) { + const access = resolveSelectedCompanyAccess(provider); + const sql = + provider?.dialect === 'sqlite3' + ? SQLITE_DELETE_BY_COMPANY_AND_ID + : MSSQL_DELETE_BY_COMPANY_AND_ID; /** * Deletes a customer group for the user's selected company by its id. * * @param {number} googleAuthId - The id of the Google auth user. * @param {number} id - The id of the customer group to delete. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise} * @throws {Error} If the customer group is not found. * * @example * await store.customergroup.delete(42, 5); */ - return async (googleAuthId, id) => { + return async (googleAuthId, id, tx) => { if (!Number.isFinite(id)) { throw new Error('Customer group not found'); } - const { companyId } = await access(googleAuthId); + const { companyId } = await access(googleAuthId, tx); try { - const result = await pool - .request() - .input('id', id) - .input('companyId', companyId) - .query(DELETE_BY_COMPANY_AND_ID); + const result = await makeRunner(provider, tx).query(sql, { + id, + companyId, + }); - if (!result.rowsAffected[0]) { + if (!result.rowsAffected) { throw new Error('Customer group not found'); } } catch (err) { - if (err.number === 547) { + if (isForeignKeyConstraintError(err)) { throw new Error('Customer group in use'); } throw err; diff --git a/src/store/customergroup/list.js b/src/store/customergroup/list.js index 5088f1e..05dede7 100644 --- a/src/store/customergroup/list.js +++ b/src/store/customergroup/list.js @@ -1,28 +1,32 @@ -import { LIST_BY_COMPANY } from '../../providers/mssql/queries/customergroup.js'; +import { LIST_BY_COMPANY as MSSQL_LIST_BY_COMPANY } from '../../providers/mssql/queries/customergroup.js'; +import { LIST_BY_COMPANY as SQLITE_LIST_BY_COMPANY } from '../../providers/sqlite3/queries/customergroup.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; +import makeRunner from '../runner.js'; -export default function list(pool) { - const access = resolveSelectedCompanyAccess(pool); +export default function list(provider) { + const access = resolveSelectedCompanyAccess(provider); + const sql = + provider?.dialect === 'sqlite3' + ? SQLITE_LIST_BY_COMPANY + : MSSQL_LIST_BY_COMPANY; /** * Lists the customer groups of the user's selected company. * * @param {number} googleAuthId - The id of the Google auth user. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise>} The customer groups. * * @example * const groups = await store.customergroup.list(42); * // => [{ id, name, sort }] */ - return async (googleAuthId) => { - const { companyId } = await access(googleAuthId); + return async (googleAuthId, tx) => { + const { companyId } = await access(googleAuthId, tx); - const result = await pool - .request() - .input('companyId', companyId) - .query(LIST_BY_COMPANY); + const result = await makeRunner(provider, tx).query(sql, { companyId }); - return result.recordset.map((row) => ({ + return result.rows.map((row) => ({ id: row.id, name: row.name, sort: row.sort, diff --git a/src/store/customergroup/order.js b/src/store/customergroup/order.js index 031e222..421f9ad 100644 --- a/src/store/customergroup/order.js +++ b/src/store/customergroup/order.js @@ -1,12 +1,21 @@ import { - LIST_IDS_BY_COMPANY, - UPDATE_SORT, + LIST_IDS_BY_COMPANY as MSSQL_LIST_IDS_BY_COMPANY, + UPDATE_SORT as MSSQL_UPDATE_SORT, } from '../../providers/mssql/queries/customergroup.js'; -import request from '../request.js'; +import { + LIST_IDS_BY_COMPANY as SQLITE_LIST_IDS_BY_COMPANY, + UPDATE_SORT as SQLITE_UPDATE_SORT, +} from '../../providers/sqlite3/queries/customergroup.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; +import makeRunner from '../runner.js'; -export default function order(pool) { - const access = resolveSelectedCompanyAccess(pool); +export default function order(provider) { + const access = resolveSelectedCompanyAccess(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + const listIdsSql = isSqlite + ? SQLITE_LIST_IDS_BY_COMPANY + : MSSQL_LIST_IDS_BY_COMPANY; + const updateSortSql = isSqlite ? SQLITE_UPDATE_SORT : MSSQL_UPDATE_SORT; /** * Reorders the customer groups of the user's selected company. @@ -14,6 +23,7 @@ export default function order(pool) { * @param {number} googleAuthId - The id of the Google auth user. * @param {object} input - The reorder input. * @param {Array} input.customergroupIds - The customer group ids in their new order. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise<{customergroupIds: Array}>} The applied order. * @throws {Error} If the order is invalid. * @@ -21,58 +31,51 @@ export default function order(pool) { * const result = await store.customergroup.order(42, { customergroupIds: [3, 1, 2] }); * // => { customergroupIds: [3, 1, 2] } */ - return async (googleAuthId, { customergroupIds } = {}) => { + return async (googleAuthId, { customergroupIds } = {}, tx) => { if (!Array.isArray(customergroupIds)) { throw new Error('Invalid customer group order'); } - const { companyId } = await access(googleAuthId); + const runOrder = async (runner) => { + const { companyId } = await access(googleAuthId, runner); - const groups = await pool - .request() - .input('companyId', companyId) - .query(LIST_IDS_BY_COMPANY); + const groups = await runner.query(listIdsSql, { companyId }); - const existing = new Set(groups.recordset.map((r) => r.id)); + const existing = new Set(groups.rows.map((r) => r.id)); - if (customergroupIds.length !== existing.size) { - throw new Error('Invalid customer group order'); - } - - const seen = new Set(); - for (const customergroupId of customergroupIds) { - if ( - !Number.isFinite(customergroupId) || - !existing.has(customergroupId) || - seen.has(customergroupId) - ) { + if (customergroupIds.length !== existing.size) { throw new Error('Invalid customer group order'); } - seen.add(customergroupId); - } - const tx = pool.transaction(); - await tx.begin(); - - try { - for (let sort = 0; sort < customergroupIds.length; sort++) { - await request(pool, tx) - .input('id', customergroupIds[sort]) - .input('companyId', companyId) - .input('sort', sort) - .query(UPDATE_SORT); + const seen = new Set(); + for (const customergroupId of customergroupIds) { + if ( + !Number.isFinite(customergroupId) || + !existing.has(customergroupId) || + seen.has(customergroupId) + ) { + throw new Error('Invalid customer group order'); + } + seen.add(customergroupId); } - await tx.commit(); + for (let sort = 0; sort < customergroupIds.length; sort++) { + await runner.query(updateSortSql, { + id: customergroupIds[sort], + companyId, + sort, + }); + } return { customergroupIds }; - } catch (err) { - try { - await tx.rollback(); - } catch { - // ignore - } - throw err; + }; + + if (tx) { + return runOrder(makeRunner(provider, tx)); } + if (typeof provider?.transaction === 'function') { + return provider.transaction(runOrder); + } + return runOrder(makeRunner(provider)); }; } diff --git a/src/store/customergroup/resolveCustomerGroupAccess.js b/src/store/customergroup/resolveCustomerGroupAccess.js index 65853aa..9b6709b 100644 --- a/src/store/customergroup/resolveCustomerGroupAccess.js +++ b/src/store/customergroup/resolveCustomerGroupAccess.js @@ -1,19 +1,25 @@ -import { GET_BY_COMPANY_AND_ID } from '../../providers/mssql/queries/customergroup.js'; -import request from '../request.js'; +import { GET_BY_COMPANY_AND_ID as MSSQL_GET_BY_COMPANY_AND_ID } from '../../providers/mssql/queries/customergroup.js'; +import { GET_BY_COMPANY_AND_ID as SQLITE_GET_BY_COMPANY_AND_ID } from '../../providers/sqlite3/queries/customergroup.js'; +import makeRunner from '../runner.js'; /** Ensure customergroup belongs to company. Throws Customer group not found. */ -export default function resolveCustomerGroupAccess(pool) { +export default function resolveCustomerGroupAccess(provider) { + const sql = + provider?.dialect === 'sqlite3' + ? SQLITE_GET_BY_COMPANY_AND_ID + : MSSQL_GET_BY_COMPANY_AND_ID; + return async (companyId, customergroupId, tx) => { if (!Number.isFinite(customergroupId)) { throw new Error('Customer group not found'); } - const result = await request(pool, tx) - .input('id', customergroupId) - .input('companyId', companyId) - .query(GET_BY_COMPANY_AND_ID); + const result = await makeRunner(provider, tx).query(sql, { + id: customergroupId, + companyId, + }); - const row = result.recordset[0]; + const row = result.rows[0]; if (!row) { throw new Error('Customer group not found'); } diff --git a/src/store/customergroup/update.js b/src/store/customergroup/update.js index a742458..b3e48f1 100644 --- a/src/store/customergroup/update.js +++ b/src/store/customergroup/update.js @@ -1,6 +1,14 @@ -import { GET_BY_COMPANY_AND_ID, UPDATE } from '../../providers/mssql/queries/customergroup.js'; +import { + GET_BY_COMPANY_AND_ID as MSSQL_GET_BY_COMPANY_AND_ID, + UPDATE as MSSQL_UPDATE, +} from '../../providers/mssql/queries/customergroup.js'; +import { + GET_BY_COMPANY_AND_ID as SQLITE_GET_BY_COMPANY_AND_ID, + UPDATE as SQLITE_UPDATE, +} from '../../providers/sqlite3/queries/customergroup.js'; import { POS_GROUP_NAME, SUPPLIER_GROUP_NAME } from '../../providers/mssql/queries/entity.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; +import makeRunner from '../runner.js'; const LOCKED_NAMES = new Set([POS_GROUP_NAME, SUPPLIER_GROUP_NAME]); @@ -8,8 +16,13 @@ function shape(row) { return { id: row.id, name: row.name, sort: row.sort }; } -export default function update(pool) { - const access = resolveSelectedCompanyAccess(pool); +export default function update(provider) { + const access = resolveSelectedCompanyAccess(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + const getSql = isSqlite + ? SQLITE_GET_BY_COMPANY_AND_ID + : MSSQL_GET_BY_COMPANY_AND_ID; + const updateSql = isSqlite ? SQLITE_UPDATE : MSSQL_UPDATE; /** * Updates the name of a customer group for the user's selected company. @@ -18,6 +31,7 @@ export default function update(pool) { * @param {number} id - The id of the customer group to update. * @param {object} input - The update input. * @param {string} input.name - The new customer group name. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise<{id: number, name: string, sort: number}>} The updated customer group. * @throws {Error} If the customer group is not found or the name is empty. * @@ -25,7 +39,7 @@ export default function update(pool) { * const group = await store.customergroup.update(42, 5, { name: 'Retail' }); * // => { id, name, sort } */ - return async (googleAuthId, id, { name } = {}) => { + return async (googleAuthId, id, { name } = {}, tx) => { if (!Number.isFinite(id)) { throw new Error('Customer group not found'); } @@ -35,15 +49,12 @@ export default function update(pool) { throw new Error('Name required'); } - const { companyId } = await access(googleAuthId); + const runner = makeRunner(provider, tx); + const { companyId } = await access(googleAuthId, tx); - const existing = await pool - .request() - .input('id', id) - .input('companyId', companyId) - .query(GET_BY_COMPANY_AND_ID); + const existing = await runner.query(getSql, { id, companyId }); - const current = existing.recordset[0]; + const current = existing.rows[0]; if (!current) { throw new Error('Customer group not found'); } @@ -52,17 +63,13 @@ export default function update(pool) { throw new Error('Name cannot be changed'); } - const result = await pool - .request() - .input('id', id) - .input('companyId', companyId) - .input('name', cleaned) - .query(UPDATE); + const result = await runner.query(updateSql, { + id, + companyId, + name: cleaned, + }); - const row = result.recordset[0]; - if (!row) { - throw new Error('Customer group not found'); - } + const row = result.rows[0] ?? { id, name: cleaned, sort: current.sort }; return shape(row); }; } diff --git a/src/store/delivery/attachments.js b/src/store/delivery/attachments.js index 8df86ae..cc8f98d 100644 --- a/src/store/delivery/attachments.js +++ b/src/store/delivery/attachments.js @@ -1,6 +1,7 @@ import { createHash } from 'node:crypto'; -import { LIST_BY_MANIFEST } from '../../providers/mssql/queries/manifestattachment.js'; -import request from '../request.js'; +import * as mssqlManifestAttachmentQueries from '../../providers/mssql/queries/manifestattachment.js'; +import * as sqliteManifestAttachmentQueries from '../../providers/sqlite3/queries/manifestattachment.js'; +import makeRunner from '../runner.js'; import generateDocumentThumb from '../../util/generateDocumentThumb.js'; import { parseMeta } from '../manifestattachment/listByManifestId.js'; @@ -33,12 +34,25 @@ export function shapeAttachmentMeta(row) { /** * Loads attachment metadata (without binary content) for a manifest. */ -export async function loadAttachments(pool, tx, manifestId) { - const result = await request(pool, tx) - .input('manifestId', manifestId) - .query(LIST_BY_MANIFEST); +export async function loadAttachments(provider, tx, manifestId) { + const runner = makeRunner(provider, tx); + const isSqlite = provider?.dialect === 'sqlite3'; + const queries = isSqlite ? sqliteManifestAttachmentQueries : mssqlManifestAttachmentQueries; - return result.recordset.map(shapeAttachmentMeta); + const result = await runner(queries.LIST_BY_MANIFEST, { manifestId }); + const rows = await Promise.all( + result.rows.map(async (row) => { + let thumbavif = row.thumbavif; + if (isSqlite) { + thumbavif = await (runner.readThumbAvif ? runner.readThumbAvif(row.hash) : provider?.readThumbAvif?.(row.hash)); + } + return shapeAttachmentMeta({ + ...row, + thumbavif, + }); + }), + ); + return rows; } function normalizeAttachment(raw, index) { @@ -57,25 +71,26 @@ function normalizeAttachment(raw, index) { throw new Error('Invalid file'); } - if (raw.content == null) { + if (rawContent(raw) == null) { throw new Error('Invalid file'); } let content; - if (Buffer.isBuffer(raw.content)) { - content = raw.content; - } else if (typeof raw.content === 'string') { - const trimmed = raw.content.trim(); + const rawData = rawContent(raw); + if (Buffer.isBuffer(rawData)) { + content = rawData; + } else if (typeof rawData === 'string') { + const trimmed = rawData.trim(); if (mimetype === 'application/pdf' || trimmed.startsWith('JVBER') || /^[A-Za-z0-9+/=]+$/.test(trimmed.slice(0, 100))) { try { content = Buffer.from(trimmed, 'base64'); } catch { - content = Buffer.from(raw.content, 'utf8'); + content = Buffer.from(rawData, 'utf8'); } } else { - content = Buffer.from(raw.content, 'utf8'); + content = Buffer.from(rawData, 'utf8'); } } else { - content = Buffer.from(raw.content); + content = Buffer.from(rawData); } const size = Number.isFinite(raw.size) && raw.size >= 0 @@ -126,54 +141,80 @@ function normalizeAttachment(raw, index) { }; } +function rawContent(raw) { + return raw.content; +} + /** * Inserts pdf/txt attachments for a delivery (manifest) inside an existing * transaction. Sort orders continue after the current maximum. * - * @param {object} pool + * @param {object} provider * @param {object} tx - Active transaction. * @param {object} sql - SQL statements from delivery.js. * @param {number} manifestId * @param {Array<{mimetype: string, name: string, content: Buffer, sort?: number, size?: number, created?: Date|string, modified?: Date|string, hash?: string}>} [files] * @returns {Promise>} Attachment metadata rows. */ -export async function insertAttachments(pool, tx, sql, manifestId, files) { +export async function insertAttachments(provider, tx, sql, manifestId, files) { + const runner = makeRunner(provider, tx); const attachments = (files ?? []).map(normalizeAttachment); - const sortRes = await request(pool, tx) - .input('manifestId', manifestId) - .query(sql.GET_NEXT_ATTACHMENT_SORT); - let nextSort = sortRes.recordset[0].sort; + const sortRes = await runner(sql.GET_NEXT_ATTACHMENT_SORT, { manifestId }); + let nextSort = sortRes.rows[0].sort; const results = []; for (const a of attachments) { const sort = a.sort ?? nextSort; nextSort = Math.max(nextSort, sort) + 1; - // Generate thumbnail server-side (e.g. pdftocairo + sharp for PDF) - const { thumbavif, thumbx, thumby } = await generateDocumentThumb(a.content, a.mimetype, { filename: a.name }); + // Use client-provided thumbavif if present, otherwise generate server-side + let thumbavif = a.thumbavif + ? (Buffer.isBuffer(a.thumbavif) + ? a.thumbavif + : Buffer.from(a.thumbavif, typeof a.thumbavif === 'string' ? 'base64' : undefined)) + : null; + let thumbx = Number.isFinite(a.thumbx) ? Number(a.thumbx) : null; + let thumby = Number.isFinite(a.thumby) ? Number(a.thumby) : null; + + if (!thumbavif) { + const generated = await generateDocumentThumb(a.content, a.mimetype, { filename: a.name }); + thumbavif = generated.thumbavif; + thumbx = generated.thumbx; + thumby = generated.thumby; + } + + if (provider?.dialect === 'sqlite3') { + if (runner.writeFileContent) { + await runner.writeFileContent(a.hash, a.content, a.mimetype); + if (thumbavif) await runner.writeThumbAvif(a.hash, thumbavif); + } else if (provider?.writeFileContent) { + await provider.writeFileContent(a.hash, a.content, a.mimetype); + if (thumbavif) await provider.writeThumbAvif(a.hash, thumbavif); + } + } - const fileRes = await request(pool, tx) - .input('hash', a.hash) - .input('mimetype', a.mimetype) - .input('name', a.name) - .input('size', a.size) - .input('created', a.created) - .input('modified', a.modified) - .input('content', a.content) - .input('thumbavif', thumbavif) - .input('thumbx', thumbx) - .input('thumby', thumby) - .input('meta', a.meta === null ? null : JSON.stringify(a.meta)) - .query(sql.CREATE_FILE); + const fileRes = await runner(sql.CREATE_FILE, { + hash: a.hash, + mimetype: a.mimetype, + name: a.name, + size: a.size, + created: a.created, + modified: a.modified, + content: a.content, + thumbavif, + thumbx, + thumby, + meta: a.meta === null ? null : JSON.stringify(a.meta), + }); - const fileId = fileRes.recordset[0].id; + const fileId = fileRes.rows[0]?.id ?? fileRes.insertId; - await request(pool, tx) - .input('manifestId', manifestId) - .input('fileId', fileId) - .input('sort', sort) - .query(sql.CREATE_ATTACHMENT); + await runner(sql.CREATE_ATTACHMENT, { + manifestId, + fileId, + sort, + }); results.push({ manifestId, diff --git a/src/store/delivery/create.js b/src/store/delivery/create.js index e82ae19..a71e5c7 100644 --- a/src/store/delivery/create.js +++ b/src/store/delivery/create.js @@ -1,11 +1,5 @@ -import { - CREATE_MANIFEST, - CREATE_FILE, - CREATE_ATTACHMENT, - GET_NEXT_ATTACHMENT_SORT, - DELIVERY_TYPE, -} from '../../providers/mssql/queries/delivery.js'; -import request from '../request.js'; +import * as mssqlQueries from '../../providers/mssql/queries/delivery.js'; +import * as sqliteQueries from '../../providers/sqlite3/queries/delivery.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import nextNumber from '../numbers/nextNumber.js'; import taxEnsure from '../tax/ensure.js'; @@ -13,30 +7,15 @@ import { insertLines, normalizeLines, parseDate } from '../manifestLines.js'; import { loadDelivery } from './helpers.js'; import { insertAttachments } from './attachments.js'; import { assertSupplier } from './writeLines.js'; +import makeRunner from '../runner.js'; -export default function create(pool) { - const access = resolveSelectedCompanyAccess(pool); - const ensureTax = taxEnsure(pool); - const allocateNumber = nextNumber(pool); +export default function create(provider) { + const access = resolveSelectedCompanyAccess(provider); + const ensureTax = taxEnsure(provider); + const allocateNumber = nextNumber(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + const queries = isSqlite ? sqliteQueries : mssqlQueries; - /** - * Creates a new delivery for the user's selected company with its lines. - * The delivery number is allocated internally via `getnextnumberwithprefixandpadding` - * (`type` `delivery`); supply `externalNumber` for an external (supplier) reference. - * - * @param {number} googleAuthId - The id of the Google auth user. - * @param {object} body - The delivery input. - * @param {string} body.date - The delivery date. - * @param {number} body.supplierId - The id of the supplier. - * @param {string} [body.externalNumber] - An external (supplier) reference number. - * @param {Array<{sku?: string, name?: string, pu?: number, amount: number, net: number, rebate?: number, taxrate: number, comment?: string|null}>} [body.lines] - The delivery lines. - * @returns {Promise<{id: number, number: string, type: string, entityId: number, externalNumber: string|null, date: string, lines: Array<{id: number, sku: string|null, name: string|null, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string|null}>}>} The created delivery. - * @throws {Error} If the delivery is invalid. - * - * @example - * const delivery = await store.delivery.create(42, { date: '2026-01-01', supplierId: 5, lines: [...] }); - * // => { id, number, type, entityId, externalNumber, date, lines: [...] } - */ return async (googleAuthId, body = {}) => { const date = parseDate(body.date); if (!date) { @@ -50,58 +29,51 @@ export default function create(pool) { const { companyId } = await access(googleAuthId); - const tx = pool.transaction(); - await tx.begin(); - - try { + const run = async (runner, tx) => { const supplierId = await assertSupplier( - pool, + provider, tx, companyId, body.supplierId ?? null, ); - const number = await allocateNumber(companyId, DELIVERY_TYPE, tx); + const number = await allocateNumber(companyId, queries.DELIVERY_TYPE, tx); - const created = await request(pool, tx) - .input('companyId', companyId) - .input('number', number) - .input('type', DELIVERY_TYPE) - .input('entityId', supplierId) - .input('externalNumber', externalNumber) - .input('date', date) - .query(CREATE_MANIFEST); + const created = await runner(queries.CREATE_MANIFEST, { + companyId, + number, + type: queries.DELIVERY_TYPE, + entityId: supplierId, + externalNumber, + date, + }); - const manifestId = created.recordset[0].id; + const manifestId = created.rows[0]?.id ?? created.insertId; - await insertLines(pool, tx, { + await insertLines(provider, tx, { manifestId, lines, ensureTax, }); await insertAttachments( - pool, + provider, tx, { - GET_NEXT_ATTACHMENT_SORT, - CREATE_FILE, - CREATE_ATTACHMENT, + GET_NEXT_ATTACHMENT_SORT: queries.GET_NEXT_ATTACHMENT_SORT, + CREATE_FILE: queries.CREATE_FILE, + CREATE_ATTACHMENT: queries.CREATE_ATTACHMENT, }, manifestId, body.files, ); - const delivery = await loadDelivery(pool, tx, companyId, manifestId); - await tx.commit(); - return delivery; - } catch (err) { - try { - await tx.rollback(); - } catch { - // ignore - } - throw err; + return loadDelivery(provider, tx, companyId, manifestId); + }; + + if (typeof provider?.transaction === 'function') { + return provider.transaction((runner, tx) => run(runner, tx)); } + return run(makeRunner(provider)); }; } diff --git a/src/store/delivery/delete.js b/src/store/delivery/delete.js index 271e640..8025730 100644 --- a/src/store/delivery/delete.js +++ b/src/store/delivery/delete.js @@ -1,35 +1,29 @@ -import { DELETE_MANIFEST, DELIVERY_TYPE } from '../../providers/mssql/queries/delivery.js'; +import * as mssqlQueries from '../../providers/mssql/queries/delivery.js'; +import * as sqliteQueries from '../../providers/sqlite3/queries/delivery.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; +import makeRunner from '../runner.js'; -export default function del(pool) { - const access = resolveSelectedCompanyAccess(pool); +export default function del(provider) { + const access = resolveSelectedCompanyAccess(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + const queries = isSqlite ? sqliteQueries : mssqlQueries; - /** - * Deletes a delivery by its id within the user's selected company. - * - * @param {number} googleAuthId - The id of the Google auth user. - * @param {number} id - The id of the delivery to delete. - * @returns {Promise} - * @throws {Error} If the delivery is not found. - * - * @example - * await store.delivery.delete(42, 5); - */ return async (googleAuthId, id) => { if (!Number.isFinite(id)) { throw new Error('Delivery not found'); } const { companyId } = await access(googleAuthId); + const runner = makeRunner(provider); - const result = await pool - .request() - .input('id', id) - .input('companyId', companyId) - .input('type', DELIVERY_TYPE) - .query(DELETE_MANIFEST); + const result = await runner(queries.DELETE_MANIFEST, { + id, + companyId, + type: queries.DELIVERY_TYPE, + }); - if (!result.rowsAffected[0]) { + const rowsAffected = result.rowsAffected?.[0] ?? (typeof result.rowsAffected === 'number' ? result.rowsAffected : 0); + if (!rowsAffected) { throw new Error('Delivery not found'); } }; diff --git a/src/store/delivery/getById.js b/src/store/delivery/getById.js index 7c8efa6..a56e540 100644 --- a/src/store/delivery/getById.js +++ b/src/store/delivery/getById.js @@ -1,21 +1,9 @@ import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import { loadDelivery } from './helpers.js'; -export default function getById(pool) { - const access = resolveSelectedCompanyAccess(pool); +export default function getById(provider) { + const access = resolveSelectedCompanyAccess(provider); - /** - * Returns a single delivery by its id within the user's selected company. - * - * @param {number} googleAuthId - The id of the Google auth user. - * @param {number} id - The id of the delivery. - * @returns {Promise<{id: number, number: string, type: string, entityId: number, externalNumber: string|null, date: string, lines: Array<{id: number, sku: string|null, name: string|null, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string|null}>}>} The delivery. - * @throws {Error} If the delivery is not found. - * - * @example - * const delivery = await store.delivery.getById(42, 5); - * // => { id, number, type, entityId, externalNumber, date, lines: [...] } - */ return async (googleAuthId, id) => { if (!Number.isFinite(id)) { throw new Error('Delivery not found'); @@ -23,7 +11,7 @@ export default function getById(pool) { const { companyId } = await access(googleAuthId); - const delivery = await loadDelivery(pool, null, companyId, id); + const delivery = await loadDelivery(provider, null, companyId, id); if (!delivery) { throw new Error('Delivery not found'); } diff --git a/src/store/delivery/helpers.js b/src/store/delivery/helpers.js index 3c147e7..50bd861 100644 --- a/src/store/delivery/helpers.js +++ b/src/store/delivery/helpers.js @@ -1,12 +1,11 @@ -import { - DELIVERY_TYPE, - GET_DELIVERY_HEADER, - GET_DELIVERY_LINES, -} from '../../providers/mssql/queries/delivery.js'; -import request from '../request.js'; +import * as mssqlQueries from '../../providers/mssql/queries/delivery.js'; +import * as sqliteQueries from '../../providers/sqlite3/queries/delivery.js'; +import makeRunner from '../runner.js'; import { shapeLine } from '../manifestLines.js'; import { loadAttachments } from './attachments.js'; +export const DELIVERY_TYPE = mssqlQueries.DELIVERY_TYPE; + export function shapeSummary(row) { return { id: row.id, @@ -18,27 +17,31 @@ export function shapeSummary(row) { }; } -export async function loadDelivery(pool, tx, companyId, id) { - const header = await request(pool, tx) - .input('id', id) - .input('companyId', companyId) - .input('type', DELIVERY_TYPE) - .query(GET_DELIVERY_HEADER); +export async function loadDelivery(provider, tx, companyId, id) { + const runner = makeRunner(provider, tx); + const isSqlite = provider?.dialect === 'sqlite3'; + const queries = isSqlite ? sqliteQueries : mssqlQueries; - const row = header.recordset[0]; + const header = await runner(queries.GET_DELIVERY_HEADER, { + id, + companyId, + type: DELIVERY_TYPE, + }); + + const row = header.rows[0]; if (!row) { return null; } - const lines = await request(pool, tx) - .input('manifestId', id) - .query(GET_DELIVERY_LINES); + const lines = await runner(queries.GET_DELIVERY_LINES, { + manifestId: id, + }); - const files = await loadAttachments(pool, tx, id); + const files = await loadAttachments(provider, tx, id); return { ...shapeSummary(row), - lines: lines.recordset.map(shapeLine), + lines: lines.rows.map(shapeLine), files, }; } diff --git a/src/store/delivery/list.js b/src/store/delivery/list.js index 9ca3cf8..a10b78b 100644 --- a/src/store/delivery/list.js +++ b/src/store/delivery/list.js @@ -1,45 +1,37 @@ -import { DELIVERY_TYPE, LIST_DELIVERIES, LIST_DELIVERY_FILES } from '../../providers/mssql/queries/delivery.js'; -import request from '../request.js'; +import * as mssqlQueries from '../../providers/mssql/queries/delivery.js'; +import * as sqliteQueries from '../../providers/sqlite3/queries/delivery.js'; +import makeRunner from '../runner.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import { shapeSummary } from './helpers.js'; import { shapeAttachmentMeta } from './attachments.js'; -export default function list(pool) { - const access = resolveSelectedCompanyAccess(pool); +export default function list(provider) { + const access = resolveSelectedCompanyAccess(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + const queries = isSqlite ? sqliteQueries : mssqlQueries; - /** - * Lists the deliveries of the user's selected company. - * - * @param {number} googleAuthId - The id of the Google auth user. - * @returns {Promise>} The deliveries. - * - * @example - * const deliveries = await store.delivery.list(42); - * // => [{ id, number, type, entityId, externalNumber, date }] - */ return async (googleAuthId) => { const { companyId } = await access(googleAuthId); + const runner = makeRunner(provider); - const result = await pool - .request() - .input('companyId', companyId) - .input('type', DELIVERY_TYPE) - .query(LIST_DELIVERIES); + const result = await runner(queries.LIST_DELIVERIES, { + companyId, + type: queries.DELIVERY_TYPE, + }); - const filesResult = await pool - .request() - .input('companyId', companyId) - .input('type', DELIVERY_TYPE) - .query(LIST_DELIVERY_FILES); + const filesResult = await runner(queries.LIST_DELIVERY_FILES, { + companyId, + type: queries.DELIVERY_TYPE, + }); const filesByManifest = new Map(); - for (const row of filesResult.recordset) { + for (const row of filesResult.rows) { const list = filesByManifest.get(row.manifestId) ?? []; list.push(shapeAttachmentMeta(row)); filesByManifest.set(row.manifestId, list); } - return result.recordset.map((row) => ({ + return result.rows.map((row) => ({ ...shapeSummary(row), files: filesByManifest.get(row.id) ?? [], })); diff --git a/src/store/delivery/update.js b/src/store/delivery/update.js index ab99e75..06382c0 100644 --- a/src/store/delivery/update.js +++ b/src/store/delivery/update.js @@ -1,42 +1,19 @@ -import { - DELETE_LINES_BY_MANIFEST, - DELIVERY_TYPE, - GET_DELIVERY_HEADER, - GET_NEXT_ATTACHMENT_SORT, - CREATE_FILE, - CREATE_ATTACHMENT, - UPDATE_MANIFEST, -} from '../../providers/mssql/queries/delivery.js'; -import request from '../request.js'; +import * as mssqlQueries from '../../providers/mssql/queries/delivery.js'; +import * as sqliteQueries from '../../providers/sqlite3/queries/delivery.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import taxEnsure from '../tax/ensure.js'; import { insertLines, normalizeLines, parseDate } from '../manifestLines.js'; import { loadDelivery, shapeSummary } from './helpers.js'; import { insertAttachments } from './attachments.js'; import { assertSupplier } from './writeLines.js'; +import makeRunner from '../runner.js'; -export default function update(pool) { - const access = resolveSelectedCompanyAccess(pool); - const ensureTax = taxEnsure(pool); +export default function update(provider) { + const access = resolveSelectedCompanyAccess(provider); + const ensureTax = taxEnsure(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + const queries = isSqlite ? sqliteQueries : mssqlQueries; - /** - * Updates a delivery's header and/or lines. - * - * @param {number} googleAuthId - The id of the Google auth user. - * @param {number} id - The id of the delivery to update. - * @param {object} patch - The update patch. - * @param {string} [patch.number] - The new delivery number. - * @param {string} [patch.date] - The new delivery date. - * @param {number} [patch.supplierId] - The new supplier id. - * @param {string} [patch.externalNumber] - The new external (supplier) reference number. - * @param {Array<{sku?: string, name?: string, pu?: number, amount: number, net: number, rebate?: number, taxrate: number, comment?: string|null}>} [patch.lines] - The new delivery lines. - * @returns {Promise<{id: number, number: string, type: string, entityId: number, externalNumber: string|null, date: string, lines: Array<{id: number, sku: string|null, name: string|null, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string|null}>}>} The updated delivery. - * @throws {Error} If the delivery is not found. - * - * @example - * const delivery = await store.delivery.update(42, 5, { number: 'D-2', lines: [...] }); - * // => { id, number, type, entityId, date, lines: [...] } - */ return async (googleAuthId, id, patch = {}) => { if (!Number.isFinite(id)) { throw new Error('Delivery not found'); @@ -63,17 +40,14 @@ export default function update(pool) { const { companyId } = await access(googleAuthId); - const tx = pool.transaction(); - await tx.begin(); + const run = async (runner, tx) => { + const existing = await runner(queries.GET_DELIVERY_HEADER, { + id, + companyId, + type: queries.DELIVERY_TYPE, + }); - try { - const existing = await request(pool, tx) - .input('id', id) - .input('companyId', companyId) - .input('type', DELIVERY_TYPE) - .query(GET_DELIVERY_HEADER); - - const current = existing.recordset[0]; + const current = existing.rows[0]; if (!current) { throw new Error('Delivery not found'); } @@ -99,7 +73,7 @@ export default function update(pool) { let supplierId = summary.supplierId; if (hasSupplierId) { supplierId = await assertSupplier( - pool, + provider, tx, companyId, patch.supplierId ?? null, @@ -113,22 +87,22 @@ export default function update(pool) { : null; } - await request(pool, tx) - .input('id', id) - .input('companyId', companyId) - .input('type', DELIVERY_TYPE) - .input('number', number) - .input('entityId', supplierId) - .input('externalNumber', externalNumber) - .input('date', date) - .query(UPDATE_MANIFEST); + await runner(queries.UPDATE_MANIFEST, { + id, + companyId, + type: queries.DELIVERY_TYPE, + number, + entityId: supplierId, + externalNumber, + date, + }); if (hasLines) { - await request(pool, tx) - .input('manifestId', id) - .query(DELETE_LINES_BY_MANIFEST); + await runner(queries.DELETE_LINES_BY_MANIFEST, { + manifestId: id, + }); - await insertLines(pool, tx, { + await insertLines(provider, tx, { manifestId: id, lines: normalizedLines, ensureTax, @@ -137,28 +111,24 @@ export default function update(pool) { if (hasFiles) { await insertAttachments( - pool, + provider, tx, { - GET_NEXT_ATTACHMENT_SORT, - CREATE_FILE, - CREATE_ATTACHMENT, + GET_NEXT_ATTACHMENT_SORT: queries.GET_NEXT_ATTACHMENT_SORT, + CREATE_FILE: queries.CREATE_FILE, + CREATE_ATTACHMENT: queries.CREATE_ATTACHMENT, }, id, patch.files, ); } - const delivery = await loadDelivery(pool, tx, companyId, id); - await tx.commit(); - return delivery; - } catch (err) { - try { - await tx.rollback(); - } catch { - // ignore - } - throw err; + return loadDelivery(provider, tx, companyId, id); + }; + + if (typeof provider?.transaction === 'function') { + return provider.transaction((runner, tx) => run(runner, tx)); } + return run(makeRunner(provider)); }; } diff --git a/src/store/delivery/writeLines.js b/src/store/delivery/writeLines.js index 3dd8e73..ad17a4e 100644 --- a/src/store/delivery/writeLines.js +++ b/src/store/delivery/writeLines.js @@ -1,7 +1,8 @@ -import { GET_SUPPLIER_BY_ID, SUPPLIER_GROUP_NAME } from '../../providers/mssql/queries/entity.js'; -import request from '../request.js'; +import * as mssqlQueries from '../../providers/mssql/queries/entity.js'; +import * as sqliteQueries from '../../providers/sqlite3/queries/entity.js'; +import makeRunner from '../runner.js'; -export async function assertSupplier(pool, tx, companyId, supplierId) { +export async function assertSupplier(provider, tx, companyId, supplierId) { if (supplierId == null) { return null; } @@ -9,13 +10,17 @@ export async function assertSupplier(pool, tx, companyId, supplierId) { throw new Error('Supplier not found'); } - const result = await request(pool, tx) - .input('id', supplierId) - .input('companyId', companyId) - .input('groupName', SUPPLIER_GROUP_NAME) - .query(GET_SUPPLIER_BY_ID); + const isSqlite = provider?.dialect === 'sqlite3'; + const queries = isSqlite ? sqliteQueries : mssqlQueries; + const runner = makeRunner(provider, tx); - if (!result.recordset[0]) { + const result = await runner(queries.GET_SUPPLIER_BY_ID, { + id: supplierId, + companyId, + groupName: queries.SUPPLIER_GROUP_NAME, + }); + + if (!result.rows[0]) { throw new Error('Supplier not found'); } return supplierId; diff --git a/src/store/entity/create.js b/src/store/entity/create.js index 7945bb0..0bfd158 100644 --- a/src/store/entity/create.js +++ b/src/store/entity/create.js @@ -1,19 +1,26 @@ -import { CREATE } from '../../providers/mssql/queries/entity.js'; -import { INSERT as INSERT_HISTORY } from '../../providers/mssql/queries/entityhistory.js'; -import request from '../request.js'; +import { CREATE as MSSQL_CREATE } from '../../providers/mssql/queries/entity.js'; +import { CREATE as SQLITE_CREATE } from '../../providers/sqlite3/queries/entity.js'; +import { INSERT as MSSQL_INSERT_HISTORY } from '../../providers/mssql/queries/entityhistory.js'; +import { INSERT as SQLITE_INSERT_HISTORY } from '../../providers/sqlite3/queries/entityhistory.js'; +import { isUniqueConstraintError } from '../../providers/provider.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import resolveCustomerGroupAccess from '../customergroup/resolveCustomerGroupAccess.js'; import nextNumber from '../numbers/nextNumber.js'; import shape from './shape.js'; +import makeRunner from '../runner.js'; -export default function create(pool) { - const access = resolveSelectedCompanyAccess(pool); - const groupAccess = resolveCustomerGroupAccess(pool); - const allocateNumber = nextNumber(pool); +export default function create(provider) { + const access = resolveSelectedCompanyAccess(provider); + const groupAccess = resolveCustomerGroupAccess(provider); + const allocateNumber = nextNumber(provider); + + const isSqlite = provider?.dialect === 'sqlite3'; + const createSql = isSqlite ? SQLITE_CREATE : MSSQL_CREATE; + const insertHistorySql = isSqlite ? SQLITE_INSERT_HISTORY : MSSQL_INSERT_HISTORY; /** * Creates a new entity (customer) in a customer group, recording history. - * Optional `number`; if omitted, allocates via `getnextnumberwithprefixandpadding` + * Optional `number`; if omitted, allocates via `numbers.nextNumber` * (`type` `entity`). * * @param {number} googleAuthId - The id of the Google auth user. @@ -22,6 +29,7 @@ export default function create(pool) { * @param {string} input.name - The entity name. * @param {string} [input.firstName] - The entity first name. * @param {string} [input.number] - Optional explicit customer number. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise<{id: number, customergroupId: number, firstName: string|null, name: string, number: string, createdAt: string, updatedAt: string}>} The created entity. * @throws {Error} If the name is empty. * @@ -29,7 +37,7 @@ export default function create(pool) { * const entity = await store.entity.create(42, { customergroupId: 5, name: 'Doe', firstName: 'John' }); * // => { id, customergroupId, firstName, name, number, createdAt, updatedAt } */ - return async (googleAuthId, { customergroupId, name, firstName = null, number } = {}) => { + return async (googleAuthId, { customergroupId, name, firstName = null, number } = {}, tx) => { const cleaned = name?.trim(); if (!cleaned) { throw new Error('Name required'); @@ -45,43 +53,50 @@ export default function create(pool) { ? null : String(number).trim() || null; - const { companyId, companyuserId } = await access(googleAuthId); - await groupAccess(companyId, customergroupId); + const runCreate = async (runner) => { + const { companyId, companyuserId } = await access(googleAuthId, runner); + await groupAccess(companyId, customergroupId, runner); - const tx = pool.transaction(); - await tx.begin(); - - try { const entityNumber = - explicitNumber ?? (await allocateNumber(companyId, 'entity', tx)); + explicitNumber ?? (await allocateNumber(companyId, 'entity', runner)); - const created = await request(pool, tx) - .input('customergroupId', customergroupId) - .input('firstName', first) - .input('name', cleaned) - .input('number', entityNumber) - .query(CREATE); - - const row = created.recordset[0]; - - await request(pool, tx) - .input('entityId', row.id) - .input('companyuserId', companyuserId) - .input('text', 'Erstellt') - .query(INSERT_HISTORY); - - await tx.commit(); - return shape(row); - } catch (err) { try { - await tx.rollback(); - } catch { - // ignore + const created = await runner.query(createSql, { + customergroupId, + firstName: first, + name: cleaned, + number: entityNumber, + }); + + const row = created.rows[0] ?? { + id: created.insertId, + customergroupId, + firstName: first, + name: cleaned, + number: entityNumber, + }; + + await runner.query(insertHistorySql, { + entityId: row.id, + companyuserId, + text: 'Erstellt', + }); + + return shape(row); + } catch (err) { + if (isUniqueConstraintError(err)) { + throw new Error('Number already in use'); + } + throw err; } - if (err.number === 2601 || err.number === 2627) { - throw new Error('Number already in use'); - } - throw err; + }; + + if (tx) { + return runCreate(makeRunner(provider, tx)); } + if (typeof provider?.transaction === 'function') { + return provider.transaction(runCreate); + } + return runCreate(makeRunner(provider)); }; } diff --git a/src/store/entity/delete.js b/src/store/entity/delete.js index 48b1504..b82a577 100644 --- a/src/store/entity/delete.js +++ b/src/store/entity/delete.js @@ -1,34 +1,37 @@ -import { DELETE_BY_COMPANY_AND_ID } from '../../providers/mssql/queries/entity.js'; +import { DELETE_BY_COMPANY_AND_ID as MSSQL_DELETE } from '../../providers/mssql/queries/entity.js'; +import { DELETE_BY_COMPANY_AND_ID as SQLITE_DELETE } from '../../providers/sqlite3/queries/entity.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; +import makeRunner from '../runner.js'; -export default function del(pool) { - const access = resolveSelectedCompanyAccess(pool); +export default function del(provider) { + const access = resolveSelectedCompanyAccess(provider); + const sql = provider?.dialect === 'sqlite3' ? SQLITE_DELETE : MSSQL_DELETE; /** * Deletes an entity by its id within the user's selected company. * * @param {number} googleAuthId - The id of the Google auth user. * @param {number} id - The id of the entity to delete. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise} * @throws {Error} If the entity is not found. * * @example * await store.entity.delete(42, 5); */ - return async (googleAuthId, id) => { + return async (googleAuthId, id, tx) => { if (!Number.isFinite(id)) { throw new Error('Entity not found'); } - const { companyId } = await access(googleAuthId); + const { companyId } = await access(googleAuthId, tx); - const result = await pool - .request() - .input('id', id) - .input('companyId', companyId) - .query(DELETE_BY_COMPANY_AND_ID); + const result = await makeRunner(provider, tx).query(sql, { + id, + companyId, + }); - if (!result.rowsAffected[0]) { + if (!result.rowsAffected) { throw new Error('Entity not found'); } }; diff --git a/src/store/entity/getById.js b/src/store/entity/getById.js index af8d5c2..2cb7928 100644 --- a/src/store/entity/getById.js +++ b/src/store/entity/getById.js @@ -1,15 +1,22 @@ -import { GET_BY_COMPANY_AND_ID } from '../../providers/mssql/queries/entity.js'; +import { GET_BY_COMPANY_AND_ID as MSSQL_GET_BY_COMPANY_AND_ID } from '../../providers/mssql/queries/entity.js'; +import { GET_BY_COMPANY_AND_ID as SQLITE_GET_BY_COMPANY_AND_ID } from '../../providers/sqlite3/queries/entity.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import shape from './shape.js'; +import makeRunner from '../runner.js'; -export default function getById(pool) { - const access = resolveSelectedCompanyAccess(pool); +export default function getById(provider) { + const access = resolveSelectedCompanyAccess(provider); + const sql = + provider?.dialect === 'sqlite3' + ? SQLITE_GET_BY_COMPANY_AND_ID + : MSSQL_GET_BY_COMPANY_AND_ID; /** * Returns a single entity by its id within the user's selected company. * * @param {number} googleAuthId - The id of the Google auth user. * @param {number} id - The id of the entity. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise<{id: number, customergroupId: number, firstName: string|null, name: string, number: string, createdAt: string, updatedAt: string}>} The entity. * @throws {Error} If the entity is not found. * @@ -17,20 +24,19 @@ export default function getById(pool) { * const entity = await store.entity.getById(42, 5); * // => { id, customergroupId, firstName, name, number, createdAt, updatedAt } */ - return async (googleAuthId, id) => { + return async (googleAuthId, id, tx) => { if (!Number.isFinite(id)) { throw new Error('Entity not found'); } - const { companyId } = await access(googleAuthId); + const { companyId } = await access(googleAuthId, tx); - const result = await pool - .request() - .input('id', id) - .input('companyId', companyId) - .query(GET_BY_COMPANY_AND_ID); + const result = await makeRunner(provider, tx).query(sql, { + id, + companyId, + }); - const row = result.recordset[0]; + const row = result.rows[0]; if (!row) { throw new Error('Entity not found'); } diff --git a/src/store/entity/isNumberFree.js b/src/store/entity/isNumberFree.js index 1dad8a8..d15df60 100644 --- a/src/store/entity/isNumberFree.js +++ b/src/store/entity/isNumberFree.js @@ -1,8 +1,14 @@ -import { NUMBER_TAKEN_IN_COMPANY } from '../../providers/mssql/queries/entity.js'; +import { NUMBER_TAKEN_IN_COMPANY as MSSQL_NUMBER_TAKEN } from '../../providers/mssql/queries/entity.js'; +import { NUMBER_TAKEN_IN_COMPANY as SQLITE_NUMBER_TAKEN } from '../../providers/sqlite3/queries/entity.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; +import makeRunner from '../runner.js'; -export default function isNumberFree(pool) { - const access = resolveSelectedCompanyAccess(pool); +export default function isNumberFree(provider) { + const access = resolveSelectedCompanyAccess(provider); + const sql = + provider?.dialect === 'sqlite3' + ? SQLITE_NUMBER_TAKEN + : MSSQL_NUMBER_TAKEN; /** * Returns whether a customer number is free within the user's selected company. @@ -12,6 +18,7 @@ export default function isNumberFree(pool) { * @param {string} number - The customer number to check. * @param {object} [options] - Optional options. * @param {number} [options.excludeEntityId] - An entity id to exclude from the check. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise} Whether the number is free. * @throws {Error} If the number is empty. * @@ -21,7 +28,7 @@ export default function isNumberFree(pool) { * const freeOnEdit = await store.entity.isNumberFree(42, 'C-100', { excludeEntityId: 5 }); * // => true */ - return async (googleAuthId, number, { excludeEntityId } = {}) => { + return async (googleAuthId, number, { excludeEntityId } = {}, tx) => { const cleaned = number == null || number === '' ? null : String(number).trim() || null; if (!cleaned) { @@ -35,15 +42,14 @@ export default function isNumberFree(pool) { throw new Error('Entity not found'); } - const { companyId } = await access(googleAuthId); + const { companyId } = await access(googleAuthId, tx); - const result = await pool - .request() - .input('companyId', companyId) - .input('number', cleaned) - .input('excludeEntityId', excludeEntityId ?? null) - .query(NUMBER_TAKEN_IN_COMPANY); + const result = await makeRunner(provider, tx).query(sql, { + companyId, + number: cleaned, + excludeEntityId: excludeEntityId ?? null, + }); - return !result.recordset[0]; + return !result.rows[0]; }; } diff --git a/src/store/entity/list.js b/src/store/entity/list.js index 5433fdc..0a86958 100644 --- a/src/store/entity/list.js +++ b/src/store/entity/list.js @@ -1,17 +1,34 @@ import { - LIST_BY_COMPANY, - LIST_BY_COMPANY_AND_GROUP, - LIST_CUSTOMERS_BY_COMPANY, + LIST_BY_COMPANY as MSSQL_LIST_BY_COMPANY, + LIST_BY_COMPANY_AND_GROUP as MSSQL_LIST_BY_COMPANY_AND_GROUP, + LIST_CUSTOMERS_BY_COMPANY as MSSQL_LIST_CUSTOMERS_BY_COMPANY, POS_GROUP_NAME, SUPPLIER_GROUP_NAME, } from '../../providers/mssql/queries/entity.js'; +import { + LIST_BY_COMPANY as SQLITE_LIST_BY_COMPANY, + LIST_BY_COMPANY_AND_GROUP as SQLITE_LIST_BY_COMPANY_AND_GROUP, + LIST_CUSTOMERS_BY_COMPANY as SQLITE_LIST_CUSTOMERS_BY_COMPANY, +} from '../../providers/sqlite3/queries/entity.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import resolveCustomerGroupAccess from '../customergroup/resolveCustomerGroupAccess.js'; import shape from './shape.js'; +import makeRunner from '../runner.js'; -export default function list(pool) { - const access = resolveSelectedCompanyAccess(pool); - const groupAccess = resolveCustomerGroupAccess(pool); +export default function list(provider) { + const access = resolveSelectedCompanyAccess(provider); + const groupAccess = resolveCustomerGroupAccess(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + + const listAllSql = isSqlite + ? SQLITE_LIST_BY_COMPANY + : MSSQL_LIST_BY_COMPANY; + const listGroupSql = isSqlite + ? SQLITE_LIST_BY_COMPANY_AND_GROUP + : MSSQL_LIST_BY_COMPANY_AND_GROUP; + const listCustomersSql = isSqlite + ? SQLITE_LIST_CUSTOMERS_BY_COMPANY + : MSSQL_LIST_CUSTOMERS_BY_COMPANY; /** * Lists the entities (customers) of the user's selected company, optionally @@ -21,6 +38,7 @@ export default function list(pool) { * @param {object} [options] - Optional options. * @param {number} [options.customergroupId] - Filter by customer group id. * @param {string} [options.role] - Restrict to `'customer'` role if set. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise>} The entities. * @throws {Error} If the role is invalid. * @@ -28,38 +46,33 @@ export default function list(pool) { * const entities = await store.entity.list(42, { customergroupId: 5 }); * // => [{ id, customergroupId, firstName, name, number, createdAt, updatedAt }] */ - return async (googleAuthId, { customergroupId, role } = {}) => { - const { companyId } = await access(googleAuthId); + return async (googleAuthId, { customergroupId, role } = {}, tx) => { + const runner = makeRunner(provider, tx); + const { companyId } = await access(googleAuthId, tx); if (role != null && role !== 'customer') { throw new Error('Invalid role'); } if (role === 'customer') { - const result = await pool - .request() - .input('companyId', companyId) - .input('supplierGroupName', SUPPLIER_GROUP_NAME) - .input('posGroupName', POS_GROUP_NAME) - .query(LIST_CUSTOMERS_BY_COMPANY); - return result.recordset.map(shape); + const result = await runner.query(listCustomersSql, { + companyId, + supplierGroupName: SUPPLIER_GROUP_NAME, + posGroupName: POS_GROUP_NAME, + }); + return result.rows.map(shape); } if (customergroupId != null) { - await groupAccess(companyId, customergroupId); - const result = await pool - .request() - .input('companyId', companyId) - .input('customergroupId', customergroupId) - .query(LIST_BY_COMPANY_AND_GROUP); - return result.recordset.map(shape); + await groupAccess(companyId, customergroupId, tx); + const result = await runner.query(listGroupSql, { + companyId, + customergroupId, + }); + return result.rows.map(shape); } - const result = await pool - .request() - .input('companyId', companyId) - .query(LIST_BY_COMPANY); - - return result.recordset.map(shape); + const result = await runner.query(listAllSql, { companyId }); + return result.rows.map(shape); }; } diff --git a/src/store/entity/peekNextNumber.js b/src/store/entity/peekNextNumber.js index f2b941a..1b9fd99 100644 --- a/src/store/entity/peekNextNumber.js +++ b/src/store/entity/peekNextNumber.js @@ -1,23 +1,24 @@ import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import peekNumber from '../numbers/peekNumber.js'; -export default function peekNextNumber(pool) { - const access = resolveSelectedCompanyAccess(pool); - const peek = peekNumber(pool); +export default function peekNextNumber(provider) { + const access = resolveSelectedCompanyAccess(provider); + const peek = peekNumber(provider); /** * Returns the next customer number that would be allocated for the user's * selected company (`numbers` type `entity`) without consuming it. * * @param {number} googleAuthId - The id of the Google auth user. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise} The next entity number. * * @example * const number = await store.entity.peekNextNumber(42); * // => '1' */ - return async (googleAuthId) => { - const { companyId } = await access(googleAuthId); - return peek(companyId, 'entity'); + return async (googleAuthId, tx) => { + const { companyId } = await access(googleAuthId, tx); + return peek(companyId, 'entity', tx); }; } diff --git a/src/store/entity/resolveEntityAccess.js b/src/store/entity/resolveEntityAccess.js index 944ea19..41f4099 100644 --- a/src/store/entity/resolveEntityAccess.js +++ b/src/store/entity/resolveEntityAccess.js @@ -1,19 +1,25 @@ -import { GET_BY_COMPANY_AND_ID } from '../../providers/mssql/queries/entity.js'; -import request from '../request.js'; +import { GET_BY_COMPANY_AND_ID as MSSQL_GET_BY_COMPANY_AND_ID } from '../../providers/mssql/queries/entity.js'; +import { GET_BY_COMPANY_AND_ID as SQLITE_GET_BY_COMPANY_AND_ID } from '../../providers/sqlite3/queries/entity.js'; +import makeRunner from '../runner.js'; /** Ensure entity belongs to company. Throws Entity not found. */ -export default function resolveEntityAccess(pool) { +export default function resolveEntityAccess(provider) { + const sql = + provider?.dialect === 'sqlite3' + ? SQLITE_GET_BY_COMPANY_AND_ID + : MSSQL_GET_BY_COMPANY_AND_ID; + return async (companyId, entityId, tx) => { if (!Number.isFinite(entityId)) { throw new Error('Entity not found'); } - const result = await request(pool, tx) - .input('id', entityId) - .input('companyId', companyId) - .query(GET_BY_COMPANY_AND_ID); + const result = await makeRunner(provider, tx).query(sql, { + id: entityId, + companyId, + }); - const row = result.recordset[0]; + const row = result.rows[0]; if (!row) { throw new Error('Entity not found'); } diff --git a/src/store/entity/update.js b/src/store/entity/update.js index d070f31..6ecb702 100644 --- a/src/store/entity/update.js +++ b/src/store/entity/update.js @@ -1,11 +1,28 @@ -import { GET_BY_COMPANY_AND_ID, UPDATE } from '../../providers/mssql/queries/entity.js'; -import { INSERT as INSERT_HISTORY } from '../../providers/mssql/queries/entityhistory.js'; -import request from '../request.js'; +import { + GET_BY_COMPANY_AND_ID as MSSQL_GET_BY_COMPANY_AND_ID, + UPDATE as MSSQL_UPDATE, +} from '../../providers/mssql/queries/entity.js'; +import { + GET_BY_COMPANY_AND_ID as SQLITE_GET_BY_COMPANY_AND_ID, + UPDATE as SQLITE_UPDATE, +} from '../../providers/sqlite3/queries/entity.js'; +import { INSERT as MSSQL_INSERT_HISTORY } from '../../providers/mssql/queries/entityhistory.js'; +import { INSERT as SQLITE_INSERT_HISTORY } from '../../providers/sqlite3/queries/entityhistory.js'; +import { isUniqueConstraintError } from '../../providers/provider.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import shape from './shape.js'; +import makeRunner from '../runner.js'; -export default function update(pool) { - const access = resolveSelectedCompanyAccess(pool); +export default function update(provider) { + const access = resolveSelectedCompanyAccess(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + const getSql = isSqlite + ? SQLITE_GET_BY_COMPANY_AND_ID + : MSSQL_GET_BY_COMPANY_AND_ID; + const updateSql = isSqlite ? SQLITE_UPDATE : MSSQL_UPDATE; + const insertHistorySql = isSqlite + ? SQLITE_INSERT_HISTORY + : MSSQL_INSERT_HISTORY; /** * Updates name, first name, and/or customer number of an entity, recording history. @@ -17,6 +34,7 @@ export default function update(pool) { * @param {string} [patch.name] - The new entity name. * @param {string} [patch.firstName] - The new first name. * @param {string} [patch.number] - The new customer number. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise<{id: number, customergroupId: number, firstName: string|null, name: string, number: string, createdAt: string, updatedAt: string}>} The updated entity. * @throws {Error} If no valid field is provided or the entity is not found. * @@ -24,7 +42,7 @@ export default function update(pool) { * const entity = await store.entity.update(42, 5, { name: 'Doe', firstName: 'Jane', number: 'C-42' }); * // => { id, customergroupId, firstName, name, number, createdAt, updatedAt } */ - return async (googleAuthId, id, patch = {}) => { + return async (googleAuthId, id, patch = {}, tx) => { if (!Number.isFinite(id)) { throw new Error('Entity not found'); } @@ -36,102 +54,101 @@ export default function update(pool) { throw new Error('Name required'); } - const { companyId, companyuserId } = await access(googleAuthId); + const runUpdate = async (runner) => { + const { companyId, companyuserId } = await access(googleAuthId, runner); - const existing = await pool - .request() - .input('id', id) - .input('companyId', companyId) - .query(GET_BY_COMPANY_AND_ID); + const existing = await runner.query(getSql, { id, companyId }); - const current = existing.recordset[0]; - if (!current) { - throw new Error('Entity not found'); - } - - let name = current.name; - if (hasName) { - name = patch.name?.trim(); - if (!name) { - throw new Error('Name required'); - } - } - - let firstName = current.firstName ?? null; - if (hasFirstName) { - firstName = - patch.firstName == null || patch.firstName === '' - ? null - : String(patch.firstName).trim() || null; - } - - let number = current.number ?? null; - if (hasNumber) { - number = - patch.number == null || patch.number === '' - ? null - : String(patch.number).trim() || null; - } - - const historyLines = []; - const oldFirst = current.firstName ?? ''; - const newFirst = firstName ?? ''; - if (oldFirst !== newFirst) { - historyLines.push( - `Vorname geändert von "${oldFirst}" auf "${newFirst}"`, - ); - } - if (current.name !== name) { - historyLines.push( - `Name geändert von "${current.name}" auf "${name}"`, - ); - } - const oldNumber = current.number ?? ''; - const newNumber = number ?? ''; - if (oldNumber !== newNumber) { - historyLines.push( - `Nummer geändert von "${oldNumber}" auf "${newNumber}"`, - ); - } - - if (!historyLines.length) { - return shape(current); - } - - const tx = pool.transaction(); - await tx.begin(); - - try { - const updated = await request(pool, tx) - .input('id', id) - .input('firstName', firstName) - .input('name', name) - .input('number', number) - .query(UPDATE); - - const row = updated.recordset[0]; - if (!row) { + const current = existing.rows[0]; + if (!current) { throw new Error('Entity not found'); } - await request(pool, tx) - .input('entityId', id) - .input('companyuserId', companyuserId) - .input('text', historyLines.join('\n')) - .query(INSERT_HISTORY); + let name = current.name; + if (hasName) { + name = patch.name?.trim(); + if (!name) { + throw new Error('Name required'); + } + } + + let firstName = current.firstName ?? null; + if (hasFirstName) { + firstName = + patch.firstName == null || patch.firstName === '' + ? null + : String(patch.firstName).trim() || null; + } + + let number = current.number ?? null; + if (hasNumber) { + number = + patch.number == null || patch.number === '' + ? null + : String(patch.number).trim() || null; + } + + const historyLines = []; + const oldFirst = current.firstName ?? ''; + const newFirst = firstName ?? ''; + if (oldFirst !== newFirst) { + historyLines.push( + `Vorname geändert von "${oldFirst}" auf "${newFirst}"`, + ); + } + if (current.name !== name) { + historyLines.push( + `Name geändert von "${current.name}" auf "${name}"`, + ); + } + const oldNumber = current.number ?? ''; + const newNumber = number ?? ''; + if (oldNumber !== newNumber) { + historyLines.push( + `Nummer geändert von "${oldNumber}" auf "${newNumber}"`, + ); + } + + if (!historyLines.length) { + return shape(current); + } - await tx.commit(); - return shape(row); - } catch (err) { try { - await tx.rollback(); - } catch { - // ignore + const updated = await runner.query(updateSql, { + id, + firstName, + name, + number, + }); + + const row = updated.rows[0] ?? { + ...current, + firstName, + name, + number, + }; + + await runner.query(insertHistorySql, { + entityId: id, + companyuserId, + text: historyLines.join('\n'), + }); + + return shape(row); + } catch (err) { + if (isUniqueConstraintError(err)) { + throw new Error('Number already in use'); + } + throw err; } - if (err.number === 2601 || err.number === 2627) { - throw new Error('Number already in use'); - } - throw err; + }; + + if (tx) { + return runUpdate(makeRunner(provider, tx)); } + if (typeof provider?.transaction === 'function') { + return provider.transaction(runUpdate); + } + return runUpdate(makeRunner(provider)); }; } diff --git a/src/store/entityaddress/create.js b/src/store/entityaddress/create.js index a00caf2..5a7998b 100644 --- a/src/store/entityaddress/create.js +++ b/src/store/entityaddress/create.js @@ -1,6 +1,14 @@ -import { CREATE, GET_NEXT_SORT } from '../../providers/mssql/queries/entityaddress.js'; +import { + CREATE as MSSQL_CREATE, + GET_NEXT_SORT as MSSQL_GET_NEXT_SORT, +} from '../../providers/mssql/queries/entityaddress.js'; +import { + CREATE as SQLITE_CREATE, + GET_NEXT_SORT as SQLITE_GET_NEXT_SORT, +} from '../../providers/sqlite3/queries/entityaddress.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import resolveEntityAccess from '../entity/resolveEntityAccess.js'; +import makeRunner from '../runner.js'; function shape(row) { return { @@ -24,9 +32,12 @@ function optionalString(value) { return s || null; } -export default function create(pool) { - const access = resolveSelectedCompanyAccess(pool); - const entityAccess = resolveEntityAccess(pool); +export default function create(provider) { + const access = resolveSelectedCompanyAccess(provider); + const entityAccess = resolveEntityAccess(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + const getSortSql = isSqlite ? SQLITE_GET_NEXT_SORT : MSSQL_GET_NEXT_SORT; + const createSql = isSqlite ? SQLITE_CREATE : MSSQL_CREATE; /** * Creates a new address for an entity. @@ -43,6 +54,7 @@ export default function create(pool) { * @param {string} [input.city] - The city. * @param {string} [input.county] - The county. * @param {number} [input.sort] - Optional sort order. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise<{id: number, entityId: number, firstName: string|null, name: string|null, street: string|null, streetNumber: string|null, zip: string|null, city: string|null, county: string|null, addressType: string, sort: number}>} The created address. * @throws {Error} If the address type is missing. * @@ -50,7 +62,7 @@ export default function create(pool) { * const address = await store.entityaddress.create(42, 5, { addressType: 'billing', street: 'Main St', city: 'Berlin' }); * // => { id, entityId, firstName, name, street, streetNumber, zip, city, county, addressType, sort } */ - return async (googleAuthId, entityId, input = {}) => { + return async (googleAuthId, entityId, input = {}, tx) => { const addressType = input.addressType?.trim?.() ? input.addressType.trim() : null; @@ -58,34 +70,45 @@ export default function create(pool) { throw new Error('Address type required'); } - const { companyId } = await access(googleAuthId); - await entityAccess(companyId, entityId); + const runner = makeRunner(provider, tx); + const { companyId } = await access(googleAuthId, tx); + await entityAccess(companyId, entityId, tx); let sort = input.sort; if (sort === undefined || sort === null) { - const next = await pool - .request() - .input('entityId', entityId) - .query(GET_NEXT_SORT); - sort = next.recordset[0].sort; + const next = await runner.query(getSortSql, { entityId }); + sort = next.rows[0].sort; } else if (!Number.isFinite(sort)) { throw new Error('Invalid sort'); } - const result = await pool - .request() - .input('entityId', entityId) - .input('firstName', optionalString(input.firstName)) - .input('name', optionalString(input.name)) - .input('street', optionalString(input.street)) - .input('streetNumber', optionalString(input.streetNumber)) - .input('zip', optionalString(input.zip)) - .input('city', optionalString(input.city)) - .input('county', optionalString(input.county)) - .input('addressType', addressType) - .input('sort', sort) - .query(CREATE); + const result = await runner.query(createSql, { + entityId, + firstName: optionalString(input.firstName), + name: optionalString(input.name), + street: optionalString(input.street), + streetNumber: optionalString(input.streetNumber), + zip: optionalString(input.zip), + city: optionalString(input.city), + county: optionalString(input.county), + addressType, + sort, + }); - return shape(result.recordset[0]); + const row = result.rows[0] ?? { + id: result.insertId, + entityId, + firstName: optionalString(input.firstName), + name: optionalString(input.name), + street: optionalString(input.street), + streetNumber: optionalString(input.streetNumber), + zip: optionalString(input.zip), + city: optionalString(input.city), + county: optionalString(input.county), + addressType, + sort, + }; + + return shape(row); }; } diff --git a/src/store/entityaddress/delete.js b/src/store/entityaddress/delete.js index c6a8867..547ef52 100644 --- a/src/store/entityaddress/delete.js +++ b/src/store/entityaddress/delete.js @@ -1,10 +1,13 @@ -import { DELETE_BY_ENTITY_AND_ID } from '../../providers/mssql/queries/entityaddress.js'; +import { DELETE_BY_ENTITY_AND_ID as MSSQL_DELETE } from '../../providers/mssql/queries/entityaddress.js'; +import { DELETE_BY_ENTITY_AND_ID as SQLITE_DELETE } from '../../providers/sqlite3/queries/entityaddress.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import resolveEntityAccess from '../entity/resolveEntityAccess.js'; +import makeRunner from '../runner.js'; -export default function del(pool) { - const access = resolveSelectedCompanyAccess(pool); - const entityAccess = resolveEntityAccess(pool); +export default function del(provider) { + const access = resolveSelectedCompanyAccess(provider); + const entityAccess = resolveEntityAccess(provider); + const sql = provider?.dialect === 'sqlite3' ? SQLITE_DELETE : MSSQL_DELETE; /** * Deletes an address of an entity. @@ -12,27 +15,28 @@ export default function del(pool) { * @param {number} googleAuthId - The id of the Google auth user. * @param {number} entityId - The id of the entity. * @param {number} id - The id of the address to delete. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise} * @throws {Error} If the address is not found. * * @example * await store.entityaddress.delete(42, 5, 9); */ - return async (googleAuthId, entityId, id) => { + return async (googleAuthId, entityId, id, tx) => { if (!Number.isFinite(id)) { throw new Error('Address not found'); } - const { companyId } = await access(googleAuthId); - await entityAccess(companyId, entityId); + const runner = makeRunner(provider, tx); + const { companyId } = await access(googleAuthId, tx); + await entityAccess(companyId, entityId, tx); - const result = await pool - .request() - .input('id', id) - .input('entityId', entityId) - .query(DELETE_BY_ENTITY_AND_ID); + const result = await runner.query(sql, { + id, + entityId, + }); - if (!result.rowsAffected[0]) { + if (!result.rowsAffected) { throw new Error('Address not found'); } }; diff --git a/src/store/entityaddress/list.js b/src/store/entityaddress/list.js index c951b9a..310756e 100644 --- a/src/store/entityaddress/list.js +++ b/src/store/entityaddress/list.js @@ -1,6 +1,8 @@ -import { LIST_BY_ENTITY } from '../../providers/mssql/queries/entityaddress.js'; +import { LIST_BY_ENTITY as MSSQL_LIST_BY_ENTITY } from '../../providers/mssql/queries/entityaddress.js'; +import { LIST_BY_ENTITY as SQLITE_LIST_BY_ENTITY } from '../../providers/sqlite3/queries/entityaddress.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import resolveEntityAccess from '../entity/resolveEntityAccess.js'; +import makeRunner from '../runner.js'; function shape(row) { return { @@ -18,30 +20,33 @@ function shape(row) { }; } -export default function list(pool) { - const access = resolveSelectedCompanyAccess(pool); - const entityAccess = resolveEntityAccess(pool); +export default function list(provider) { + const access = resolveSelectedCompanyAccess(provider); + const entityAccess = resolveEntityAccess(provider); + const sql = + provider?.dialect === 'sqlite3' + ? SQLITE_LIST_BY_ENTITY + : MSSQL_LIST_BY_ENTITY; /** * Lists the addresses of an entity. * * @param {number} googleAuthId - The id of the Google auth user. * @param {number} entityId - The id of the entity. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise>} The addresses. * * @example * const addresses = await store.entityaddress.list(42, 5); * // => [{ id, entityId, firstName, name, street, streetNumber, zip, city, county, addressType, sort }] */ - return async (googleAuthId, entityId) => { - const { companyId } = await access(googleAuthId); - await entityAccess(companyId, entityId); + return async (googleAuthId, entityId, tx) => { + const runner = makeRunner(provider, tx); + const { companyId } = await access(googleAuthId, tx); + await entityAccess(companyId, entityId, tx); - const result = await pool - .request() - .input('entityId', entityId) - .query(LIST_BY_ENTITY); + const result = await runner.query(sql, { entityId }); - return result.recordset.map(shape); + return result.rows.map(shape); }; } diff --git a/src/store/entityaddress/update.js b/src/store/entityaddress/update.js index 0b4c2d2..cfbb0cb 100644 --- a/src/store/entityaddress/update.js +++ b/src/store/entityaddress/update.js @@ -1,6 +1,14 @@ -import { GET_BY_ENTITY_AND_ID, UPDATE } from '../../providers/mssql/queries/entityaddress.js'; +import { + GET_BY_ENTITY_AND_ID as MSSQL_GET_BY_ENTITY_AND_ID, + UPDATE as MSSQL_UPDATE, +} from '../../providers/mssql/queries/entityaddress.js'; +import { + GET_BY_ENTITY_AND_ID as SQLITE_GET_BY_ENTITY_AND_ID, + UPDATE as SQLITE_UPDATE, +} from '../../providers/sqlite3/queries/entityaddress.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import resolveEntityAccess from '../entity/resolveEntityAccess.js'; +import makeRunner from '../runner.js'; function shape(row) { return { @@ -24,9 +32,14 @@ function optionalString(value) { return s || null; } -export default function update(pool) { - const access = resolveSelectedCompanyAccess(pool); - const entityAccess = resolveEntityAccess(pool); +export default function update(provider) { + const access = resolveSelectedCompanyAccess(provider); + const entityAccess = resolveEntityAccess(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + const getSql = isSqlite + ? SQLITE_GET_BY_ENTITY_AND_ID + : MSSQL_GET_BY_ENTITY_AND_ID; + const updateSql = isSqlite ? SQLITE_UPDATE : MSSQL_UPDATE; /** * Updates an address of an entity. @@ -44,6 +57,7 @@ export default function update(pool) { * @param {string} [patch.city] - The city. * @param {string} [patch.county] - The county. * @param {number} [patch.sort] - The sort order. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise<{id: number, entityId: number, firstName: string|null, name: string|null, street: string|null, streetNumber: string|null, zip: string|null, city: string|null, county: string|null, addressType: string, sort: number}>} The updated address. * @throws {Error} If the address is not found. * @@ -51,21 +65,18 @@ export default function update(pool) { * const address = await store.entityaddress.update(42, 5, 9, { city: 'Hamburg' }); * // => { id, entityId, firstName, name, street, streetNumber, zip, city, county, addressType, sort } */ - return async (googleAuthId, entityId, id, patch = {}) => { + return async (googleAuthId, entityId, id, patch = {}, tx) => { if (!Number.isFinite(id)) { throw new Error('Address not found'); } - const { companyId } = await access(googleAuthId); - await entityAccess(companyId, entityId); + const runner = makeRunner(provider, tx); + const { companyId } = await access(googleAuthId, tx); + await entityAccess(companyId, entityId, tx); - const existing = await pool - .request() - .input('id', id) - .input('entityId', entityId) - .query(GET_BY_ENTITY_AND_ID); + const existing = await runner.query(getSql, { id, entityId }); - const current = existing.recordset[0]; + const current = existing.rows[0]; if (!current) { throw new Error('Address not found'); } @@ -113,25 +124,34 @@ export default function update(pool) { sort = patch.sort; } - const result = await pool - .request() - .input('id', id) - .input('entityId', entityId) - .input('firstName', firstName) - .input('name', name) - .input('street', street) - .input('streetNumber', streetNumber) - .input('zip', zip) - .input('city', city) - .input('county', county) - .input('addressType', addressType) - .input('sort', sort) - .query(UPDATE); + const result = await runner.query(updateSql, { + id, + entityId, + firstName, + name, + street, + streetNumber, + zip, + city, + county, + addressType, + sort, + }); + + const row = result.rows[0] ?? { + id, + entityId, + firstName, + name, + street, + streetNumber, + zip, + city, + county, + addressType, + sort, + }; - const row = result.recordset[0]; - if (!row) { - throw new Error('Address not found'); - } return shape(row); }; } diff --git a/src/store/entitycontact/create.js b/src/store/entitycontact/create.js index 06c737f..5894a67 100644 --- a/src/store/entitycontact/create.js +++ b/src/store/entitycontact/create.js @@ -1,6 +1,14 @@ -import { CREATE, GET_NEXT_SORT } from '../../providers/mssql/queries/entitycontact.js'; +import { + CREATE as MSSQL_CREATE, + GET_NEXT_SORT as MSSQL_GET_NEXT_SORT, +} from '../../providers/mssql/queries/entitycontact.js'; +import { + CREATE as SQLITE_CREATE, + GET_NEXT_SORT as SQLITE_GET_NEXT_SORT, +} from '../../providers/sqlite3/queries/entitycontact.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import resolveEntityAccess from '../entity/resolveEntityAccess.js'; +import makeRunner from '../runner.js'; function shape(row) { return { @@ -12,9 +20,12 @@ function shape(row) { }; } -export default function create(pool) { - const access = resolveSelectedCompanyAccess(pool); - const entityAccess = resolveEntityAccess(pool); +export default function create(provider) { + const access = resolveSelectedCompanyAccess(provider); + const entityAccess = resolveEntityAccess(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + const getSortSql = isSqlite ? SQLITE_GET_NEXT_SORT : MSSQL_GET_NEXT_SORT; + const createSql = isSqlite ? SQLITE_CREATE : MSSQL_CREATE; /** * Creates a new contact for an entity. @@ -25,6 +36,7 @@ export default function create(pool) { * @param {string} input.contactType - The contact type (e.g. `'email'`). * @param {string} input.contact - The contact value. * @param {number} [input.sort] - Optional sort order. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise<{id: number, entityId: number, contactType: string, contact: string, sort: number}>} The created contact. * @throws {Error} If the contact type or value is missing. * @@ -32,7 +44,7 @@ export default function create(pool) { * const contact = await store.entitycontact.create(42, 5, { contactType: 'email', contact: 'a@b.c' }); * // => { id, entityId, contactType, contact, sort } */ - return async (googleAuthId, entityId, input = {}) => { + return async (googleAuthId, entityId, input = {}, tx) => { const contactType = input.contactType?.trim?.() ? input.contactType.trim() : null; @@ -41,28 +53,33 @@ export default function create(pool) { throw new Error('Contact required'); } - const { companyId } = await access(googleAuthId); - await entityAccess(companyId, entityId); + const runner = makeRunner(provider, tx); + const { companyId } = await access(googleAuthId, tx); + await entityAccess(companyId, entityId, tx); let sort = input.sort; if (sort === undefined || sort === null) { - const next = await pool - .request() - .input('entityId', entityId) - .query(GET_NEXT_SORT); - sort = next.recordset[0].sort; + const next = await runner.query(getSortSql, { entityId }); + sort = next.rows[0].sort; } else if (!Number.isFinite(sort)) { throw new Error('Invalid sort'); } - const result = await pool - .request() - .input('entityId', entityId) - .input('contactType', contactType) - .input('contact', contact) - .input('sort', sort) - .query(CREATE); + const result = await runner.query(createSql, { + entityId, + contactType, + contact, + sort, + }); - return shape(result.recordset[0]); + const row = result.rows[0] ?? { + id: result.insertId, + entityId, + contactType, + contact, + sort, + }; + + return shape(row); }; } diff --git a/src/store/entitycontact/delete.js b/src/store/entitycontact/delete.js index f1817bb..ae6d796 100644 --- a/src/store/entitycontact/delete.js +++ b/src/store/entitycontact/delete.js @@ -1,10 +1,13 @@ -import { DELETE_BY_ENTITY_AND_ID } from '../../providers/mssql/queries/entitycontact.js'; +import { DELETE_BY_ENTITY_AND_ID as MSSQL_DELETE } from '../../providers/mssql/queries/entitycontact.js'; +import { DELETE_BY_ENTITY_AND_ID as SQLITE_DELETE } from '../../providers/sqlite3/queries/entitycontact.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import resolveEntityAccess from '../entity/resolveEntityAccess.js'; +import makeRunner from '../runner.js'; -export default function del(pool) { - const access = resolveSelectedCompanyAccess(pool); - const entityAccess = resolveEntityAccess(pool); +export default function del(provider) { + const access = resolveSelectedCompanyAccess(provider); + const entityAccess = resolveEntityAccess(provider); + const sql = provider?.dialect === 'sqlite3' ? SQLITE_DELETE : MSSQL_DELETE; /** * Deletes a contact of an entity. @@ -12,27 +15,28 @@ export default function del(pool) { * @param {number} googleAuthId - The id of the Google auth user. * @param {number} entityId - The id of the entity. * @param {number} id - The id of the contact to delete. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise} * @throws {Error} If the contact is not found. * * @example * await store.entitycontact.delete(42, 5, 9); */ - return async (googleAuthId, entityId, id) => { + return async (googleAuthId, entityId, id, tx) => { if (!Number.isFinite(id)) { throw new Error('Contact not found'); } - const { companyId } = await access(googleAuthId); - await entityAccess(companyId, entityId); + const runner = makeRunner(provider, tx); + const { companyId } = await access(googleAuthId, tx); + await entityAccess(companyId, entityId, tx); - const result = await pool - .request() - .input('id', id) - .input('entityId', entityId) - .query(DELETE_BY_ENTITY_AND_ID); + const result = await runner.query(sql, { + id, + entityId, + }); - if (!result.rowsAffected[0]) { + if (!result.rowsAffected) { throw new Error('Contact not found'); } }; diff --git a/src/store/entitycontact/list.js b/src/store/entitycontact/list.js index 0890d5d..e5a156d 100644 --- a/src/store/entitycontact/list.js +++ b/src/store/entitycontact/list.js @@ -1,6 +1,8 @@ -import { LIST_BY_ENTITY } from '../../providers/mssql/queries/entitycontact.js'; +import { LIST_BY_ENTITY as MSSQL_LIST_BY_ENTITY } from '../../providers/mssql/queries/entitycontact.js'; +import { LIST_BY_ENTITY as SQLITE_LIST_BY_ENTITY } from '../../providers/sqlite3/queries/entitycontact.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import resolveEntityAccess from '../entity/resolveEntityAccess.js'; +import makeRunner from '../runner.js'; function shape(row) { return { @@ -12,30 +14,33 @@ function shape(row) { }; } -export default function list(pool) { - const access = resolveSelectedCompanyAccess(pool); - const entityAccess = resolveEntityAccess(pool); +export default function list(provider) { + const access = resolveSelectedCompanyAccess(provider); + const entityAccess = resolveEntityAccess(provider); + const sql = + provider?.dialect === 'sqlite3' + ? SQLITE_LIST_BY_ENTITY + : MSSQL_LIST_BY_ENTITY; /** * Lists the contacts of an entity. * * @param {number} googleAuthId - The id of the Google auth user. * @param {number} entityId - The id of the entity. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise>} The contacts. * * @example * const contacts = await store.entitycontact.list(42, 5); * // => [{ id, entityId, contactType, contact, sort }] */ - return async (googleAuthId, entityId) => { - const { companyId } = await access(googleAuthId); - await entityAccess(companyId, entityId); + return async (googleAuthId, entityId, tx) => { + const runner = makeRunner(provider, tx); + const { companyId } = await access(googleAuthId, tx); + await entityAccess(companyId, entityId, tx); - const result = await pool - .request() - .input('entityId', entityId) - .query(LIST_BY_ENTITY); + const result = await runner.query(sql, { entityId }); - return result.recordset.map(shape); + return result.rows.map(shape); }; } diff --git a/src/store/entitycontact/update.js b/src/store/entitycontact/update.js index bda57ae..fc30271 100644 --- a/src/store/entitycontact/update.js +++ b/src/store/entitycontact/update.js @@ -1,6 +1,14 @@ -import { GET_BY_ENTITY_AND_ID, UPDATE } from '../../providers/mssql/queries/entitycontact.js'; +import { + GET_BY_ENTITY_AND_ID as MSSQL_GET_BY_ENTITY_AND_ID, + UPDATE as MSSQL_UPDATE, +} from '../../providers/mssql/queries/entitycontact.js'; +import { + GET_BY_ENTITY_AND_ID as SQLITE_GET_BY_ENTITY_AND_ID, + UPDATE as SQLITE_UPDATE, +} from '../../providers/sqlite3/queries/entitycontact.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import resolveEntityAccess from '../entity/resolveEntityAccess.js'; +import makeRunner from '../runner.js'; function shape(row) { return { @@ -12,9 +20,14 @@ function shape(row) { }; } -export default function update(pool) { - const access = resolveSelectedCompanyAccess(pool); - const entityAccess = resolveEntityAccess(pool); +export default function update(provider) { + const access = resolveSelectedCompanyAccess(provider); + const entityAccess = resolveEntityAccess(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + const getSql = isSqlite + ? SQLITE_GET_BY_ENTITY_AND_ID + : MSSQL_GET_BY_ENTITY_AND_ID; + const updateSql = isSqlite ? SQLITE_UPDATE : MSSQL_UPDATE; /** * Updates a contact of an entity. @@ -23,6 +36,7 @@ export default function update(pool) { * @param {number} entityId - The id of the entity. * @param {number} id - The id of the contact to update. * @param {object} patch - The update patch (any subset of contact fields). + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise<{id: number, entityId: number, contactType: string, contact: string, sort: number}>} The updated contact. * @throws {Error} If the contact is not found. * @@ -30,21 +44,18 @@ export default function update(pool) { * const contact = await store.entitycontact.update(42, 5, 9, { contact: 'b@c.d' }); * // => { id, entityId, contactType, contact, sort } */ - return async (googleAuthId, entityId, id, patch = {}) => { + return async (googleAuthId, entityId, id, patch = {}, tx) => { if (!Number.isFinite(id)) { throw new Error('Contact not found'); } - const { companyId } = await access(googleAuthId); - await entityAccess(companyId, entityId); + const runner = makeRunner(provider, tx); + const { companyId } = await access(googleAuthId, tx); + await entityAccess(companyId, entityId, tx); - const existing = await pool - .request() - .input('id', id) - .input('entityId', entityId) - .query(GET_BY_ENTITY_AND_ID); + const existing = await runner.query(getSql, { id, entityId }); - const current = existing.recordset[0]; + const current = existing.rows[0]; if (!current) { throw new Error('Contact not found'); } @@ -73,19 +84,22 @@ export default function update(pool) { sort = patch.sort; } - const result = await pool - .request() - .input('id', id) - .input('entityId', entityId) - .input('contactType', contactType) - .input('contact', contact) - .input('sort', sort) - .query(UPDATE); + const result = await runner.query(updateSql, { + id, + entityId, + contactType, + contact, + sort, + }); + + const row = result.rows[0] ?? { + id, + entityId, + contactType, + contact, + sort, + }; - const row = result.recordset[0]; - if (!row) { - throw new Error('Contact not found'); - } return shape(row); }; } diff --git a/src/store/initialize.js b/src/store/initialize.js index 1df1675..b88e3e2 100644 --- a/src/store/initialize.js +++ b/src/store/initialize.js @@ -2,7 +2,7 @@ import { SCHEMA_SCRIPTS } from '../providers/mssql/schema.js'; export default function initialize(provider) { /** - * Initializes the database schema by running all schema scripts. + * Initializes the database schema by delegating to the provider. * * @returns {Promise} * @@ -10,8 +10,16 @@ export default function initialize(provider) { * await store.initialize(); */ return async () => { + if (typeof provider.initialize === 'function') { + return provider.initialize(); + } + // Transitional fallback for raw mssql pool for (const script of SCHEMA_SCRIPTS) { - await provider.query(script); + if (typeof provider.query === 'function') { + await provider.query(script); + } else if (typeof provider.request === 'function') { + await provider.request().query(script); + } } }; } diff --git a/src/store/manifestDoc/helpers.js b/src/store/manifestDoc/helpers.js index 3e7e53a..b1464ce 100644 --- a/src/store/manifestDoc/helpers.js +++ b/src/store/manifestDoc/helpers.js @@ -1,13 +1,22 @@ import { - GET_MANIFEST_HEADER, - GET_MANIFEST_LINES, + GET_MANIFEST_HEADER as MSSQL_GET_MANIFEST_HEADER, + GET_MANIFEST_LINES as MSSQL_GET_MANIFEST_LINES, } from '../../providers/mssql/queries/manifestDoc.js'; import { - GET_CUSTOMER_BY_ID, - POS_GROUP_NAME, - SUPPLIER_GROUP_NAME, + GET_MANIFEST_HEADER as SQLITE_GET_MANIFEST_HEADER, + GET_MANIFEST_LINES as SQLITE_GET_MANIFEST_LINES, +} from '../../providers/sqlite3/queries/manifestDoc.js'; +import { + GET_CUSTOMER_BY_ID as MSSQL_GET_CUSTOMER_BY_ID, + POS_GROUP_NAME as MSSQL_POS_GROUP_NAME, + SUPPLIER_GROUP_NAME as MSSQL_SUPPLIER_GROUP_NAME, } from '../../providers/mssql/queries/entity.js'; -import request from '../request.js'; +import { + GET_CUSTOMER_BY_ID as SQLITE_GET_CUSTOMER_BY_ID, + POS_GROUP_NAME as SQLITE_POS_GROUP_NAME, + SUPPLIER_GROUP_NAME as SQLITE_SUPPLIER_GROUP_NAME, +} from '../../providers/sqlite3/queries/entity.js'; +import makeRunner from '../runner.js'; import { shapeLine } from '../manifestLines.js'; export function shapeSummary(type, row) { @@ -21,7 +30,21 @@ export function shapeSummary(type, row) { }; } -export async function assertCustomerEntity(pool, tx, companyId, entityId) { +export async function assertCustomerEntity(runnerOrPool, txOrCompanyId, maybeCompanyId, maybeEntityId) { + let runner; + let companyId; + let entityId; + + if (maybeEntityId !== undefined) { + runner = makeRunner(runnerOrPool, txOrCompanyId); + companyId = maybeCompanyId; + entityId = maybeEntityId; + } else { + runner = makeRunner(runnerOrPool); + companyId = txOrCompanyId; + entityId = maybeCompanyId; + } + if (entityId == null) { return null; } @@ -29,37 +52,58 @@ export async function assertCustomerEntity(pool, tx, companyId, entityId) { throw new Error('Entity not found'); } - const result = await request(pool, tx) - .input('id', entityId) - .input('companyId', companyId) - .input('supplierGroupName', SUPPLIER_GROUP_NAME) - .input('posGroupName', POS_GROUP_NAME) - .query(GET_CUSTOMER_BY_ID); + const isSqlite = runner?.dialect === 'sqlite3'; + const getCustomerSql = isSqlite ? SQLITE_GET_CUSTOMER_BY_ID : MSSQL_GET_CUSTOMER_BY_ID; + const supplierGroupName = isSqlite ? SQLITE_SUPPLIER_GROUP_NAME : MSSQL_SUPPLIER_GROUP_NAME; + const posGroupName = isSqlite ? SQLITE_POS_GROUP_NAME : MSSQL_POS_GROUP_NAME; - if (!result.recordset[0]) { + const result = await runner.query(getCustomerSql, { + id: entityId, + companyId, + supplierGroupName, + posGroupName, + }); + + if (!result.rows[0]) { throw new Error('Entity not found'); } return entityId; } -export async function loadDoc(pool, tx, { type, companyId, id }) { - const header = await request(pool, tx) - .input('id', id) - .input('companyId', companyId) - .input('type', type) - .query(GET_MANIFEST_HEADER); +export async function loadDoc(runnerOrPool, txOrParams, maybeParams) { + let runner; + let params; - const row = header.recordset[0]; + if (maybeParams !== undefined) { + runner = makeRunner(runnerOrPool, txOrParams); + params = maybeParams; + } else { + runner = makeRunner(runnerOrPool); + params = txOrParams; + } + + const { type, companyId, id } = params; + const isSqlite = runner?.dialect === 'sqlite3'; + const getHeaderSql = isSqlite ? SQLITE_GET_MANIFEST_HEADER : MSSQL_GET_MANIFEST_HEADER; + const getLinesSql = isSqlite ? SQLITE_GET_MANIFEST_LINES : MSSQL_GET_MANIFEST_LINES; + + const header = await runner.query(getHeaderSql, { + id, + companyId, + type, + }); + + const row = header.rows[0]; if (!row) { return null; } - const lines = await request(pool, tx) - .input('manifestId', id) - .query(GET_MANIFEST_LINES); + const lines = await runner.query(getLinesSql, { + manifestId: id, + }); return { ...shapeSummary(type, row), - lines: lines.recordset.map(shapeLine), + lines: lines.rows.map(shapeLine), }; } diff --git a/src/store/manifestLines.js b/src/store/manifestLines.js index 5cba73b..c13e7a2 100644 --- a/src/store/manifestLines.js +++ b/src/store/manifestLines.js @@ -1,5 +1,6 @@ -import { CREATE_LINE } from '../providers/mssql/queries/manifestDoc.js'; -import request from './request.js'; +import { CREATE_LINE as MSSQL_CREATE_LINE } from '../providers/mssql/queries/manifestDoc.js'; +import { CREATE_LINE as SQLITE_CREATE_LINE } from '../providers/sqlite3/queries/manifestDoc.js'; +import makeRunner from './runner.js'; /** * Normalizes and validates an array of manifest line inputs. @@ -92,35 +93,47 @@ export function shapeLine(row) { * Inserts manifest lines for a document, storing each SKU and taxrate directly, * and ensuring the tax rate exists in the tax catalog. * - * @param {object} pool - The mssql connection pool. - * @param {object} tx - The transaction. - * @param {object} params - The insert parameters. - * @param {number} params.manifestId - The manifest id. - * @param {Array} params.lines - The normalized lines. - * @param {Function} [params.ensureTax] - The tax ensure function. + * @param {object} runnerOrPool - The runner or mssql connection pool. + * @param {object} txOrParams - The transaction or params object. + * @param {object} [maybeParams] - The insert parameters. * @returns {Promise} */ export async function insertLines( - pool, - tx, - { manifestId, lines, ensureTax }, + runnerOrPool, + txOrParams, + maybeParams, ) { + let runner; + let params; + if (maybeParams !== undefined) { + runner = makeRunner(runnerOrPool, txOrParams); + params = maybeParams; + } else { + runner = makeRunner(runnerOrPool); + params = txOrParams; + } + + const { manifestId, lines, ensureTax } = params; + const isSqlite = runner?.dialect === 'sqlite3'; + const createLineSql = isSqlite ? SQLITE_CREATE_LINE : MSSQL_CREATE_LINE; + for (const line of lines) { if (ensureTax) { - await ensureTax(line.taxrate, tx); + await ensureTax(line.taxrate, runner); } - await request(pool, tx) - .input('manifestId', manifestId) - .input('sku', line.sku ?? null) - .input('name', line.name ?? null) - .input('pu', line.pu ?? 1) - .input('amount', line.amount) - .input('net', line.net) - .input('comment', line.comment) - .input('rebate', line.rebate) - .input('taxrate', line.taxrate) - .query(CREATE_LINE); + await runner.query(createLineSql, { + manifestId, + sku: line.sku ?? null, + name: line.name ?? null, + pu: line.pu ?? 1, + amount: line.amount, + net: line.net, + comment: line.comment, + rebate: line.rebate, + taxrate: line.taxrate, + }); } } + diff --git a/src/store/manifestattachment/add.js b/src/store/manifestattachment/add.js index 700b1cd..9bf551f 100644 --- a/src/store/manifestattachment/add.js +++ b/src/store/manifestattachment/add.js @@ -1,48 +1,18 @@ import { createHash } from 'node:crypto'; import sharp from 'sharp'; import generateDocumentThumb from '../../util/generateDocumentThumb.js'; -import { - CREATE_FILE, - CREATE_ATTACHMENT, - GET_NEXT_SORT, -} from '../../providers/mssql/queries/manifestattachment.js'; +import * as mssqlQueries from '../../providers/mssql/queries/manifestattachment.js'; +import * as sqliteQueries from '../../providers/sqlite3/queries/manifestattachment.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import resolveManifestAccess from './resolveManifestAccess.js'; -import request from '../request.js'; +import makeRunner from '../runner.js'; -export default function add(pool) { - const access = resolveSelectedCompanyAccess(pool); - const manifestAccess = resolveManifestAccess(pool); +export default function add(provider) { + const access = resolveSelectedCompanyAccess(provider); + const manifestAccess = resolveManifestAccess(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + const queries = isSqlite ? sqliteQueries : mssqlQueries; - /** - * Attaches a file to an existing manifest. - * - * @param {number} googleAuthId - The id of the Google auth user. - * @param {number} manifestId - The id of the manifest. - * @param {object} input - The file data. - * @param {string} input.mimetype - The MIME type of the file (e.g. `'application/pdf'`). - * @param {string} input.name - The file name. - * @param {Buffer} input.content - The file content. - * @param {number} [input.sort] - Optional display sort order. - * @param {number} [input.size] - Optional file size in bytes (defaults to content length). - * @param {Date|string} [input.created] - Optional creation timestamp (defaults to current date). - * @param {Date|string} [input.modified] - Optional modified timestamp (defaults to created date). - * @param {string} [input.hash] - The file hash (auto-computed SHA-256 if omitted). - * @param {Buffer} [input.thumbavif] - Optional AVIF thumbnail buffer. - * @param {number} [input.thumbx] - Optional thumbnail width. - * @param {number} [input.thumby] - Optional thumbnail height. - * @param {object} [input.meta] - Optional string key/value metadata stored on the file row. - * @returns {Promise<{id: number, manifestId: number, fileId: number, sort: number, hash: string, mimetype: string, name: string, size: number, created: Date, modified: Date, thumbavif: Buffer|null, thumbx: number|null, thumby: number|null, meta: object|null}>} The created attachment record. - * @throws {Error} If the manifest is not found or file input is invalid. - * - * @example - * const attachment = await store.manifestattachment.add(42, 1, { - * mimetype: 'application/pdf', - * name: 'invoice.pdf', - * content: fileBuffer, - * }); - * // => { id, manifestId, fileId, sort, hash, mimetype, name, size, created, modified, thumbavif, thumbx, thumby } - */ return async (googleAuthId, manifestId, input, ...rest) => { let rawMimetype, rawName, rawContent, rawHash, rawThumbavif, rawThumbx, rawThumby, rawSort, rawSize, rawCreated, rawModified, rawMeta; if (typeof input === 'object' && input !== null && !Buffer.isBuffer(input)) { @@ -166,43 +136,46 @@ export default function add(pool) { const { companyId } = await access(googleAuthId); await manifestAccess(companyId, manifestId); - const tx = pool.transaction(); - await tx.begin(); - - try { + const run = async (runner) => { let sort = rawSort; if (sort === undefined || sort === null) { - const sortRes = await request(pool, tx) - .input('manifestId', manifestId) - .query(GET_NEXT_SORT); - sort = sortRes.recordset[0].sort; + const sortRes = await runner(queries.GET_NEXT_SORT, { manifestId }); + sort = sortRes.rows[0].sort; } - const fileRes = await request(pool, tx) - .input('hash', hash) - .input('mimetype', mimetype) - .input('name', name) - .input('size', size) - .input('created', created) - .input('modified', modified) - .input('content', content) - .input('thumbavif', thumbavif) - .input('thumbx', thumbx) - .input('thumby', thumby) - .input('meta', meta === null ? null : JSON.stringify(meta)) - .query(CREATE_FILE); + if (isSqlite) { + if (runner.writeFileContent) { + await runner.writeFileContent(hash, content, mimetype); + if (thumbavif) await runner.writeThumbAvif(hash, thumbavif); + } else if (provider?.writeFileContent) { + await provider.writeFileContent(hash, content, mimetype); + if (thumbavif) await provider.writeThumbAvif(hash, thumbavif); + } + } - const fileId = fileRes.recordset[0].id; + const fileRes = await runner(queries.CREATE_FILE, { + hash, + mimetype, + name, + size, + created, + modified, + content, + thumbavif, + thumbx, + thumby, + meta: meta === null ? null : JSON.stringify(meta), + }); - const attachmentRes = await request(pool, tx) - .input('manifestId', manifestId) - .input('fileId', fileId) - .input('sort', sort) - .query(CREATE_ATTACHMENT); + const fileId = fileRes.rows[0]?.id ?? fileRes.insertId; - const attachmentId = attachmentRes.recordset[0].id; + const attachmentRes = await runner(queries.CREATE_ATTACHMENT, { + manifestId, + fileId, + sort, + }); - await tx.commit(); + const attachmentId = attachmentRes.rows[0]?.id ?? attachmentRes.insertId; return { id: attachmentId, @@ -220,13 +193,11 @@ export default function add(pool) { thumby, meta, }; - } catch (err) { - try { - await tx.rollback(); - } catch { - // ignore - } - throw err; + }; + + if (typeof provider?.transaction === 'function') { + return provider.transaction(run); } + return run(makeRunner(provider)); }; } diff --git a/src/store/manifestattachment/getById.js b/src/store/manifestattachment/getById.js index 2b0e115..9906337 100644 --- a/src/store/manifestattachment/getById.js +++ b/src/store/manifestattachment/getById.js @@ -1,12 +1,15 @@ -import { GET_BY_ID } from '../../providers/mssql/queries/manifestattachment.js'; +import { GET_BY_ID as MSSQL_GET_BY_ID } from '../../providers/mssql/queries/manifestattachment.js'; +import { GET_BY_ID as SQLITE_GET_BY_ID } from '../../providers/sqlite3/queries/manifestattachment.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import resolveManifestAccess from './resolveManifestAccess.js'; import { parseMeta } from './listByManifestId.js'; -import request from '../request.js'; +import makeRunner from '../runner.js'; -export default function getById(pool) { - const access = resolveSelectedCompanyAccess(pool); - const manifestAccess = resolveManifestAccess(pool); +export default function getById(provider) { + const access = resolveSelectedCompanyAccess(provider); + const manifestAccess = resolveManifestAccess(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + const sql = isSqlite ? SQLITE_GET_BY_ID : MSSQL_GET_BY_ID; /** * Gets an attachment for a manifest by its attachment ID or file ID, including full file content. @@ -14,6 +17,7 @@ export default function getById(pool) { * @param {number} googleAuthId - The id of the Google auth user. * @param {number} manifestId - The manifest ID. * @param {number} id - The attachment ID or file ID. + * @param {object} [tx] - Optional transaction. * @returns {Promise<{id: number, manifestId: number, fileId: number, sort: number, hash: string, mimetype: string, name: string, size: number, created: Date, modified: Date, content: Buffer, thumbavif: Buffer|null, thumbx: number|null, thumby: number|null, meta: object|null}>} The attachment record with binary content. * @throws {Error} If the manifest or attachment is not found, or user is not authorized. * @@ -21,24 +25,32 @@ export default function getById(pool) { * const attachment = await store.manifestattachment.getById(42, 1, 5); * // => { id, manifestId, fileId, sort, hash, mimetype, name, size, created, modified, content, thumbavif, thumbx, thumby } */ - return async (googleAuthId, manifestId, id) => { + return async (googleAuthId, manifestId, id, tx) => { if (!Number.isFinite(id)) { throw new Error('Attachment not found'); } - const { companyId } = await access(googleAuthId); - await manifestAccess(companyId, manifestId); + const runner = makeRunner(provider, tx); + const { companyId } = await access(googleAuthId, runner); + await manifestAccess(companyId, manifestId, runner); - const res = await request(pool) - .input('manifestId', manifestId) - .input('id', id) - .query(GET_BY_ID); + const res = await runner.query(sql, { + manifestId, + id, + }); - const row = res.recordset[0]; + const row = res.rows[0]; if (!row) { throw new Error('Attachment not found'); } + let content = row.content; + let thumbavif = row.thumbavif ?? null; + if (isSqlite) { + content = await (runner.readFileContent ? runner.readFileContent(row.hash, row.mimetype) : provider.readFileContent?.(row.hash, row.mimetype)); + thumbavif = await (runner.readThumbAvif ? runner.readThumbAvif(row.hash) : provider.readThumbAvif?.(row.hash)); + } + return { id: row.id, manifestId: row.manifestId, @@ -50,11 +62,12 @@ export default function getById(pool) { size: Number(row.size), created: row.created, modified: row.modified, - content: row.content, - thumbavif: row.thumbavif ?? null, + content, + thumbavif, thumbx: row.thumbx ?? null, thumby: row.thumby ?? null, meta: parseMeta(row.meta), }; }; } + diff --git a/src/store/manifestattachment/listByManifestId.js b/src/store/manifestattachment/listByManifestId.js index 6771ac9..a122fd6 100644 --- a/src/store/manifestattachment/listByManifestId.js +++ b/src/store/manifestattachment/listByManifestId.js @@ -1,7 +1,8 @@ -import { LIST_BY_MANIFEST } from '../../providers/mssql/queries/manifestattachment.js'; +import { LIST_BY_MANIFEST as MSSQL_LIST } from '../../providers/mssql/queries/manifestattachment.js'; +import { LIST_BY_MANIFEST as SQLITE_LIST } from '../../providers/sqlite3/queries/manifestattachment.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import resolveManifestAccess from './resolveManifestAccess.js'; -import request from '../request.js'; +import makeRunner from '../runner.js'; /** Parse the file.meta JSON text column into an object (string key/value). */ export function parseMeta(raw) { @@ -34,15 +35,18 @@ function shape(row) { }; } -export default function listByManifestId(pool) { - const access = resolveSelectedCompanyAccess(pool); - const manifestAccess = resolveManifestAccess(pool); +export default function listByManifestId(provider) { + const access = resolveSelectedCompanyAccess(provider); + const manifestAccess = resolveManifestAccess(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + const sql = isSqlite ? SQLITE_LIST : MSSQL_LIST; /** * Lists all attachments for a given manifest, without loading their full binary content. * * @param {number} googleAuthId - The id of the Google auth user. * @param {number} manifestId - The manifest ID. + * @param {object} [tx] - Optional transaction. * @returns {Promise>} A list of attachment metadata objects. * @throws {Error} If the manifest is not found or the user is not authorized. * @@ -50,14 +54,29 @@ export default function listByManifestId(pool) { * const attachments = await store.manifestattachment.listByManifestId(42, 1); * // => [{ id, manifestId, fileId, sort, hash, mimetype, name, size, created, modified, thumbavif, thumbx, thumby }] */ - return async (googleAuthId, manifestId) => { - const { companyId } = await access(googleAuthId); - await manifestAccess(companyId, manifestId); + return async (googleAuthId, manifestId, tx) => { + const runner = makeRunner(provider, tx); + const { companyId } = await access(googleAuthId, runner); + await manifestAccess(companyId, manifestId, runner); - const res = await request(pool) - .input('manifestId', manifestId) - .query(LIST_BY_MANIFEST); + const res = await runner.query(sql, { + manifestId, + }); - return res.recordset.map(shape); + const rows = await Promise.all( + res.rows.map(async (row) => { + let thumbavif = row.thumbavif ?? null; + if (isSqlite) { + thumbavif = await (runner.readThumbAvif ? runner.readThumbAvif(row.hash) : provider.readThumbAvif?.(row.hash)); + } + return shape({ + ...row, + thumbavif, + }); + }), + ); + + return rows; }; } + diff --git a/src/store/manifestattachment/order.js b/src/store/manifestattachment/order.js index 47219c1..440a255 100644 --- a/src/store/manifestattachment/order.js +++ b/src/store/manifestattachment/order.js @@ -1,15 +1,25 @@ import { - LIST_IDS_BY_MANIFEST, - SET_TEMP_SORTS, - UPDATE_SORT, + LIST_IDS_BY_MANIFEST as MSSQL_LIST_IDS, + SET_TEMP_SORTS as MSSQL_SET_TEMP_SORTS, + UPDATE_SORT as MSSQL_UPDATE_SORT, } from '../../providers/mssql/queries/manifestattachment.js'; +import { + LIST_IDS_BY_MANIFEST as SQLITE_LIST_IDS, + SET_TEMP_SORTS as SQLITE_SET_TEMP_SORTS, + UPDATE_SORT as SQLITE_UPDATE_SORT, +} from '../../providers/sqlite3/queries/manifestattachment.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import resolveManifestAccess from './resolveManifestAccess.js'; -import request from '../request.js'; +import makeRunner from '../runner.js'; -export default function order(pool) { - const access = resolveSelectedCompanyAccess(pool); - const manifestAccess = resolveManifestAccess(pool); +export default function order(provider) { + const access = resolveSelectedCompanyAccess(provider); + const manifestAccess = resolveManifestAccess(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + + const listIdsSql = isSqlite ? SQLITE_LIST_IDS : MSSQL_LIST_IDS; + const setTempSortsSql = isSqlite ? SQLITE_SET_TEMP_SORTS : MSSQL_SET_TEMP_SORTS; + const updateSortSql = isSqlite ? SQLITE_UPDATE_SORT : MSSQL_UPDATE_SORT; /** * Reorders the attachments of a manifest. @@ -18,6 +28,7 @@ export default function order(pool) { * @param {number} manifestId - The manifest ID. * @param {object|Array} input - The reorder input or array of IDs. * @param {Array} [input.ids] - The attachment ids in their new order. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise<{ids: Array}>} The applied order. * @throws {Error} If the order is invalid. * @@ -25,64 +36,60 @@ export default function order(pool) { * const result = await store.manifestattachment.order(42, 1, { ids: [12, 10, 15] }); * // => { ids: [12, 10, 15] } */ - return async (googleAuthId, manifestId, input) => { + return async (googleAuthId, manifestId, input, tx) => { const rawIds = Array.isArray(input) ? input : input?.ids; if (!Array.isArray(rawIds)) { throw new Error('Invalid attachment order'); } - const { companyId } = await access(googleAuthId); - await manifestAccess(companyId, manifestId); + const runOrder = async (runner) => { + const { companyId } = await access(googleAuthId, runner); + await manifestAccess(companyId, manifestId, runner); - const current = await pool - .request() - .input('manifestId', manifestId) - .query(LIST_IDS_BY_MANIFEST); + const current = await runner.query(listIdsSql, { + manifestId, + }); - const existing = new Set(current.recordset.map((r) => r.id)); + const existing = new Set(current.rows.map((r) => r.id)); - if (rawIds.length !== existing.size) { - throw new Error('Invalid attachment order'); - } - - const seen = new Set(); - for (const id of rawIds) { - if (!Number.isFinite(id) || !existing.has(id) || seen.has(id)) { + if (rawIds.length !== existing.size) { throw new Error('Invalid attachment order'); } - seen.add(id); - } - const tx = pool.transaction(); - await tx.begin(); + const seen = new Set(); + for (const id of rawIds) { + if (!Number.isFinite(id) || !existing.has(id) || seen.has(id)) { + throw new Error('Invalid attachment order'); + } + seen.add(id); + } - try { - await request(pool, tx) - .input('manifestId', manifestId) - .query(SET_TEMP_SORTS); + await runner.query(setTempSortsSql, { + manifestId, + }); for (let sort = 0; sort < rawIds.length; sort++) { - const updateRes = await request(pool, tx) - .input('id', rawIds[sort]) - .input('manifestId', manifestId) - .input('sort', sort) - .query(UPDATE_SORT); + const updateRes = await runner.query(updateSortSql, { + id: rawIds[sort], + manifestId, + sort, + }); - if (!updateRes.rowsAffected[0]) { + if (!updateRes.rowsAffected) { throw new Error('Invalid attachment order'); } } - await tx.commit(); - return { ids: rawIds }; - } catch (err) { - try { - await tx.rollback(); - } catch { - // ignore - } - throw err; + }; + + if (tx) { + return runOrder(makeRunner(provider, tx)); } + if (typeof provider?.transaction === 'function') { + return provider.transaction(runOrder); + } + return runOrder(makeRunner(provider)); }; } + diff --git a/src/store/manifestattachment/remove.js b/src/store/manifestattachment/remove.js index 61c5390..926e309 100644 --- a/src/store/manifestattachment/remove.js +++ b/src/store/manifestattachment/remove.js @@ -1,11 +1,21 @@ -import { FIND_ATTACHMENT, DELETE_FILE } from '../../providers/mssql/queries/manifestattachment.js'; +import { + FIND_ATTACHMENT as MSSQL_FIND, + DELETE_FILE as MSSQL_DELETE, +} from '../../providers/mssql/queries/manifestattachment.js'; +import { + FIND_ATTACHMENT as SQLITE_FIND, + DELETE_FILE as SQLITE_DELETE, +} from '../../providers/sqlite3/queries/manifestattachment.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import resolveManifestAccess from './resolveManifestAccess.js'; -import request from '../request.js'; +import makeRunner from '../runner.js'; -export default function remove(pool) { - const access = resolveSelectedCompanyAccess(pool); - const manifestAccess = resolveManifestAccess(pool); +export default function remove(provider) { + const access = resolveSelectedCompanyAccess(provider); + const manifestAccess = resolveManifestAccess(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + const findSql = isSqlite ? SQLITE_FIND : MSSQL_FIND; + const deleteSql = isSqlite ? SQLITE_DELETE : MSSQL_DELETE; /** * Removes a file attachment from a manifest and deletes the underlying file. @@ -13,32 +23,43 @@ export default function remove(pool) { * @param {number} googleAuthId - The id of the Google auth user. * @param {number} manifestId - The manifest ID. * @param {number} id - The attachment ID or file ID to remove. + * @param {object} [tx] - Optional transaction. * @returns {Promise} * @throws {Error} If the manifest or attachment is not found, or user is not authorized. * * @example * await store.manifestattachment.remove(42, 1, 5); */ - return async (googleAuthId, manifestId, id) => { + return async (googleAuthId, manifestId, id, tx) => { if (!Number.isFinite(id)) { throw new Error('Attachment not found'); } - const { companyId } = await access(googleAuthId); - await manifestAccess(companyId, manifestId); + const runner = makeRunner(provider, tx); + const { companyId } = await access(googleAuthId, runner); + await manifestAccess(companyId, manifestId, runner); - const findRes = await request(pool) - .input('manifestId', manifestId) - .input('id', id) - .query(FIND_ATTACHMENT); + const findRes = await runner.query(findSql, { + manifestId, + id, + }); - const row = findRes.recordset[0]; + const row = findRes.rows[0]; if (!row) { throw new Error('Attachment not found'); } - await request(pool) - .input('fileId', row.fileId) - .query(DELETE_FILE); + await runner.query(deleteSql, { + fileId: row.fileId, + }); + + if (isSqlite && row.hash) { + if (runner.deleteFile) { + await runner.deleteFile(row.hash); + } else if (provider?.deleteFile) { + await provider.deleteFile(row.hash); + } + } }; } + diff --git a/src/store/manifestattachment/resolveManifestAccess.js b/src/store/manifestattachment/resolveManifestAccess.js index ababa2b..0403b51 100644 --- a/src/store/manifestattachment/resolveManifestAccess.js +++ b/src/store/manifestattachment/resolveManifestAccess.js @@ -1,22 +1,27 @@ -import { CHECK_MANIFEST_ACCESS } from '../../providers/mssql/queries/manifestattachment.js'; -import request from '../request.js'; +import { CHECK_MANIFEST_ACCESS as MSSQL_CHECK } from '../../providers/mssql/queries/manifestattachment.js'; +import { CHECK_MANIFEST_ACCESS as SQLITE_CHECK } from '../../providers/sqlite3/queries/manifestattachment.js'; +import makeRunner from '../runner.js'; /** Ensure manifest belongs to company. Throws Manifest not found. */ -export default function resolveManifestAccess(pool) { +export default function resolveManifestAccess(provider) { + const sql = provider?.dialect === 'sqlite3' ? SQLITE_CHECK : MSSQL_CHECK; + return async (companyId, manifestId, tx) => { if (!Number.isFinite(manifestId)) { throw new Error('Manifest not found'); } - const result = await request(pool, tx) - .input('manifestId', manifestId) - .input('companyId', companyId) - .query(CHECK_MANIFEST_ACCESS); + const runner = makeRunner(provider, tx); + const result = await runner.query(sql, { + manifestId, + companyId, + }); - const row = result.recordset[0]; + const row = result.rows[0]; if (!row) { throw new Error('Manifest not found'); } return row; }; } + diff --git a/src/store/numbers/list.js b/src/store/numbers/list.js index fd7786d..67ac33c 100644 --- a/src/store/numbers/list.js +++ b/src/store/numbers/list.js @@ -1,5 +1,7 @@ -import { LIST_BY_COMPANY } from '../../providers/mssql/queries/numbers.js'; +import { LIST_BY_COMPANY as MSSQL_LIST_BY_COMPANY } from '../../providers/mssql/queries/numbers.js'; +import { LIST_BY_COMPANY as SQLITE_LIST_BY_COMPANY } from '../../providers/sqlite3/queries/numbers.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; +import makeRunner from '../runner.js'; function shape(row) { return { @@ -11,27 +13,29 @@ function shape(row) { }; } -export default function list(pool) { - const access = resolveSelectedCompanyAccess(pool); +export default function list(provider) { + const access = resolveSelectedCompanyAccess(provider); + const sql = + provider?.dialect === 'sqlite3' + ? SQLITE_LIST_BY_COMPANY + : MSSQL_LIST_BY_COMPANY; /** * Lists the number sequences of the user's selected company. * * @param {number} googleAuthId - The id of the Google auth user. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise>} The number sequences. * * @example * const numbers = await store.numbers.list(42); * // => [{ id, type, prefix, padding, next }] */ - return async (googleAuthId) => { - const { companyId } = await access(googleAuthId); + return async (googleAuthId, tx) => { + const { companyId } = await access(googleAuthId, tx); - const result = await pool - .request() - .input('companyId', companyId) - .query(LIST_BY_COMPANY); + const result = await makeRunner(provider, tx).query(sql, { companyId }); - return result.recordset.map(shape); + return result.rows.map(shape); }; } \ No newline at end of file diff --git a/src/store/numbers/nextNumber.js b/src/store/numbers/nextNumber.js index aec8d74..bc8c694 100644 --- a/src/store/numbers/nextNumber.js +++ b/src/store/numbers/nextNumber.js @@ -1,7 +1,28 @@ -import { ENSURE_TYPE } from '../../providers/mssql/queries/numbers.js'; -import request from '../request.js'; +import { + ENSURE_TYPE as MSSQL_ENSURE_TYPE, + GET_FOR_UPDATE as MSSQL_GET_FOR_UPDATE, + INCREMENT as MSSQL_INCREMENT, +} from '../../providers/mssql/queries/numbers.js'; +import { + ENSURE_TYPE as SQLITE_ENSURE_TYPE, + GET_FOR_UPDATE as SQLITE_GET_FOR_UPDATE, + INCREMENT as SQLITE_INCREMENT, +} from '../../providers/sqlite3/queries/numbers.js'; +import makeRunner from '../runner.js'; + +function formatNumber(prefix, padding, num) { + const p = prefix ?? ''; + const nStr = String(num); + const padded = padding > 0 ? nStr.padStart(padding, '0') : nStr; + return `${p}${padded}`; +} + +export default function nextNumber(provider) { + const isSqlite = provider?.dialect === 'sqlite3'; + const ensureSql = isSqlite ? SQLITE_ENSURE_TYPE : MSSQL_ENSURE_TYPE; + const getSql = isSqlite ? SQLITE_GET_FOR_UPDATE : MSSQL_GET_FOR_UPDATE; + const incSql = isSqlite ? SQLITE_INCREMENT : MSSQL_INCREMENT; -export default function nextNumber(pool) { /** * Allocates and returns the next number for a company and type, consuming it. * @@ -15,17 +36,24 @@ export default function nextNumber(pool) { * // => '1' */ return async (companyId, type, tx) => { - await request(pool, tx) - .input('companyId', companyId) - .input('type', type) - .query(ENSURE_TYPE); + const doAlloc = async (runner) => { + await runner.query(ensureSql, { companyId, type }); + const res = await runner.query(getSql, { companyId, type }); + const row = res.rows[0]; + if (!row) { + throw new Error('Number type not found for company'); + } + const value = formatNumber(row.prefix, row.padding, row.next); + await runner.query(incSql, { id: row.id }); + return value; + }; - const req = request(pool, tx); - req.input('company_id', companyId); - req.input('type', type); - req.output('value'); - - const result = await req.execute('getnextnumberwithprefixandpadding'); - return result.output.value; + if (tx) { + return doAlloc(makeRunner(provider, tx)); + } + if (typeof provider?.transaction === 'function') { + return provider.transaction(doAlloc); + } + return doAlloc(makeRunner(provider)); }; } diff --git a/src/store/numbers/nextSku.js b/src/store/numbers/nextSku.js index 98a3523..79264a9 100644 --- a/src/store/numbers/nextSku.js +++ b/src/store/numbers/nextSku.js @@ -1,7 +1,7 @@ import nextNumber from './nextNumber.js'; -export default function nextSku(pool) { - const allocate = nextNumber(pool); +export default function nextSku(provider) { + const allocate = nextNumber(provider); /** * Allocates and returns the next SKU for a company, consuming it. * diff --git a/src/store/numbers/peekNumber.js b/src/store/numbers/peekNumber.js index 9a92b30..c0a76f8 100644 --- a/src/store/numbers/peekNumber.js +++ b/src/store/numbers/peekNumber.js @@ -1,7 +1,25 @@ -import { ENSURE_TYPE } from '../../providers/mssql/queries/numbers.js'; -import request from '../request.js'; +import { + ENSURE_TYPE as MSSQL_ENSURE_TYPE, + GET_FOR_UPDATE as MSSQL_GET_FOR_UPDATE, +} from '../../providers/mssql/queries/numbers.js'; +import { + ENSURE_TYPE as SQLITE_ENSURE_TYPE, + GET_FOR_UPDATE as SQLITE_GET_FOR_UPDATE, +} from '../../providers/sqlite3/queries/numbers.js'; +import makeRunner from '../runner.js'; + +function formatNumber(prefix, padding, num) { + const p = prefix ?? ''; + const nStr = String(num); + const padded = padding > 0 ? nStr.padStart(padding, '0') : nStr; + return `${p}${padded}`; +} + +export default function peekNumber(provider) { + const isSqlite = provider?.dialect === 'sqlite3'; + const ensureSql = isSqlite ? SQLITE_ENSURE_TYPE : MSSQL_ENSURE_TYPE; + const getSql = isSqlite ? SQLITE_GET_FOR_UPDATE : MSSQL_GET_FOR_UPDATE; -export default function peekNumber(pool) { /** * Returns the next number for a company and type without consuming it. * @@ -15,17 +33,22 @@ export default function peekNumber(pool) { * // => '1' */ return async (companyId, type, tx) => { - await request(pool, tx) - .input('companyId', companyId) - .input('type', type) - .query(ENSURE_TYPE); + const doPeek = async (runner) => { + await runner.query(ensureSql, { companyId, type }); + const res = await runner.query(getSql, { companyId, type }); + const row = res.rows[0]; + if (!row) { + throw new Error('Number type not found for company'); + } + return formatNumber(row.prefix, row.padding, row.next); + }; - const req = request(pool, tx); - req.input('company_id', companyId); - req.input('type', type); - req.output('value'); - - const result = await req.execute('peeknextnumberwithprefixandpadding'); - return result.output.value; + if (tx) { + return doPeek(makeRunner(provider, tx)); + } + if (typeof provider?.transaction === 'function') { + return provider.transaction(doPeek); + } + return doPeek(makeRunner(provider)); }; } diff --git a/src/store/numbers/update.js b/src/store/numbers/update.js index 65a9786..e7f25f5 100644 --- a/src/store/numbers/update.js +++ b/src/store/numbers/update.js @@ -1,8 +1,13 @@ import { - GET_BY_COMPANY_AND_ID, - UPDATE_BY_COMPANY_AND_ID, + GET_BY_COMPANY_AND_ID as MSSQL_GET_BY_COMPANY_AND_ID, + UPDATE_BY_COMPANY_AND_ID as MSSQL_UPDATE_BY_COMPANY_AND_ID, } from '../../providers/mssql/queries/numbers.js'; +import { + GET_BY_COMPANY_AND_ID as SQLITE_GET_BY_COMPANY_AND_ID, + UPDATE_BY_COMPANY_AND_ID as SQLITE_UPDATE_BY_COMPANY_AND_ID, +} from '../../providers/sqlite3/queries/numbers.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; +import makeRunner from '../runner.js'; function shape(row) { return { @@ -14,8 +19,15 @@ function shape(row) { }; } -export default function update(pool) { - const access = resolveSelectedCompanyAccess(pool); +export default function update(provider) { + const access = resolveSelectedCompanyAccess(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + const getSql = isSqlite + ? SQLITE_GET_BY_COMPANY_AND_ID + : MSSQL_GET_BY_COMPANY_AND_ID; + const updateSql = isSqlite + ? SQLITE_UPDATE_BY_COMPANY_AND_ID + : MSSQL_UPDATE_BY_COMPANY_AND_ID; /** * Updates the sequence settings of a number for the user's selected company. @@ -31,6 +43,7 @@ export default function update(pool) { * @param {string} [input.prefix] - The new prefix. * @param {number} [input.padding] - The new padding length. * @param {number} [input.next] - The new next value. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise<{id: number, type: string, prefix: string, padding: number, next: number}>} The updated number sequence. * @throws {Error} If the number is not found or a field is invalid. * @@ -38,20 +51,17 @@ export default function update(pool) { * const number = await store.numbers.update(42, 3, { prefix: 'A-', padding: 4, next: 100 }); * // => { id, type, prefix, padding, next } */ - return async (googleAuthId, id, input = {}) => { + return async (googleAuthId, id, input = {}, tx) => { if (!Number.isFinite(id)) { throw new Error('Number not found'); } - const { companyId } = await access(googleAuthId); + const runner = makeRunner(provider, tx); + const { companyId } = await access(googleAuthId, tx); - const existing = await pool - .request() - .input('id', id) - .input('companyId', companyId) - .query(GET_BY_COMPANY_AND_ID); + const existing = await runner.query(getSql, { id, companyId }); - const current = existing.recordset[0]; + const current = existing.rows[0]; if (!current) { throw new Error('Number not found'); } @@ -82,16 +92,15 @@ export default function update(pool) { patch.next = next; } - const result = await pool - .request() - .input('id', id) - .input('companyId', companyId) - .input('prefix', patch.prefix) - .input('padding', patch.padding) - .input('next', patch.next) - .query(UPDATE_BY_COMPANY_AND_ID); + const result = await runner.query(updateSql, { + id, + companyId, + prefix: patch.prefix, + padding: patch.padding, + next: patch.next, + }); - const row = result.recordset[0]; + const row = result.rows[0]; if (!row) { throw new Error('Number not found'); } diff --git a/src/store/order/create.js b/src/store/order/create.js index 7c09968..deafcf2 100644 --- a/src/store/order/create.js +++ b/src/store/order/create.js @@ -1,13 +1,24 @@ -import { CREATE_MANIFEST, ORDER_TYPE } from '../../providers/mssql/queries/manifestDoc.js'; -import request from '../request.js'; +import { + CREATE_MANIFEST as MSSQL_CREATE_MANIFEST, + ORDER_TYPE as MSSQL_ORDER_TYPE, +} from '../../providers/mssql/queries/manifestDoc.js'; +import { + CREATE_MANIFEST as SQLITE_CREATE_MANIFEST, + ORDER_TYPE as SQLITE_ORDER_TYPE, +} from '../../providers/sqlite3/queries/manifestDoc.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import taxEnsure from '../tax/ensure.js'; import { insertLines, normalizeLines, parseDate } from '../manifestLines.js'; import { assertCustomerEntity, loadDoc } from '../manifestDoc/helpers.js'; +import makeRunner from '../runner.js'; -export default function create(pool) { - const access = resolveSelectedCompanyAccess(pool); - const ensureTax = taxEnsure(pool); +export default function create(provider) { + const access = resolveSelectedCompanyAccess(provider); + const ensureTax = taxEnsure(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + + const createManifestSql = isSqlite ? SQLITE_CREATE_MANIFEST : MSSQL_CREATE_MANIFEST; + const orderType = isSqlite ? SQLITE_ORDER_TYPE : MSSQL_ORDER_TYPE; /** * Creates a new order for the user's selected company with its lines. @@ -19,6 +30,7 @@ export default function create(pool) { * @param {number} [body.entityId] - The id of the customer entity. * @param {string} [body.externalNumber] - An external (customer) reference number. * @param {Array<{sku?: string, name?: string, pu?: number, amount: number, net: number, rebate?: number, taxrate: number, comment?: string|null}>} [body.lines] - The order lines. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise<{id: number, number: string, type: string, entityId: number|null, externalNumber: string|null, date: string, lines: Array<{id: number, sku: string|null, name: string|null, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string|null}>}>} The created order. * @throws {Error} If the order is invalid. * @@ -26,7 +38,7 @@ export default function create(pool) { * const order = await store.order.create(42, { number: 'B-2026-001', date: '2026-09-15', entityId: 12, lines: [...] }); * // => { id, number, type, entityId, externalNumber, date, lines: [...] } */ - return async (googleAuthId, body = {}) => { + return async (googleAuthId, body = {}, tx) => { const number = body.number?.trim?.() ? body.number.trim() : null; const date = parseDate(body.date); if (!number || !date) { @@ -37,50 +49,49 @@ export default function create(pool) { : null; const lines = normalizeLines(body.lines ?? []); - const { companyId } = await access(googleAuthId); - const tx = pool.transaction(); - await tx.begin(); + const runCreate = async (runner) => { + const { companyId } = await access(googleAuthId, runner); - try { const entityId = await assertCustomerEntity( - pool, - tx, + runner, companyId, body.entityId ?? null, ); - const created = await request(pool, tx) - .input('companyId', companyId) - .input('number', number) - .input('type', ORDER_TYPE) - .input('entityId', entityId) - .input('externalNumber', externalNumber) - .input('date', date) - .query(CREATE_MANIFEST); + const created = await runner.query(createManifestSql, { + companyId, + number, + type: orderType, + entityId, + externalNumber, + date, + }); - const manifestId = created.recordset[0].id; + const manifestId = created.rows[0]?.id ?? created.insertId; - await insertLines(pool, tx, { + await insertLines(runner, { manifestId, lines, ensureTax, }); - const doc = await loadDoc(pool, tx, { - type: ORDER_TYPE, + const doc = await loadDoc(runner, { + type: orderType, companyId, id: manifestId, }); - await tx.commit(); + return doc; - } catch (err) { - try { - await tx.rollback(); - } catch { - // ignore - } - throw err; + }; + + if (tx) { + return runCreate(makeRunner(provider, tx)); } + if (typeof provider?.transaction === 'function') { + return provider.transaction(runCreate); + } + return runCreate(makeRunner(provider)); }; } + diff --git a/src/store/order/delete.js b/src/store/order/delete.js index 71e33da..4f5d157 100644 --- a/src/store/order/delete.js +++ b/src/store/order/delete.js @@ -1,36 +1,49 @@ -import { DELETE_MANIFEST, ORDER_TYPE } from '../../providers/mssql/queries/manifestDoc.js'; +import { + DELETE_MANIFEST as MSSQL_DELETE, + ORDER_TYPE as MSSQL_ORDER_TYPE, +} from '../../providers/mssql/queries/manifestDoc.js'; +import { + DELETE_MANIFEST as SQLITE_DELETE, + ORDER_TYPE as SQLITE_ORDER_TYPE, +} from '../../providers/sqlite3/queries/manifestDoc.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; +import makeRunner from '../runner.js'; -export default function del(pool) { - const access = resolveSelectedCompanyAccess(pool); +export default function del(provider) { + const access = resolveSelectedCompanyAccess(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + const deleteSql = isSqlite ? SQLITE_DELETE : MSSQL_DELETE; + const orderType = isSqlite ? SQLITE_ORDER_TYPE : MSSQL_ORDER_TYPE; /** * Deletes an order by its id within the user's selected company. * * @param {number} googleAuthId - The id of the Google auth user. * @param {number} id - The id of the order to delete. + * @param {object} [tx] - Optional transaction. * @returns {Promise} * @throws {Error} If the order is not found. * * @example * await store.order.delete(42, 5); */ - return async (googleAuthId, id) => { + return async (googleAuthId, id, tx) => { if (!Number.isFinite(id)) { throw new Error('Order not found'); } - const { companyId } = await access(googleAuthId); + const runner = makeRunner(provider, tx); + const { companyId } = await access(googleAuthId, runner); - const result = await pool - .request() - .input('id', id) - .input('companyId', companyId) - .input('type', ORDER_TYPE) - .query(DELETE_MANIFEST); + const result = await runner.query(deleteSql, { + id, + companyId, + type: orderType, + }); - if (!result.rowsAffected[0]) { + if (!result.rowsAffected) { throw new Error('Order not found'); } }; } + diff --git a/src/store/order/getById.js b/src/store/order/getById.js index 48e9212..cf0bed3 100644 --- a/src/store/order/getById.js +++ b/src/store/order/getById.js @@ -1,15 +1,20 @@ -import { ORDER_TYPE } from '../../providers/mssql/queries/manifestDoc.js'; +import { ORDER_TYPE as MSSQL_ORDER_TYPE } from '../../providers/mssql/queries/manifestDoc.js'; +import { ORDER_TYPE as SQLITE_ORDER_TYPE } from '../../providers/sqlite3/queries/manifestDoc.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import { loadDoc } from '../manifestDoc/helpers.js'; +import makeRunner from '../runner.js'; -export default function getById(pool) { - const access = resolveSelectedCompanyAccess(pool); +export default function getById(provider) { + const access = resolveSelectedCompanyAccess(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + const orderType = isSqlite ? SQLITE_ORDER_TYPE : MSSQL_ORDER_TYPE; /** * Returns a single order by its id within the user's selected company. * * @param {number} googleAuthId - The id of the Google auth user. * @param {number} id - The id of the order. + * @param {object} [tx] - Optional transaction. * @returns {Promise<{id: number, number: string, type: string, entityId: number|null, externalNumber: string|null, date: string, lines: Array<{id: number, sku: string|null, name: string|null, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string|null}>}>} The order with its lines. * @throws {Error} If the order is not found. * @@ -17,17 +22,19 @@ export default function getById(pool) { * const order = await store.order.getById(42, 5); * // => { id, number, type, entityId, externalNumber, date, lines: [...] } */ - return async (googleAuthId, id) => { + return async (googleAuthId, id, tx) => { if (!Number.isFinite(id)) { throw new Error('Order not found'); } - const { companyId } = await access(googleAuthId); + const runner = makeRunner(provider, tx); + const { companyId } = await access(googleAuthId, runner); - const doc = await loadDoc(pool, null, { type: ORDER_TYPE, companyId, id }); + const doc = await loadDoc(runner, { type: orderType, companyId, id }); if (!doc) { throw new Error('Order not found'); } return doc; }; } + diff --git a/src/store/order/list.js b/src/store/order/list.js index 58c8dee..a20b760 100644 --- a/src/store/order/list.js +++ b/src/store/order/list.js @@ -1,29 +1,42 @@ -import { LIST_MANIFESTS, ORDER_TYPE } from '../../providers/mssql/queries/manifestDoc.js'; +import { + LIST_MANIFESTS as MSSQL_LIST, + ORDER_TYPE as MSSQL_ORDER_TYPE, +} from '../../providers/mssql/queries/manifestDoc.js'; +import { + LIST_MANIFESTS as SQLITE_LIST, + ORDER_TYPE as SQLITE_ORDER_TYPE, +} from '../../providers/sqlite3/queries/manifestDoc.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import { shapeSummary } from '../manifestDoc/helpers.js'; +import makeRunner from '../runner.js'; -export default function list(pool) { - const access = resolveSelectedCompanyAccess(pool); +export default function list(provider) { + const access = resolveSelectedCompanyAccess(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + const sql = isSqlite ? SQLITE_LIST : MSSQL_LIST; + const orderType = isSqlite ? SQLITE_ORDER_TYPE : MSSQL_ORDER_TYPE; /** * Lists the orders of the user's selected company. * * @param {number} googleAuthId - The id of the Google auth user. + * @param {object} [tx] - Optional transaction. * @returns {Promise>} The orders. * * @example * const orders = await store.order.list(42); * // => [{ id, number, type, entityId, externalNumber, date }] */ - return async (googleAuthId) => { - const { companyId } = await access(googleAuthId); + return async (googleAuthId, tx) => { + const runner = makeRunner(provider, tx); + const { companyId } = await access(googleAuthId, runner); - const result = await pool - .request() - .input('companyId', companyId) - .input('type', ORDER_TYPE) - .query(LIST_MANIFESTS); + const result = await runner.query(sql, { + companyId, + type: orderType, + }); - return result.recordset.map((row) => shapeSummary(ORDER_TYPE, row)); + return result.rows.map((row) => shapeSummary(orderType, row)); }; } + diff --git a/src/store/order/update.js b/src/store/order/update.js index e1b0aa1..7471a81 100644 --- a/src/store/order/update.js +++ b/src/store/order/update.js @@ -1,18 +1,30 @@ import { - DELETE_LINES_BY_MANIFEST, - GET_MANIFEST_HEADER, - ORDER_TYPE, - UPDATE_MANIFEST, + DELETE_LINES_BY_MANIFEST as MSSQL_DELETE_LINES, + GET_MANIFEST_HEADER as MSSQL_GET_HEADER, + ORDER_TYPE as MSSQL_ORDER_TYPE, + UPDATE_MANIFEST as MSSQL_UPDATE_MANIFEST, } from '../../providers/mssql/queries/manifestDoc.js'; -import request from '../request.js'; +import { + DELETE_LINES_BY_MANIFEST as SQLITE_DELETE_LINES, + GET_MANIFEST_HEADER as SQLITE_GET_HEADER, + ORDER_TYPE as SQLITE_ORDER_TYPE, + UPDATE_MANIFEST as SQLITE_UPDATE_MANIFEST, +} from '../../providers/sqlite3/queries/manifestDoc.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import taxEnsure from '../tax/ensure.js'; import { insertLines, normalizeLines, parseDate } from '../manifestLines.js'; import { assertCustomerEntity, loadDoc, shapeSummary } from '../manifestDoc/helpers.js'; +import makeRunner from '../runner.js'; -export default function update(pool) { - const access = resolveSelectedCompanyAccess(pool); - const ensureTax = taxEnsure(pool); +export default function update(provider) { + const access = resolveSelectedCompanyAccess(provider); + const ensureTax = taxEnsure(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + + const deleteLinesSql = isSqlite ? SQLITE_DELETE_LINES : MSSQL_DELETE_LINES; + const getHeaderSql = isSqlite ? SQLITE_GET_HEADER : MSSQL_GET_HEADER; + const orderType = isSqlite ? SQLITE_ORDER_TYPE : MSSQL_ORDER_TYPE; + const updateManifestSql = isSqlite ? SQLITE_UPDATE_MANIFEST : MSSQL_UPDATE_MANIFEST; /** * Updates an order's header and/or lines. @@ -25,6 +37,7 @@ export default function update(pool) { * @param {number} [patch.entityId] - The new customer entity id. * @param {string} [patch.externalNumber] - The new external (customer) reference number. * @param {Array<{sku?: string, name?: string, pu?: number, amount: number, net: number, rebate?: number, taxrate: number, comment?: string|null}>} [patch.lines] - The new order lines. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise<{id: number, number: string, type: string, entityId: number|null, externalNumber: string|null, date: string, lines: Array<{id: number, sku: string|null, name: string|null, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string|null}>}>} The updated order. * @throws {Error} If the order is not found. * @@ -32,7 +45,7 @@ export default function update(pool) { * const order = await store.order.update(42, 5, { number: 'B-2026-002', lines: [...] }); * // => { id, number, type, entityId, date, lines: [...] } */ - return async (googleAuthId, id, patch = {}) => { + return async (googleAuthId, id, patch = {}, tx) => { if (!Number.isFinite(id)) { throw new Error('Order not found'); } @@ -52,24 +65,21 @@ export default function update(pool) { normalizedLines = normalizeLines(patch.lines); } - const { companyId } = await access(googleAuthId); + const runUpdate = async (runner) => { + const { companyId } = await access(googleAuthId, runner); - const tx = pool.transaction(); - await tx.begin(); + const existing = await runner.query(getHeaderSql, { + id, + companyId, + type: orderType, + }); - try { - const existing = await request(pool, tx) - .input('id', id) - .input('companyId', companyId) - .input('type', ORDER_TYPE) - .query(GET_MANIFEST_HEADER); - - const current = existing.recordset[0]; + const current = existing.rows[0]; if (!current) { throw new Error('Order not found'); } - const summary = shapeSummary(ORDER_TYPE, current); + const summary = shapeSummary(orderType, current); let number = summary.number; if (hasNumber) { @@ -90,8 +100,7 @@ export default function update(pool) { let entityId = summary.entityId; if (hasEntityId) { entityId = await assertCustomerEntity( - pool, - tx, + runner, companyId, patch.entityId ?? null, ); @@ -104,38 +113,39 @@ export default function update(pool) { : null; } - await request(pool, tx) - .input('id', id) - .input('companyId', companyId) - .input('type', ORDER_TYPE) - .input('number', number) - .input('entityId', entityId) - .input('externalNumber', externalNumber) - .input('date', date) - .query(UPDATE_MANIFEST); + await runner.query(updateManifestSql, { + id, + companyId, + type: orderType, + number, + entityId, + externalNumber, + date, + }); if (hasLines) { - await request(pool, tx) - .input('manifestId', id) - .query(DELETE_LINES_BY_MANIFEST); + await runner.query(deleteLinesSql, { + manifestId: id, + }); - await insertLines(pool, tx, { + await insertLines(runner, { manifestId: id, lines: normalizedLines, ensureTax, }); } - const doc = await loadDoc(pool, tx, { type: ORDER_TYPE, companyId, id }); - await tx.commit(); + const doc = await loadDoc(runner, { type: orderType, companyId, id }); return doc; - } catch (err) { - try { - await tx.rollback(); - } catch { - // ignore - } - throw err; + }; + + if (tx) { + return runUpdate(makeRunner(provider, tx)); } + if (typeof provider?.transaction === 'function') { + return provider.transaction(runUpdate); + } + return runUpdate(makeRunner(provider)); }; } + diff --git a/src/store/pic/getImageByHash.js b/src/store/pic/getImageByHash.js index 60f56a0..3bfbf76 100644 --- a/src/store/pic/getImageByHash.js +++ b/src/store/pic/getImageByHash.js @@ -1,14 +1,18 @@ -import { GET_FULL_BY_HASH } from '../../providers/mssql/queries/pic.js'; +import { GET_FULL_BY_HASH as MSSQL_GET } from '../../providers/mssql/queries/pic.js'; +import { GET_FULL_BY_HASH as SQLITE_GET } from '../../providers/sqlite3/queries/pic.js'; import resolvePicAccess from './resolvePicAccess.js'; +import makeRunner from '../runner.js'; -export default function getImageByHash(pool) { - const authorize = resolvePicAccess(pool); +export default function getImageByHash(provider) { + const authorize = resolvePicAccess(provider); + const sql = provider?.dialect === 'sqlite3' ? SQLITE_GET : MSSQL_GET; /** * Returns the full-size image (AVIF) for a given hash. * * @param {number} googleAuthId - The id of the Google auth user. * @param {string} fullHash - The full image hash. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise<{buffer: Buffer, w: number, h: number, size: number, contentType: string}>} The full image. * @throws {Error} If the image is not found. * @@ -16,21 +20,30 @@ export default function getImageByHash(pool) { * const image = await store.pic.getImageByHash(42, 'abc123'); * // => { buffer, w, h, size, contentType: 'image/avif' } */ - return async (googleAuthId, fullHash) => { - await authorize(googleAuthId, fullHash); + return async (googleAuthId, fullHash, tx) => { + await authorize(googleAuthId, fullHash, tx); - const result = await pool - .request() - .input('hash', fullHash) - .query(GET_FULL_BY_HASH); + const runner = makeRunner(provider, tx); + const result = await runner.query(sql, { hash: fullHash }); - const row = result.recordset[0]; - if (!row?.buffer) { + const row = result.rows[0]; + if (!row) { + throw new Error('Image not found'); + } + + let buffer; + if (provider?.dialect === 'sqlite3') { + buffer = await (runner.readPicFull ? runner.readPicFull(fullHash) : provider.readPicFull?.(fullHash)); + } else { + buffer = row.buffer ?? row.fullbinary; + } + + if (!buffer) { throw new Error('Image not found'); } return { - buffer: row.buffer, + buffer, w: row.w, h: row.h, size: row.size, diff --git a/src/store/pic/getThumbByHash.js b/src/store/pic/getThumbByHash.js index d88acd9..3decade 100644 --- a/src/store/pic/getThumbByHash.js +++ b/src/store/pic/getThumbByHash.js @@ -1,14 +1,18 @@ -import { GET_THUMB_BY_HASH } from '../../providers/mssql/queries/pic.js'; +import { GET_THUMB_BY_HASH as MSSQL_GET } from '../../providers/mssql/queries/pic.js'; +import { GET_THUMB_BY_HASH as SQLITE_GET } from '../../providers/sqlite3/queries/pic.js'; import resolvePicAccess from './resolvePicAccess.js'; +import makeRunner from '../runner.js'; -export default function getThumbByHash(pool) { - const authorize = resolvePicAccess(pool); +export default function getThumbByHash(provider) { + const authorize = resolvePicAccess(provider); + const sql = provider?.dialect === 'sqlite3' ? SQLITE_GET : MSSQL_GET; /** * Returns the thumbnail image (AVIF) for a given hash. * * @param {number} googleAuthId - The id of the Google auth user. * @param {string} fullHash - The full image hash. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise<{buffer: Buffer, w: number, h: number, contentType: string}>} The thumbnail image. * @throws {Error} If the image is not found. * @@ -16,21 +20,30 @@ export default function getThumbByHash(pool) { * const thumb = await store.pic.getThumbByHash(42, 'abc123'); * // => { buffer, w, h, contentType: 'image/avif' } */ - return async (googleAuthId, fullHash) => { - await authorize(googleAuthId, fullHash); + return async (googleAuthId, fullHash, tx) => { + await authorize(googleAuthId, fullHash, tx); - const result = await pool - .request() - .input('hash', fullHash) - .query(GET_THUMB_BY_HASH); + const runner = makeRunner(provider, tx); + const result = await runner.query(sql, { hash: fullHash }); - const row = result.recordset[0]; - if (!row?.buffer) { + const row = result.rows[0]; + if (!row) { + throw new Error('Image not found'); + } + + let buffer; + if (provider?.dialect === 'sqlite3') { + buffer = await (runner.readPicThumb ? runner.readPicThumb(fullHash) : provider.readPicThumb?.(fullHash)); + } else { + buffer = row.buffer ?? row.thumbbinary; + } + + if (!buffer) { throw new Error('Image not found'); } return { - buffer: row.buffer, + buffer, w: row.w, h: row.h, contentType: 'image/avif', diff --git a/src/store/pic/resolvePicAccess.js b/src/store/pic/resolvePicAccess.js index dc8414e..c49dddd 100644 --- a/src/store/pic/resolvePicAccess.js +++ b/src/store/pic/resolvePicAccess.js @@ -1,20 +1,20 @@ -import { GET_CATEGORY_ID_BY_HASH } from '../../providers/mssql/queries/pic.js'; +import { GET_CATEGORY_ID_BY_HASH as MSSQL_GET } from '../../providers/mssql/queries/pic.js'; +import { GET_CATEGORY_ID_BY_HASH as SQLITE_GET } from '../../providers/sqlite3/queries/pic.js'; import resolveCategoryAccess from '../category/resolveCategoryAccess.js'; +import makeRunner from '../runner.js'; -export default function resolvePicAccess(pool) { - const access = resolveCategoryAccess(pool); +export default function resolvePicAccess(provider) { + const access = resolveCategoryAccess(provider); + const sql = provider?.dialect === 'sqlite3' ? SQLITE_GET : MSSQL_GET; - return async (googleAuthId, fullHash) => { - const result = await pool - .request() - .input('hash', fullHash) - .query(GET_CATEGORY_ID_BY_HASH); + return async (googleAuthId, fullHash, tx) => { + const result = await makeRunner(provider, tx).query(sql, { hash: fullHash }); - const categoryId = result.recordset[0]?.categoryId; + const categoryId = result.rows[0]?.categoryId; if (!categoryId) { throw new Error('Image not found'); } - await access(googleAuthId, categoryId); + await access(googleAuthId, categoryId, tx); }; } diff --git a/src/store/posterminal/delete.js b/src/store/posterminal/delete.js index 302d252..8260dcf 100644 --- a/src/store/posterminal/delete.js +++ b/src/store/posterminal/delete.js @@ -1,34 +1,37 @@ -import { DELETE_BY_COMPANY_AND_ID } from '../../providers/mssql/queries/posterminal.js'; +import { DELETE_BY_COMPANY_AND_ID as MSSQL_DELETE } from '../../providers/mssql/queries/posterminal.js'; +import { DELETE_BY_COMPANY_AND_ID as SQLITE_DELETE } from '../../providers/sqlite3/queries/posterminal.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; +import makeRunner from '../runner.js'; -export default function del(pool) { - const access = resolveSelectedCompanyAccess(pool); +export default function del(provider) { + const access = resolveSelectedCompanyAccess(provider); + const sql = provider?.dialect === 'sqlite3' ? SQLITE_DELETE : MSSQL_DELETE; /** * Deletes a posterminal by its id within the user's selected company. * * @param {number} googleAuthId - The id of the Google auth user. * @param {number} id - The id of the posterminal to delete. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise} * @throws {Error} If the posterminal is not found. * * @example * await store.posterminal.delete(42, 5); */ - return async (googleAuthId, id) => { + return async (googleAuthId, id, tx) => { if (!Number.isFinite(id)) { throw new Error('Posterminal not found'); } - const { companyId } = await access(googleAuthId); + const { companyId } = await access(googleAuthId, tx); - const result = await pool - .request() - .input('id', id) - .input('companyId', companyId) - .query(DELETE_BY_COMPANY_AND_ID); + const result = await makeRunner(provider, tx).query(sql, { + id, + companyId, + }); - if (!result.rowsAffected[0]) { + if (!result.rowsAffected) { throw new Error('Posterminal not found'); } }; diff --git a/src/store/posterminal/getCategoryPosterminalIds.js b/src/store/posterminal/getCategoryPosterminalIds.js index 9a69f35..c4c1141 100644 --- a/src/store/posterminal/getCategoryPosterminalIds.js +++ b/src/store/posterminal/getCategoryPosterminalIds.js @@ -1,25 +1,30 @@ -import { POSTERMINAL_IDS_BY_COMPANY } from '../../providers/mssql/queries/posterminal.js'; +import { POSTERMINAL_IDS_BY_COMPANY as MSSQL_POSTERMINAL_IDS } from '../../providers/mssql/queries/posterminal.js'; +import { POSTERMINAL_IDS_BY_COMPANY as SQLITE_POSTERMINAL_IDS } from '../../providers/sqlite3/queries/posterminal.js'; +import makeRunner from '../runner.js'; + +export default function getCategoryPosterminalIds(provider) { + const sql = + provider?.dialect === 'sqlite3' + ? SQLITE_POSTERMINAL_IDS + : MSSQL_POSTERMINAL_IDS; -export default function getCategoryPosterminalIds(pool) { /** * Returns a map of category id to the posterminal ids assigned to it for a * company. * * @param {number} companyId - The id of the company. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise>>} A map of category id to posterminal ids. * * @example - * const map = await getCategoryPosterminalIds(pool)(1); + * const map = await getCategoryPosterminalIds(provider)(1); * // => { 5: [1, 2], 9: [1] } */ - return async (companyId) => { - const result = await pool - .request() - .input('companyId', companyId) - .query(POSTERMINAL_IDS_BY_COMPANY); + return async (companyId, tx) => { + const result = await makeRunner(provider, tx).query(sql, { companyId }); const map = {}; - for (const row of result.recordset) { + for (const row of result.rows) { if (!map[row.categoryId]) { map[row.categoryId] = []; } diff --git a/src/store/posterminal/list.js b/src/store/posterminal/list.js index c6d9729..d40ad46 100644 --- a/src/store/posterminal/list.js +++ b/src/store/posterminal/list.js @@ -1,27 +1,28 @@ -import { LIST_BY_COMPANY } from '../../providers/mssql/queries/posterminal.js'; +import { LIST_BY_COMPANY as MSSQL_LIST } from '../../providers/mssql/queries/posterminal.js'; +import { LIST_BY_COMPANY as SQLITE_LIST } from '../../providers/sqlite3/queries/posterminal.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; +import makeRunner from '../runner.js'; -export default function list(pool) { - const access = resolveSelectedCompanyAccess(pool); +export default function list(provider) { + const access = resolveSelectedCompanyAccess(provider); + const sql = provider?.dialect === 'sqlite3' ? SQLITE_LIST : MSSQL_LIST; /** * Lists the posterminals of the user's selected company. * * @param {number} googleAuthId - The id of the Google auth user. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise>} The posterminals. * * @example * const terminals = await store.posterminal.list(42); * // => [{ id, name, token, pairingpin }] */ - return async (googleAuthId) => { - const { companyId } = await access(googleAuthId); + return async (googleAuthId, tx) => { + const { companyId } = await access(googleAuthId, tx); - const result = await pool - .request() - .input('companyId', companyId) - .query(LIST_BY_COMPANY); + const result = await makeRunner(provider, tx).query(sql, { companyId }); - return result.recordset; + return result.rows; }; } diff --git a/src/store/posterminal/setOff.js b/src/store/posterminal/setOff.js index 2a85079..0cc041b 100644 --- a/src/store/posterminal/setOff.js +++ b/src/store/posterminal/setOff.js @@ -1,8 +1,25 @@ -import { GET_BY_ID, SET_OFF, LIST_CATEGORY_IDS } from '../../providers/mssql/queries/posterminal.js'; +import { + GET_BY_ID as MSSQL_GET_BY_ID, + SET_OFF as MSSQL_SET_OFF, + LIST_CATEGORY_IDS as MSSQL_LIST_CATEGORY_IDS, +} from '../../providers/mssql/queries/posterminal.js'; +import { + GET_BY_ID as SQLITE_GET_BY_ID, + SET_OFF as SQLITE_SET_OFF, + LIST_CATEGORY_IDS as SQLITE_LIST_CATEGORY_IDS, +} from '../../providers/sqlite3/queries/posterminal.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; +import makeRunner from '../runner.js'; -export default function setOff(pool) { - const access = resolveSelectedCompanyAccess(pool); +export default function setOff(provider) { + const access = resolveSelectedCompanyAccess(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + + const getByIdSql = isSqlite ? SQLITE_GET_BY_ID : MSSQL_GET_BY_ID; + const setOffSql = isSqlite ? SQLITE_SET_OFF : MSSQL_SET_OFF; + const listCatIdsSql = isSqlite + ? SQLITE_LIST_CATEGORY_IDS + : MSSQL_LIST_CATEGORY_IDS; /** * Removes a category assignment from a posterminal (turns it off). @@ -10,6 +27,7 @@ export default function setOff(pool) { * @param {number} googleAuthId - The id of the Google auth user. * @param {number} posterminalId - The id of the posterminal. * @param {number} categoryId - The id of the category to remove. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise<{id: number, categoryIds: Array}>} The posterminal with its remaining category ids. * @throws {Error} If the posterminal is not found. * @@ -17,36 +35,30 @@ export default function setOff(pool) { * const result = await store.posterminal.setOff(42, 5, 9); * // => { id, categoryIds: [...] } */ - return async (googleAuthId, posterminalId, categoryId) => { + return async (googleAuthId, posterminalId, categoryId, tx) => { if (!Number.isFinite(posterminalId) || !Number.isFinite(categoryId)) { throw new Error('Invalid posterminal or category'); } - const { companyId } = await access(googleAuthId); + const runner = makeRunner(provider, tx); + const { companyId } = await access(googleAuthId, tx); - const existing = await pool - .request() - .input('id', posterminalId) - .query(GET_BY_ID); + const existing = await runner.query(getByIdSql, { id: posterminalId }); - if (!existing.recordset[0] || existing.recordset[0].companyId !== companyId) { + if (!existing.rows[0] || existing.rows[0].companyId !== companyId) { throw new Error('Posterminal not found'); } - await pool - .request() - .input('posterminalId', posterminalId) - .input('categoryId', categoryId) - .query(SET_OFF); + await runner.query(setOffSql, { + posterminalId, + categoryId, + }); - const result = await pool - .request() - .input('posterminalId', posterminalId) - .query(LIST_CATEGORY_IDS); + const result = await runner.query(listCatIdsSql, { posterminalId }); return { id: posterminalId, - categoryIds: result.recordset.map((r) => r.categoryId), + categoryIds: result.rows.map((r) => r.categoryId), }; }; } diff --git a/src/store/posterminal/setOffAll.js b/src/store/posterminal/setOffAll.js index 09d2fca..897a38d 100644 --- a/src/store/posterminal/setOffAll.js +++ b/src/store/posterminal/setOffAll.js @@ -1,14 +1,32 @@ -import { GET_BY_ID, SET_OFF_ALL, LIST_CATEGORY_IDS } from '../../providers/mssql/queries/posterminal.js'; +import { + GET_BY_ID as MSSQL_GET_BY_ID, + SET_OFF_ALL as MSSQL_SET_OFF_ALL, + LIST_CATEGORY_IDS as MSSQL_LIST_CATEGORY_IDS, +} from '../../providers/mssql/queries/posterminal.js'; +import { + GET_BY_ID as SQLITE_GET_BY_ID, + SET_OFF_ALL as SQLITE_SET_OFF_ALL, + LIST_CATEGORY_IDS as SQLITE_LIST_CATEGORY_IDS, +} from '../../providers/sqlite3/queries/posterminal.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; +import makeRunner from '../runner.js'; -export default function setOffAll(pool) { - const access = resolveSelectedCompanyAccess(pool); +export default function setOffAll(provider) { + const access = resolveSelectedCompanyAccess(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + + const getByIdSql = isSqlite ? SQLITE_GET_BY_ID : MSSQL_GET_BY_ID; + const setOffAllSql = isSqlite ? SQLITE_SET_OFF_ALL : MSSQL_SET_OFF_ALL; + const listCatIdsSql = isSqlite + ? SQLITE_LIST_CATEGORY_IDS + : MSSQL_LIST_CATEGORY_IDS; /** * Removes all category assignments from a posterminal. * * @param {number} googleAuthId - The id of the Google auth user. * @param {number} posterminalId - The id of the posterminal. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise<{id: number, categoryIds: Array}>} The posterminal with an empty category list. * @throws {Error} If the posterminal is not found. * @@ -16,36 +34,28 @@ export default function setOffAll(pool) { * const result = await store.posterminal.setOffAll(42, 5); * // => { id, categoryIds: [] } */ - return async (googleAuthId, posterminalId) => { + return async (googleAuthId, posterminalId, tx) => { if (!Number.isFinite(posterminalId)) { throw new Error('Invalid posterminal'); } - const { companyId } = await access(googleAuthId); + const runner = makeRunner(provider, tx); + const { companyId } = await access(googleAuthId, tx); // Verify posterminal belongs to company - const pt = await pool - .request() - .input('id', posterminalId) - .query(GET_BY_ID); + const pt = await runner.query(getByIdSql, { id: posterminalId }); - if (!pt.recordset[0] || pt.recordset[0].companyId !== companyId) { + if (!pt.rows[0] || pt.rows[0].companyId !== companyId) { throw new Error('Posterminal not found'); } - await pool - .request() - .input('posterminalId', posterminalId) - .query(SET_OFF_ALL); + await runner.query(setOffAllSql, { posterminalId }); - const result = await pool - .request() - .input('posterminalId', posterminalId) - .query(LIST_CATEGORY_IDS); + const result = await runner.query(listCatIdsSql, { posterminalId }); return { id: posterminalId, - categoryIds: result.recordset.map((r) => r.categoryId), + categoryIds: result.rows.map((r) => r.categoryId), }; }; } diff --git a/src/store/posterminal/setOn.js b/src/store/posterminal/setOn.js index 48471ff..e20dfdf 100644 --- a/src/store/posterminal/setOn.js +++ b/src/store/posterminal/setOn.js @@ -1,8 +1,25 @@ -import { GET_BY_ID, SET_ON, LIST_CATEGORY_IDS } from '../../providers/mssql/queries/posterminal.js'; +import { + GET_BY_ID as MSSQL_GET_BY_ID, + SET_ON as MSSQL_SET_ON, + LIST_CATEGORY_IDS as MSSQL_LIST_CATEGORY_IDS, +} from '../../providers/mssql/queries/posterminal.js'; +import { + GET_BY_ID as SQLITE_GET_BY_ID, + SET_ON as SQLITE_SET_ON, + LIST_CATEGORY_IDS as SQLITE_LIST_CATEGORY_IDS, +} from '../../providers/sqlite3/queries/posterminal.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; +import makeRunner from '../runner.js'; -export default function setOn(pool) { - const access = resolveSelectedCompanyAccess(pool); +export default function setOn(provider) { + const access = resolveSelectedCompanyAccess(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + + const getByIdSql = isSqlite ? SQLITE_GET_BY_ID : MSSQL_GET_BY_ID; + const setOnSql = isSqlite ? SQLITE_SET_ON : MSSQL_SET_ON; + const listCatIdsSql = isSqlite + ? SQLITE_LIST_CATEGORY_IDS + : MSSQL_LIST_CATEGORY_IDS; /** * Assigns a category to a posterminal (turns it on for that category). @@ -10,6 +27,7 @@ export default function setOn(pool) { * @param {number} googleAuthId - The id of the Google auth user. * @param {number} posterminalId - The id of the posterminal. * @param {number} categoryId - The id of the category to assign. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise<{id: number, categoryIds: Array}>} The posterminal with its category ids. * @throws {Error} If the posterminal is not found. * @@ -17,36 +35,30 @@ export default function setOn(pool) { * const result = await store.posterminal.setOn(42, 5, 9); * // => { id, categoryIds: [9, ...] } */ - return async (googleAuthId, posterminalId, categoryId) => { + return async (googleAuthId, posterminalId, categoryId, tx) => { if (!Number.isFinite(posterminalId) || !Number.isFinite(categoryId)) { throw new Error('Invalid posterminal or category'); } - const { companyId } = await access(googleAuthId); + const runner = makeRunner(provider, tx); + const { companyId } = await access(googleAuthId, tx); - const existing = await pool - .request() - .input('id', posterminalId) - .query(GET_BY_ID); + const existing = await runner.query(getByIdSql, { id: posterminalId }); - if (!existing.recordset[0] || existing.recordset[0].companyId !== companyId) { + if (!existing.rows[0] || existing.rows[0].companyId !== companyId) { throw new Error('Posterminal not found'); } - await pool - .request() - .input('posterminalId', posterminalId) - .input('categoryId', categoryId) - .query(SET_ON); + await runner.query(setOnSql, { + posterminalId, + categoryId, + }); - const result = await pool - .request() - .input('posterminalId', posterminalId) - .query(LIST_CATEGORY_IDS); + const result = await runner.query(listCatIdsSql, { posterminalId }); return { id: posterminalId, - categoryIds: result.recordset.map((r) => r.categoryId), + categoryIds: result.rows.map((r) => r.categoryId), }; }; } diff --git a/src/store/posterminal/setOnAll.js b/src/store/posterminal/setOnAll.js index 1dc34dc..c8eb37f 100644 --- a/src/store/posterminal/setOnAll.js +++ b/src/store/posterminal/setOnAll.js @@ -1,15 +1,35 @@ -import { GET_BY_ID, SET_ON_ALL, LIST_CATEGORY_IDS } from '../../providers/mssql/queries/posterminal.js'; +import { + GET_BY_ID as MSSQL_GET_BY_ID, + SET_ON_ALL as MSSQL_SET_ON_ALL, + LIST_CATEGORY_IDS as MSSQL_LIST_CATEGORY_IDS, +} from '../../providers/mssql/queries/posterminal.js'; +import { + GET_BY_ID as SQLITE_GET_BY_ID, + SET_ON_ALL as SQLITE_SET_ON_ALL, + LIST_CATEGORY_IDS as SQLITE_LIST_CATEGORY_IDS, +} from '../../providers/sqlite3/queries/posterminal.js'; +import { GET_COMPANY_ROOT_CATEGORY as MSSQL_ROOT } from '../../providers/mssql/queries/company.js'; +import { GET_COMPANY_ROOT_CATEGORY as SQLITE_ROOT } from '../../providers/sqlite3/queries/company.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; -import { GET_COMPANY_ROOT_CATEGORY } from '../../providers/mssql/queries/company.js'; +import makeRunner from '../runner.js'; -export default function setOnAll(pool) { - const access = resolveSelectedCompanyAccess(pool); +export default function setOnAll(provider) { + const access = resolveSelectedCompanyAccess(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + + const getByIdSql = isSqlite ? SQLITE_GET_BY_ID : MSSQL_GET_BY_ID; + const setOnAllSql = isSqlite ? SQLITE_SET_ON_ALL : MSSQL_SET_ON_ALL; + const listCatIdsSql = isSqlite + ? SQLITE_LIST_CATEGORY_IDS + : MSSQL_LIST_CATEGORY_IDS; + const rootSql = isSqlite ? SQLITE_ROOT : MSSQL_ROOT; /** * Assigns all categories of the user's selected company to a posterminal. * * @param {number} googleAuthId - The id of the Google auth user. * @param {number} posterminalId - The id of the posterminal. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise<{id: number, categoryIds: Array}>} The posterminal with all category ids. * @throws {Error} If the posterminal is not found. * @@ -17,48 +37,39 @@ export default function setOnAll(pool) { * const result = await store.posterminal.setOnAll(42, 5); * // => { id, categoryIds: [...] } */ - return async (googleAuthId, posterminalId) => { + return async (googleAuthId, posterminalId, tx) => { if (!Number.isFinite(posterminalId)) { throw new Error('Invalid posterminal'); } - const { companyId } = await access(googleAuthId); + const runner = makeRunner(provider, tx); + const { companyId } = await access(googleAuthId, tx); // Verify posterminal belongs to company - const pt = await pool - .request() - .input('id', posterminalId) - .query(GET_BY_ID); + const pt = await runner.query(getByIdSql, { id: posterminalId }); - if (!pt.recordset[0] || pt.recordset[0].companyId !== companyId) { + if (!pt.rows[0] || pt.rows[0].companyId !== companyId) { throw new Error('Posterminal not found'); } // Get the company's root category to walk the tree - const root = await pool - .request() - .input('companyId', companyId) - .query(GET_COMPANY_ROOT_CATEGORY); + const root = await runner.query(rootSql, { companyId }); - const rootCategoryId = root.recordset[0]?.rootCategoryId; + const rootCategoryId = root.rows[0]?.rootCategoryId; if (!rootCategoryId) { throw new Error('Company not found'); } - await pool - .request() - .input('posterminalId', posterminalId) - .input('rootCategoryId', rootCategoryId) - .query(SET_ON_ALL); + await runner.query(setOnAllSql, { + posterminalId, + rootCategoryId, + }); - const result = await pool - .request() - .input('posterminalId', posterminalId) - .query(LIST_CATEGORY_IDS); + const result = await runner.query(listCatIdsSql, { posterminalId }); return { id: posterminalId, - categoryIds: result.recordset.map((r) => r.categoryId), + categoryIds: result.rows.map((r) => r.categoryId), }; }; } diff --git a/src/store/posterminal/upsert.js b/src/store/posterminal/upsert.js index 5bd2f29..f945d3e 100644 --- a/src/store/posterminal/upsert.js +++ b/src/store/posterminal/upsert.js @@ -1,8 +1,23 @@ -import { GET_BY_ID, CREATE, UPDATE } from '../../providers/mssql/queries/posterminal.js'; +import { + GET_BY_ID as MSSQL_GET_BY_ID, + CREATE as MSSQL_CREATE, + UPDATE as MSSQL_UPDATE, +} from '../../providers/mssql/queries/posterminal.js'; +import { + GET_BY_ID as SQLITE_GET_BY_ID, + CREATE as SQLITE_CREATE, + UPDATE as SQLITE_UPDATE, +} from '../../providers/sqlite3/queries/posterminal.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; +import makeRunner from '../runner.js'; -export default function upsert(pool) { - const access = resolveSelectedCompanyAccess(pool); +export default function upsert(provider) { + const access = resolveSelectedCompanyAccess(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + + const getByIdSql = isSqlite ? SQLITE_GET_BY_ID : MSSQL_GET_BY_ID; + const createSql = isSqlite ? SQLITE_CREATE : MSSQL_CREATE; + const updateSql = isSqlite ? SQLITE_UPDATE : MSSQL_UPDATE; /** * Creates or updates a posterminal for the user's selected company. @@ -13,6 +28,7 @@ export default function upsert(pool) { * @param {string} input.pairingpin - The pairing pin. * @param {string} [input.token] - Optional token. * @param {number} [input.id] - Optional id to update an existing posterminal. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise<{id: number, name: string, token: string, pairingpin: string}>} The upserted posterminal. * @throws {Error} If the posterminal is invalid. * @@ -20,7 +36,7 @@ export default function upsert(pool) { * const terminal = await store.posterminal.upsert(42, { name: 'Kasse 1', pairingpin: '1234', token: 'abc' }); * // => { id, name, token, pairingpin } */ - return async (googleAuthId, input = {}) => { + return async (googleAuthId, input = {}, tx) => { const name = input.name?.trim(); if (!name) { throw new Error('Invalid posterminal'); @@ -32,43 +48,46 @@ export default function upsert(pool) { const token = input.token ?? null; const id = input.id ?? null; - const { companyId } = await access(googleAuthId); + const runner = makeRunner(provider, tx); + const { companyId } = await access(googleAuthId, tx); if (id) { - const existing = await pool - .request() - .input('id', id) - .query(GET_BY_ID); + const existing = await runner.query(getByIdSql, { id }); - if (!existing.recordset[0] || existing.recordset[0].companyId !== companyId) { + if (!existing.rows[0] || existing.rows[0].companyId !== companyId) { throw new Error('Posterminal not found'); } - await pool - .request() - .input('id', id) - .input('companyId', companyId) - .input('name', name) - .input('token', token) - .input('pairingpin', pairingpin) - .query(UPDATE); + await runner.query(updateSql, { + id, + companyId, + name, + token, + pairingpin, + }); return { id, name, token, pairingpin }; } - const created = await pool - .request() - .input('companyId', companyId) - .input('name', name) - .input('token', token) - .input('pairingpin', pairingpin) - .query(CREATE); + const created = await runner.query(createSql, { + companyId, + name, + token, + pairingpin, + }); + + const row = created.rows[0] ?? { + id: created.insertId, + name, + token, + pairingpin, + }; return { - id: created.recordset[0].id, - name: created.recordset[0].name, - token: created.recordset[0].token, - pairingpin: created.recordset[0].pairingpin, + id: row.id, + name: row.name, + token: row.token, + pairingpin: row.pairingpin, }; }; } diff --git a/src/store/purge.js b/src/store/purge.js index 5dfe4dc..39930d9 100644 --- a/src/store/purge.js +++ b/src/store/purge.js @@ -2,7 +2,7 @@ import { buildPurgeSql } from '../providers/mssql/purgeSql.js'; export default function purge(provider) { /** - * Purges (wipes) all data from the database. + * Purges (wipes) all data from the database by delegating to the provider. * * @returns {Promise} * @@ -10,6 +10,15 @@ export default function purge(provider) { * await store.purge(); */ return async () => { - await provider.query(buildPurgeSql()); + if (typeof provider.purge === 'function') { + return provider.purge(); + } + // Transitional fallback for raw mssql pool + const sql = buildPurgeSql(); + if (typeof provider.query === 'function') { + await provider.query(sql); + } else if (typeof provider.request === 'function') { + await provider.request().query(sql); + } }; } diff --git a/src/store/quote/create.js b/src/store/quote/create.js index 7aeb3e4..11b7e00 100644 --- a/src/store/quote/create.js +++ b/src/store/quote/create.js @@ -1,13 +1,24 @@ -import { CREATE_MANIFEST, QUOTE_TYPE } from '../../providers/mssql/queries/manifestDoc.js'; -import request from '../request.js'; +import { + CREATE_MANIFEST as MSSQL_CREATE_MANIFEST, + QUOTE_TYPE as MSSQL_QUOTE_TYPE, +} from '../../providers/mssql/queries/manifestDoc.js'; +import { + CREATE_MANIFEST as SQLITE_CREATE_MANIFEST, + QUOTE_TYPE as SQLITE_QUOTE_TYPE, +} from '../../providers/sqlite3/queries/manifestDoc.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import taxEnsure from '../tax/ensure.js'; import { insertLines, normalizeLines, parseDate } from '../manifestLines.js'; import { assertCustomerEntity, loadDoc } from '../manifestDoc/helpers.js'; +import makeRunner from '../runner.js'; -export default function create(pool) { - const access = resolveSelectedCompanyAccess(pool); - const ensureTax = taxEnsure(pool); +export default function create(provider) { + const access = resolveSelectedCompanyAccess(provider); + const ensureTax = taxEnsure(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + + const createManifestSql = isSqlite ? SQLITE_CREATE_MANIFEST : MSSQL_CREATE_MANIFEST; + const quoteType = isSqlite ? SQLITE_QUOTE_TYPE : MSSQL_QUOTE_TYPE; /** * Creates a new quote for the user's selected company with its lines. @@ -19,6 +30,7 @@ export default function create(pool) { * @param {number} [body.entityId] - The id of the customer entity. * @param {string} [body.externalNumber] - An external (customer) reference number. * @param {Array<{sku?: string, name?: string, pu?: number, amount: number, net: number, rebate?: number, taxrate: number, comment?: string|null}>} [body.lines] - The quote lines. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise<{id: number, number: string, type: string, entityId: number|null, externalNumber: string|null, date: string, lines: Array<{id: number, sku: string|null, name: string|null, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string|null}>}>} The created quote. * @throws {Error} If the quote is invalid. * @@ -26,7 +38,7 @@ export default function create(pool) { * const quote = await store.quote.create(42, { number: 'A-2026-001', date: '2026-09-01', entityId: 12, lines: [...] }); * // => { id, number, type, entityId, externalNumber, date, lines: [...] } */ - return async (googleAuthId, body = {}) => { + return async (googleAuthId, body = {}, tx) => { const number = body.number?.trim?.() ? body.number.trim() : null; const date = parseDate(body.date); if (!number || !date) { @@ -37,50 +49,49 @@ export default function create(pool) { : null; const lines = normalizeLines(body.lines ?? []); - const { companyId } = await access(googleAuthId); - const tx = pool.transaction(); - await tx.begin(); + const runCreate = async (runner) => { + const { companyId } = await access(googleAuthId, runner); - try { const entityId = await assertCustomerEntity( - pool, - tx, + runner, companyId, body.entityId ?? null, ); - const created = await request(pool, tx) - .input('companyId', companyId) - .input('number', number) - .input('type', QUOTE_TYPE) - .input('entityId', entityId) - .input('externalNumber', externalNumber) - .input('date', date) - .query(CREATE_MANIFEST); + const created = await runner.query(createManifestSql, { + companyId, + number, + type: quoteType, + entityId, + externalNumber, + date, + }); - const manifestId = created.recordset[0].id; + const manifestId = created.rows[0]?.id ?? created.insertId; - await insertLines(pool, tx, { + await insertLines(runner, { manifestId, lines, ensureTax, }); - const doc = await loadDoc(pool, tx, { - type: QUOTE_TYPE, + const doc = await loadDoc(runner, { + type: quoteType, companyId, id: manifestId, }); - await tx.commit(); + return doc; - } catch (err) { - try { - await tx.rollback(); - } catch { - // ignore - } - throw err; + }; + + if (tx) { + return runCreate(makeRunner(provider, tx)); } + if (typeof provider?.transaction === 'function') { + return provider.transaction(runCreate); + } + return runCreate(makeRunner(provider)); }; } + diff --git a/src/store/quote/delete.js b/src/store/quote/delete.js index d688309..80f969a 100644 --- a/src/store/quote/delete.js +++ b/src/store/quote/delete.js @@ -1,36 +1,49 @@ -import { DELETE_MANIFEST, QUOTE_TYPE } from '../../providers/mssql/queries/manifestDoc.js'; +import { + DELETE_MANIFEST as MSSQL_DELETE, + QUOTE_TYPE as MSSQL_QUOTE_TYPE, +} from '../../providers/mssql/queries/manifestDoc.js'; +import { + DELETE_MANIFEST as SQLITE_DELETE, + QUOTE_TYPE as SQLITE_QUOTE_TYPE, +} from '../../providers/sqlite3/queries/manifestDoc.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; +import makeRunner from '../runner.js'; -export default function del(pool) { - const access = resolveSelectedCompanyAccess(pool); +export default function del(provider) { + const access = resolveSelectedCompanyAccess(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + const deleteSql = isSqlite ? SQLITE_DELETE : MSSQL_DELETE; + const quoteType = isSqlite ? SQLITE_QUOTE_TYPE : MSSQL_QUOTE_TYPE; /** * Deletes a quote by its id within the user's selected company. * * @param {number} googleAuthId - The id of the Google auth user. * @param {number} id - The id of the quote to delete. + * @param {object} [tx] - Optional transaction. * @returns {Promise} * @throws {Error} If the quote is not found. * * @example * await store.quote.delete(42, 5); */ - return async (googleAuthId, id) => { + return async (googleAuthId, id, tx) => { if (!Number.isFinite(id)) { throw new Error('Quote not found'); } - const { companyId } = await access(googleAuthId); + const runner = makeRunner(provider, tx); + const { companyId } = await access(googleAuthId, runner); - const result = await pool - .request() - .input('id', id) - .input('companyId', companyId) - .input('type', QUOTE_TYPE) - .query(DELETE_MANIFEST); + const result = await runner.query(deleteSql, { + id, + companyId, + type: quoteType, + }); - if (!result.rowsAffected[0]) { + if (!result.rowsAffected) { throw new Error('Quote not found'); } }; } + diff --git a/src/store/quote/getById.js b/src/store/quote/getById.js index f336108..29233cb 100644 --- a/src/store/quote/getById.js +++ b/src/store/quote/getById.js @@ -1,15 +1,20 @@ -import { QUOTE_TYPE } from '../../providers/mssql/queries/manifestDoc.js'; +import { QUOTE_TYPE as MSSQL_QUOTE_TYPE } from '../../providers/mssql/queries/manifestDoc.js'; +import { QUOTE_TYPE as SQLITE_QUOTE_TYPE } from '../../providers/sqlite3/queries/manifestDoc.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import { loadDoc } from '../manifestDoc/helpers.js'; +import makeRunner from '../runner.js'; -export default function getById(pool) { - const access = resolveSelectedCompanyAccess(pool); +export default function getById(provider) { + const access = resolveSelectedCompanyAccess(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + const quoteType = isSqlite ? SQLITE_QUOTE_TYPE : MSSQL_QUOTE_TYPE; /** * Returns a single quote by its id within the user's selected company. * * @param {number} googleAuthId - The id of the Google auth user. * @param {number} id - The id of the quote. + * @param {object} [tx] - Optional transaction. * @returns {Promise<{id: number, number: string, type: string, entityId: number|null, externalNumber: string|null, date: string, lines: Array<{id: number, sku: string|null, name: string|null, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string|null}>}>} The quote with its lines. * @throws {Error} If the quote is not found. * @@ -17,17 +22,19 @@ export default function getById(pool) { * const quote = await store.quote.getById(42, 5); * // => { id, number, type, entityId, externalNumber, date, lines: [...] } */ - return async (googleAuthId, id) => { + return async (googleAuthId, id, tx) => { if (!Number.isFinite(id)) { throw new Error('Quote not found'); } - const { companyId } = await access(googleAuthId); + const runner = makeRunner(provider, tx); + const { companyId } = await access(googleAuthId, runner); - const doc = await loadDoc(pool, null, { type: QUOTE_TYPE, companyId, id }); + const doc = await loadDoc(runner, { type: quoteType, companyId, id }); if (!doc) { throw new Error('Quote not found'); } return doc; }; } + diff --git a/src/store/quote/list.js b/src/store/quote/list.js index c64703b..a52f30c 100644 --- a/src/store/quote/list.js +++ b/src/store/quote/list.js @@ -1,29 +1,42 @@ -import { LIST_MANIFESTS, QUOTE_TYPE } from '../../providers/mssql/queries/manifestDoc.js'; +import { + LIST_MANIFESTS as MSSQL_LIST, + QUOTE_TYPE as MSSQL_QUOTE_TYPE, +} from '../../providers/mssql/queries/manifestDoc.js'; +import { + LIST_MANIFESTS as SQLITE_LIST, + QUOTE_TYPE as SQLITE_QUOTE_TYPE, +} from '../../providers/sqlite3/queries/manifestDoc.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import { shapeSummary } from '../manifestDoc/helpers.js'; +import makeRunner from '../runner.js'; -export default function list(pool) { - const access = resolveSelectedCompanyAccess(pool); +export default function list(provider) { + const access = resolveSelectedCompanyAccess(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + const sql = isSqlite ? SQLITE_LIST : MSSQL_LIST; + const quoteType = isSqlite ? SQLITE_QUOTE_TYPE : MSSQL_QUOTE_TYPE; /** * Lists the quotes of the user's selected company. * * @param {number} googleAuthId - The id of the Google auth user. + * @param {object} [tx] - Optional transaction. * @returns {Promise>} The quotes. * * @example * const quotes = await store.quote.list(42); * // => [{ id, number, type, entityId, externalNumber, date }] */ - return async (googleAuthId) => { - const { companyId } = await access(googleAuthId); + return async (googleAuthId, tx) => { + const runner = makeRunner(provider, tx); + const { companyId } = await access(googleAuthId, runner); - const result = await pool - .request() - .input('companyId', companyId) - .input('type', QUOTE_TYPE) - .query(LIST_MANIFESTS); + const result = await runner.query(sql, { + companyId, + type: quoteType, + }); - return result.recordset.map((row) => shapeSummary(QUOTE_TYPE, row)); + return result.rows.map((row) => shapeSummary(quoteType, row)); }; } + diff --git a/src/store/quote/update.js b/src/store/quote/update.js index 5348419..46c3dc0 100644 --- a/src/store/quote/update.js +++ b/src/store/quote/update.js @@ -1,18 +1,30 @@ import { - DELETE_LINES_BY_MANIFEST, - GET_MANIFEST_HEADER, - QUOTE_TYPE, - UPDATE_MANIFEST, + DELETE_LINES_BY_MANIFEST as MSSQL_DELETE_LINES, + GET_MANIFEST_HEADER as MSSQL_GET_HEADER, + QUOTE_TYPE as MSSQL_QUOTE_TYPE, + UPDATE_MANIFEST as MSSQL_UPDATE_MANIFEST, } from '../../providers/mssql/queries/manifestDoc.js'; -import request from '../request.js'; +import { + DELETE_LINES_BY_MANIFEST as SQLITE_DELETE_LINES, + GET_MANIFEST_HEADER as SQLITE_GET_HEADER, + QUOTE_TYPE as SQLITE_QUOTE_TYPE, + UPDATE_MANIFEST as SQLITE_UPDATE_MANIFEST, +} from '../../providers/sqlite3/queries/manifestDoc.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import taxEnsure from '../tax/ensure.js'; import { insertLines, normalizeLines, parseDate } from '../manifestLines.js'; import { assertCustomerEntity, loadDoc, shapeSummary } from '../manifestDoc/helpers.js'; +import makeRunner from '../runner.js'; -export default function update(pool) { - const access = resolveSelectedCompanyAccess(pool); - const ensureTax = taxEnsure(pool); +export default function update(provider) { + const access = resolveSelectedCompanyAccess(provider); + const ensureTax = taxEnsure(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + + const deleteLinesSql = isSqlite ? SQLITE_DELETE_LINES : MSSQL_DELETE_LINES; + const getHeaderSql = isSqlite ? SQLITE_GET_HEADER : MSSQL_GET_HEADER; + const quoteType = isSqlite ? SQLITE_QUOTE_TYPE : MSSQL_QUOTE_TYPE; + const updateManifestSql = isSqlite ? SQLITE_UPDATE_MANIFEST : MSSQL_UPDATE_MANIFEST; /** * Updates a quote's header and/or lines. @@ -25,6 +37,7 @@ export default function update(pool) { * @param {number} [patch.entityId] - The new customer entity id. * @param {string} [patch.externalNumber] - The new external (customer) reference number. * @param {Array<{sku?: string, name?: string, pu?: number, amount: number, net: number, rebate?: number, taxrate: number, comment?: string|null}>} [patch.lines] - The new quote lines. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise<{id: number, number: string, type: string, entityId: number|null, externalNumber: string|null, date: string, lines: Array<{id: number, sku: string|null, name: string|null, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string|null}>}>} The updated quote. * @throws {Error} If the quote is not found. * @@ -32,7 +45,7 @@ export default function update(pool) { * const quote = await store.quote.update(42, 5, { number: 'A-2026-002', lines: [...] }); * // => { id, number, type, entityId, date, lines: [...] } */ - return async (googleAuthId, id, patch = {}) => { + return async (googleAuthId, id, patch = {}, tx) => { if (!Number.isFinite(id)) { throw new Error('Quote not found'); } @@ -52,24 +65,21 @@ export default function update(pool) { normalizedLines = normalizeLines(patch.lines); } - const { companyId } = await access(googleAuthId); + const runUpdate = async (runner) => { + const { companyId } = await access(googleAuthId, runner); - const tx = pool.transaction(); - await tx.begin(); + const existing = await runner.query(getHeaderSql, { + id, + companyId, + type: quoteType, + }); - try { - const existing = await request(pool, tx) - .input('id', id) - .input('companyId', companyId) - .input('type', QUOTE_TYPE) - .query(GET_MANIFEST_HEADER); - - const current = existing.recordset[0]; + const current = existing.rows[0]; if (!current) { throw new Error('Quote not found'); } - const summary = shapeSummary(QUOTE_TYPE, current); + const summary = shapeSummary(quoteType, current); let number = summary.number; if (hasNumber) { @@ -90,8 +100,7 @@ export default function update(pool) { let entityId = summary.entityId; if (hasEntityId) { entityId = await assertCustomerEntity( - pool, - tx, + runner, companyId, patch.entityId ?? null, ); @@ -104,38 +113,39 @@ export default function update(pool) { : null; } - await request(pool, tx) - .input('id', id) - .input('companyId', companyId) - .input('type', QUOTE_TYPE) - .input('number', number) - .input('entityId', entityId) - .input('externalNumber', externalNumber) - .input('date', date) - .query(UPDATE_MANIFEST); + await runner.query(updateManifestSql, { + id, + companyId, + type: quoteType, + number, + entityId, + externalNumber, + date, + }); if (hasLines) { - await request(pool, tx) - .input('manifestId', id) - .query(DELETE_LINES_BY_MANIFEST); + await runner.query(deleteLinesSql, { + manifestId: id, + }); - await insertLines(pool, tx, { + await insertLines(runner, { manifestId: id, lines: normalizedLines, ensureTax, }); } - const doc = await loadDoc(pool, tx, { type: QUOTE_TYPE, companyId, id }); - await tx.commit(); + const doc = await loadDoc(runner, { type: quoteType, companyId, id }); return doc; - } catch (err) { - try { - await tx.rollback(); - } catch { - // ignore - } - throw err; + }; + + if (tx) { + return runUpdate(makeRunner(provider, tx)); } + if (typeof provider?.transaction === 'function') { + return provider.transaction(runUpdate); + } + return runUpdate(makeRunner(provider)); }; } + diff --git a/src/store/request.js b/src/store/request.js deleted file mode 100644 index d020696..0000000 --- a/src/store/request.js +++ /dev/null @@ -1,3 +0,0 @@ -export default function request(pool, tx) { - return (tx ?? pool).request(); -} diff --git a/src/store/runner.js b/src/store/runner.js index 197e6c5..275125b 100644 --- a/src/store/runner.js +++ b/src/store/runner.js @@ -1,15 +1,15 @@ /** * Resolves the object a store method should query through. * - * Converted store modules call `runner.query(sql, params)`. During the - * incremental migration (see TODO.md §1) they may still be constructed with a + * Converted store modules call `runner(sql, params)` or `runner.query(sql, params)`. + * During the incremental migration they may still be constructed with a * raw `mssql` pool, or called with a raw `mssql` Transaction as `tx`. This - * adapter exposes the provider contract over both so mixed call sites keep + * adapter exposes the unified runner contract over both so mixed call sites keep * working until every module is converted. * * @param {import('../providers/provider.js').DatabaseProvider} provider * @param {object} [tx] Optional provider runner or legacy mssql Transaction. - * @returns {import('../providers/provider.js').DatabaseProvider} + * @returns {((sql: string, params?: object) => Promise) & { query: Function, dialect?: string }} */ /** Wraps a raw mssql pool/transaction in the provider query contract. */ @@ -33,8 +33,7 @@ function wrapMssql(target) { function normalize(target) { if (!target) return null; - // Provider instances expose `dialect`; legacy mssql pools also have a - // `query()` method, so check for the marker before trusting `query`. + if (typeof target === 'function' && typeof target.query === 'function') return target; if (target.dialect) return target; if (typeof target.request === 'function') return wrapMssql(target); if (typeof target.query === 'function') return target; @@ -43,7 +42,34 @@ function normalize(target) { export default function makeRunner(provider, tx) { if (tx && tx !== provider) { - return normalize(tx) ?? normalize(provider) ?? provider; + if (typeof tx === 'function' && typeof tx.query === 'function') return tx; + const normTx = normalize(tx); + if (normTx) return createCallableRunner(normTx); } - return normalize(provider) ?? provider; + if (typeof provider === 'function' && typeof provider.query === 'function') return provider; + const normProvider = normalize(provider); + if (normProvider) return createCallableRunner(normProvider); + return null; +} + +function createCallableRunner(base) { + if (typeof base === 'function' && typeof base.query === 'function') return base; + const run = async (sql, params) => { + return base.query(sql, params); + }; + run.query = run; + run.exec = base.exec ? base.exec.bind(base) : undefined; + run.dialect = base.dialect; + run.provider = base.provider || base; + run.writeFileContent = base.writeFileContent?.bind(base); + run.readFileContent = base.readFileContent?.bind(base); + run.writeThumbAvif = base.writeThumbAvif?.bind(base); + run.readThumbAvif = base.readThumbAvif?.bind(base); + run.writePicFull = base.writePicFull?.bind(base); + run.readPicFull = base.readPicFull?.bind(base); + run.writePicThumb = base.writePicThumb?.bind(base); + run.readPicThumb = base.readPicThumb?.bind(base); + run.deleteFile = base.deleteFile?.bind(base); + run.deletePic = base.deletePic?.bind(base); + return run; } diff --git a/src/store/supplier/create.js b/src/store/supplier/create.js index 3bdd6b0..1447eda 100644 --- a/src/store/supplier/create.js +++ b/src/store/supplier/create.js @@ -1,11 +1,18 @@ -import { CREATE } from '../../providers/mssql/queries/entity.js'; -import { INSERT as INSERT_HISTORY } from '../../providers/mssql/queries/entityhistory.js'; -import request from '../request.js'; +import { CREATE as MSSQL_CREATE } from '../../providers/mssql/queries/entity.js'; +import { CREATE as SQLITE_CREATE } from '../../providers/sqlite3/queries/entity.js'; +import { INSERT as MSSQL_INSERT_HISTORY } from '../../providers/mssql/queries/entityhistory.js'; +import { INSERT as SQLITE_INSERT_HISTORY } from '../../providers/sqlite3/queries/entityhistory.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import ensureLieferantenGroup from './ensureLieferantenGroup.js'; +import makeRunner from '../runner.js'; -export default function create(pool) { - const access = resolveSelectedCompanyAccess(pool); +export default function create(provider) { + const access = resolveSelectedCompanyAccess(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + const createSql = isSqlite ? SQLITE_CREATE : MSSQL_CREATE; + const insertHistorySql = isSqlite + ? SQLITE_INSERT_HISTORY + : MSSQL_INSERT_HISTORY; /** * Creates a new supplier for the user's selected company, recording history. @@ -13,6 +20,7 @@ export default function create(pool) { * @param {number} googleAuthId - The id of the Google auth user. * @param {object} input - The supplier input. * @param {string} input.name - The supplier name. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise<{id: number, name: string, createdAt: string, updatedAt: string}>} The created supplier. * @throws {Error} If the name is empty. * @@ -20,36 +28,35 @@ export default function create(pool) { * const supplier = await store.supplier.create(42, { name: 'Acme Supplies' }); * // => { id, name, createdAt, updatedAt } */ - return async (googleAuthId, { name } = {}) => { + return async (googleAuthId, { name } = {}, tx) => { const cleaned = name?.trim(); if (!cleaned) { throw new Error('Name required'); } - const { companyId, companyuserId } = await access(googleAuthId); + const runCreate = async (runner) => { + const { companyId, companyuserId } = await access(googleAuthId, runner); + const customergroupId = await ensureLieferantenGroup(provider, companyId, runner); - const tx = pool.transaction(); - await tx.begin(); + const created = await runner.query(createSql, { + customergroupId, + firstName: null, + name: cleaned, + number: null, + }); - try { - const customergroupId = await ensureLieferantenGroup(pool, companyId, tx); + const row = created.rows[0] ?? { + id: created.insertId, + name: cleaned, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; - const created = await request(pool, tx) - .input('customergroupId', customergroupId) - .input('firstName', null) - .input('name', cleaned) - .input('number', null) - .query(CREATE); - - const row = created.recordset[0]; - - await request(pool, tx) - .input('entityId', row.id) - .input('companyuserId', companyuserId) - .input('text', 'Erstellt') - .query(INSERT_HISTORY); - - await tx.commit(); + await runner.query(insertHistorySql, { + entityId: row.id, + companyuserId, + text: 'Erstellt', + }); return { id: row.id, @@ -57,13 +64,14 @@ export default function create(pool) { createdAt: row.createdAt, updatedAt: row.updatedAt, }; - } catch (err) { - try { - await tx.rollback(); - } catch { - // ignore - } - throw err; + }; + + if (tx) { + return runCreate(makeRunner(provider, tx)); } + if (typeof provider?.transaction === 'function') { + return provider.transaction(runCreate); + } + return runCreate(makeRunner(provider)); }; } diff --git a/src/store/supplier/delete.js b/src/store/supplier/delete.js index 9205298..fcf8c6a 100644 --- a/src/store/supplier/delete.js +++ b/src/store/supplier/delete.js @@ -1,35 +1,42 @@ -import { DELETE_BY_ID, SUPPLIER_GROUP_NAME } from '../../providers/mssql/queries/entity.js'; +import { + DELETE_BY_ID as MSSQL_DELETE_BY_ID, + SUPPLIER_GROUP_NAME, +} from '../../providers/mssql/queries/entity.js'; +import { DELETE_BY_ID as SQLITE_DELETE_BY_ID } from '../../providers/sqlite3/queries/entity.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; +import makeRunner from '../runner.js'; -export default function del(pool) { - const access = resolveSelectedCompanyAccess(pool); +export default function del(provider) { + const access = resolveSelectedCompanyAccess(provider); + const sql = provider?.dialect === 'sqlite3' ? SQLITE_DELETE_BY_ID : MSSQL_DELETE_BY_ID; /** * Deletes a supplier by its id within the user's selected company. * * @param {number} googleAuthId - The id of the Google auth user. * @param {number} id - The id of the supplier to delete. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise} * @throws {Error} If the supplier is not found. * * @example * await store.supplier.delete(42, 5); */ - return async (googleAuthId, id) => { + return async (googleAuthId, id, tx) => { if (!Number.isFinite(id)) { throw new Error('Supplier not found'); } - const { companyId } = await access(googleAuthId); + const runner = makeRunner(provider, tx); + const { companyId } = await access(googleAuthId, tx); - const result = await pool - .request() - .input('id', id) - .input('companyId', companyId) - .input('groupName', SUPPLIER_GROUP_NAME) - .query(DELETE_BY_ID); + const result = await runner.query(sql, { + id, + companyId, + groupName: SUPPLIER_GROUP_NAME, + }); - if (!result.rowsAffected[0]) { + if (!result.rowsAffected) { throw new Error('Supplier not found'); } }; diff --git a/src/store/supplier/ensureLieferantenGroup.js b/src/store/supplier/ensureLieferantenGroup.js index b6abd0e..b408e71 100644 --- a/src/store/supplier/ensureLieferantenGroup.js +++ b/src/store/supplier/ensureLieferantenGroup.js @@ -1,33 +1,44 @@ import { - CREATE, - GET_BY_COMPANY_AND_NAME, - GET_NEXT_SORT, + CREATE as MSSQL_CREATE, + GET_BY_COMPANY_AND_NAME as MSSQL_GET_BY_COMPANY_AND_NAME, + GET_NEXT_SORT as MSSQL_GET_NEXT_SORT, } from '../../providers/mssql/queries/customergroup.js'; +import { + CREATE as SQLITE_CREATE, + GET_BY_COMPANY_AND_NAME as SQLITE_GET_BY_COMPANY_AND_NAME, + GET_NEXT_SORT as SQLITE_GET_NEXT_SORT, +} from '../../providers/sqlite3/queries/customergroup.js'; import { SUPPLIER_GROUP_NAME } from '../../providers/mssql/queries/entity.js'; +import makeRunner from '../runner.js'; /** Ensure company has customergroup "Lieferanten"; return its id. */ -export default async function ensureLieferantenGroup(pool, companyId, tx) { - const req = () => (tx ?? pool).request(); +export default async function ensureLieferantenGroup(provider, companyId, tx) { + const runner = makeRunner(provider, tx); + const isSqlite = runner?.dialect === 'sqlite3'; - const existing = await req() - .input('companyId', companyId) - .input('name', SUPPLIER_GROUP_NAME) - .query(GET_BY_COMPANY_AND_NAME); + const getByNameSql = isSqlite + ? SQLITE_GET_BY_COMPANY_AND_NAME + : MSSQL_GET_BY_COMPANY_AND_NAME; + const getSortSql = isSqlite ? SQLITE_GET_NEXT_SORT : MSSQL_GET_NEXT_SORT; + const createSql = isSqlite ? SQLITE_CREATE : MSSQL_CREATE; - if (existing.recordset[0]) { - return existing.recordset[0].id; + const existing = await runner.query(getByNameSql, { + companyId, + name: SUPPLIER_GROUP_NAME, + }); + + if (existing.rows[0]) { + return existing.rows[0].id; } - const next = await req() - .input('companyId', companyId) - .query(GET_NEXT_SORT); - const sort = next.recordset[0].sort; + const next = await runner.query(getSortSql, { companyId }); + const sort = next.rows[0].sort; - const created = await req() - .input('companyId', companyId) - .input('name', SUPPLIER_GROUP_NAME) - .input('sort', sort) - .query(CREATE); + const created = await runner.query(createSql, { + companyId, + name: SUPPLIER_GROUP_NAME, + sort, + }); - return created.recordset[0].id; + return created.rows[0]?.id ?? created.insertId; } diff --git a/src/store/supplier/list.js b/src/store/supplier/list.js index 8185f48..3e2fcd6 100644 --- a/src/store/supplier/list.js +++ b/src/store/supplier/list.js @@ -1,29 +1,35 @@ -import { LIST_SUPPLIERS_BY_COMPANY, SUPPLIER_GROUP_NAME } from '../../providers/mssql/queries/entity.js'; +import { + LIST_SUPPLIERS_BY_COMPANY as MSSQL_LIST, + SUPPLIER_GROUP_NAME, +} from '../../providers/mssql/queries/entity.js'; +import { LIST_SUPPLIERS_BY_COMPANY as SQLITE_LIST } from '../../providers/sqlite3/queries/entity.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; +import makeRunner from '../runner.js'; -export default function list(pool) { - const access = resolveSelectedCompanyAccess(pool); +export default function list(provider) { + const access = resolveSelectedCompanyAccess(provider); + const sql = provider?.dialect === 'sqlite3' ? SQLITE_LIST : MSSQL_LIST; /** * Lists the suppliers of the user's selected company. * * @param {number} googleAuthId - The id of the Google auth user. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise>} The suppliers. * * @example * const suppliers = await store.supplier.list(42); * // => [{ id, name, createdAt, updatedAt }] */ - return async (googleAuthId) => { - const { companyId } = await access(googleAuthId); + return async (googleAuthId, tx) => { + const { companyId } = await access(googleAuthId, tx); - const result = await pool - .request() - .input('companyId', companyId) - .input('groupName', SUPPLIER_GROUP_NAME) - .query(LIST_SUPPLIERS_BY_COMPANY); + const result = await makeRunner(provider, tx).query(sql, { + companyId, + groupName: SUPPLIER_GROUP_NAME, + }); - return result.recordset.map((row) => ({ + return result.rows.map((row) => ({ id: row.id, name: row.name, createdAt: row.createdAt, diff --git a/src/store/supplier/update.js b/src/store/supplier/update.js index a658247..c417ca8 100644 --- a/src/store/supplier/update.js +++ b/src/store/supplier/update.js @@ -1,12 +1,18 @@ import { - GET_GROUP_ID_FOR_SUPPLIER, - GET_SUPPLIER_BY_ID, + GET_GROUP_ID_FOR_SUPPLIER as MSSQL_GET_GROUP_ID_FOR_SUPPLIER, + GET_SUPPLIER_BY_ID as MSSQL_GET_SUPPLIER_BY_ID, SUPPLIER_GROUP_NAME, - UPDATE_NAME, + UPDATE_NAME as MSSQL_UPDATE_NAME, } from '../../providers/mssql/queries/entity.js'; -import { INSERT as INSERT_HISTORY } from '../../providers/mssql/queries/entityhistory.js'; -import request from '../request.js'; +import { + GET_GROUP_ID_FOR_SUPPLIER as SQLITE_GET_GROUP_ID_FOR_SUPPLIER, + GET_SUPPLIER_BY_ID as SQLITE_GET_SUPPLIER_BY_ID, + UPDATE_NAME as SQLITE_UPDATE_NAME, +} from '../../providers/sqlite3/queries/entity.js'; +import { INSERT as MSSQL_INSERT_HISTORY } from '../../providers/mssql/queries/entityhistory.js'; +import { INSERT as SQLITE_INSERT_HISTORY } from '../../providers/sqlite3/queries/entityhistory.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; +import makeRunner from '../runner.js'; function shape(row) { return { @@ -17,8 +23,20 @@ function shape(row) { }; } -export default function update(pool) { - const access = resolveSelectedCompanyAccess(pool); +export default function update(provider) { + const access = resolveSelectedCompanyAccess(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + + const getGroupIdSql = isSqlite + ? SQLITE_GET_GROUP_ID_FOR_SUPPLIER + : MSSQL_GET_GROUP_ID_FOR_SUPPLIER; + const getSupplierByIdSql = isSqlite + ? SQLITE_GET_SUPPLIER_BY_ID + : MSSQL_GET_SUPPLIER_BY_ID; + const updateNameSql = isSqlite ? SQLITE_UPDATE_NAME : MSSQL_UPDATE_NAME; + const insertHistorySql = isSqlite + ? SQLITE_INSERT_HISTORY + : MSSQL_INSERT_HISTORY; /** * Updates the name of a supplier, recording history. @@ -27,6 +45,7 @@ export default function update(pool) { * @param {number} id - The id of the supplier to update. * @param {object} input - The update input. * @param {string} input.name - The new supplier name. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise<{id: number, name: string, createdAt: string, updatedAt: string}>} The updated supplier. * @throws {Error} If the supplier is not found or the name is empty. * @@ -34,7 +53,7 @@ export default function update(pool) { * const supplier = await store.supplier.update(42, 5, { name: 'Acme GmbH' }); * // => { id, name, createdAt, updatedAt } */ - return async (googleAuthId, id, { name } = {}) => { + return async (googleAuthId, id, { name } = {}, tx) => { if (!Number.isFinite(id)) { throw new Error('Supplier not found'); } @@ -44,64 +63,55 @@ export default function update(pool) { throw new Error('Name required'); } - const { companyId, companyuserId } = await access(googleAuthId); + const runUpdate = async (runner) => { + const { companyId, companyuserId } = await access(googleAuthId, runner); - const existing = await pool - .request() - .input('id', id) - .input('companyId', companyId) - .input('groupName', SUPPLIER_GROUP_NAME) - .query(GET_GROUP_ID_FOR_SUPPLIER); + const existing = await runner.query(getGroupIdSql, { + id, + companyId, + groupName: SUPPLIER_GROUP_NAME, + }); - const current = existing.recordset[0]; - if (!current) { - throw new Error('Supplier not found'); - } - - if (current.name === cleaned) { - const same = await pool - .request() - .input('id', id) - .input('companyId', companyId) - .input('groupName', SUPPLIER_GROUP_NAME) - .query(GET_SUPPLIER_BY_ID); - return shape(same.recordset[0]); - } - - const tx = pool.transaction(); - await tx.begin(); - - try { - const updated = await request(pool, tx) - .input('id', id) - .input('customergroupId', current.customergroupId) - .input('name', cleaned) - .query(UPDATE_NAME); - - const row = updated.recordset[0]; - if (!row) { + const current = existing.rows[0]; + if (!current) { throw new Error('Supplier not found'); } - await request(pool, tx) - .input('entityId', id) - .input('companyuserId', companyuserId) - .input( - 'text', - `Name geändert von "${current.name}" auf "${cleaned}"`, - ) - .query(INSERT_HISTORY); + if (current.name === cleaned) { + const same = await runner.query(getSupplierByIdSql, { + id, + companyId, + groupName: SUPPLIER_GROUP_NAME, + }); + return shape(same.rows[0]); + } - await tx.commit(); + const updated = await runner.query(updateNameSql, { + id, + customergroupId: current.customergroupId, + name: cleaned, + }); + + const row = updated.rows[0] ?? { + ...current, + name: cleaned, + }; + + await runner.query(insertHistorySql, { + entityId: id, + companyuserId, + text: `Name geändert von "${current.name}" auf "${cleaned}"`, + }); return shape(row); - } catch (err) { - try { - await tx.rollback(); - } catch { - // ignore - } - throw err; + }; + + if (tx) { + return runUpdate(makeRunner(provider, tx)); } + if (typeof provider?.transaction === 'function') { + return provider.transaction(runUpdate); + } + return runUpdate(makeRunner(provider)); }; } diff --git a/src/store/unit/delete.js b/src/store/unit/delete.js index 7becb26..7d9be0c 100644 --- a/src/store/unit/delete.js +++ b/src/store/unit/delete.js @@ -1,39 +1,44 @@ -import { DELETE_BY_COMPANY_AND_SI } from '../../providers/mssql/queries/unit.js'; +import { DELETE_BY_COMPANY_AND_SI as MSSQL_DELETE } from '../../providers/mssql/queries/unit.js'; +import { DELETE_BY_COMPANY_AND_SI as SQLITE_DELETE } from '../../providers/sqlite3/queries/unit.js'; +import { isForeignKeyConstraintError } from '../../providers/provider.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; +import makeRunner from '../runner.js'; -export default function del(pool) { - const access = resolveSelectedCompanyAccess(pool); +export default function del(provider) { + const access = resolveSelectedCompanyAccess(provider); + const sql = provider?.dialect === 'sqlite3' ? SQLITE_DELETE : MSSQL_DELETE; /** * Deletes a unit for the user's selected company by its SI code. * * @param {number} googleAuthId - The id of the Google auth user. * @param {string} si - The SI code of the unit to delete. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise} * @throws {Error} If the unit is not found. * * @example * await store.unit.delete(42, 'l'); */ - return async (googleAuthId, si) => { + return async (googleAuthId, si, tx) => { if (!si?.trim()) { throw new Error('Unit not found'); } - const { companyId } = await access(googleAuthId); + const runner = makeRunner(provider, tx); + const { companyId } = await access(googleAuthId, tx); try { - const result = await pool - .request() - .input('companyId', companyId) - .input('si', si.trim()) - .query(DELETE_BY_COMPANY_AND_SI); + const result = await runner.query(sql, { + companyId, + si: si.trim(), + }); - if (!result.rowsAffected[0]) { + if (!result.rowsAffected) { throw new Error('Unit not found'); } } catch (err) { - if (err.number === 547) { + if (isForeignKeyConstraintError(err)) { throw new Error('Unit in use'); } throw err; diff --git a/src/store/unit/ensureDefault.js b/src/store/unit/ensureDefault.js index 9e43afc..55d41dd 100644 --- a/src/store/unit/ensureDefault.js +++ b/src/store/unit/ensureDefault.js @@ -1,46 +1,68 @@ -import { CREATE, GET_BY_SI } from '../../providers/mssql/queries/unit.js'; import { - CREATE as CREATE_UNITTEXT, - GET_BY_UNIT_AND_LANGUAGE, + CREATE as MSSQL_CREATE, + GET_BY_SI as MSSQL_GET_BY_SI, +} from '../../providers/mssql/queries/unit.js'; +import { + CREATE as SQLITE_CREATE, + GET_BY_SI as SQLITE_GET_BY_SI, +} from '../../providers/sqlite3/queries/unit.js'; +import { + CREATE as MSSQL_CREATE_UNITTEXT, + GET_BY_UNIT_AND_LANGUAGE as MSSQL_GET_BY_UNIT_AND_LANGUAGE, } from '../../providers/mssql/queries/unittext.js'; -import request from '../request.js'; +import { + CREATE as SQLITE_CREATE_UNITTEXT, + GET_BY_UNIT_AND_LANGUAGE as SQLITE_GET_BY_UNIT_AND_LANGUAGE, +} from '../../providers/sqlite3/queries/unittext.js'; +import makeRunner from '../runner.js'; const DEFAULT_SI = 'pc'; const DEFAULT_SYMBOL = 'pc'; const DEFAULT_NAME = 'Piece'; -export default function ensureDefaultUnit(pool) { - return async (tx, languageId, companyId) => { - const existing = await request(pool, tx) - .input('companyId', companyId) - .input('si', DEFAULT_SI) - .query(GET_BY_SI); +export default function ensureDefaultUnit(provider) { + const isSqlite = provider?.dialect === 'sqlite3'; + const getBySiSql = isSqlite ? SQLITE_GET_BY_SI : MSSQL_GET_BY_SI; + const createSql = isSqlite ? SQLITE_CREATE : MSSQL_CREATE; + const getUnitTextSql = isSqlite + ? SQLITE_GET_BY_UNIT_AND_LANGUAGE + : MSSQL_GET_BY_UNIT_AND_LANGUAGE; + const createUnitTextSql = isSqlite + ? SQLITE_CREATE_UNITTEXT + : MSSQL_CREATE_UNITTEXT; - let unitId = existing.recordset[0]?.id; + return async (tx, languageId, companyId) => { + const runner = makeRunner(provider, tx); + const existing = await runner.query(getBySiSql, { + companyId, + si: DEFAULT_SI, + }); + + let unitId = existing.rows[0]?.id; if (!unitId) { - const created = await request(pool, tx) - .input('companyId', companyId) - .input('si', DEFAULT_SI) - .input('basesi', DEFAULT_SI) - .input('basesifactor', 1) - .query(CREATE); - unitId = created.recordset[0].id; + const created = await runner.query(createSql, { + companyId, + si: DEFAULT_SI, + basesi: DEFAULT_SI, + basesifactor: 1, + }); + unitId = created.rows[0]?.id ?? created.insertId; } if (languageId != null) { - const text = await request(pool, tx) - .input('unitId', unitId) - .input('languageId', languageId) - .query(GET_BY_UNIT_AND_LANGUAGE); + const text = await runner.query(getUnitTextSql, { + unitId, + languageId, + }); - if (!text.recordset[0]) { - await request(pool, tx) - .input('unitId', unitId) - .input('languageId', languageId) - .input('symbol', DEFAULT_SYMBOL) - .input('name', DEFAULT_NAME) - .query(CREATE_UNITTEXT); + if (!text.rows[0]) { + await runner.query(createUnitTextSql, { + unitId, + languageId, + symbol: DEFAULT_SYMBOL, + name: DEFAULT_NAME, + }); } } diff --git a/src/store/unit/list.js b/src/store/unit/list.js index 9bdf2ed..8d82038 100644 --- a/src/store/unit/list.js +++ b/src/store/unit/list.js @@ -1,29 +1,36 @@ -import { LIST_BY_COMPANY } from '../../providers/mssql/queries/unit.js'; +import { LIST_BY_COMPANY as MSSQL_LIST_BY_COMPANY } from '../../providers/mssql/queries/unit.js'; +import { LIST_BY_COMPANY as SQLITE_LIST_BY_COMPANY } from '../../providers/sqlite3/queries/unit.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import shapeUnit from './shapeUnit.js'; +import makeRunner from '../runner.js'; -export default function list(pool) { - const access = resolveSelectedCompanyAccess(pool); +export default function list(provider) { + const access = resolveSelectedCompanyAccess(provider); + const sql = + provider?.dialect === 'sqlite3' + ? SQLITE_LIST_BY_COMPANY + : MSSQL_LIST_BY_COMPANY; /** * Lists the units available for the user's selected company. * * @param {number} googleAuthId - The id of the Google auth user. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise>} The units. * * @example * const units = await store.unit.list(42); * // => [{ id, si, basesi, basesifactor, symbol, name }] */ - return async (googleAuthId) => { - const { companyId, languageId } = await access(googleAuthId); + return async (googleAuthId, tx) => { + const runner = makeRunner(provider, tx); + const { companyId, languageId } = await access(googleAuthId, tx); - const result = await pool - .request() - .input('companyId', companyId) - .input('languageId', languageId) - .query(LIST_BY_COMPANY); + const result = await runner.query(sql, { + companyId, + languageId, + }); - return result.recordset.map(shapeUnit); + return result.rows.map(shapeUnit); }; } diff --git a/src/store/unit/upsert.js b/src/store/unit/upsert.js index b08879b..735fba3 100644 --- a/src/store/unit/upsert.js +++ b/src/store/unit/upsert.js @@ -1,17 +1,28 @@ import { - CREATE as CREATE_UNIT, - GET_BY_SI, - GET_FULL_BY_SI, - UPDATE_BY_SI, + CREATE as MSSQL_CREATE_UNIT, + GET_BY_SI as MSSQL_GET_BY_SI, + GET_FULL_BY_SI as MSSQL_GET_FULL_BY_SI, + UPDATE_BY_SI as MSSQL_UPDATE_BY_SI, } from '../../providers/mssql/queries/unit.js'; import { - CREATE as CREATE_UNITTEXT, - GET_BY_UNIT_AND_LANGUAGE, - UPDATE_BY_UNIT_AND_LANGUAGE, + CREATE as SQLITE_CREATE_UNIT, + GET_BY_SI as SQLITE_GET_BY_SI, + GET_FULL_BY_SI as SQLITE_GET_FULL_BY_SI, + UPDATE_BY_SI as SQLITE_UPDATE_BY_SI, +} from '../../providers/sqlite3/queries/unit.js'; +import { + CREATE as MSSQL_CREATE_UNITTEXT, + GET_BY_UNIT_AND_LANGUAGE as MSSQL_GET_BY_UNIT_AND_LANGUAGE, + UPDATE_BY_UNIT_AND_LANGUAGE as MSSQL_UPDATE_BY_UNIT_AND_LANGUAGE, } from '../../providers/mssql/queries/unittext.js'; +import { + CREATE as SQLITE_CREATE_UNITTEXT, + GET_BY_UNIT_AND_LANGUAGE as SQLITE_GET_BY_UNIT_AND_LANGUAGE, + UPDATE_BY_UNIT_AND_LANGUAGE as SQLITE_UPDATE_BY_UNIT_AND_LANGUAGE, +} from '../../providers/sqlite3/queries/unittext.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; -import request from '../request.js'; import shapeUnit from './shapeUnit.js'; +import makeRunner from '../runner.js'; function validateUnit({ si, basesi, basesifactor, symbol, name }) { if (!si?.trim() || !basesi?.trim() || !symbol?.trim() || !name?.trim()) { @@ -30,8 +41,23 @@ function validateUnit({ si, basesi, basesifactor, symbol, name }) { }; } -export default function upsert(pool) { - const access = resolveSelectedCompanyAccess(pool); +export default function upsert(provider) { + const access = resolveSelectedCompanyAccess(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + + const getBySiSql = isSqlite ? SQLITE_GET_BY_SI : MSSQL_GET_BY_SI; + const getFullSql = isSqlite ? SQLITE_GET_FULL_BY_SI : MSSQL_GET_FULL_BY_SI; + const createUnitSql = isSqlite ? SQLITE_CREATE_UNIT : MSSQL_CREATE_UNIT; + const updateUnitSql = isSqlite ? SQLITE_UPDATE_BY_SI : MSSQL_UPDATE_BY_SI; + const getUnitTextSql = isSqlite + ? SQLITE_GET_BY_UNIT_AND_LANGUAGE + : MSSQL_GET_BY_UNIT_AND_LANGUAGE; + const createUnitTextSql = isSqlite + ? SQLITE_CREATE_UNITTEXT + : MSSQL_CREATE_UNITTEXT; + const updateUnitTextSql = isSqlite + ? SQLITE_UPDATE_BY_UNIT_AND_LANGUAGE + : MSSQL_UPDATE_BY_UNIT_AND_LANGUAGE; /** * Creates or updates a unit for the user's selected company, keyed by its SI code. @@ -43,6 +69,7 @@ export default function upsert(pool) { * @param {number} input.basesifactor - The base SI factor (must be > 0). * @param {string} input.symbol - The unit symbol. * @param {string} input.name - The unit name. + * @param {object} [tx] - Optional transaction to run within. * @returns {Promise<{id: number, si: string, basesi: string, basesifactor: number, symbol: string, name: string}>} The upserted unit. * @throws {Error} If the unit is invalid. * @@ -50,83 +77,79 @@ export default function upsert(pool) { * const unit = await store.unit.upsert(42, { si: 'l', basesi: 'm3', basesifactor: 0.001, symbol: 'L', name: 'Liter' }); * // => { id, si, basesi, basesifactor, symbol, name } */ - return async (googleAuthId, input) => { + return async (googleAuthId, input, tx) => { const unit = validateUnit(input); - const { companyId, languageId } = await access(googleAuthId); - const tx = pool.transaction(); - await tx.begin(); + const runUpsert = async (runner) => { + const { companyId, languageId } = await access(googleAuthId, runner); - try { - const existing = await request(pool, tx) - .input('companyId', companyId) - .input('si', unit.si) - .query(GET_BY_SI); + const existing = await runner.query(getBySiSql, { + companyId, + si: unit.si, + }); - let unitId = existing.recordset[0]?.id; + let unitId = existing.rows[0]?.id; if (!unitId) { - const created = await request(pool, tx) - .input('companyId', companyId) - .input('si', unit.si) - .input('basesi', unit.basesi) - .input('basesifactor', unit.basesifactor) - .query(CREATE_UNIT); - unitId = created.recordset[0].id; + const created = await runner.query(createUnitSql, { + companyId, + si: unit.si, + basesi: unit.basesi, + basesifactor: unit.basesifactor, + }); + unitId = created.rows[0]?.id ?? created.insertId; - await request(pool, tx) - .input('unitId', unitId) - .input('languageId', languageId) - .input('symbol', unit.symbol) - .input('name', unit.name) - .query(CREATE_UNITTEXT); + await runner.query(createUnitTextSql, { + unitId, + languageId, + symbol: unit.symbol, + name: unit.name, + }); } else { - await request(pool, tx) - .input('companyId', companyId) - .input('si', unit.si) - .input('basesi', unit.basesi) - .input('basesifactor', unit.basesifactor) - .query(UPDATE_BY_SI); + await runner.query(updateUnitSql, { + companyId, + si: unit.si, + basesi: unit.basesi, + basesifactor: unit.basesifactor, + }); - const text = await request(pool, tx) - .input('unitId', unitId) - .input('languageId', languageId) - .query(GET_BY_UNIT_AND_LANGUAGE); + const text = await runner.query(getUnitTextSql, { + unitId, + languageId, + }); - if (text.recordset[0]) { - await request(pool, tx) - .input('unitId', unitId) - .input('languageId', languageId) - .input('symbol', unit.symbol) - .input('name', unit.name) - .query(UPDATE_BY_UNIT_AND_LANGUAGE); + if (text.rows[0]) { + await runner.query(updateUnitTextSql, { + unitId, + languageId, + symbol: unit.symbol, + name: unit.name, + }); } else { - await request(pool, tx) - .input('unitId', unitId) - .input('languageId', languageId) - .input('symbol', unit.symbol) - .input('name', unit.name) - .query(CREATE_UNITTEXT); + await runner.query(createUnitTextSql, { + unitId, + languageId, + symbol: unit.symbol, + name: unit.name, + }); } } - await tx.commit(); - } catch (err) { - try { - await tx.rollback(); - } catch { - // ignore - } - throw err; + const full = await runner.query(getFullSql, { + companyId, + si: unit.si, + languageId, + }); + + return shapeUnit(full.rows[0]); + }; + + if (tx) { + return runUpsert(makeRunner(provider, tx)); } - - const result = await pool - .request() - .input('companyId', companyId) - .input('si', unit.si) - .input('languageId', languageId) - .query(GET_FULL_BY_SI); - - return shapeUnit(result.recordset[0]); + if (typeof provider?.transaction === 'function') { + return provider.transaction(runUpsert); + } + return runUpsert(makeRunner(provider)); }; } diff --git a/src/util/generateDocumentThumb.js b/src/util/generateDocumentThumb.js index 4b986f9..6ca1c51 100644 --- a/src/util/generateDocumentThumb.js +++ b/src/util/generateDocumentThumb.js @@ -28,6 +28,40 @@ async function renderPdfPageToPng(pdfBuffer) { * @param {Object} [options] * @returns {Promise<{thumbavif: Buffer|null, thumbx: number|null, thumby: number|null}>} */ + +function escapeXml(str) { + return str.replace(/[&<>'"]/g, (c) => { + switch (c) { + case '&': return '&'; + case '<': return '<'; + case '>': return '>'; + case "'": return '''; + case '"': return '"'; + default: return c; + } + }); +} + +function renderTextToSvg(text, width = 400, height = 560) { + const lines = text.split(/\r?\n/).slice(0, 32); + const paddingX = 24; + const paddingTop = 32; + const lineHeight = 16; + + const textElements = lines + .map((line, idx) => { + const y = paddingTop + idx * lineHeight; + const truncated = line.length > 50 ? line.slice(0, 47) + '...' : line; + return `${escapeXml(truncated)}`; + }) + .join('\n'); + + return Buffer.from(` + + ${textElements} + `, 'utf8'); +} + export default async function generateDocumentThumb(content, mimetype, options = {}) { if (!Buffer.isBuffer(content) || content.length === 0) { return { thumbavif: null, thumbx: null, thumby: null }; @@ -45,6 +79,19 @@ export default async function generateDocumentThumb(content, mimetype, options = } } else if (type.startsWith('image/')) { imageBuffer = content; + } else if ( + type === 'text/plain' || + type === 'text/csv' || + type.startsWith('text/') || + options.filename?.toLowerCase().endsWith('.txt') || + options.filename?.toLowerCase().endsWith('.csv') + ) { + try { + imageBuffer = renderTextToSvg(content.toString('utf8')); + } catch (err) { + console.warn('generateDocumentThumb: Text render failed:', err.message); + return { thumbavif: null, thumbx: null, thumby: null }; + } } else { return { thumbavif: null, thumbx: null, thumby: null }; } diff --git a/vfs.md b/vfs.md new file mode 100644 index 0000000..d7b664b --- /dev/null +++ b/vfs.md @@ -0,0 +1,190 @@ +Here is a complete design and implementation document for a block-based Virtual File System (VFS) in SQL Server. This incorporates the architectural fixes, concurrency safety, and performance optimizations discussed earlier. + +SQL Server Virtual File System (VFS) Implementation Guide +1. Architecture Overview +This VFS architecture mimics a traditional inode-based disk filesystem inside a relational database. Instead of storing large files in a single VARBINARY(MAX) column—which causes severe memory pressure and transaction log bloat—files are divided into fixed-size chunks (blocks) at the application layer and streamed into the database. + +Key Benefits: + +Low Memory Footprint: Applications can stream gigabytes of data using small, fixed-size memory buffers (e.g., 1MB at a time). + +Resumable Uploads: If a network failure occurs, the client only needs to resume from the last successfully written block. + +Azure SQL Compatibility: Provides FILESTREAM-like chunking benefits in PaaS environments where native FILESTREAM is unsupported. + +2. Database Schema +The schema separates metadata (Nodes) from the binary payload (Blocks). It includes a soft-delete flag to prevent massive transaction log spikes when deleting huge files. + +SQL +-- Represents files, directories, and metadata +CREATE TABLE dbo.Filesystem_Nodes ( + NodeID BIGINT IDENTITY(1,1) PRIMARY KEY, + ParentNodeID BIGINT NULL FOREIGN KEY REFERENCES dbo.Filesystem_Nodes(NodeID), + Name NVARCHAR(255) NOT NULL, + IsDirectory BIT NOT NULL DEFAULT 0, + ByteSize BIGINT NOT NULL DEFAULT 0, + IsDeleted BIT NOT NULL DEFAULT 0, -- Soft-delete flag for garbage collection + CreatedAt DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(), + ModifiedAt DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(), + + CONSTRAINT UQ_Parent_Name_NotDeleted UNIQUE (ParentNodeID, Name, IsDeleted) +); + +-- Index to quickly find files pending deletion +CREATE NONCLUSTERED INDEX IX_Filesystem_Nodes_Deleted +ON dbo.Filesystem_Nodes(IsDeleted) +INCLUDE (NodeID) +WHERE IsDeleted = 1; + +-- Represents the raw binary chunks of each file +CREATE TABLE dbo.Filesystem_Blocks ( + NodeID BIGINT NOT NULL, + BlockIndex BIGINT NOT NULL, + Data VARBINARY(MAX) NOT NULL, + + -- Crucial: Clustered PK ensures contiguous disk storage for sequential reads + CONSTRAINT PK_Filesystem_Blocks PRIMARY KEY CLUSTERED (NodeID, BlockIndex) +); + +-- Foreign key is NO ACTION because we handle cascading deletes via background jobs +ALTER TABLE dbo.Filesystem_Blocks +ADD CONSTRAINT FK_Blocks_Nodes FOREIGN KEY (NodeID) +REFERENCES dbo.Filesystem_Nodes(NodeID); +3. Core Stored Procedures +A. Create a Node (File or Directory) +Initializes the metadata record. + +SQL +CREATE OR ALTER PROCEDURE dbo.sp_FS_CreateNode + @ParentNodeID BIGINT = NULL, + @Name NVARCHAR(255), + @IsDirectory BIT = 0, + @NodeID BIGINT OUTPUT +AS +BEGIN + SET NOCOUNT ON; + + INSERT INTO dbo.Filesystem_Nodes (ParentNodeID, Name, IsDirectory, ByteSize) + VALUES (@ParentNodeID, @Name, @IsDirectory, 0); + + SET @NodeID = SCOPE_IDENTITY(); +END; +B. Write a Block (Concurrency-Safe) +Handles writing or overwriting a specific chunk. Uses an UPDATE / INSERT pattern to avoid MERGE deadlocks and correctly calculates total file size based on a standard block size. + +SQL +CREATE OR ALTER PROCEDURE dbo.sp_FS_WriteBlock + @NodeID BIGINT, + @BlockIndex BIGINT, + @Data VARBINARY(MAX), + @StandardBlockSize BIGINT = 1048576 -- Default 1MB blocks +AS +BEGIN + SET NOCOUNT ON; + + -- 1. Upsert the block safely + BEGIN TRAN; + UPDATE dbo.Filesystem_Blocks + SET Data = @Data + WHERE NodeID = @NodeID AND BlockIndex = @BlockIndex; + + IF @@ROWCOUNT = 0 + BEGIN + INSERT INTO dbo.Filesystem_Blocks (NodeID, BlockIndex, Data) + VALUES (@NodeID, @BlockIndex, @Data); + END + COMMIT TRAN; + + -- 2. Calculate the file's current max boundary + -- (Previous blocks * Standard Size) + Size of current block + DECLARE @CalculatedSize BIGINT = (@BlockIndex * @StandardBlockSize) + DATALENGTH(@Data); + + -- 3. Expand file size if we wrote past the current known end + UPDATE dbo.Filesystem_Nodes + SET ByteSize = CASE WHEN @CalculatedSize > ByteSize THEN @CalculatedSize ELSE ByteSize END, + ModifiedAt = SYSUTCDATETIME() + WHERE NodeID = @NodeID; +END; +C. Read a Block +Retrieves a specific chunk for streaming back to the client. + +SQL +CREATE OR ALTER PROCEDURE dbo.sp_FS_ReadBlock + @NodeID BIGINT, + @BlockIndex BIGINT +AS +BEGIN + SET NOCOUNT ON; + + SELECT + BlockIndex, + Data, + DATALENGTH(Data) AS BlockByteSize + FROM dbo.Filesystem_Blocks + WHERE NodeID = @NodeID + AND BlockIndex = @BlockIndex; +END; +D. Delete a File (Soft Delete) +Marks the file as deleted to immediately hide it from the UI/Application, preventing long synchronous locks. + +SQL +CREATE OR ALTER PROCEDURE dbo.sp_FS_DeleteNode + @NodeID BIGINT +AS +BEGIN + SET NOCOUNT ON; + + -- Soft delete the node. A background job will clean up the physical blocks. + UPDATE dbo.Filesystem_Nodes + SET IsDeleted = 1, + ModifiedAt = SYSUTCDATETIME(), + -- Scramble the name slightly to free up the unique constraint + -- so a new file with the same name can be created immediately + Name = Name + '_deleted_' + CAST(NEWID() AS NVARCHAR(36)) + WHERE NodeID = @NodeID; +END; +4. Background Maintenance (Garbage Collection) +Because large files can contain tens of thousands of blocks, a single DELETE statement would lock the table and bloat the transaction log. This script should be scheduled via SQL Server Agent to run every few minutes to physically delete blocks in small, manageable batches. + +SQL +CREATE OR ALTER PROCEDURE dbo.sp_FS_GarbageCollection + @BatchSize INT = 2000 +AS +BEGIN + SET NOCOUNT ON; + DECLARE @DeletedNodes TABLE (NodeID BIGINT); + DECLARE @RowsAffected INT = 1; + + -- Get a list of nodes pending deletion + INSERT INTO @DeletedNodes (NodeID) + SELECT NodeID FROM dbo.Filesystem_Nodes WHERE IsDeleted = 1; + + -- Delete blocks in small batches to protect the Transaction Log + WHILE @RowsAffected > 0 + BEGIN + DELETE TOP (@BatchSize) B + FROM dbo.Filesystem_Blocks B + INNER JOIN @DeletedNodes D ON B.NodeID = D.NodeID; + + SET @RowsAffected = @@ROWCOUNT; + + -- Brief pause to yield to production workloads + IF @RowsAffected > 0 WAITFOR DELAY '00:00:00.100'; + END + + -- Once all blocks are gone, delete the metadata nodes + DELETE N + FROM dbo.Filesystem_Nodes N + INNER JOIN @DeletedNodes D ON N.NodeID = D.NodeID + WHERE NOT EXISTS ( + SELECT 1 FROM dbo.Filesystem_Blocks B WHERE B.NodeID = N.NodeID + ); +END; +5. Application Layer Implementation Notes +To use this database architecture successfully, the application layer (e.g., C#, Python, Node.js) must follow these rules: + +Enforce Block Sizes: The application MUST slice incoming files into exact standard sizes (e.g., 1MB) for every block except the final block, which can be smaller. + +Concurrency: Multiple threads can upload different blocks of the same file simultaneously (e.g., Thread A writes Block 0, Thread B writes Block 1) without locking each other out. + +Read Streaming: To download a file, the application should query sp_FS_ReadBlock in a loop (incrementing BlockIndex from 0 upwards) and pipe the binary result directly to the HTTP Response stream until an empty result is returned. \ No newline at end of file