From 35d6592948063757c19674e0070d2833ee42a3b5 Mon Sep 17 00:00:00 2001 From: Rev Albrecht von Nullpointer <160512015+revxshafi@users.noreply.github.com> Date: Fri, 21 Aug 2026 09:08:22 +0000 Subject: [PATCH 1/4] Add tooling scaffold: Biome, v8 coverage, security workflow - Biome (biome.json) as the lint + format gate; new `quality` CI job runs `biome ci .` - v8 coverage wired into the postgres CI job (report-only, thresholds deferred) - security.yml: pnpm audit (high/critical) + CodeQL on PR/push + weekly cron - one-time Biome reflow + two lint fixes (dead pg-driver field, thenable ignore) --- .github/workflows/ci.yml | 55 +++- .github/workflows/security.yml | 69 +++++ biome.json | 45 ++++ package.json | 13 +- pnpm-lock.yaml | 324 +++++++++++++++++++---- scripts/db-engine-swap.ts | 8 +- scripts/serve-docs.ts | 12 +- scripts/smoke-test.ts | 46 +++- scripts/swap-test.ts | 38 ++- src/database/drivers/postgres-drizzle.ts | 18 +- src/database/drivers/sqlite-drizzle.ts | 5 +- src/database/engine-swap.ts | 30 ++- src/database/index.ts | 1 + src/database/utils/collector.ts | 4 +- src/database/utils/handles.ts | 1 - test/bulk-write.test.ts | 8 +- test/collector-breaker-guards.test.ts | 2 +- test/collector-hooks.test.ts | 6 +- test/double-connect.test.ts | 15 +- test/engine-swap-durability.test.ts | 11 +- test/harness.test.ts | 4 +- test/helpers/child.ts | 6 +- test/pg-resilience.test.ts | 7 +- test/sqlite-config.test.ts | 10 +- test/value-integrity.test.ts | 2 +- vitest.config.ts | 13 + 26 files changed, 622 insertions(+), 131 deletions(-) create mode 100644 .github/workflows/security.yml create mode 100644 biome.json 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/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..0090f10 100644 --- a/package.json +++ b/package.json @@ -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/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. + }, }, }); From 5c25c3160212811f32a011c36e926cbd69e48aa6 Mon Sep 17 00:00:00 2001 From: Rev Albrecht von Nullpointer <160512015+revxshafi@users.noreply.github.com> Date: Fri, 21 Aug 2026 11:06:40 +0000 Subject: [PATCH 2/4] Initial commit --- HANDOFF.md | 203 +++++++++++++++++++++++++++++++++++++++++++++++++++ package.json | 2 +- 2 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 HANDOFF.md diff --git a/HANDOFF.md b/HANDOFF.md new file mode 100644 index 0000000..948e15e --- /dev/null +++ b/HANDOFF.md @@ -0,0 +1,203 @@ +# HANDOFF — sql-switch → professional grade + +**To:** Opus 5, ultracode effort. +**From:** Opus 4.8 (release + audit pass). +**Date:** 2026-08-21. +**Mission:** Take `sql-switch` from "shipped and working" to "professional grade." Fix the real +bugs first, then close the tooling and polish gaps. Leave it as a package a senior reviewer would +sign off on without flinching. + +--- + +## 0. Read this first + +- **Ground truth is CLAUDE.md.** House comment style, variable naming, invariants, commands, + Replit sandbox constraints — all there. Follow the comment mechanics exactly (`=>` not em-dash, + `&` not "and" in comments, lowercase casual comments, `userdata` never `user_data`). Several + older files violate this; see item S13. +- **v0.2.0 is already live on npm** (both `sql-switch` and `@creative-softworks/sql-switch`) and has + a GitHub Release. Do NOT re-publish 0.2.0. Any fixes ship as **0.2.1** (patch) or **0.3.0** + (new/changed public surface). Release mechanics in §5. +- **Work on a branch off `origin/main`, open a PR.** `main` has branch protection (required checks + `test (22)`, `test (24)`, `docs`; squash-only). Never push straight to main. +- This audit changed **nothing** in the code — it's all still open work. + +## 1. Quality gates (what "green" means) + +Run before claiming anything works: `pnpm typecheck && pnpm test && pnpm smoke` (add +`pnpm swap-test` when `DATABASE_URL` is set). Notes: + +- `pnpm typecheck` is currently **clean**. Keep it that way. +- `pnpm test` **segfaults locally in this sandbox** — a better-sqlite3 native-ABI mismatch + (exit 139 / SIGSEGV), NOT a logic failure. Only pure-logic test files pass here. **CI on Node + 22/24 (ubuntu) is authoritative** for anything touching the native binding. Don't chase the + local segfault; verify native-path work via CI. +- Postgres-backed tests self-skip without `DATABASE_URL`. That's by design — but see C2 below, + it's also a coverage hole. + +## 2. Backlog — CRITICAL (do these first) + +These two are genuine behavioral bugs, not opinion: + +- **[C1] Un-awaited `delete()` is a silent no-op.** `src/database/index.ts:218-224`. `delete()` + only *defines* `run` and returns a lazy `WriteOperation`; nothing executes until `.then()`/ + `await`/`.force()`. So `db.schema(s).table(t).key(id).delete()` fire-and-forget does nothing. + This is the exact footgun `set()` was deliberately made eager to avoid (read `set()`'s own + docstring). **Fix:** execute `run()` eagerly inside `delete()` like `set()` does, keep the + returned handle awaitable, guard the fire-and-forget path with `.catch`. Add a regression test. + +- **[C2] Engine-swap leaks SIGINT/SIGTERM handlers when a driver import fails.** + `src/database/engine-swap.ts:613-617` (swapUp) and `882-886` (swapDown). `hookswapexit()` + registers process signal listeners *before* the dynamic `import('pg')` / + `import('better-sqlite3')`, and those imports sit *before* the `try` whose `finally` calls + `exit.release()`. A missing driver throws out of the function, listeners never removed → + orphaned handlers accumulate across calls, `MaxListenersExceededWarning`, corrupted host + shutdown. **Fix:** register the exit hook *after* the imports/pool are built, or move + `hookswapexit()` inside the `try`. Test with a swap where the target driver isn't installed. + +## 3. Backlog — SHOULD-FIX (correctness & consistency) + +- **[S3] Fire-and-forget `.force()` and forced `delete()` can crash the process.** + `index.ts:196-204, 218-224`. The queued `set()` path guards with `void scheduled.catch(...)`; + the `.force()` closure and `delete()` `run` do not, so an un-awaited rejection becomes an + unhandled rejection (process crash under default Node). Attach the same defensive `.catch`. + +- **[S4] A corrupt/locked `.db` file aborts the whole upward migration.** + `engine-swap.ts:644`. `new Database(sqlitePath, {readonly:true})` is built *outside* the + per-file `try` (starts at 651), so a bad header / OS lock / permission error takes down the + entire multi-schema run — contradicting the module's own "skip & report via `skippedNames`" + ethos. Wrap the open, route failures into `skippedNames` + `onProgress`. + +- **[S5] `WriteOperation` implements `then` but not `catch`/`finally`.** `index.ts:77-95`. TS + users are shielded by the `PromiseLike` type; a JS caller doing `.catch(...)`/`.finally(...)` + hits `TypeError: catch is not a function`. Add `catch`/`finally` delegating to `then` (or + return a real `Promise`). + +- **[S6] Pure reads have write side effects (auto-create schema/table).** + `sqlite-drizzle.ts:148-176, 263-320`, `postgres-drizzle.ts:363-410` via `ensureTable`. + `get`/`has`/`scan`/`count`/`deleteAll` all run `CREATE TABLE IF NOT EXISTS` (+ `mkdir`/file + create on SQLite, `CREATE SCHEMA IF NOT EXISTS` on PG). A `count()` on a never-written schema + materializes it — and on PG a stray read creates an empty logical schema that `swapDown` later + enumerates as "user data." Make pure reads not auto-create (treat missing relation as empty), + or document as intentional. Prefer the fix. + +- **[S7] A failed `engineSwap()` inside `swapEngine()` leaves the DAL silently unusable.** + `index.ts:761-785`. It `await this.close()` (nulls driver/collector) then `await engineSwap()`; + if that throws, the DAL is closed but `this.config` is still set → every later call throws + `NotConnectedError`, no rollback, no reconnect. Reconnect to the original config on failure (or + document that a failed swap requires an explicit `connect()`). Pair with the S-tier test gap below. + +## 4. Backlog — TESTS, TOOLING & CI (the biggest "professional" gap) + +The pure-logic layer is genuinely well tested (collector/breaker, value integrity, name +validation, pg classifiers, journal/chunking). The gaps are concentrated in cross-engine and +runtime-driver paths, and there are no quality-of-life gates. + +**Critical coverage holes:** +- **[T1] `swapDown` (engine-swap.ts:858-1130) has ZERO Vitest coverage.** No `direction:'down'` + call exists in `test/`. Untested: file-level conflict decline, all-foreign-schema stub cleanup, + `wal_checkpoint(TRUNCATE)` before rename, tmp→`.db` atomic rename, stale `.tmp-wal`/`-shm` + cleanup, keyset pagination, JSONB→TEXT re-serialization. Single biggest risk area — add a + gated down-swap durability test mirroring the up-swap one. +- **[T2] Up-swap row migration + all `PostgresDriver` runtime methods only run in one + secret-gated CI job.** Everything behind `describe.skipIf(!DATABASE_URL)` silently reports green + on fork PRs and across the matrix. A pg-driver or migration regression can merge red-free. + At minimum make "green without secret ≠ tested" loud; ideally run the gated set on the matrix. +- **[T3] No dependency/security audit in CI** — no `pnpm audit`/CodeQL/OSV/Trivy. Dependabot only + bumps versions, it won't fail CI on a CVE. Notable for a provenance-published package. +- **[T4] No code coverage at all** — no `@vitest/coverage-v8`, no `test:coverage`, no thresholds, + no report upload. Land this FIRST: it makes T1/T2 self-evident instead of hand-discovered. + +**Should-fix tooling:** +- **[T5] No linter.** Add `@typescript-eslint` (or Biome) + a CI lint job. Most conspicuous + missing gate given the strict tsconfig. CLAUDE.md's "no lint is wired up" invariant must be + updated when you do this. +- **[T6] No enforced formatter.** `.editorconfig` exists but nothing enforces it — add Prettier + or Biome `format --check` to CI. +- **[T7] No pre-commit hooks** (husky / simple-git-hooks + lint-staged) to run typecheck/lint + before push. +- **[T8] Untested branches, all cheap unit tests:** `resolveSwapOptions` error branches + (engine-swap.ts:243-266, pure fn — no DB needed); `NotConnectedError` (index.ts:696,740 — the + one error class of five never asserted); `swapEngine`/`DalSwapOptions` reconnect logic + (index.ts:739-793); `add(Infinity)`→`InvalidValueError`; breaker `closed→open` re-trip cycle; + direct unit tests for `utils/shutdown.ts` and `utils/value.ts`. + +## 5. Backlog — DOCS & PACKAGING POLISH + +- **[D1] README has zero badges.** Add a badge row: npm version, license (MIT), CI status, node + engine (`>=22`), provenance. (`README.md`.) +- **[D2] Hosted TypeDoc is built + deployed to GitHub Pages but never linked.** Add a + "Documentation" section/link in README; consider pointing `package.json` `homepage` at the + Pages site instead of `#readme`. (`.github/workflows/docs.yml` deploys it.) +- **[D3] `sideEffects` field missing** from both `package.json` and `scoped/package.json`. The + module is import-side-effect-free (signal handlers register at runtime inside `connect()`, not + at import). Add `"sideEffects": false` to both — real tree-shaking win + expected metadata. +- **[D4] No "Error handling" section in README.** The error classes are public and each carries a + discriminant `code` (`DATABASE_UNAVAILABLE`, `INVALID_NAME`, `CONFIGURATION_ERROR`, + `NOT_CONNECTED`, `INVALID_VALUE`). Show the `instanceof`/`.code` pattern + a table. +- **[D5] Exported config types invisible in README** — `DALConfig`, `CollectorConfig`, + `CollectorHooks`, `ScanOptions`, `BreakerState`, `DalSwapOptions`, `EngineSwap*`. Add a typed- + config snippet and link the hosted type reference. (TSDoc in `types.ts` is already excellent.) +- **[D6] Thin keywords (6)** in both package.json files — add `orm`, `postgres`, `better-sqlite3`, + `pg`, `migration`, `database-abstraction`, `write-batching`, `circuit-breaker`, `neon`. +- **[D7] CLAUDE.md handoff drift** — it says `node >=18` and "matrix 18/20/22/24", but real + `engines` is `>=22.0.0` and `ci.yml` runs `[22,24]`. Reconcile. +- **[D8] `master-blueprint.md` is referenced by CLAUDE.md but does not exist.** Either write it or + drop the pointer — a handoff reader hits a dead reference. +- **[D9] Nice-to-have:** `.github/CODEOWNERS`, `funding` field / `FUNDING.yml` (only if a channel + exists), badge/`sideEffects` parity for the scoped alias. + +## 6. Backlog — NICE-TO-HAVE (cleanup a reviewer will notice) + +- **[N1]** Dead deprecated `deleteAfterMigration` config field (`types.ts:189-197`) — remove on + next major. +- **[N2]** Duplicated chunking + upsert-SQL logic: `engine-swap.ts:336-378` vs + `postgres-drizzle.ts:284-333`. Two near-identical generators/builders that can drift. Consolidate. +- **[N3]** Backpressure hook can go silent under sustained churn — `overHighWater` only re-arms + when the buffer fully clears (`collector.ts:463-468, 493`); an oscillating-above-mark buffer + never re-fires `onBackpressure`. +- **[N4]** NUL scrub in error message replaces only the first occurrence (`value.ts:86`, missing + `/g`). Cosmetic. +- **[N5]** No DAL-level breaker-state accessor — `collector` has `isTripped`/`breakerState`, `DAL` + only surfaces `pendingWrites`. Consider a `db.breakerState` passthrough. +- **[N6]** `pull()` overload ambiguous for function-valued array elements (`index.ts:370`) — doc note. +- **[S13/N7] House-style comment violations** per CLAUDE.md in the older files: `errors.ts` + (em-dashes throughout), `schema.ts`, `types.ts`, and the top-of-file doc blocks in + `postgres-drizzle.ts` / `sqlite-drizzle.ts`. Bring them to `=>` / `&` / lowercase style. + +## 7. Suggested sequencing + +1. **Tooling scaffold first** — coverage (T4), linter (T5), formatter (T6), CI audit (T3). This + surfaces the real gaps and gives you gates for everything after. +2. **Critical bugs** — C1, C2 (each with a regression test). +3. **Correctness/consistency** — S3-S7. +4. **Coverage** — T1 (swapDown), T2 (make gated paths visible/matrix'd), T8 (cheap unit branches). +5. **Docs & packaging** — D1-D8. +6. **Cleanup** — N1-N7 / house style. +7. Update CHANGELOG under a new `[Unreleased]`; bump to 0.2.1 (fixes only) or 0.3.0 (if S5/S6/N5 + widen the public surface — an exports/`.d.ts` change is a deliberate act per the invariants). + +## 8. Release mechanics (when it's time) + +Bump **three** spots to the same version: root `package.json` `version`, `scoped/package.json` +`version`, and `scoped/package.json` `dependencies["sql-switch"]`. Push tag `v`. The +`publish.yml` workflow guards tag == all three before publishing both packages with provenance, +then create the GitHub Release. The npm credential is now a working Automation-class token +(a granular "select packages" token previously failed to create the unscoped package with a +misleading E404 — don't regress to one). + +## 9. Hard constraints (do not violate) + +- Public API = only what `src/database/index.ts` re-exports (exports map + `.d.ts`). Widening it + is a deliberate act — update the exports and the invariants list on purpose, and treat it as a + minor version bump pre-1.0. +- Driver packages (`better-sqlite3`, `pg`) are **optional** peer deps loaded via dynamic, + mode-gated `import()`. Never add a static import — a SQLite-only app must not gain a `pg` dep. +- Ships dual CJS + ESM from one source; both entrypoints must keep resolving. +- TypeDoc must stay at **zero warnings** (`treatWarningsAsErrors`); `@internal` symbols are + excluded, so never `{@link}` one from a public comment. README is the TypeDoc readme — a broken + link there fails `pnpm docs` too. +- Don't commit `.claude-data/` or `quick.db/`; don't overwrite `.graphify/graph.json` (stale, and + the installed CLI's `build` writes an incompatible format — see CLAUDE.md). +- Rotate the Neon `DATABASE_URL` if it was ever exposed; redact any token in output. + diff --git a/package.json b/package.json index 0090f10..9a11900 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sql-switch", - "version": "0.2.0", + "version": "1", "description": "Universal hot-swappable DAL — SQLite in dev, PostgreSQL in prod, same fluent API", "type": "module", "packageManager": "pnpm@10.26.1", From 17867a43280b55b216289e2c0756cb3e3aa0b86f Mon Sep 17 00:00:00 2001 From: Rev Albrecht von Nullpointer <160512015+revxshafi@users.noreply.github.com> Date: Fri, 21 Aug 2026 11:14:08 +0000 Subject: [PATCH 3/4] Remove HANDOFF.md scratch doc --- HANDOFF.md | 203 ----------------------------------------------------- 1 file changed, 203 deletions(-) delete mode 100644 HANDOFF.md diff --git a/HANDOFF.md b/HANDOFF.md deleted file mode 100644 index 948e15e..0000000 --- a/HANDOFF.md +++ /dev/null @@ -1,203 +0,0 @@ -# HANDOFF — sql-switch → professional grade - -**To:** Opus 5, ultracode effort. -**From:** Opus 4.8 (release + audit pass). -**Date:** 2026-08-21. -**Mission:** Take `sql-switch` from "shipped and working" to "professional grade." Fix the real -bugs first, then close the tooling and polish gaps. Leave it as a package a senior reviewer would -sign off on without flinching. - ---- - -## 0. Read this first - -- **Ground truth is CLAUDE.md.** House comment style, variable naming, invariants, commands, - Replit sandbox constraints — all there. Follow the comment mechanics exactly (`=>` not em-dash, - `&` not "and" in comments, lowercase casual comments, `userdata` never `user_data`). Several - older files violate this; see item S13. -- **v0.2.0 is already live on npm** (both `sql-switch` and `@creative-softworks/sql-switch`) and has - a GitHub Release. Do NOT re-publish 0.2.0. Any fixes ship as **0.2.1** (patch) or **0.3.0** - (new/changed public surface). Release mechanics in §5. -- **Work on a branch off `origin/main`, open a PR.** `main` has branch protection (required checks - `test (22)`, `test (24)`, `docs`; squash-only). Never push straight to main. -- This audit changed **nothing** in the code — it's all still open work. - -## 1. Quality gates (what "green" means) - -Run before claiming anything works: `pnpm typecheck && pnpm test && pnpm smoke` (add -`pnpm swap-test` when `DATABASE_URL` is set). Notes: - -- `pnpm typecheck` is currently **clean**. Keep it that way. -- `pnpm test` **segfaults locally in this sandbox** — a better-sqlite3 native-ABI mismatch - (exit 139 / SIGSEGV), NOT a logic failure. Only pure-logic test files pass here. **CI on Node - 22/24 (ubuntu) is authoritative** for anything touching the native binding. Don't chase the - local segfault; verify native-path work via CI. -- Postgres-backed tests self-skip without `DATABASE_URL`. That's by design — but see C2 below, - it's also a coverage hole. - -## 2. Backlog — CRITICAL (do these first) - -These two are genuine behavioral bugs, not opinion: - -- **[C1] Un-awaited `delete()` is a silent no-op.** `src/database/index.ts:218-224`. `delete()` - only *defines* `run` and returns a lazy `WriteOperation`; nothing executes until `.then()`/ - `await`/`.force()`. So `db.schema(s).table(t).key(id).delete()` fire-and-forget does nothing. - This is the exact footgun `set()` was deliberately made eager to avoid (read `set()`'s own - docstring). **Fix:** execute `run()` eagerly inside `delete()` like `set()` does, keep the - returned handle awaitable, guard the fire-and-forget path with `.catch`. Add a regression test. - -- **[C2] Engine-swap leaks SIGINT/SIGTERM handlers when a driver import fails.** - `src/database/engine-swap.ts:613-617` (swapUp) and `882-886` (swapDown). `hookswapexit()` - registers process signal listeners *before* the dynamic `import('pg')` / - `import('better-sqlite3')`, and those imports sit *before* the `try` whose `finally` calls - `exit.release()`. A missing driver throws out of the function, listeners never removed → - orphaned handlers accumulate across calls, `MaxListenersExceededWarning`, corrupted host - shutdown. **Fix:** register the exit hook *after* the imports/pool are built, or move - `hookswapexit()` inside the `try`. Test with a swap where the target driver isn't installed. - -## 3. Backlog — SHOULD-FIX (correctness & consistency) - -- **[S3] Fire-and-forget `.force()` and forced `delete()` can crash the process.** - `index.ts:196-204, 218-224`. The queued `set()` path guards with `void scheduled.catch(...)`; - the `.force()` closure and `delete()` `run` do not, so an un-awaited rejection becomes an - unhandled rejection (process crash under default Node). Attach the same defensive `.catch`. - -- **[S4] A corrupt/locked `.db` file aborts the whole upward migration.** - `engine-swap.ts:644`. `new Database(sqlitePath, {readonly:true})` is built *outside* the - per-file `try` (starts at 651), so a bad header / OS lock / permission error takes down the - entire multi-schema run — contradicting the module's own "skip & report via `skippedNames`" - ethos. Wrap the open, route failures into `skippedNames` + `onProgress`. - -- **[S5] `WriteOperation` implements `then` but not `catch`/`finally`.** `index.ts:77-95`. TS - users are shielded by the `PromiseLike` type; a JS caller doing `.catch(...)`/`.finally(...)` - hits `TypeError: catch is not a function`. Add `catch`/`finally` delegating to `then` (or - return a real `Promise`). - -- **[S6] Pure reads have write side effects (auto-create schema/table).** - `sqlite-drizzle.ts:148-176, 263-320`, `postgres-drizzle.ts:363-410` via `ensureTable`. - `get`/`has`/`scan`/`count`/`deleteAll` all run `CREATE TABLE IF NOT EXISTS` (+ `mkdir`/file - create on SQLite, `CREATE SCHEMA IF NOT EXISTS` on PG). A `count()` on a never-written schema - materializes it — and on PG a stray read creates an empty logical schema that `swapDown` later - enumerates as "user data." Make pure reads not auto-create (treat missing relation as empty), - or document as intentional. Prefer the fix. - -- **[S7] A failed `engineSwap()` inside `swapEngine()` leaves the DAL silently unusable.** - `index.ts:761-785`. It `await this.close()` (nulls driver/collector) then `await engineSwap()`; - if that throws, the DAL is closed but `this.config` is still set → every later call throws - `NotConnectedError`, no rollback, no reconnect. Reconnect to the original config on failure (or - document that a failed swap requires an explicit `connect()`). Pair with the S-tier test gap below. - -## 4. Backlog — TESTS, TOOLING & CI (the biggest "professional" gap) - -The pure-logic layer is genuinely well tested (collector/breaker, value integrity, name -validation, pg classifiers, journal/chunking). The gaps are concentrated in cross-engine and -runtime-driver paths, and there are no quality-of-life gates. - -**Critical coverage holes:** -- **[T1] `swapDown` (engine-swap.ts:858-1130) has ZERO Vitest coverage.** No `direction:'down'` - call exists in `test/`. Untested: file-level conflict decline, all-foreign-schema stub cleanup, - `wal_checkpoint(TRUNCATE)` before rename, tmp→`.db` atomic rename, stale `.tmp-wal`/`-shm` - cleanup, keyset pagination, JSONB→TEXT re-serialization. Single biggest risk area — add a - gated down-swap durability test mirroring the up-swap one. -- **[T2] Up-swap row migration + all `PostgresDriver` runtime methods only run in one - secret-gated CI job.** Everything behind `describe.skipIf(!DATABASE_URL)` silently reports green - on fork PRs and across the matrix. A pg-driver or migration regression can merge red-free. - At minimum make "green without secret ≠ tested" loud; ideally run the gated set on the matrix. -- **[T3] No dependency/security audit in CI** — no `pnpm audit`/CodeQL/OSV/Trivy. Dependabot only - bumps versions, it won't fail CI on a CVE. Notable for a provenance-published package. -- **[T4] No code coverage at all** — no `@vitest/coverage-v8`, no `test:coverage`, no thresholds, - no report upload. Land this FIRST: it makes T1/T2 self-evident instead of hand-discovered. - -**Should-fix tooling:** -- **[T5] No linter.** Add `@typescript-eslint` (or Biome) + a CI lint job. Most conspicuous - missing gate given the strict tsconfig. CLAUDE.md's "no lint is wired up" invariant must be - updated when you do this. -- **[T6] No enforced formatter.** `.editorconfig` exists but nothing enforces it — add Prettier - or Biome `format --check` to CI. -- **[T7] No pre-commit hooks** (husky / simple-git-hooks + lint-staged) to run typecheck/lint - before push. -- **[T8] Untested branches, all cheap unit tests:** `resolveSwapOptions` error branches - (engine-swap.ts:243-266, pure fn — no DB needed); `NotConnectedError` (index.ts:696,740 — the - one error class of five never asserted); `swapEngine`/`DalSwapOptions` reconnect logic - (index.ts:739-793); `add(Infinity)`→`InvalidValueError`; breaker `closed→open` re-trip cycle; - direct unit tests for `utils/shutdown.ts` and `utils/value.ts`. - -## 5. Backlog — DOCS & PACKAGING POLISH - -- **[D1] README has zero badges.** Add a badge row: npm version, license (MIT), CI status, node - engine (`>=22`), provenance. (`README.md`.) -- **[D2] Hosted TypeDoc is built + deployed to GitHub Pages but never linked.** Add a - "Documentation" section/link in README; consider pointing `package.json` `homepage` at the - Pages site instead of `#readme`. (`.github/workflows/docs.yml` deploys it.) -- **[D3] `sideEffects` field missing** from both `package.json` and `scoped/package.json`. The - module is import-side-effect-free (signal handlers register at runtime inside `connect()`, not - at import). Add `"sideEffects": false` to both — real tree-shaking win + expected metadata. -- **[D4] No "Error handling" section in README.** The error classes are public and each carries a - discriminant `code` (`DATABASE_UNAVAILABLE`, `INVALID_NAME`, `CONFIGURATION_ERROR`, - `NOT_CONNECTED`, `INVALID_VALUE`). Show the `instanceof`/`.code` pattern + a table. -- **[D5] Exported config types invisible in README** — `DALConfig`, `CollectorConfig`, - `CollectorHooks`, `ScanOptions`, `BreakerState`, `DalSwapOptions`, `EngineSwap*`. Add a typed- - config snippet and link the hosted type reference. (TSDoc in `types.ts` is already excellent.) -- **[D6] Thin keywords (6)** in both package.json files — add `orm`, `postgres`, `better-sqlite3`, - `pg`, `migration`, `database-abstraction`, `write-batching`, `circuit-breaker`, `neon`. -- **[D7] CLAUDE.md handoff drift** — it says `node >=18` and "matrix 18/20/22/24", but real - `engines` is `>=22.0.0` and `ci.yml` runs `[22,24]`. Reconcile. -- **[D8] `master-blueprint.md` is referenced by CLAUDE.md but does not exist.** Either write it or - drop the pointer — a handoff reader hits a dead reference. -- **[D9] Nice-to-have:** `.github/CODEOWNERS`, `funding` field / `FUNDING.yml` (only if a channel - exists), badge/`sideEffects` parity for the scoped alias. - -## 6. Backlog — NICE-TO-HAVE (cleanup a reviewer will notice) - -- **[N1]** Dead deprecated `deleteAfterMigration` config field (`types.ts:189-197`) — remove on - next major. -- **[N2]** Duplicated chunking + upsert-SQL logic: `engine-swap.ts:336-378` vs - `postgres-drizzle.ts:284-333`. Two near-identical generators/builders that can drift. Consolidate. -- **[N3]** Backpressure hook can go silent under sustained churn — `overHighWater` only re-arms - when the buffer fully clears (`collector.ts:463-468, 493`); an oscillating-above-mark buffer - never re-fires `onBackpressure`. -- **[N4]** NUL scrub in error message replaces only the first occurrence (`value.ts:86`, missing - `/g`). Cosmetic. -- **[N5]** No DAL-level breaker-state accessor — `collector` has `isTripped`/`breakerState`, `DAL` - only surfaces `pendingWrites`. Consider a `db.breakerState` passthrough. -- **[N6]** `pull()` overload ambiguous for function-valued array elements (`index.ts:370`) — doc note. -- **[S13/N7] House-style comment violations** per CLAUDE.md in the older files: `errors.ts` - (em-dashes throughout), `schema.ts`, `types.ts`, and the top-of-file doc blocks in - `postgres-drizzle.ts` / `sqlite-drizzle.ts`. Bring them to `=>` / `&` / lowercase style. - -## 7. Suggested sequencing - -1. **Tooling scaffold first** — coverage (T4), linter (T5), formatter (T6), CI audit (T3). This - surfaces the real gaps and gives you gates for everything after. -2. **Critical bugs** — C1, C2 (each with a regression test). -3. **Correctness/consistency** — S3-S7. -4. **Coverage** — T1 (swapDown), T2 (make gated paths visible/matrix'd), T8 (cheap unit branches). -5. **Docs & packaging** — D1-D8. -6. **Cleanup** — N1-N7 / house style. -7. Update CHANGELOG under a new `[Unreleased]`; bump to 0.2.1 (fixes only) or 0.3.0 (if S5/S6/N5 - widen the public surface — an exports/`.d.ts` change is a deliberate act per the invariants). - -## 8. Release mechanics (when it's time) - -Bump **three** spots to the same version: root `package.json` `version`, `scoped/package.json` -`version`, and `scoped/package.json` `dependencies["sql-switch"]`. Push tag `v`. The -`publish.yml` workflow guards tag == all three before publishing both packages with provenance, -then create the GitHub Release. The npm credential is now a working Automation-class token -(a granular "select packages" token previously failed to create the unscoped package with a -misleading E404 — don't regress to one). - -## 9. Hard constraints (do not violate) - -- Public API = only what `src/database/index.ts` re-exports (exports map + `.d.ts`). Widening it - is a deliberate act — update the exports and the invariants list on purpose, and treat it as a - minor version bump pre-1.0. -- Driver packages (`better-sqlite3`, `pg`) are **optional** peer deps loaded via dynamic, - mode-gated `import()`. Never add a static import — a SQLite-only app must not gain a `pg` dep. -- Ships dual CJS + ESM from one source; both entrypoints must keep resolving. -- TypeDoc must stay at **zero warnings** (`treatWarningsAsErrors`); `@internal` symbols are - excluded, so never `{@link}` one from a public comment. README is the TypeDoc readme — a broken - link there fails `pnpm docs` too. -- Don't commit `.claude-data/` or `quick.db/`; don't overwrite `.graphify/graph.json` (stale, and - the installed CLI's `build` writes an incompatible format — see CLAUDE.md). -- Rotate the Neon `DATABASE_URL` if it was ever exposed; redact any token in output. - From e8a609d84ef57353b00686da73ee7b23e54d56ab Mon Sep 17 00:00:00 2001 From: Rev Albrecht von Nullpointer <160512015+revxshafi@users.noreply.github.com> Date: Fri, 21 Aug 2026 11:35:18 +0000 Subject: [PATCH 4/4] Release v1.0.0: mark API stable, ship quality-gate hardening --- CHANGELOG.md | 24 ++++++++++++++++++++++-- package.json | 2 +- scoped/package.json | 4 ++-- 3 files changed, 25 insertions(+), 5 deletions(-) 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/package.json b/package.json index 9a11900..1fcb687 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sql-switch", - "version": "1", + "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", 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" } }