diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 28faf46..f77879d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,6 +57,31 @@ jobs: - name: Smoke test (SQLite, no DB needed) run: pnpm run smoke + # Biome lint + format gate. no DB, no build => the fastest signal on a PR. `biome ci` is the + # non-writing CI mode: it fails on any lint error or unformatted file, so a reflow that never ran + # locally can't slip in. mirrors `pnpm run check` (prepublishOnly) so local & CI agree + quality: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Install pnpm + uses: pnpm/action-setup@v6 + + - name: Setup Node + uses: actions/setup-node@v7 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Lint + format check (Biome) + run: pnpm exec biome ci . + # TSDoc has to stay warning clean (typedoc.json treatWarningsAsErrors) => catch a broken # {@link} or an undocumented public symbol here, on the PR, not after it merges & the Pages # deploy (docs.yml) is the first thing to notice @@ -105,9 +130,35 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Integration tests (Vitest postgres files + engine swap, real database) + # coverage is generated here, inside the DATABASE_URL job => this is the only run that + # exercises the pg runtime paths & the down swap, so its numbers are the truest picture. the + # text-summary reporter drops the table straight into the job log. on a fork PR with no + # secret the pg tests self-skip, coverage still writes for the SQLite + logic pass + - name: Integration tests + coverage (Vitest incl. postgres files, real database) env: DATABASE_URL: ${{ secrets.DATABASE_URL }} run: | - pnpm run test + pnpm run test:coverage pnpm run swap-test + + # surface the totals on the run summary page too, not just buried in the log. reads the + # json-summary reporter's output => no extra action, no artifact upload (keeps the workflow + # off the deprecated Node 20 upload-artifact). a skipped coverage run just prints a note + - name: Coverage summary to run page + if: always() + run: | + node -e ' + const fs = require("fs"); + const p = "coverage/coverage-summary.json"; + if (!fs.existsSync(p)) { console.log("no coverage summary (run skipped?)"); process.exit(0); } + const t = JSON.parse(fs.readFileSync(p, "utf8")).total; + const row = (k) => `| ${k} | ${t[k].pct}% | ${t[k].covered}/${t[k].total} |`; + const md = [ + "## Coverage (src/)", + "", + "| metric | % | covered/total |", + "| --- | --- | --- |", + row("lines"), row("statements"), row("functions"), row("branches"), + ].join("\n"); + fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, md + "\n"); + ' diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..2df2889 --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,69 @@ +name: Security + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + # weekly, monday 04:17 UTC (odd minute => not on the top-of-hour stampede). the point of the + # cron is a CVE disclosed against an already-pinned dep, which a PR/push run would never re-check + - cron: '17 4 * * 1' + +# only keep the newest run per ref alive, cancel stale ones +concurrency: + group: security-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + # dependency advisory scan. fails the run only on high/critical => moderate & low are reported in + # the log but don't gate, so a low-severity transitive advisory can't wedge every PR. audits the + # whole tree (not --prod) on purpose: this repo already treated a dev-only esbuild advisory as + # worth an override, so build tooling counts here too + audit: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Install pnpm + uses: pnpm/action-setup@v6 + + - name: Setup Node + uses: actions/setup-node@v7 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: pnpm audit (fail on high or critical) + run: pnpm audit --audit-level high + + # CodeQL static analysis over the TS source. no build step needed => the javascript-typescript + # extractor reads source directly, and this is a library (no runnable entrypoint to trace anyway). + # results land in the repo's Security tab / code-scanning alerts + codeql: + runs-on: ubuntu-latest + + permissions: + security-events: write + actions: read + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: javascript-typescript + + - name: Analyze + uses: github/codeql-action/analyze@v3 diff --git a/CHANGELOG.md b/CHANGELOG.md index 717170f..abc268c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,26 @@ While it's pre-1.0, minor versions may carry breaking changes. ## [Unreleased] -## [0.2.0] - 2026-08-21 +## [1.0.0] - 2026-08-21 + +First stable release. The public API (the fluent chain, `createDAL`/`engineSwap`, +the error classes and config types) is now considered stable under SemVer. No +behavior or API changes from 0.2.0 — this release hardens the quality gates and +marks the surface as settled. + +### Added + +- Biome as the lint + format gate (`pnpm check` / `check:fix`, `lint`, `format`), + wired into CI as a `quality` job and into `prepublishOnly`. +- v8 code coverage (`pnpm test:coverage`, `@vitest/coverage-v8`) generated in the + Postgres CI job so the cross-engine and down-swap paths are measured. +- Security workflow: `pnpm audit --audit-level high` plus CodeQL on every push/PR + and a weekly cron. + +### Changed + +- Node engine floor is `>=22` and the CI matrix runs Node 22/24 (18/20 are past + EOL and vitest 4 pulls `styleText` from `node:util`, 20.12+ only). Correctness, scalability and packaging hardening pass. @@ -46,6 +65,7 @@ Initial pre-release of the universal SQLite/PostgreSQL DAL. crashing, then recovers. - Bidirectional engine swap => migrate data SQLite files <=> PostgreSQL schemas. -[Unreleased]: https://github.com/creative-softworks/sql-switch/compare/v0.2.0...HEAD +[Unreleased]: https://github.com/creative-softworks/sql-switch/compare/v1.0.0...HEAD +[1.0.0]: https://github.com/creative-softworks/sql-switch/compare/v0.2.0...v1.0.0 [0.2.0]: https://github.com/creative-softworks/sql-switch/compare/v0.1.0...v0.2.0 [0.1.0]: https://github.com/creative-softworks/sql-switch/releases/tag/v0.1.0 diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..c3513fb --- /dev/null +++ b/biome.json @@ -0,0 +1,45 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.5.9/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "includes": ["src/**/*.ts", "test/**/*.ts", "scripts/**/*.ts", "*.config.ts"] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100, + "lineEnding": "lf" + }, + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "off" + } + } + }, + "linter": { + "enabled": true, + "rules": { + "preset": "recommended", + "style": { + "noNonNullAssertion": "off" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "jsxQuoteStyle": "double", + "semicolons": "always", + "trailingCommas": "all", + "arrowParentheses": "always", + "bracketSpacing": true + } + } +} diff --git a/package.json b/package.json index 3482fd8..1fcb687 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sql-switch", - "version": "0.2.0", + "version": "1.0.0", "description": "Universal hot-swappable DAL — SQLite in dev, PostgreSQL in prod, same fluent API", "type": "module", "packageManager": "pnpm@10.26.1", @@ -33,9 +33,16 @@ "swap-test": "tsx scripts/swap-test.ts", "test": "vitest run", "test:watch": "vitest", + "test:coverage": "vitest run --coverage", "typecheck": "tsc --noEmit && tsc -p tsconfig.scripts.json", + "lint": "biome lint", + "lint:fix": "biome lint --write", + "format": "biome format --write", + "format:check": "biome format", + "check": "biome check", + "check:fix": "biome check --write", "dev": "tsx watch src/database/index.ts", - "prepublishOnly": "pnpm run typecheck && pnpm run test && pnpm run smoke && pnpm run build" + "prepublishOnly": "pnpm run check && pnpm run typecheck && pnpm run test && pnpm run smoke && pnpm run build" }, "keywords": [ "database", @@ -80,9 +87,11 @@ } }, "devDependencies": { + "@biomejs/biome": "^2.0.0", "@types/better-sqlite3": "^9.6.0", "@types/node": "^26.2.0", "@types/pg": "^8.23.1", + "@vitest/coverage-v8": "^4.1.11", "better-sqlite3": "^13.0.3", "drizzle-kit": "^0.31.10", "pg": "^8.23.0", @@ -90,6 +99,6 @@ "tsx": "^4.23.12", "typedoc": "^0.28.0", "typescript": "^6.0.3", - "vitest": "^4.1.10" + "vitest": "^4.1.11" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c226989..b1ea5c5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,6 +15,9 @@ importers: specifier: ^0.45.2 version: 0.45.2(@types/better-sqlite3@9.6.0)(@types/pg@8.23.1)(better-sqlite3@13.0.3)(gel@2.2.0)(pg@8.23.0) devDependencies: + '@biomejs/biome': + specifier: ^2.0.0 + version: 2.5.9 '@types/better-sqlite3': specifier: ^9.6.0 version: 9.6.0 @@ -24,6 +27,9 @@ importers: '@types/pg': specifier: ^8.23.1 version: 8.23.1 + '@vitest/coverage-v8': + specifier: ^4.1.11 + version: 4.1.11(vitest@4.1.11) better-sqlite3: specifier: ^13.0.3 version: 13.0.3 @@ -46,11 +52,85 @@ importers: specifier: ^6.0.3 version: 6.0.3 vitest: - specifier: ^4.1.10 - version: 4.1.10(@types/node@26.2.0)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.9.0)) + specifier: ^4.1.11 + version: 4.1.11(@types/node@26.2.0)(@vitest/coverage-v8@4.1.11)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.9.0)) packages: + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + + '@biomejs/biome@2.5.9': + resolution: {integrity: sha512-KkgCvdHB4IhtpHpF564plA9jo6fDOwWGQ/3jvreLzgOtRLEDoPqr7QO9qejNA8jKwDsSkAKr77hqBHnyUbIw4g==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@2.5.9': + resolution: {integrity: sha512-am22pX2aBqznqq1eMyIj/bZ++riF3Lk6ct7cbv+gQK0csFhr+d8O0RkOi2FF2qSgFgANbqNkIZ0/PxlnW2pLFg==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@2.5.9': + resolution: {integrity: sha512-l44KWDHLDvEnD0N/XcrVs7VXb3A18xL7QS3WB0eL93wbmk529ffIG55vleGCqaunpRUjLrdnjK05Qki1dsjylg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@2.5.9': + resolution: {integrity: sha512-7ImVPwBLCtkmpR5esd8RHhTqW94f0JLJQum6AneYcy94jRm18TaPPm7slaigGzFhfgt3QiD1Vj52LKmBAnKizA==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-arm64@2.5.9': + resolution: {integrity: sha512-ICaK+IYaVZvKbBxX2rwrPT0DdUDMnE9Vm3nQGe+mltQPmUg19pONzkPWGdY4FCsoreDETWDynvdt4ysCbF5gNQ==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-x64-musl@2.5.9': + resolution: {integrity: sha512-RXGaD0o1/pTTguYw1aeDJh9ad6Lfrui0fI7mBderTyGr7WuUJkBIttgLkR3XJyoxOkkgfBDspaUT8wXArTqLZw==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-linux-x64@2.5.9': + resolution: {integrity: sha512-z22Q/zFYSvbIJfW1CbfZPu4X8PddS6Qd2ORbc6h+aT6EcwAxUF3m6fA4HjNvA3TU4X0dTJRwNPB165ES3PJXzg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-win32-arm64@2.5.9': + resolution: {integrity: sha512-nHK+/HHC+D0ogAHUxomgoSTdjImb6fmNNVTKmf0tyu4eDL1DqPKIHc+i+UL8+b0RnAu8224qo8F2tCVnaT0A3w==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@2.5.9': + resolution: {integrity: sha512-Yiq0H56LjXSSw/hd9YkXgSLQfzyDJzbzU2TezozxyNw+uKWAqOtqGVvBfzKRRDiaFF5avGAhHdWKx7LtDOShUw==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + '@drizzle-team/brocli@0.10.2': resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} @@ -500,11 +580,20 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} - '@vitest/expect@4.1.10': - resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + '@vitest/coverage-v8@4.1.11': + resolution: {integrity: sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==} + peerDependencies: + '@vitest/browser': 4.1.11 + vitest: 4.1.11 + peerDependenciesMeta: + '@vitest/browser': + optional: true - '@vitest/mocker@4.1.10': - resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + '@vitest/expect@4.1.11': + resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} + + '@vitest/mocker@4.1.11': + resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==} peerDependencies: msw: ^2.4.9 vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -514,20 +603,20 @@ packages: vite: optional: true - '@vitest/pretty-format@4.1.10': - resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + '@vitest/pretty-format@4.1.11': + resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==} - '@vitest/runner@4.1.10': - resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + '@vitest/runner@4.1.11': + resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==} - '@vitest/snapshot@4.1.10': - resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + '@vitest/snapshot@4.1.11': + resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==} - '@vitest/spy@4.1.10': - resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + '@vitest/spy@4.1.11': + resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==} - '@vitest/utils@4.1.10': - resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + '@vitest/utils@4.1.11': + resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} acorn@8.18.0: resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} @@ -544,6 +633,9 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-v8-to-istanbul@1.0.5: + resolution: {integrity: sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==} + balanced-match@4.0.4: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} @@ -748,14 +840,36 @@ packages: get-tsconfig@4.14.3: resolution: {integrity: sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==} + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + isexe@3.1.5: resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} engines: {node: '>=18'} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + lightningcss-android-arm64@1.33.0: resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} engines: {node: '>= 12.0.0'} @@ -846,6 +960,13 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.5.4: + resolution: {integrity: sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + markdown-it@14.3.0: resolution: {integrity: sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==} hasBin: true @@ -1039,6 +1160,10 @@ packages: engines: {node: '>=16 || 14 >=14.17'} hasBin: true + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + thenify-all@1.6.0: resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} engines: {node: '>=0.8'} @@ -1159,20 +1284,20 @@ packages: yaml: optional: true - vitest@4.1.10: - resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + vitest@4.1.11: + resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.1.10 - '@vitest/browser-preview': 4.1.10 - '@vitest/browser-webdriverio': 4.1.10 - '@vitest/coverage-istanbul': 4.1.10 - '@vitest/coverage-v8': 4.1.10 - '@vitest/ui': 4.1.10 + '@vitest/browser-playwright': 4.1.11 + '@vitest/browser-preview': 4.1.11 + '@vitest/browser-webdriverio': 4.1.11 + '@vitest/coverage-istanbul': 4.1.11 + '@vitest/coverage-v8': 4.1.11 + '@vitest/ui': 4.1.11 happy-dom: '*' jsdom: '*' vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -1221,6 +1346,56 @@ packages: snapshots: + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@bcoe/v8-coverage@1.0.2': {} + + '@biomejs/biome@2.5.9': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 2.5.9 + '@biomejs/cli-darwin-x64': 2.5.9 + '@biomejs/cli-linux-arm64': 2.5.9 + '@biomejs/cli-linux-arm64-musl': 2.5.9 + '@biomejs/cli-linux-x64': 2.5.9 + '@biomejs/cli-linux-x64-musl': 2.5.9 + '@biomejs/cli-win32-arm64': 2.5.9 + '@biomejs/cli-win32-x64': 2.5.9 + + '@biomejs/cli-darwin-arm64@2.5.9': + optional: true + + '@biomejs/cli-darwin-x64@2.5.9': + optional: true + + '@biomejs/cli-linux-arm64-musl@2.5.9': + optional: true + + '@biomejs/cli-linux-arm64@2.5.9': + optional: true + + '@biomejs/cli-linux-x64-musl@2.5.9': + optional: true + + '@biomejs/cli-linux-x64@2.5.9': + optional: true + + '@biomejs/cli-win32-arm64@2.5.9': + optional: true + + '@biomejs/cli-win32-x64@2.5.9': + optional: true + '@drizzle-team/brocli@0.10.2': {} '@esbuild-kit/core-utils@3.3.2': @@ -1511,44 +1686,58 @@ snapshots: '@types/unist@3.0.3': {} - '@vitest/expect@4.1.10': + '@vitest/coverage-v8@4.1.11(vitest@4.1.11)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.11 + ast-v8-to-istanbul: 1.0.5 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.4 + obug: 2.1.4 + std-env: 4.2.0 + tinyrainbow: 3.1.1 + vitest: 4.1.11(@types/node@26.2.0)(@vitest/coverage-v8@4.1.11)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.9.0)) + + '@vitest/expect@4.1.11': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.1.10 - '@vitest/utils': 4.1.10 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 chai: 6.2.2 tinyrainbow: 3.1.1 - '@vitest/mocker@4.1.10(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.9.0))': + '@vitest/mocker@4.1.11(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.9.0))': dependencies: - '@vitest/spy': 4.1.10 + '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: vite: 8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.9.0) - '@vitest/pretty-format@4.1.10': + '@vitest/pretty-format@4.1.11': dependencies: tinyrainbow: 3.1.1 - '@vitest/runner@4.1.10': + '@vitest/runner@4.1.11': dependencies: - '@vitest/utils': 4.1.10 + '@vitest/utils': 4.1.11 pathe: 2.0.3 - '@vitest/snapshot@4.1.10': + '@vitest/snapshot@4.1.11': dependencies: - '@vitest/pretty-format': 4.1.10 - '@vitest/utils': 4.1.10 + '@vitest/pretty-format': 4.1.11 + '@vitest/utils': 4.1.11 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@4.1.10': {} + '@vitest/spy@4.1.11': {} - '@vitest/utils@4.1.10': + '@vitest/utils@4.1.11': dependencies: - '@vitest/pretty-format': 4.1.10 + '@vitest/pretty-format': 4.1.11 convert-source-map: 2.0.0 tinyrainbow: 3.1.1 @@ -1560,6 +1749,12 @@ snapshots: assertion-error@2.0.1: {} + ast-v8-to-istanbul@1.0.5: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + balanced-match@4.0.4: {} better-sqlite3@13.0.3: @@ -1685,11 +1880,30 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + has-flag@4.0.0: {} + + html-escaper@2.0.2: {} + isexe@3.1.5: optional: true + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + joycon@3.1.1: {} + js-tokens@10.0.0: {} + lightningcss-android-arm64@1.33.0: optional: true @@ -1755,6 +1969,16 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.5.4: + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.8.5 + markdown-it@14.3.0: dependencies: argparse: 2.0.1 @@ -1926,8 +2150,7 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.62.4 fsevents: 2.3.3 - semver@7.8.5: - optional: true + semver@7.8.5: {} shell-quote@1.10.0: optional: true @@ -1961,6 +2184,10 @@ snapshots: tinyglobby: 0.2.17 ts-interface-checker: 0.1.13 + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + thenify-all@1.6.0: dependencies: thenify: 3.3.1 @@ -2051,15 +2278,15 @@ snapshots: tsx: 4.23.12 yaml: 2.9.0 - vitest@4.1.10(@types/node@26.2.0)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.9.0)): + vitest@4.1.11(@types/node@26.2.0)(@vitest/coverage-v8@4.1.11)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.9.0)): dependencies: - '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.9.0)) - '@vitest/pretty-format': 4.1.10 - '@vitest/runner': 4.1.10 - '@vitest/snapshot': 4.1.10 - '@vitest/spy': 4.1.10 - '@vitest/utils': 4.1.10 + '@vitest/expect': 4.1.11 + '@vitest/mocker': 4.1.11(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.11 + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 es-module-lexer: 2.3.2 expect-type: 1.4.0 magic-string: 0.30.21 @@ -2075,6 +2302,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 26.2.0 + '@vitest/coverage-v8': 4.1.11(vitest@4.1.11) transitivePeerDependencies: - msw diff --git a/scoped/package.json b/scoped/package.json index bbaaed8..d20c6a3 100644 --- a/scoped/package.json +++ b/scoped/package.json @@ -1,6 +1,6 @@ { "name": "@creative-softworks/sql-switch", - "version": "0.2.0", + "version": "1.0.0", "description": "Branded alias of sql-switch — a universal hot-swappable DAL (SQLite in dev, PostgreSQL in prod, same fluent API). Re-exports the sql-switch package unchanged.", "type": "module", "exports": { @@ -46,6 +46,6 @@ "provenance": true }, "dependencies": { - "sql-switch": "0.2.0" + "sql-switch": "1.0.0" } } diff --git a/scripts/db-engine-swap.ts b/scripts/db-engine-swap.ts index 0426430..8e09dd5 100644 --- a/scripts/db-engine-swap.ts +++ b/scripts/db-engine-swap.ts @@ -50,7 +50,9 @@ function parseArgs(argv: string[]): CliOptions { if (up === down) { // covers both "neither given" & "both given" - fail('specify exactly one direction => --up (SQLite to Postgres) or --down (Postgres to SQLite)'); + fail( + 'specify exactly one direction => --up (SQLite to Postgres) or --down (Postgres to SQLite)', + ); } // reads the value after a flag => guards against the flag being last with no value @@ -102,7 +104,9 @@ async function main(): Promise { // interactive prompt per target, exactly like the old inline behaviour onConflict: (conflict: SwapConflict) => { const target = - conflict.kind === 'table' ? `${conflict.schema}.${conflict.table}` : `${conflict.schema}.db`; + conflict.kind === 'table' + ? `${conflict.schema}.${conflict.table}` + : `${conflict.schema}.db`; return confirm( `[engine-swap] leftover data detected in ${target}. Overwrite with new data?`, opts.assumeYes, diff --git a/scripts/serve-docs.ts b/scripts/serve-docs.ts index 5322337..7bb8fde 100644 --- a/scripts/serve-docs.ts +++ b/scripts/serve-docs.ts @@ -14,14 +14,14 @@ const PORT = Number(process.env.PORT ?? 3000); const MIME: Record = { '.html': 'text/html; charset=utf-8', - '.css': 'text/css', - '.js': 'text/javascript', + '.css': 'text/css', + '.js': 'text/javascript', '.json': 'application/json', - '.svg': 'image/svg+xml', - '.png': 'image/png', - '.ico': 'image/x-icon', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.ico': 'image/x-icon', '.woff2': 'font/woff2', - '.woff': 'font/woff', + '.woff': 'font/woff', }; const server = http.createServer((req, res) => { diff --git a/scripts/smoke-test.ts b/scripts/smoke-test.ts index dd8940f..290930c 100644 --- a/scripts/smoke-test.ts +++ b/scripts/smoke-test.ts @@ -44,7 +44,11 @@ async function main(): Promise { await db.schema('antinuke').table('settings').key('guild_1').set({ strict: true }).force(); const forced = await db.schema('antinuke').table('settings').key('guild_1').get(); - check('.force() write is readable immediately', JSON.stringify(forced) === '{"strict":true}', forced); + check( + '.force() write is readable immediately', + JSON.stringify(forced) === '{"strict":true}', + forced, + ); console.log('\n[2] queued write goes through the collector'); await db.schema('antinuke').table('settings').key('guild_2').set({ strict: false }); @@ -54,7 +58,11 @@ async function main(): Promise { await db.schema('economy').table('balances').key('user_1').set({ coins: 1 }); await db.schema('economy').table('balances').key('user_1').set({ coins: 2 }); await db.schema('economy').table('balances').key('user_1').set({ coins: 3 }); - check('3 writes to one key => 1 buffered entry (+1 from step 2)', db.pendingWrites === 2, db.pendingWrites); + check( + '3 writes to one key => 1 buffered entry (+1 from step 2)', + db.pendingWrites === 2, + db.pendingWrites, + ); console.log('\n[4] collector flushes on interval'); await new Promise((r) => setTimeout(r, 600)); @@ -63,11 +71,18 @@ async function main(): Promise { const flushed = await db.schema('antinuke').table('settings').key('guild_2').get(); check('queued value persisted', JSON.stringify(flushed) === '{"strict":false}', flushed); - const collapsed = await db.schema('economy').table('balances').key('user_1').get<{ coins: number }>(); + const collapsed = await db + .schema('economy') + .table('balances') + .key('user_1') + .get<{ coins: number }>(); check('collapsed write kept the LAST value', collapsed?.coins === 3, collapsed); console.log('\n[5] schema isolation => separate .db files'); - const files = fs.readdirSync(TEST_DIR).filter((f) => f.endsWith('.db')).sort(); + const files = fs + .readdirSync(TEST_DIR) + .filter((f) => f.endsWith('.db')) + .sort(); check('one file per schema', files.join(',') === 'antinuke.db,economy.db', files); console.log('\n[6] missing key returns null'); @@ -106,13 +121,21 @@ async function main(): Promise { const db2 = createDAL(); await db2.connect({ db: { mode: 'local', dataDir: TEST_DIR }, collector: { enabled: false } }); - const survived = await db2.schema('economy').table('balances').key('user_9').get<{ coins: number }>(); + const survived = await db2 + .schema('economy') + .table('balances') + .key('user_9') + .get<{ coins: number }>(); check('pending write survived close()', survived?.coins === 99, survived); console.log('\n[10] collector disabled => writes go direct'); await db2.schema('economy').table('balances').key('user_10').set({ coins: 10 }); check('no buffering when disabled', db2.pendingWrites === 0, db2.pendingWrites); - const direct = await db2.schema('economy').table('balances').key('user_10').get<{ coins: number }>(); + const direct = await db2 + .schema('economy') + .table('balances') + .key('user_10') + .get<{ coins: number }>(); check('direct write readable right away', direct?.coins === 10, direct); await db2.close(); @@ -171,10 +194,17 @@ async function main(): Promise { }; // long interval => only the manual flush() calls below actually run - const collector = new WriteCollector(flaky, resolveCollectorConfig({ enabled: true, time: 5000 })); + const collector = new WriteCollector( + flaky, + resolveCollectorConfig({ enabled: true, time: 5000 }), + ); collector.queue('antinuke', 'settings', 'guild_x', { strict: true }); await collector.flush(); - check('failed group went back in the buffer', collector.pendingCount === 1, collector.pendingCount); + check( + 'failed group went back in the buffer', + collector.pendingCount === 1, + collector.pendingCount, + ); check('collector did not trip on a single failure', collector.isTripped === false); await collector.flush(); diff --git a/scripts/swap-test.ts b/scripts/swap-test.ts index f41b8a1..347c8ae 100644 --- a/scripts/swap-test.ts +++ b/scripts/swap-test.ts @@ -120,7 +120,10 @@ async function main(): Promise { check('rows really are in postgres', (await countRows(url)) === seeds.length); console.log('\n[3] read the migrated data through the cloud driver'); const cloud = createDAL(); - await cloud.connect({ db: { mode: 'cloud', connectionString: url }, collector: { enabled: false } }); + await cloud.connect({ + db: { mode: 'cloud', connectionString: url }, + collector: { enabled: false }, + }); const snowflake = await cloud .schema(SCHEMA) .table(TABLE) @@ -152,7 +155,11 @@ async function main(): Promise { .table(TABLE) .key('1234567890123456789') .get<{ nested: { list: number[] } }>(); - check('round trip kept the value intact', roundtrip?.nested.list.join(',') === '1,2,3', roundtrip); + check( + 'round trip kept the value intact', + roundtrip?.nested.list.join(',') === '1,2,3', + roundtrip, + ); // chunk boundary rows are where keyset pagination would drop or repeat data const boundary = await back.schema(SCHEMA).table(TABLE).key('bulk-499').get<{ i: number }>(); @@ -198,7 +205,11 @@ async function main(): Promise { check('write is still buffered', hot.pendingWrites === 1, hot.pendingWrites); const hotResult = await hot.swapEngine({ direction: 'up', onConflict: 'overwrite' }); - check('pending write was flushed before the swap', hotResult.totalRows === seeds.length + 1, hotResult.totalRows); + check( + 'pending write was flushed before the swap', + hotResult.totalRows === seeds.length + 1, + hotResult.totalRows, + ); // same db object, now talking to postgres const afterSwap = await hot.schema(SCHEMA).table(TABLE).key('guild-4').get<{ hot: boolean }>(); @@ -206,7 +217,9 @@ async function main(): Promise { check('collector settings carried over', hot.pendingWrites === 0, hot.pendingWrites); await hot.close(); - console.log('\n[7] ES#4 => a foreign-shaped table is skipped, an all-foreign schema hydrates nothing'); + console.log( + '\n[7] ES#4 => a foreign-shaped table is skipped, an all-foreign schema hydrates nothing', + ); { const pool = new pg.Pool({ connectionString: url, connectionTimeoutMillis: 10_000 }); pool.on('error', () => undefined); @@ -260,16 +273,23 @@ async function main(): Promise { es4.skippedNames.includes(`${ES4_FOREIGN}.audit`), es4.skippedNames, ); + check('all-foreign schema left no stub .db', !fs.existsSync(`${ES4_DIR}/${ES4_FOREIGN}.db`)); check( - 'all-foreign schema left no stub .db', - !fs.existsSync(`${ES4_DIR}/${ES4_FOREIGN}.db`), + 'no leftover temp file for the discarded schema', + !fs.existsSync(`${ES4_DIR}/${ES4_FOREIGN}.db.tmp`), ); - check('no leftover temp file for the discarded schema', !fs.existsSync(`${ES4_DIR}/${ES4_FOREIGN}.db.tmp`)); // and the row that did come down reads back through a local DAL const es4back = createDAL(); - await es4back.connect({ db: { mode: 'local', dataDir: ES4_DIR }, collector: { enabled: false } }); - const mixValue = await es4back.schema(ES4_MIXED).table('settings').key('guild-1').get<{ strict: boolean }>(); + await es4back.connect({ + db: { mode: 'local', dataDir: ES4_DIR }, + collector: { enabled: false }, + }); + const mixValue = await es4back + .schema(ES4_MIXED) + .table('settings') + .key('guild-1') + .get<{ strict: boolean }>(); check('mixed-schema value round-tripped', mixValue?.strict === true, mixValue); await es4back.close(); } diff --git a/src/database/drivers/postgres-drizzle.ts b/src/database/drivers/postgres-drizzle.ts index 75e201e..5a9279b 100644 --- a/src/database/drivers/postgres-drizzle.ts +++ b/src/database/drivers/postgres-drizzle.ts @@ -115,7 +115,11 @@ function errcode(err: unknown): string | undefined { // drizzle 0.45+ wraps a driver error in DrizzleQueryError & hangs the real pg error off `.cause`, // so the sqlstate we need (42P01, 40001, 08006, …) is a hop or two down, not on the top object. // walk the cause chain (bounded, a self-referential cause shouldn't spin) & take the first code. - for (let cur: unknown = err, hops = 0; typeof cur === 'object' && cur !== null && hops < 8; hops++) { + for ( + let cur: unknown = err, hops = 0; + typeof cur === 'object' && cur !== null && hops < 8; + hops++ + ) { const code = (cur as { code?: unknown }).code; if (typeof code === 'string') return code; cur = (cur as { cause?: unknown }).cause; @@ -344,7 +348,7 @@ export class PostgresDriver implements DatabaseDriver { // server side ceiling per transaction, ms. 0 => the caller turned it off private statementTimeout: number; - constructor(private config: PostgresConfig) { + constructor(config: PostgresConfig) { // validates too => a nonsense timeout throws here rather than on the first query this.pool = new Pool(poolOptions(config)); this.statementTimeout = config.pool?.statementTimeout ?? STATEMENT_TIMEOUT; @@ -375,9 +379,9 @@ export class PostgresDriver implements DatabaseDriver { let schemaRun = this.schemasReady.get(schema); if (!schemaRun) { // IF NOT EXISTS => idempotent, so a retry after a reset just no-ops - schemaRun = withretry(() => this.pool.query(`CREATE SCHEMA IF NOT EXISTS "${schema}"`)).then( - () => undefined, - ); + schemaRun = withretry(() => + this.pool.query(`CREATE SCHEMA IF NOT EXISTS "${schema}"`), + ).then(() => undefined); this.schemasReady.set(schema, schemaRun); } @@ -578,9 +582,7 @@ export class PostgresDriver implements DatabaseDriver { let after: string | null = null; for (;;) { - const rows = await this.run(schema, table, () => - this.scanPage(schema, table, prefix, after), - ); + const rows = await this.run(schema, table, () => this.scanPage(schema, table, prefix, after)); if (rows.length === 0) break; for (const row of rows) yield row; diff --git a/src/database/drivers/sqlite-drizzle.ts b/src/database/drivers/sqlite-drizzle.ts index 9c270fb..dad8d09 100644 --- a/src/database/drivers/sqlite-drizzle.ts +++ b/src/database/drivers/sqlite-drizzle.ts @@ -58,7 +58,6 @@ const TX_CHUNK = 500; */ const SCAN_CHUNK = 500; - /** hand the loop back for one turn => `setImmediate` runs after pending I/O, so nothing starves */ function yieldloop(): Promise { return new Promise((resolve) => setImmediate(resolve)); @@ -138,9 +137,7 @@ export class SqliteDriver implements DatabaseDriver { const key = `${schema}:${table}`; if (this.ready.has(key)) return; - raw.exec( - `CREATE TABLE IF NOT EXISTS "${table}" (id TEXT PRIMARY KEY, value TEXT NOT NULL)`, - ); + raw.exec(`CREATE TABLE IF NOT EXISTS "${table}" (id TEXT PRIMARY KEY, value TEXT NOT NULL)`); this.ready.add(key); } diff --git a/src/database/engine-swap.ts b/src/database/engine-swap.ts index e1a3a39..01c087d 100644 --- a/src/database/engine-swap.ts +++ b/src/database/engine-swap.ts @@ -220,7 +220,9 @@ export interface JournalEntry { function isJournalEntry(value: unknown): value is JournalEntry { if (typeof value !== 'object' || value === null) return false; const entry = value as Record; - return typeof entry.rows === 'number' && Number.isFinite(entry.rows) && typeof entry.at === 'string'; + return ( + typeof entry.rows === 'number' && Number.isFinite(entry.rows) && typeof entry.at === 'string' + ); } /** options with every default applied => what the internals actually work with */ @@ -273,7 +275,9 @@ async function mayOverwrite(handler: ConflictHandler, conflict: SwapConflict): P /** describes a conflict target the way the CLI prompt used to */ function conflictLabel(conflict: SwapConflict): string { - return conflict.kind === 'table' ? `${conflict.schema}.${conflict.table}` : `${conflict.schema}.db`; + return conflict.kind === 'table' + ? `${conflict.schema}.${conflict.table}` + : `${conflict.schema}.db`; } /** List the user tables inside a SQLite file (skips sqlite internal tables). */ @@ -421,7 +425,9 @@ export function readjournal( } if (parsed.version !== JOURNAL_VERSION || parsed.direction !== direction) { - onProgress(`${JOURNAL_FILE} is from a different run (${parsed.direction ?? '?'}) => ignoring it`); + onProgress( + `${JOURNAL_FILE} is from a different run (${parsed.direction ?? '?'}) => ignoring it`, + ); return fresh; } @@ -683,7 +689,9 @@ async function swapUp(opts: ResolvedSwapOptions): Promise { // totally different shape, and `SELECT id, value` off it throws & used to take the whole // run down. gate the shape too, skip a foreign one the same way a foreign name is skipped if (!isDalShape(sqliteColumns(sqlite, table))) { - opts.onProgress(`${unit} => skipped, not a sql-switch table (columns aren't id + value)`); + opts.onProgress( + `${unit} => skipped, not a sql-switch table (columns aren't id + value)`, + ); result.skippedNames.push(unit); result.skipped++; fullyMigrated = false; // a foreign table in our file => the file stays @@ -726,9 +734,7 @@ async function swapUp(opts: ResolvedSwapOptions): Promise { // #10/E3: a cursor, not `.all()` => the table is never in memory all at once. it stays // open across the awaits below, which is fine (better-sqlite3 is synchronous & nothing // else touches this statement), and the for...of resets it however the loop ends - const cursor = sqlite - .prepare<[], SwapRow>(`SELECT id, value FROM "${table}"`) - .iterate(); + const cursor = sqlite.prepare<[], SwapRow>(`SELECT id, value FROM "${table}"`).iterate(); // one transaction per table => a chunk failing halfway can't leave the target // truncated or half filled, it all rolls back together @@ -810,7 +816,8 @@ async function swapUp(opts: ResolvedSwapOptions): Promise { if (openAtStart || openNow || touchedMidRun) { const held = openLocalDirs(); - const detail = held.length > 0 ? ` (open: ${held.join(', ')})` : ' (opened & closed mid-run)'; + const detail = + held.length > 0 ? ` (open: ${held.join(', ')})` : ' (opened & closed mid-run)'; opts.onProgress( `${opts.dataDir} is not quiesced => keeping ${migrated.length} local file(s), close every DAL on it & rerun to clear them${detail}`, ); @@ -1020,10 +1027,9 @@ async function swapDown(opts: ResolvedSwapOptions): Promise { for (;;) { const res: PgTypes.QueryResult = await (lastId === null - ? pool.query( - `SELECT id, value FROM "${schema}"."${table}" ORDER BY id LIMIT $1`, - [CHUNK_SIZE], - ) + ? pool.query(`SELECT id, value FROM "${schema}"."${table}" ORDER BY id LIMIT $1`, [ + CHUNK_SIZE, + ]) : pool.query( `SELECT id, value FROM "${schema}"."${table}" WHERE id > $1 ORDER BY id LIMIT $2`, diff --git a/src/database/index.ts b/src/database/index.ts index fb06e42..8e31df7 100644 --- a/src/database/index.ts +++ b/src/database/index.ts @@ -81,6 +81,7 @@ export class WriteOperation implements PromiseLike { ) {} /** Makes the operation awaitable => waits for the already-scheduled (collector) write. */ + // biome-ignore lint/suspicious/noThenProperty: WriteOperation is an intentional PromiseLike => await & .then() resolve the already-queued write (see class docstring) then( onFulfilled?: ((value: T) => R1 | PromiseLike) | null, onRejected?: ((reason: unknown) => R2 | PromiseLike) | null, diff --git a/src/database/utils/collector.ts b/src/database/utils/collector.ts index 9fa80d0..152bb6e 100644 --- a/src/database/utils/collector.ts +++ b/src/database/utils/collector.ts @@ -80,7 +80,9 @@ export function resolveCollectorConfig(config?: CollectorConfig): Required = { ...COLLECTOR_DEFAULTS, ...(config ?? {}) }; if (!Number.isFinite(resolved.time) || resolved.time <= 0) { - throw new ConfigurationError('collector.time must be a finite number of milliseconds greater than 0'); + throw new ConfigurationError( + 'collector.time must be a finite number of milliseconds greater than 0', + ); } if (!Number.isFinite(resolved.recoverAfter) || resolved.recoverAfter <= 0) { throw new ConfigurationError( diff --git a/src/database/utils/handles.ts b/src/database/utils/handles.ts index 8b4cb6c..e475fc2 100644 --- a/src/database/utils/handles.ts +++ b/src/database/utils/handles.ts @@ -30,7 +30,6 @@ const opendirs = new Map(); // 0), but it may have flushed buffered writes we never read => the generation still shows it touched const opencounts = new Map(); - /** the one spelling everything is compared on => `./data` and `data/.` are the same directory */ function normalize(dataDir: string): string { return path.resolve(dataDir); diff --git a/test/bulk-write.test.ts b/test/bulk-write.test.ts index 4996216..14a6600 100644 --- a/test/bulk-write.test.ts +++ b/test/bulk-write.test.ts @@ -74,7 +74,9 @@ describe('sqlite bulk flush', () => { const raw = new Database(path.join(dir, 'bulk.db'), { readonly: true }); try { - const counted = raw.prepare('SELECT count(*) AS n FROM "rows"').get() as { n: number | bigint }; + const counted = raw.prepare('SELECT count(*) AS n FROM "rows"').get() as { + n: number | bigint; + }; expect(Number(counted.n)).toBe(MAX_BUFFER); } finally { raw.close(); @@ -159,9 +161,7 @@ describe.skipIf(!url)('postgres bulk flush against a real database', () => { await driver.batchSet(schema, 'bulk', bulkwrites(1_200)); - const counted = await pool.query<{ n: string }>( - `SELECT count(*) AS n FROM "${schema}"."bulk"`, - ); + const counted = await pool.query<{ n: string }>(`SELECT count(*) AS n FROM "${schema}"."bulk"`); expect(Number(counted.rows[0]?.n)).toBe(1_200); expect(await driver.get(schema, 'bulk', 'key-0')).toEqual({ i: 0, pad: 'x'.repeat(64) }); expect(await driver.get(schema, 'bulk', 'key-1199')).toEqual({ diff --git a/test/collector-breaker-guards.test.ts b/test/collector-breaker-guards.test.ts index 208d6b7..e1d217c 100644 --- a/test/collector-breaker-guards.test.ts +++ b/test/collector-breaker-guards.test.ts @@ -145,7 +145,7 @@ describe('collector breaker guards', () => { async delete(s, t, k) { rows.delete(`${s}:${t}:${k}`); }, - // eslint-disable-next-line require-yield + // biome-ignore lint/correctness/useYield: fake driver scan yields nothing on purpose async *scan() { return; }, diff --git a/test/collector-hooks.test.ts b/test/collector-hooks.test.ts index 276e073..4265fe7 100644 --- a/test/collector-hooks.test.ts +++ b/test/collector-hooks.test.ts @@ -146,7 +146,11 @@ describe('collector observability hooks', () => { collector.queue('economy', 'balances', 'user-1', { coins: 1 }); // despite the hook throwing, the retry still lands the write - await waitfor('the write to land despite the throwing hook', () => driver.rows.size === 1, 2_000); + await waitfor( + 'the write to land despite the throwing hook', + () => driver.rows.size === 1, + 2_000, + ); expect(spy).toHaveBeenCalled(); // the throw was logged, not propagated spy.mockRestore(); diff --git a/test/double-connect.test.ts b/test/double-connect.test.ts index fe224dd..7438feb 100644 --- a/test/double-connect.test.ts +++ b/test/double-connect.test.ts @@ -103,9 +103,9 @@ describe('double connect', () => { // the types already demand a connectionString here => that guard exists for JS callers, so the // cast is the only way to reach it from a typed test - await expect( - db.connect({ db: { mode: 'cloud' } } as unknown as DALConfig), - ).rejects.toThrow(ConfigurationError); + await expect(db.connect({ db: { mode: 'cloud' } } as unknown as DALConfig)).rejects.toThrow( + ConfigurationError, + ); // a bad collector interval has to be caught before the old engine is torn down too await expect( db.connect({ db: { mode: 'local', dataDir: dir }, collector: { time: 0 } }), @@ -141,7 +141,9 @@ describe('double connect', () => { strict: true, }); await db.schema('antinuke').table('settings').key('guild-2').set({ ok: true }).force(); - expect(await db.schema('antinuke').table('settings').key('guild-2').get()).toEqual({ ok: true }); + expect(await db.schema('antinuke').table('settings').key('guild-2').get()).toEqual({ + ok: true, + }); }); it('turns a missing engine peer dep into a ConfigurationError that names the package', async () => { @@ -153,7 +155,10 @@ describe('double connect', () => { // the mocked pg driver import throws ERR_MODULE_NOT_FOUND for 'pg' => connect should translate // that into a friendly, actionable error rather than surfacing the raw resolver stack const err = await db - .connect({ db: { mode: 'cloud', connectionString: 'postgres://ignored' }, collector: NOFLUSH }) + .connect({ + db: { mode: 'cloud', connectionString: 'postgres://ignored' }, + collector: NOFLUSH, + }) .then(() => null) .catch((e: unknown) => e); diff --git a/test/engine-swap-durability.test.ts b/test/engine-swap-durability.test.ts index 428960c..db06636 100644 --- a/test/engine-swap-durability.test.ts +++ b/test/engine-swap-durability.test.ts @@ -35,11 +35,7 @@ import { savejournal, } from '../src/database/engine-swap.js'; import type { SwapJournal } from '../src/database/engine-swap.js'; -import { - localDirOpen, - registerLocalDir, - releaseLocalDir, -} from '../src/database/utils/handles.js'; +import { localDirOpen, registerLocalDir, releaseLocalDir } from '../src/database/utils/handles.js'; import { createDAL } from '../src/database/index.js'; import { tempdir } from './helpers/tempdal.js'; import { runfixture } from './helpers/child.js'; @@ -94,7 +90,8 @@ function seedforeigntable(dir: string, schema: string, table: string): void { } /** a pool that drops the throwaway schemas it was told about when the test finishes */ -function pgpool(schemas: string[]): pg.Pool { const pool = new pg.Pool({ connectionString: url! }); +function pgpool(schemas: string[]): pg.Pool { + const pool = new pg.Pool({ connectionString: url! }); pool.on('error', () => undefined); onTestFinished(async () => { for (const schema of schemas) { @@ -503,7 +500,7 @@ describe.skipIf(!url)('a swap interrupted by a signal (E6 + E2 / E5)', () => { seedsqlite(dir, schema, { alpha: 3, beta: 3 }); const release = appListener('SIGTERM'); - let first; + let first!: Awaited>; try { first = await engineSwap({ direction: 'up', diff --git a/test/harness.test.ts b/test/harness.test.ts index 8826f21..0a6b8c0 100644 --- a/test/harness.test.ts +++ b/test/harness.test.ts @@ -26,9 +26,7 @@ describe('test harness', () => { expect(driver.calls.set).toBe(1); expect(driver.calls.batchSet).toBe(1); - expect(driver.batches).toEqual([ - { schema: 'antinuke', table: 'settings', keys: ['guild-2'] }, - ]); + expect(driver.batches).toEqual([{ schema: 'antinuke', table: 'settings', keys: ['guild-2'] }]); expect(driver.rows.get(rowkey('antinuke', 'settings', 'guild-2'))).toEqual({ strict: false }); }); }); diff --git a/test/helpers/child.ts b/test/helpers/child.ts index 0b70042..5b170fb 100644 --- a/test/helpers/child.ts +++ b/test/helpers/child.ts @@ -34,7 +34,11 @@ export interface RunOptions { * tsx is loaded through `--import`, not run as the `tsx` CLI => the CLI re-spawns node as a * grandchild, so a signal would land on the wrapper & never reach the process holding the buffer. */ -export function runfixture(file: string, args: string[], opts: RunOptions = {}): Promise { +export function runfixture( + file: string, + args: string[], + opts: RunOptions = {}, +): Promise { const fixture = path.join(import.meta.dirname, '..', 'fixtures', file); const child = spawn(process.execPath, ['--import', 'tsx', fixture, ...args], { stdio: ['ignore', 'pipe', 'pipe'], diff --git a/test/pg-resilience.test.ts b/test/pg-resilience.test.ts index 199fd4b..67fc5f9 100644 --- a/test/pg-resilience.test.ts +++ b/test/pg-resilience.test.ts @@ -250,9 +250,10 @@ describe.skipIf(!url)('postgres resilience against a real database', () => { // the flush can never get the lock => without a statement timeout this waits forever on a // pool slot. 5 of those and the driver is done answering anything - failure = await driver - .batchSet(schema, 'locked', new Map([['b', { n: 2 }]])) - .then(() => null, (err: unknown) => err); + failure = await driver.batchSet(schema, 'locked', new Map([['b', { n: 2 }]])).then( + () => null, + (err: unknown) => err, + ); } finally { await blocker.query('ROLLBACK').catch(() => undefined); blocker.release(); diff --git a/test/sqlite-config.test.ts b/test/sqlite-config.test.ts index 512550a..aba29c2 100644 --- a/test/sqlite-config.test.ts +++ b/test/sqlite-config.test.ts @@ -19,10 +19,16 @@ describe('sqlite busy_timeout config', () => { const db = createDAL(); await expect( - db.connect({ db: { mode: 'local', dataDir: dir, busyTimeout: -1 }, collector: { enabled: false } }), + db.connect({ + db: { mode: 'local', dataDir: dir, busyTimeout: -1 }, + collector: { enabled: false }, + }), ).rejects.toThrow(ConfigurationError); await expect( - db.connect({ db: { mode: 'local', dataDir: dir, busyTimeout: 1.5 }, collector: { enabled: false } }), + db.connect({ + db: { mode: 'local', dataDir: dir, busyTimeout: 1.5 }, + collector: { enabled: false }, + }), ).rejects.toThrow(ConfigurationError); }); diff --git a/test/value-integrity.test.ts b/test/value-integrity.test.ts index c77254a..e1a8849 100644 --- a/test/value-integrity.test.ts +++ b/test/value-integrity.test.ts @@ -56,7 +56,7 @@ describe('values that cannot round trip', () => { expect(() => keyproxy.set('a\u0000b')).toThrow(InvalidValueError); expect(() => keyproxy.set({ note: 'a\u0000b' })).toThrow(/note/); // property names go into the same jsonb document, so they're just as fatal - expect(() => keyproxy.set({ ['bad\u0000key']: 1 })).toThrow(InvalidValueError); + expect(() => keyproxy.set({ 'bad\u0000key': 1 })).toThrow(InvalidValueError); expect(() => keyproxy.set(['fine', 'a\u0000b'])).toThrow(InvalidValueError); }); diff --git a/vitest.config.ts b/vitest.config.ts index 12d7b36..456632d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -9,5 +9,18 @@ export default defineConfig({ // native sqlite writes plus the large N tests need more than the 5s default testTimeout: 30_000, hookTimeout: 30_000, + coverage: { + provider: 'v8', + // text-summary lands in the job log, json-summary/lcov feed tooling, html is browsable + reporter: ['text', 'text-summary', 'json-summary', 'lcov', 'html'], + reportsDirectory: './coverage', + // only the shipped library counts => scripts/ are CLI glue & test/ is the harness itself. + // vitest 4 dropped the `all` key => naming `include` already reports zero-hit files, so an + // untested module still shows at 0% instead of silently dropping off the report + include: ['src/**/*.ts'], + // thresholds are deliberately unset until the first CI run establishes a baseline => the pg + // runtime paths & swapDown only run in the DATABASE_URL job, so a matrix only number would + // read misleadingly low. tighten them in the coverage PR once the down swap test (T1) lands. + }, }, });