From cc04b55a2680afc3521837eb3ce7838456783a42 Mon Sep 17 00:00:00 2001 From: Rev Albrecht von Nullpointer <160512015+revxshafi@users.noreply.github.com> Date: Fri, 21 Aug 2026 20:38:42 +0000 Subject: [PATCH] fix: Tier 1 ship-blockers (packaging, pg value integrity, eager delete) => v1.0.1 NEW-13: tsup splitting:true so import 'sql-switch' no longer hoists both drivers into the entry chunk => a single-engine install stops crashing on import. Drivers stay lazy, mode-gated chunks (optional-peer-dep invariant restored). Guarded by test/no-driver-hoist.test.ts on both ESM & CJS output. NEW-2: pg get() no longer double-parses jsonb (drizzle ran a second JSON.parse on pg's already-parsed value) => a snowflake-looking string like "123456789012345678" stops coming back as a precision-lost number. get/set/ delete now use the same raw $n::jsonb pool path as the scans & bulk upsert. NEW-7: set(null) stores a jsonb null on every path instead of a SQL NULL that the NOT NULL value column rejected on the forced path. C1: an un-awaited delete() now executes eagerly at call time (matching set()), and evicts any buffered set for the key so a later flush can't resurrect it. No public API changes, no breaking changes => patch release. drizzle-orm is still used for the SQLite driver & schema builders. Regression tests added: eager-delete (engine-agnostic), pg-json-roundtrip (pg-only), no-driver-hoist (dist guard). CHANGELOG updated; version bumped 1.0.0 => 1.0.1 in all 3 spots. --- CHANGELOG.md | 42 ++++++++- package.json | 2 +- scoped/package.json | 4 +- src/database/drivers/postgres-drizzle.ts | 78 +++++++++------- src/database/index.ts | 20 ++++- test/eager-delete.test.ts | 78 ++++++++++++++++ test/no-driver-hoist.test.ts | 59 ++++++++++++ test/pg-json-roundtrip.test.ts | 109 +++++++++++++++++++++++ tsup.config.ts | 8 +- 9 files changed, 362 insertions(+), 38 deletions(-) create mode 100644 test/eager-delete.test.ts create mode 100644 test/no-driver-hoist.test.ts create mode 100644 test/pg-json-roundtrip.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index abc268c..2eab31a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,45 @@ While it's pre-1.0, minor versions may carry breaking changes. ## [Unreleased] +## [1.0.1] - 2026-08-21 + +Tier 1 correctness fixes (packaging + Postgres value integrity + write durability). +No public API changes and no breaking changes — safe as a patch release. + +### Fixed + +- **`import 'sql-switch'` no longer drags in both drivers** (NEW-13): with tsup + `splitting: false`, esbuild inlined each lazily-imported driver into the entry + chunk and hoisted its top-level `import pg` / `import better-sqlite3`, so a + SQLite-only (or Postgres-only) install crashed on import with "Cannot find + module …". `splitting: true` keeps the drivers as their own chunks, so a driver + is only loaded once its engine is selected — restoring the optional-peer-dep + invariant. Both the ESM and CJS entrypoints are covered by a build-output guard. +- **Postgres `get()` no longer corrupts JSON-looking strings** (NEW-2): `get()` + read through drizzle's `jsonb` column, which ran a second `JSON.parse` on a + value the pg driver had already parsed. A stored string like a snowflake id + (`"123456789012345678"`) came back as a precision-lost number, and `get()` + disagreed with `entries()`/scans on the same row. `get()` now reads through the + raw pool the way the scans always did. +- **Postgres `set(null)` is consistent across paths** (NEW-7): drizzle mapped a + JS `null` onto a SQL `NULL`, which the `NOT NULL` `value` column rejected, so + `set(null)` behaved differently on the immediate versus the buffered path. `set` + now binds `$n::jsonb` with `JSON.stringify`, storing a jsonb `null` on every + path — `get()` reads it back as `null` and `has()` still reports the row. +- **An un-awaited `delete()` now lands** (C1): `delete()` only ran its work inside + the `WriteOperation` callbacks, so a fire-and-forget `delete()` (no `await`, no + `.force()`) silently did nothing while a fire-and-forget `set()` committed. It + now executes eagerly at call time, matching `set()`; `await`/`.force()` only + decide whether you wait for it. + +### Changed + +- Postgres driver `get`/`set`/`delete` now issue raw parameterized `pool.query` + calls (the same single `$n::jsonb` path as the bulk upsert and the scans) + instead of the drizzle query builder, so every read and write path agrees + byte-for-byte. drizzle-orm is still used for the SQLite driver and schema + builders, so it remains a dependency. + ## [1.0.0] - 2026-08-21 First stable release. The public API (the fluent chain, `createDAL`/`engineSwap`, @@ -65,7 +104,8 @@ 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/v1.0.0...HEAD +[Unreleased]: https://github.com/creative-softworks/sql-switch/compare/v1.0.1...HEAD +[1.0.1]: https://github.com/creative-softworks/sql-switch/compare/v1.0.0...v1.0.1 [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 1fcb687..b70fc4b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sql-switch", - "version": "1.0.0", + "version": "1.0.1", "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 d20c6a3..a083154 100644 --- a/scoped/package.json +++ b/scoped/package.json @@ -1,6 +1,6 @@ { "name": "@creative-softworks/sql-switch", - "version": "1.0.0", + "version": "1.0.1", "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": "1.0.0" + "sql-switch": "1.0.1" } } diff --git a/src/database/drivers/postgres-drizzle.ts b/src/database/drivers/postgres-drizzle.ts index 5a9279b..ff0583e 100644 --- a/src/database/drivers/postgres-drizzle.ts +++ b/src/database/drivers/postgres-drizzle.ts @@ -1,13 +1,19 @@ /** * @packageDocumentation - * PostgreSQL driver — implements {@link DatabaseDriver} using node-postgres (pg) + drizzle-orm. + * PostgreSQL driver — implements {@link DatabaseDriver} using node-postgres (pg). * * All schemas share a single `pg.Pool`. Schema/table isolation is handled at the SQL level * via Postgres logical schemas (e.g. `antinuke.settings`). * * @remarks - * **PgBouncer compatibility**: drizzle-orm/node-postgres uses simple/unnamed queries by - * default, which is exactly what PgBouncer transaction mode requires. Named prepared + * **One SQL path**: every op is a raw parameterized `pool.query` (or a transaction of them for a + * flush group). `get`/`set`/`delete` used to go through drizzle's query builder & its `jsonb` column + * mapper, which double-parsed reads (NEW-2) and turned `set(null)` into a `NOT NULL` violation + * (NEW-7) => they now bind values as `$n::jsonb` the same way the scans & {@link buildBulkUpsert} + * always did, so a value round trips identically no matter which method wrote or read it. + * + * **PgBouncer compatibility**: raw parameterized queries go over pg's extended protocol with + * *unnamed* statements, which is exactly what PgBouncer transaction mode requires. Named prepared * statements corrupt PgBouncer pools — never call `pool.prepare()` here. * * Pool `max` defaults to 5 — keep it low when running behind PgBouncer in transaction @@ -23,9 +29,6 @@ */ import pg from 'pg'; -import { drizzle } from 'drizzle-orm/node-postgres'; -import { eq } from 'drizzle-orm'; -import { buildPgTable } from '../schema.js'; import { ConfigurationError } from '../errors.js'; import type { DatabaseDriver, PostgresConfig, ScanOptions, StoredEntry } from '../types.js'; @@ -338,7 +341,6 @@ export function buildBulkUpsert( export class PostgresDriver implements DatabaseDriver { private pool: pg.Pool; - private db: ReturnType; // tracks which schema:table pairs have had CREATE SCHEMA/TABLE IF NOT EXISTS run private ready = new Set(); // in-flight ensure calls, keyed the same way => concurrent callers share one round trip @@ -359,8 +361,6 @@ export class PostgresDriver implements DatabaseDriver { this.pool.on('error', (err) => { console.error('[sql-switch] idle postgres client error:', err); }); - - this.db = drizzle(this.pool); } // CREATE SCHEMA + TABLE on first touch, cached so subsequent calls are free @@ -450,18 +450,28 @@ export class PostgresDriver implements DatabaseDriver { } } - /** @inheritdoc */ + /** + * @remarks + * Raw `pool.query` on purpose => the pg driver already hands JSONB back as a parsed JS value, so we + * return `row.value` straight. going through drizzle's `jsonb` column ran a SECOND `JSON.parse` on + * top of that, which turned a stored string like a snowflake id `"123456789012345678"` into a + * precision lost number & made `get()` disagree with `entries()`/the scans (already raw pool) for + * the very same row (NEW-2). same single path as {@link buildBulkUpsert} now => they agree. key is + * bound, never spliced in. + * + * @inheritdoc + */ async get(schema: string, table: string, key: string): Promise { - const tbl = buildPgTable(schema, table); - - const rows = await this.run(schema, table, () => - this.db.select().from(tbl).where(eq(tbl.id, key)).limit(1), - ); - - const row = rows[0]; - if (!row) return null; - // JSONB is already a parsed JS object from the pg driver — no JSON.parse needed - return row.value; + return this.run(schema, table, async () => { + const res = await this.pool.query<{ value: unknown }>( + `SELECT value FROM "${schema}"."${table}" WHERE id = $1 LIMIT 1`, + [key], + ); + const row = res.rows[0]; + // a stored JSONB `null` comes back as JS null too => same as "no row", exists() is the + // disambiguator (see its remarks), matching how the scans read the column + return row ? row.value : null; + }); } /** @@ -484,15 +494,24 @@ export class PostgresDriver implements DatabaseDriver { }); } - /** @inheritdoc */ + /** + * @remarks + * Raw parameterized upsert, the single row form of {@link buildBulkUpsert} => `$2::jsonb` fed + * `JSON.stringify(value)` stores the value as jsonb exactly the way the batch path & the scans read + * it back. drizzle's `jsonb` column mapped a JS `null` onto a SQL `NULL`, which the `value` column + * rejects (`NOT NULL`) & made `set(null)` behave differently depending on which path wrote it + * (NEW-7). one path now => `set(null)` stores a jsonb `null` on every engine & `has()` still sees + * the row. key & value are bound, never spliced in. + * + * @inheritdoc + */ async set(schema: string, table: string, key: string, value: unknown): Promise { - const tbl = buildPgTable(schema, table); - await this.run(schema, table, async () => { - await this.db - .insert(tbl) - .values({ id: key, value }) - .onConflictDoUpdate({ target: tbl.id, set: { value } }); + await this.pool.query( + `INSERT INTO "${schema}"."${table}" (id, value) VALUES ($1, $2::jsonb)` + + ` ON CONFLICT (id) DO UPDATE SET value = EXCLUDED.value`, + [key, JSON.stringify(value)], + ); }); } @@ -557,10 +576,9 @@ export class PostgresDriver implements DatabaseDriver { /** @inheritdoc */ async delete(schema: string, table: string, key: string): Promise { - const tbl = buildPgTable(schema, table); - + // raw pool, same single path as get/set => key is bound, never spliced in await this.run(schema, table, async () => { - await this.db.delete(tbl).where(eq(tbl.id, key)); + await this.pool.query(`DELETE FROM "${schema}"."${table}" WHERE id = $1`, [key]); }); } diff --git a/src/database/index.ts b/src/database/index.ts index 8e31df7..a946657 100644 --- a/src/database/index.ts +++ b/src/database/index.ts @@ -217,11 +217,25 @@ export class KeyProxy { * the delete can't come back on the next flush. */ delete(): WriteOperation { - const run = async (): Promise => { + // start now, not on await => a bare `delete()` has to land the same way a bare `set()` does. it + // used to only run inside the WriteOperation callbacks, so a delete that was never awaited (or + // forced) silently vanished while a queued set() on the same key survived (C1). dropbuffered() + // first => evict() runs synchronously right here, so a set() queued before this is gone before + // the next flush can pick it up + const started = (async (): Promise => { await this.dropbuffered(); return this.driver.delete(this.ctx.schema, this.ctx.table, this.key); - }; - return new WriteOperation(run, run); + })(); + // fire and forget must not crash the process as an unhandled rejection => an await/.force() still + // re-observes the real error off `started`, the same guard set() uses + void started.catch(() => undefined); + + // deletes never route through the collector => queued & immediate are the one eager write. force() + // is accepted for API symmetry (see remarks), there's just nothing extra to bypass + return new WriteOperation( + () => started, + () => started, + ); } /** diff --git a/test/eager-delete.test.ts b/test/eager-delete.test.ts new file mode 100644 index 0000000..7c246f3 --- /dev/null +++ b/test/eager-delete.test.ts @@ -0,0 +1,78 @@ +/** + * @packageDocumentation + * C1 => a bare `delete()` (no await, no `.force()`) must still land. + * + * delete() used to only run its work inside the WriteOperation callbacks, so a fire and forget + * `db...delete()` never executed => meanwhile a fire and forget `set()` (already eager) did, so the + * two silently disagreed on the same key. these lock the eager behaviour in: the driver delete, and + * the buffer eviction that stops a queued set() resurrecting the row, both fire the moment delete() + * is called => await/`.force()` only decide whether you wait for it. + * + * engine agnostic on purpose => drives {@link KeyProxy} straight over the in memory fakedriver, so + * it runs everywhere, not only where a real sqlite/postgres is reachable. + */ + +import { describe, expect, it } from 'vitest'; +import { KeyProxy, TableContext } from '../src/database/index.js'; +import type { WriteCollector } from '../src/database/utils/collector.js'; +import type { DatabaseDriver } from '../src/database/types.js'; +import { NOFLUSH, testcollector } from './helpers/collector.js'; +import { fakedriver, rowkey } from './helpers/fakedriver.js'; +import { waitfor } from './helpers/wait.js'; + +const SCHEMA = 'antinuke'; +const TABLE = 'settings'; +const KEY = 'guild-1'; +const ROW = rowkey(SCHEMA, TABLE, KEY); + +function keyproxy(driver: DatabaseDriver, collector: WriteCollector | null): KeyProxy { + return new KeyProxy(new TableContext(SCHEMA, TABLE), KEY, driver, collector); +} + +describe('eager delete (C1)', () => { + it('an un-awaited delete reaches the driver with the collector off', async () => { + const driver = fakedriver(); + driver.rows.set(ROW, { strict: true }); + + // no await, no .force() + keyproxy(driver, null).delete(); + + await waitfor('the un-awaited delete reaches the driver', () => driver.calls.delete > 0); + expect(driver.rows.has(ROW)).toBe(false); + }); + + it('an un-awaited delete reaches the driver with the collector on', async () => { + const driver = fakedriver(); + const collector = testcollector(driver); + driver.rows.set(ROW, { strict: true }); + + keyproxy(driver, collector).delete(); // fire and forget + + await waitfor('the un-awaited delete reaches the driver', () => driver.calls.delete > 0); + expect(driver.rows.has(ROW)).toBe(false); + + await collector.stop(); + }); + + it('an un-awaited delete evicts a buffered set for the key, so a later flush cannot resurrect it', async () => { + const driver = fakedriver(); + const collector = testcollector(driver, NOFLUSH); // nothing flushes on its own + const key = keyproxy(driver, collector); + + // eager queue via the facade => sits in the buffer, not on the driver yet + key.set({ strict: true }); + expect(collector.pendingCount).toBe(1); + + // fire and forget delete => evict() runs synchronously, so the buffered set is already gone + key.delete(); + expect(collector.pendingCount).toBe(0); + + await waitfor('the un-awaited delete reaches the driver', () => driver.calls.delete > 0); + + // flush whatever is left => the evicted set must not come back around + await collector.flush(); + expect(driver.rows.has(ROW)).toBe(false); + + await collector.stop(); + }); +}); diff --git a/test/no-driver-hoist.test.ts b/test/no-driver-hoist.test.ts new file mode 100644 index 0000000..24fe954 --- /dev/null +++ b/test/no-driver-hoist.test.ts @@ -0,0 +1,59 @@ +/** + * @packageDocumentation + * NEW-13 => `import 'sql-switch'` must not drag BOTH drivers in with it. + * + * the two engines are optional peer deps loaded with a lazy `await import()` inside connect(). but + * each driver module has a top-level `import pg` / `import better-sqlite3`, so with tsup's + * `splitting: false` esbuild inlined the whole driver into the entry chunk & HOISTED those imports to + * module scope => a SQLite-only app crashed on `import 'sql-switch'` with "Cannot find module 'pg'". + * `splitting: true` keeps each driver as its own chunk, so the driver import only fires when that + * engine is picked. this guards the built artifact so a flip back to `splitting: false` goes red. + * + * reads dist => only meaningful after a build. CI's `test` job builds before `pnpm test`, so it runs + * there; a fresh checkout with no dist self-skips rather than failing on a missing file. + */ + +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const dist = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'dist'); +const DRIVERS = ['pg', 'better-sqlite3']; + +/** both entrypoints have to keep resolving without either driver present => check both shapes */ +const entries = ['index.js', 'index.cjs']; +const built = entries.every((f) => existsSync(resolve(dist, f))); + +/** + * lines that pull a driver in EAGERLY, at import time. + * + * esbuild emits module-scope imports/requires at column 0 (`import x from "pg"`, `var p = + * require("pg")`) & everything inside a function body indented. our lazy driver loads live inside + * async fns (`await import("pg")`, `Promise.resolve().then(() => require("pg"))`) => always indented. + * so a driver named on a non-indented import/require line is the hoist we're guarding against, in + * either module format, without having to hard code esbuild's exact wrapper. + */ +function eagerDriverLoads(src: string): string[] { + return src.split('\n').filter((line) => { + if (/^\s/.test(line)) return false; // indented => inside a function => lazy, fine + if (!/\b(import|require)\b/.test(line)) return false; // a bare string/label mention isn't a load + return DRIVERS.some((d) => line.includes(`"${d}"`) || line.includes(`'${d}'`)); + }); +} + +describe.skipIf(!built)('driver imports stay lazy in the built bundle (NEW-13)', () => { + for (const entry of entries) { + it(`${entry} pulls neither driver in at module scope`, () => { + const src = readFileSync(resolve(dist, entry), 'utf8'); + expect(eagerDriverLoads(src)).toEqual([]); + }); + + it(`${entry} still references the drivers lazily (guard isn't vacuous)`, () => { + // if a bundler change dropped the driver loads entirely the negative check above would pass + // for the wrong reason => assert the lazy loads are actually still in there + const src = readFileSync(resolve(dist, entry), 'utf8'); + for (const driver of DRIVERS) expect(src).toContain(driver); + }); + } +}); diff --git a/test/pg-json-roundtrip.test.ts b/test/pg-json-roundtrip.test.ts new file mode 100644 index 0000000..e8c5a4d --- /dev/null +++ b/test/pg-json-roundtrip.test.ts @@ -0,0 +1,109 @@ +/** + * @packageDocumentation + * NEW-2 + NEW-7 => the Postgres CRUD path (get/set/delete) has to agree with the scans on the exact + * bytes of a value. + * + * NEW-2: get() used to read through drizzle's `jsonb` column, which ran a SECOND JSON.parse on top of + * pg's own parse. a stored string that looks like a number (a snowflake id) came back as a precision + * lost number, and get() disagreed with entries()/the scans (raw pool, correct) for the same row. + * NEW-7: set(null) through drizzle mapped a JS null onto a SQL NULL, which the NOT NULL `value` column + * rejects => set(null) behaved differently depending on the path. get/set now bind `$n::jsonb` the + * same single way the scans & the bulk upsert always did, so a value round trips identically. + * + * only runs with DATABASE_URL set (own throwaway `swaptest-json-*` schema per test, dropped after). + */ + +import pg from 'pg'; +import { describe, expect, it, onTestFinished } from 'vitest'; +import { PostgresDriver } from '../src/database/drivers/postgres-drizzle.js'; + +const url = process.env.DATABASE_URL; + +/** driver + a raw pool for cleanup, both torn down (& the schema dropped) when the test finishes */ +function setup(schema: string): { driver: PostgresDriver; pool: pg.Pool } { + const driver = new PostgresDriver({ mode: 'cloud', connectionString: url! }); + const pool = new pg.Pool({ connectionString: url! }); + onTestFinished(async () => { + await pool.query(`DROP SCHEMA IF EXISTS "${schema}" CASCADE`).catch(() => undefined); + await pool.end(); + await driver.close(); + }); + return { driver, pool }; +} + +/** drain a driver scan into a Map => the "always correct" raw pool read to compare against */ +async function scanned( + driver: PostgresDriver, + schema: string, + table: string, +): Promise> { + const out = new Map(); + for await (const row of driver.scan(schema, table)) out.set(row.id, row.value); + return out; +} + +describe.skipIf(!url)('postgres value round trip against a real database', () => { + it('a JSON-looking string stays a string & get() agrees with the scan (NEW-2)', async () => { + const schema = 'swaptest-json-string'; + const { driver } = setup(schema); + + // the classic offender => a discord snowflake. a second JSON.parse turns "…678" into a number + // that's lost its last digits, so get() would neither match the input nor what the scan returns + const snowflake = '123456789012345678'; + await driver.set(schema, 'vals', 'flake', snowflake); + + const viaGet = await driver.get(schema, 'vals', 'flake'); + expect(viaGet).toBe(snowflake); + expect(typeof viaGet).toBe('string'); + + // entries()/scan is raw pool & was always right => get() has to return the identical value + const rows = await scanned(driver, schema, 'vals'); + expect(rows.get('flake')).toBe(snowflake); + expect(rows.get('flake')).toStrictEqual(viaGet); + }); + + it('get() agrees with the batch-written scan for nested JSON-looking payloads (NEW-2)', async () => { + const schema = 'swaptest-json-batch'; + const { driver } = setup(schema); + + // batchSet is the flush path (raw multi row upsert) => a leading-zero string & nested numeric + // strings must survive both the write and every read shape + await driver.batchSet( + schema, + 'vals', + new Map([ + ['zero', '007'], + ['nested', { id: '123456789012345678', ids: ['456789012345678901'] }], + ]), + ); + + expect(await driver.get(schema, 'vals', 'zero')).toBe('007'); + expect(await driver.get(schema, 'vals', 'nested')).toStrictEqual({ + id: '123456789012345678', + ids: ['456789012345678901'], + }); + + const rows = await scanned(driver, schema, 'vals'); + expect(rows.get('zero')).toStrictEqual(await driver.get(schema, 'vals', 'zero')); + expect(rows.get('nested')).toStrictEqual(await driver.get(schema, 'vals', 'nested')); + }); + + it('set(null) & batchSet(null) both store a jsonb null, and exists() still sees the row (NEW-7)', async () => { + const schema = 'swaptest-json-null'; + const { driver } = setup(schema); + + // set() is the .force() path, batchSet() is the buffered flush path => a NULL vs jsonb-null + // mismatch used to make one of them blow up on the NOT NULL column while the other stored a row + await driver.set(schema, 'vals', 'forced', null); + await driver.batchSet(schema, 'vals', new Map([['buffered', null]])); + + // get() can't tell a stored null from a missing key => both read as null + expect(await driver.get(schema, 'vals', 'forced')).toBeNull(); + expect(await driver.get(schema, 'vals', 'buffered')).toBeNull(); + // …but the rows are really there, so exists()/has() must say so on both paths + expect(await driver.exists(schema, 'vals', 'forced')).toBe(true); + expect(await driver.exists(schema, 'vals', 'buffered')).toBe(true); + // and a key that was never written stays absent => null value ≠ no row + expect(await driver.exists(schema, 'vals', 'never')).toBe(false); + }); +}); diff --git a/tsup.config.ts b/tsup.config.ts index 587fb68..7fda096 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -7,7 +7,13 @@ export default defineConfig({ // tsup's rollup-based dts bundler merges everything into one file & drops declaration maps sourcemap: true, clean: true, - splitting: false, + // MUST stay true => the drivers are pulled in with a lazy `await import()` inside connect(), but each + // driver module has a top-level `import pg` / `import better-sqlite3`. with splitting off esbuild + // inlines the whole driver into the entry chunk & hoists those top-level imports to the top of + // index.js, so a SQLite-only app suddenly needs `pg` installed just to import us (NEW-13). splitting + // keeps the dynamically-imported drivers as their own chunks => the driver import only fires when + // that engine is actually selected. verify BOTH dist/index.js & dist/index.cjs after any build change. + splitting: true, target: 'node18', outDir: 'dist', // mark runtime deps as external so consumers install their own copies