Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 41 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
4 changes: 2 additions & 2 deletions scoped/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down Expand Up @@ -46,6 +46,6 @@
"provenance": true
},
"dependencies": {
"sql-switch": "1.0.0"
"sql-switch": "1.0.1"
}
}
78 changes: 48 additions & 30 deletions src/database/drivers/postgres-drizzle.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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';

Expand Down Expand Up @@ -338,7 +341,6 @@ export function buildBulkUpsert(

export class PostgresDriver implements DatabaseDriver {
private pool: pg.Pool;
private db: ReturnType<typeof drizzle>;
// tracks which schema:table pairs have had CREATE SCHEMA/TABLE IF NOT EXISTS run
private ready = new Set<string>();
// in-flight ensure calls, keyed the same way => concurrent callers share one round trip
Expand All @@ -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
Expand Down Expand Up @@ -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<unknown> {
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;
});
}

/**
Expand All @@ -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<void> {
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)],
);
});
}

Expand Down Expand Up @@ -557,10 +576,9 @@ export class PostgresDriver implements DatabaseDriver {

/** @inheritdoc */
async delete(schema: string, table: string, key: string): Promise<void> {
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]);
});
}

Expand Down
20 changes: 17 additions & 3 deletions src/database/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,11 +217,25 @@ export class KeyProxy {
* the delete can't come back on the next flush.
*/
delete(): WriteOperation<void> {
const run = async (): Promise<void> => {
// 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<void> => {
await this.dropbuffered();
return this.driver.delete(this.ctx.schema, this.ctx.table, this.key);
};
return new WriteOperation<void>(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<void>(
() => started,
() => started,
);
}

/**
Expand Down
78 changes: 78 additions & 0 deletions test/eager-delete.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
59 changes: 59 additions & 0 deletions test/no-driver-hoist.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
}
});
Loading
Loading