diff --git a/CHANGELOG.md b/CHANGELOG.md index 2eab31a..a7e5f4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,49 @@ While it's pre-1.0, minor versions may carry breaking changes. ## [Unreleased] +## [2.0.0] - 2026-08-26 + +The one intentional breaking bump. The config object changes shape and the factory gets a new +name — both batched here, while the install base is small, so consumers migrate once. + +### Changed + +- **Config declares both engines and selects the active one** (breaking): `DALConfig.db` is no + longer a `SqliteConfig | PostgresConfig` union with a `mode` discriminant on each block. It is + now a plain selector — `db: 'local' | 'cloud'` — with the two engine configs declared alongside + it under `local` and `cloud`: + + ```ts + // before (1.x) + await db.connect({ db: { mode: 'local', dataDir: './data' } }); + // after (2.0) + await db.connect({ db: 'local', local: { dataDir: './data' } }); + ``` + + Every declared block is validated at `connect()`, so a bad cloud `connectionString` is caught at + boot while you're still on local — not the first time you flip to cloud in production. The `mode` + field is gone from `SqliteConfig`/`PostgresConfig` (the selector replaces it). A `db: 'cloud'` + with no `cloud` block throws `ConfigurationError`. + +### Added + +- **`sqlSwitch()`** is the factory's new name (matches the package, fits the `express()`/`fastify()` + package-as-factory convention). `createDAL` stays as a `@deprecated` alias — one line, no second + code path — and will be removed in 3.0. The default export is `sqlSwitch`, so + `import sqlSwitch from 'sql-switch'` needs no change. +- **`db.reconnect(target?)`**: one primitive for restart / recover-a-wedged-connection / repoint. + No argument re-opens the current engine; `'local'`/`'cloud'` switches to the other declared engine + **without moving data** (that's still `swapEngine()`). Flushes pending writes first, and is + fail-safe — the target engine is built and validated before the current one is torn down, so a bad + target leaves you on the engine you had. + +### Migration + +- `db: { mode: 'local', ...rest }` → `db: 'local', local: { ...rest }`. +- `db: { mode: 'cloud', connectionString, pool }` → `db: 'cloud', cloud: { connectionString, pool }`. +- Optionally declare both blocks and drive `db` from an env var (`db: process.env.DB_MODE`). +- `createDAL()` keeps working; rename to `sqlSwitch()` at your leisure before 3.0. + ## [1.0.1] - 2026-08-21 Tier 1 correctness fixes (packaging + Postgres value integrity + write durability). @@ -104,7 +147,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.1...HEAD +[Unreleased]: https://github.com/creative-softworks/sql-switch/compare/v2.0.0...HEAD +[2.0.0]: https://github.com/creative-softworks/sql-switch/compare/v1.0.1...v2.0.0 [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 diff --git a/README.md b/README.md index 7251fa9..efe42dd 100644 --- a/README.md +++ b/README.md @@ -23,12 +23,13 @@ npm install sql-switch better-sqlite3 ``` ```ts -import { createDAL } from 'sql-switch'; +import { sqlSwitch } from 'sql-switch'; -const db = createDAL(); +const db = sqlSwitch(); await db.connect({ - db: { mode: 'local', dataDir: './data/databases', wal: true }, + db: 'local', + local: { dataDir: './data/databases', wal: true }, collector: { enabled: true, time: 3000 }, }); @@ -46,12 +47,14 @@ That is the whole surface: `schema → table → key → operation`. The same ch ## Same code, production engine -Nothing above changes when you go to PostgreSQL — only the `connect()` config does: +Declare **both** engines up front and pick the active one with `db` — an env var is the usual +selector. Only the config changes; not one line of your data code does: ```ts await db.connect({ - db: { - mode: 'cloud', + db: process.env.DB_MODE ?? 'local', // 'local' | 'cloud' + local: { dataDir: './data/databases', wal: true }, + cloud: { connectionString: process.env.DATABASE_URL, pool: { max: 5, statementTimeout: 30_000 }, }, @@ -59,6 +62,10 @@ await db.connect({ }); ``` +Both declared blocks are validated at `connect()`, so a bad cloud `connectionString` is caught at +boot even while you're still running local. Flip engines at runtime — no data move, just repoint the +client — with `db.reconnect('cloud')`; use `db.swapEngine()` when the rows need to travel too. + In local mode each schema is its own `.db` file (`./data/databases/antinuke.db`, WAL on by default); in cloud mode each schema is a Postgres logical schema (`antinuke.settings`). Your code never sees the difference. @@ -133,7 +140,8 @@ Defaults are chosen so nothing is silently lost. All of it is configurable. ```ts await db.connect({ - db: { mode: 'cloud', connectionString: process.env.DATABASE_URL }, + db: 'cloud', + cloud: { connectionString: process.env.DATABASE_URL }, collector: { time: 3000, // flush interval autoRecover: true, // breaker heals itself after an outage @@ -247,6 +255,7 @@ if (result.skippedNames.length) console.warn('left alone:', result.skippedNames) | `table.startsWith(prefix)` | Sugar for `.entries({ prefix })`. Prefix is bound, never a pattern. | | `table.count(opts?)` | Row count, done in the DB (rows never materialized). | | `table.deleteAll(opts?)` | Delete every key (or just those under a prefix). | +| `db.reconnect(target?)` | Re-open the current engine (restart / recover a wedged connection), or repoint to the other declared engine (`'local'`/`'cloud'`) without moving data. Flushes first; fail-safe. | | `db.swapEngine(options)` | Migrate to the other engine & reconnect on it. | | `db.pendingWrites` | Number of writes currently buffered in the collector. | | `db.close()` | Flush pending writes and close all connections. | diff --git a/assets/readme/architecture.svg b/assets/readme/architecture.svg index 08a188a..49ff594 100644 --- a/assets/readme/architecture.svg +++ b/assets/readme/architecture.svg @@ -31,7 +31,7 @@ WRITE COLLECTOR buffers writes in RAM bulk-flush every 3000 ms - collapses repeats · .force() = now + collapse repeats · .force() = now circuit breaker trips at 5000 @@ -70,6 +70,6 @@ PostgreSQL schemas - chunked streaming · per-table transaction · journalled & resumable + chunked streaming · per-table transaction · journalled & resumable diff --git a/assets/readme/hero.svg b/assets/readme/hero.svg index f10b1cd..2eaea73 100644 --- a/assets/readme/hero.svg +++ b/assets/readme/hero.svg @@ -27,7 +27,7 @@ PostgreSQL in prod, one-command swap. - npm 1.0.1 + ESM + CJS · MIT · @@ -52,14 +52,14 @@ SQLite local .db file - mode: 'local' + db: 'local' PostgreSQL cloud schema - mode: 'cloud' + db: 'cloud' diff --git a/package.json b/package.json index b70fc4b..e493a45 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sql-switch", - "version": "1.0.1", + "version": "2.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 a083154..9381bc6 100644 --- a/scoped/package.json +++ b/scoped/package.json @@ -1,6 +1,6 @@ { "name": "@creative-softworks/sql-switch", - "version": "1.0.1", + "version": "2.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": "1.0.1" + "sql-switch": "2.0.0" } } diff --git a/scripts/smoke-test.ts b/scripts/smoke-test.ts index 290930c..4d79872 100644 --- a/scripts/smoke-test.ts +++ b/scripts/smoke-test.ts @@ -38,7 +38,8 @@ async function main(): Promise { console.log('\n[1] connect + immediate write via .force()'); const db = createDAL(); await db.connect({ - db: { mode: 'local', dataDir: TEST_DIR, wal: true }, + db: 'local', + local: { dataDir: TEST_DIR, wal: true }, collector: { enabled: true, time: 300 }, }); @@ -120,7 +121,7 @@ async function main(): Promise { await db.close(); const db2 = createDAL(); - await db2.connect({ db: { mode: 'local', dataDir: TEST_DIR }, collector: { enabled: false } }); + await db2.connect({ db: 'local', local: { dataDir: TEST_DIR }, collector: { enabled: false } }); const survived = await db2 .schema('economy') .table('balances') @@ -142,7 +143,7 @@ async function main(): Promise { console.log('\n[11] unserializable values are rejected at the call site'); const db3 = createDAL(); - await db3.connect({ db: { mode: 'local', dataDir: TEST_DIR }, collector: { enabled: false } }); + await db3.connect({ db: 'local', local: { dataDir: TEST_DIR }, collector: { enabled: false } }); // circular => JSON.stringify would throw later, inside a flush where nobody sees it const circular: Record = { name: 'loop' }; diff --git a/scripts/swap-test.ts b/scripts/swap-test.ts index 347c8ae..299fc8d 100644 --- a/scripts/swap-test.ts +++ b/scripts/swap-test.ts @@ -77,7 +77,8 @@ async function main(): Promise { console.log('\n[1] seed local SQLite data'); const local = createDAL(); await local.connect({ - db: { mode: 'local', dataDir: TEST_DIR, wal: true }, + db: 'local', + local: { dataDir: TEST_DIR, wal: true }, collector: { enabled: false }, }); @@ -121,7 +122,8 @@ async function main(): Promise { console.log('\n[3] read the migrated data through the cloud driver'); const cloud = createDAL(); await cloud.connect({ - db: { mode: 'cloud', connectionString: url }, + db: 'cloud', + cloud: { connectionString: url }, collector: { enabled: false }, }); const snowflake = await cloud @@ -149,7 +151,7 @@ async function main(): Promise { check('no leftover temp file', !fs.existsSync(`${TEST_DIR}/${SCHEMA}.db.tmp`)); const back = createDAL(); - await back.connect({ db: { mode: 'local', dataDir: TEST_DIR }, collector: { enabled: false } }); + await back.connect({ db: 'local', local: { dataDir: TEST_DIR }, collector: { enabled: false } }); const roundtrip = await back .schema(SCHEMA) .table(TABLE) @@ -197,7 +199,8 @@ async function main(): Promise { console.log('\n[6] db.swapEngine() migrates & reconnects in place'); const hot = createDAL(); await hot.connect({ - db: { mode: 'local', dataDir: TEST_DIR, wal: true }, + db: 'local', + local: { dataDir: TEST_DIR, wal: true }, collector: { enabled: true, time: 300 }, }); // queued (not forced) => proves swapEngine flushes before touching the files @@ -282,7 +285,8 @@ async function main(): Promise { // 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 }, + db: 'local', + local: { dataDir: ES4_DIR }, collector: { enabled: false }, }); const mixValue = await es4back diff --git a/src/database/index.ts b/src/database/index.ts index a946657..ed96ee6 100644 --- a/src/database/index.ts +++ b/src/database/index.ts @@ -8,11 +8,12 @@ * * @example Connecting * ```ts - * import { createDAL } from 'sql-switch'; + * import { sqlSwitch } from 'sql-switch'; * - * const db = createDAL(); + * const db = sqlSwitch(); * await db.connect({ - * db: { mode: 'local', dataDir: './data/databases', wal: true }, + * db: 'local', + * local: { dataDir: './data/databases', wal: true }, * collector: { enabled: true, time: 3000 }, * }); * ``` @@ -36,7 +37,13 @@ import { TableContext } from './schema-manager.js'; import { ConfigurationError, NotConnectedError } from './errors.js'; import { engineSwap } from './engine-swap.js'; import type { EngineSwapOptions, EngineSwapResult } from './engine-swap.js'; -import type { DALConfig, DatabaseDriver, ScanOptions, SqliteConfig } from './types.js'; +import type { + DALConfig, + DatabaseDriver, + PostgresConfig, + ScanOptions, + SqliteConfig, +} from './types.js'; export * from './types.js'; export * from './errors.js'; @@ -588,7 +595,7 @@ function isMissingPackage(err: unknown, pkg: string): boolean { } /** - * The DAL instance. Create one with {@link createDAL}, then `connect()` before use. + * The DAL instance. Create one with {@link sqlSwitch}, then `connect()` before use. * A single instance is meant to be shared across your whole app. */ export class DAL { @@ -618,16 +625,45 @@ export class DAL { * the old one and swap it in. */ async connect(config: DALConfig): Promise { - if (!config?.db?.mode) { - throw new ConfigurationError('config.db.mode is required — expected "local" or "cloud"'); + await this.applyConfig(config, false); + } + + /** + * Reconnect the DAL, optionally switching to the other declared engine. + * + * With no argument it re-opens the **current** engine — the escape hatch for a wedged connection + * or a deliberate "restart the DB without restarting the app". Pass `'local'`/`'cloud'` to switch + * the active engine to the other block you declared in `connect()`; the data must already live + * there — this reconnects only, it does **not** migrate (that's `swapEngine()`). + * + * Fail safe: the target engine is built & validated before the current one is torn down, so a bad + * target (or a `'cloud'` switch with no `cloud` block) throws and leaves you on the engine you had. + * Pending writes are flushed on the way out, same as {@link DAL.close}. + * + * @param target - Engine to switch to. Omit to reconnect the current one. + * @throws {@link NotConnectedError} if called before `connect()`. + * @throws {@link ConfigurationError} if the target engine's config is missing or invalid. + */ + async reconnect(target?: 'local' | 'cloud'): Promise { + if (!this.config) throw new NotConnectedError(); + const next: DALConfig = target ? { ...this.config, db: target } : this.config; + await this.applyConfig(next, true); + } + + // core connect used by both connect() (fresh) & reconnect() (silent) => builds the new engine + // fully before tearing the old one down, so a failure here can't leave you with no connection + private async applyConfig(config: DALConfig, reconnecting: boolean): Promise { + const active = config?.db; + if (active !== 'local' && active !== 'cloud') { + throw new ConfigurationError('config.db is required — expected "local" or "cloud"'); } - if (config.db.mode !== 'local' && config.db.mode !== 'cloud') { - throw new ConfigurationError( - `unknown db mode "${(config.db as { mode: string }).mode}" — expected "local" or "cloud"`, - ); + // validate EVERY declared block, not just the active one => a bad cloud connectionString is + // caught at connect() while you're still on local, not the first time you flip to cloud in prod + if (config.cloud && !config.cloud.connectionString) { + throw new ConfigurationError('config.cloud.connectionString is required'); } - if (config.db.mode === 'cloud' && !config.db.connectionString) { - throw new ConfigurationError('config.db.connectionString is required in cloud mode'); + if (active === 'cloud' && !config.cloud) { + throw new ConfigurationError('config.db is "cloud" but no config.cloud block was provided'); } // resolved up front so a bad collector setting throws before anything is torn down @@ -637,23 +673,26 @@ export class DAL { // you're not using, & its native module, never has to be installed) then construct it. both // the import & the constructor run before any teardown, so a failure here can't leave you with // no connection at all — the old one is still live until the swap below - const dbConfig = config.db; const nextDriver: DatabaseDriver = - dbConfig.mode === 'local' + active === 'local' ? await this.buildDriver('better-sqlite3', 'local', async () => { const { SqliteDriver } = await import('./drivers/sqlite-drizzle.js'); - return new SqliteDriver(dbConfig); + return new SqliteDriver(config.local ?? {}); }) : await this.buildDriver('pg', 'cloud', async () => { const { PostgresDriver } = await import('./drivers/postgres-drizzle.js'); - return new PostgresDriver(dbConfig); + // active === 'cloud' => config.cloud validated present above + return new PostgresDriver(config.cloud as PostgresConfig); }); if (this.driver) { - console.warn( - '[sql-switch] already connected — flushing & closing the previous engine before' + - ' reconnecting. call close() first to do this deliberately', - ); + // a deliberate reconnect() shouldn't nag => only warn on an accidental connect()-over-connect + if (!reconnecting) { + console.warn( + '[sql-switch] already connected — flushing & closing the previous engine before' + + ' reconnecting. call close() or reconnect() to do this deliberately', + ); + } await this.close(); } @@ -758,11 +797,8 @@ export class DAL { const reconnect = options.reconnect ?? true; // inherit from the live config so a bare { direction } call just works - const dataDir = - options.dataDir ?? (current.db.mode === 'local' ? current.db.dataDir : undefined); - const connectionString = - options.connectionString ?? - (current.db.mode === 'cloud' ? current.db.connectionString : undefined); + const dataDir = options.dataDir ?? current.local?.dataDir; + const connectionString = options.connectionString ?? current.cloud?.connectionString; // built field by field => exactOptionalPropertyTypes rejects explicit undefined const swapOptions: EngineSwapOptions = { direction: options.direction }; @@ -787,23 +823,30 @@ export class DAL { 'cannot reconnect in cloud mode => pass connectionString or set DATABASE_URL', ); } - const next: DALConfig = { db: { mode: 'cloud', connectionString: url } }; - if (current.collector !== undefined) next.collector = current.collector; + // flip the active engine to cloud, keep both declared blocks so a later reconnect('local') + // still works, and fill in the connection string the migration resolved + const next: DALConfig = { + ...current, + db: 'cloud', + cloud: { ...current.cloud, connectionString: url }, + }; await this.connect(next); return result; } - const next: DALConfig = { db: this.localConfigFrom(current, dataDir) }; - if (current.collector !== undefined) next.collector = current.collector; + const next: DALConfig = { + ...current, + db: 'local', + local: this.localConfigFrom(current, dataDir), + }; await this.connect(next); return result; } - // rebuild the local config for a downward swap, keeping the wal choice if there was one + // rebuild the local block for a downward swap, keeping wal/busyTimeout if the prior local had them private localConfigFrom(current: DALConfig, dataDir: string | undefined): SqliteConfig { - const db: SqliteConfig = { mode: 'local' }; + const db: SqliteConfig = { ...current.local }; if (dataDir !== undefined) db.dataDir = dataDir; - if (current.db.mode === 'local' && current.db.wal !== undefined) db.wal = current.db.wal; return db; } @@ -818,12 +861,18 @@ export class DAL { * * @example * ```ts - * const db = createDAL(); - * await db.connect({ db: { mode: 'local' } }); + * const db = sqlSwitch(); + * await db.connect({ db: 'local' }); * ``` */ -export function createDAL(): DAL { +export function sqlSwitch(): DAL { return new DAL(); } -export default createDAL; +/** + * @deprecated Renamed to {@link sqlSwitch} in 2.0. Kept as an alias so existing imports keep + * working; will be removed in 3.0. + */ +export const createDAL = sqlSwitch; + +export default sqlSwitch; diff --git a/src/database/types.ts b/src/database/types.ts index 6feeb44..dcb4fa1 100644 --- a/src/database/types.ts +++ b/src/database/types.ts @@ -8,7 +8,9 @@ * import type { DALConfig } from 'sql-switch'; * * const config: DALConfig = { - * db: { mode: 'local', dataDir: './data/databases', wal: true }, + * db: 'local', + * local: { dataDir: './data/databases', wal: true }, + * cloud: { connectionString: process.env.DATABASE_URL ?? '' }, * collector: { enabled: true, time: 3000 }, * }; * ``` @@ -160,7 +162,6 @@ export interface CollectorConfig { * production hits. */ export interface SqliteConfig { - mode: 'local'; /** * Directory where `.db` files are stored. * @default './data/databases' @@ -199,7 +200,6 @@ export interface SqliteConfig { /** Config for production PostgreSQL mode — one logical schema per module. */ export interface PostgresConfig { - mode: 'cloud'; /** Full Postgres connection string. e.g. `postgres://user:pass@localhost:5432/mydb` */ connectionString: string; pool?: { @@ -243,9 +243,26 @@ export interface PostgresConfig { }; } -/** Root config object passed to `db.connect()`. */ +/** + * Root config object passed to `db.connect()`. + * + * Declare **both** engines up front and select the active one with `db`. Every declared block is + * validated at `connect()`, so a bad cloud `connectionString` is caught at boot even while you're + * still running `db: 'local'`. Flip the active engine at runtime with + * `db.reconnect('cloud' | 'local')` — that reconnects only, it does **not** move data; use + * `engineSwap()` / `db.swapEngine()` when you need the rows to travel too. + * + * @remarks + * `local` may be omitted (every SQLite setting has a default). `cloud` is required whenever `db` is + * `'cloud'`, because a Postgres `connectionString` has no default. + */ export interface DALConfig { - db: SqliteConfig | PostgresConfig; + /** Which declared engine is active. */ + db: 'local' | 'cloud'; + /** SQLite settings (local mode). Omit to accept every default. */ + local?: SqliteConfig; + /** PostgreSQL settings (cloud mode). Required when `db` is `'cloud'`. */ + cloud?: PostgresConfig; /** Write collector configuration. Defaults to enabled with a 3s flush interval. */ collector?: CollectorConfig; } @@ -278,7 +295,7 @@ export interface StoredEntry { /** * Internal driver interface — both SQLite & Postgres adapters implement this. - * Not part of the public API; use the fluent interface returned by `createDAL()`. + * Not part of the public API; use the fluent interface returned by `sqlSwitch()`. * @internal */ export interface DatabaseDriver { diff --git a/test/bulk-write.test.ts b/test/bulk-write.test.ts index 14a6600..95e8791 100644 --- a/test/bulk-write.test.ts +++ b/test/bulk-write.test.ts @@ -61,7 +61,7 @@ async function tickswhile(work: () => Promise): Promise<{ result: T; ticks describe('sqlite bulk flush', () => { it(`writes a full ${MAX_BUFFER} key flush without holding the event loop`, async () => { const dir = tempdir(); - const driver = new SqliteDriver({ mode: 'local', dataDir: dir }); + const driver = new SqliteDriver({ dataDir: dir }); onTestFinished(async () => { await driver.close(); }); @@ -91,7 +91,7 @@ describe('sqlite bulk flush', () => { it('keeps chunk boundaries idempotent => a rewritten group just overwrites', async () => { const dir = tempdir(); - const driver = new SqliteDriver({ mode: 'local', dataDir: dir }); + const driver = new SqliteDriver({ dataDir: dir }); onTestFinished(async () => { await driver.close(); }); @@ -148,7 +148,7 @@ const url = process.env.DATABASE_URL; describe.skipIf(!url)('postgres bulk flush against a real database', () => { it('lands a 1200 key flush & upserts a rewritten chunk', async () => { - const driver = new PostgresDriver({ mode: 'cloud', connectionString: url! }); + const driver = new PostgresDriver({ connectionString: url! }); const pool = new pg.Pool({ connectionString: url! }); // own throwaway schema, dropped below => never touches anything the database already had, and // can't race the `swaptest` schema the swap test creates & drops diff --git a/test/double-connect.test.ts b/test/double-connect.test.ts index 7438feb..0c10b3a 100644 --- a/test/double-connect.test.ts +++ b/test/double-connect.test.ts @@ -15,7 +15,6 @@ import path from 'node:path'; import { describe, expect, it, vi, onTestFinished } from 'vitest'; import { createDAL } from '../src/database/index.js'; import { ConfigurationError } from '../src/database/errors.js'; -import type { DALConfig } from '../src/database/types.js'; import { tempdir } from './helpers/tempdal.js'; import { NOFLUSH } from './helpers/collector.js'; @@ -42,11 +41,11 @@ describe('double connect', () => { await db.close().catch(() => undefined); }); - await db.connect({ db: { mode: 'local', dataDir: first }, collector: NOFLUSH }); + await db.connect({ db: 'local', local: { dataDir: first }, collector: NOFLUSH }); await db.schema('antinuke').table('settings').key('guild-1').set({ strict: true }); expect(db.pendingWrites).toBe(1); - await db.connect({ db: { mode: 'local', dataDir: second }, collector: NOFLUSH }); + await db.connect({ db: 'local', local: { dataDir: second }, collector: NOFLUSH }); // buffer belonged to the old collector => nothing would ever have flushed it expect(db.pendingWrites).toBe(0); @@ -65,8 +64,8 @@ describe('double connect', () => { const before = process.listenerCount('SIGTERM'); - await db.connect({ db: { mode: 'local', dataDir: tempdir() }, collector: NOFLUSH }); - await db.connect({ db: { mode: 'local', dataDir: tempdir() }, collector: NOFLUSH }); + await db.connect({ db: 'local', local: { dataDir: tempdir() }, collector: NOFLUSH }); + await db.connect({ db: 'local', local: { dataDir: tempdir() }, collector: NOFLUSH }); // one live collector => one listener, not one per connect() call expect(process.listenerCount('SIGTERM')).toBe(before + 1); @@ -83,10 +82,10 @@ describe('double connect', () => { await db.close().catch(() => undefined); }); - await db.connect({ db: { mode: 'local', dataDir: tempdir() }, collector: NOFLUSH }); + await db.connect({ db: 'local', local: { dataDir: tempdir() }, collector: NOFLUSH }); expect(warn).not.toHaveBeenCalled(); - await db.connect({ db: { mode: 'local', dataDir: tempdir() }, collector: NOFLUSH }); + await db.connect({ db: 'local', local: { dataDir: tempdir() }, collector: NOFLUSH }); expect(warn.mock.calls.flat().join(' ')).toContain('already connected'); }); @@ -98,17 +97,15 @@ describe('double connect', () => { await db.close().catch(() => undefined); }); - await db.connect({ db: { mode: 'local', dataDir: dir }, collector: NOFLUSH }); + await db.connect({ db: 'local', local: { dataDir: dir }, collector: NOFLUSH }); await db.schema('antinuke').table('settings').key('guild-1').set({ strict: true }); - // 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, - ); + // db: 'cloud' selected but no cloud block declared => the runtime guard rejects it before + // anything is torn down (the type allows an omitted cloud block, so no cast is needed) + await expect(db.connect({ db: 'cloud' })).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 } }), + db.connect({ db: 'local', local: { dataDir: dir }, collector: { time: 0 } }), ).rejects.toThrow(ConfigurationError); // still the original connection, buffer included @@ -128,11 +125,11 @@ describe('double connect', () => { await db.close().catch(() => undefined); }); - await db.connect({ db: { mode: 'local', dataDir: dir }, collector: NOFLUSH }); + await db.connect({ db: 'local', local: { dataDir: dir }, collector: NOFLUSH }); await db.schema('antinuke').table('settings').key('guild-1').set({ strict: true }); await expect( - db.connect({ db: { mode: 'local', dataDir: dir, busyTimeout: -1 }, collector: NOFLUSH }), + db.connect({ db: 'local', local: { dataDir: dir, busyTimeout: -1 }, collector: NOFLUSH }), ).rejects.toThrow(ConfigurationError); // the original engine is still live => buffer intact, reads & writes still work @@ -156,7 +153,8 @@ describe('double connect', () => { // that into a friendly, actionable error rather than surfacing the raw resolver stack const err = await db .connect({ - db: { mode: 'cloud', connectionString: 'postgres://ignored' }, + db: 'cloud', + cloud: { connectionString: 'postgres://ignored' }, collector: NOFLUSH, }) .then(() => null) diff --git a/test/engine-swap-durability.test.ts b/test/engine-swap-durability.test.ts index db06636..817f6f7 100644 --- a/test/engine-swap-durability.test.ts +++ b/test/engine-swap-durability.test.ts @@ -228,7 +228,7 @@ describe('quiescence of the source (E1)', () => { expect(localDirOpen(dir)).toBe(false); const db = createDAL(); - await db.connect({ db: { mode: 'local', dataDir: dir } }); + await db.connect({ db: 'local', local: { dataDir: dir } }); expect(localDirOpen(dir)).toBe(true); // same directory spelled differently is still the same directory @@ -308,7 +308,7 @@ describe.skipIf(!url)('the up swap against a real database', () => { pgpool([schema]); const db = createDAL(); - await db.connect({ db: { mode: 'local', dataDir: dir }, collector: { enabled: false } }); + await db.connect({ db: 'local', local: { dataDir: dir }, collector: { enabled: false } }); await db.schema(schema).table('settings').key('guild-1').set({ strict: true }).force(); const lines: string[] = []; diff --git a/test/factory-rename.test.ts b/test/factory-rename.test.ts new file mode 100644 index 0000000..6767ad4 --- /dev/null +++ b/test/factory-rename.test.ts @@ -0,0 +1,27 @@ +/** + * @packageDocumentation + * 2.0 => the factory was renamed `createDAL` -> `sqlSwitch`. `createDAL` stays as a one-line + * `@deprecated` alias (removed in 3.0) and the default export follows `sqlSwitch`. + * + * These assertions never `connect()`, so they build no driver => safe to run in any environment. + */ + +import { describe, expect, it } from 'vitest'; +import sqlSwitchDefault, { sqlSwitch, createDAL } from '../src/database/index.js'; + +describe('factory rename', () => { + it('exposes sqlSwitch as the factory', () => { + const db = sqlSwitch(); + // the reconnect() primitive is part of the 2.0 surface + expect(typeof db.reconnect).toBe('function'); + expect(typeof db.connect).toBe('function'); + }); + + it('keeps createDAL as an alias for the same function', () => { + expect(createDAL).toBe(sqlSwitch); + }); + + it('defaults the export to sqlSwitch', () => { + expect(sqlSwitchDefault).toBe(sqlSwitch); + }); +}); diff --git a/test/fixtures/exit-flush-child.ts b/test/fixtures/exit-flush-child.ts index a6ec95b..675761d 100644 --- a/test/fixtures/exit-flush-child.ts +++ b/test/fixtures/exit-flush-child.ts @@ -18,7 +18,7 @@ if (!dir) throw new Error('usage: exit-flush-child.ts '); const db = createDAL(); // a flush interval far past the test => only the exit path can put this row on disk -await db.connect({ db: { mode: 'local', dataDir: dir }, collector: { time: 600_000 } }); +await db.connect({ db: 'local', local: { dataDir: dir }, collector: { time: 600_000 } }); await db.schema('antinuke').table('settings').key('guild-1').set({ strict: true }); console.log('ready'); diff --git a/test/fixtures/idle-exit-child.ts b/test/fixtures/idle-exit-child.ts index 6ca2f1a..e5a603f 100644 --- a/test/fixtures/idle-exit-child.ts +++ b/test/fixtures/idle-exit-child.ts @@ -15,7 +15,7 @@ const dir = process.argv[2]; if (!dir) throw new Error('usage: idle-exit-child.ts '); const db = createDAL(); -await db.connect({ db: { mode: 'local', dataDir: dir }, collector: { time: 3_000 } }); +await db.connect({ db: 'local', local: { dataDir: dir }, collector: { time: 3_000 } }); // buffered on purpose => the natural exit still has to put it on disk (beforeExit, see #5) await db.schema('antinuke').table('settings').key('guild-1').set({ strict: true }); diff --git a/test/helpers/tempdal.ts b/test/helpers/tempdal.ts index 21c818c..eedd46f 100644 --- a/test/helpers/tempdal.ts +++ b/test/helpers/tempdal.ts @@ -41,7 +41,8 @@ export async function localdal(collector?: CollectorConfig): Promise { const dir = tempdir(); const db = createDAL(); await db.connect({ - db: { mode: 'local', dataDir: dir, wal: true }, + db: 'local', + local: { dataDir: dir, wal: true }, ...(collector ? { collector } : {}), }); @@ -62,7 +63,7 @@ export async function localdal(collector?: CollectorConfig): Promise { */ export async function reopen(dir: string): Promise { const db = createDAL(); - await db.connect({ db: { mode: 'local', dataDir: dir }, collector: { enabled: false } }); + await db.connect({ db: 'local', local: { dataDir: dir }, collector: { enabled: false } }); onTestFinished(async () => { await db.close().catch(() => undefined); diff --git a/test/pg-json-roundtrip.test.ts b/test/pg-json-roundtrip.test.ts index e8c5a4d..e42a142 100644 --- a/test/pg-json-roundtrip.test.ts +++ b/test/pg-json-roundtrip.test.ts @@ -21,7 +21,7 @@ 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 driver = new PostgresDriver({ connectionString: url! }); const pool = new pg.Pool({ connectionString: url! }); onTestFinished(async () => { await pool.query(`DROP SCHEMA IF EXISTS "${schema}" CASCADE`).catch(() => undefined); diff --git a/test/pg-resilience.test.ts b/test/pg-resilience.test.ts index 67fc5f9..1e54738 100644 --- a/test/pg-resilience.test.ts +++ b/test/pg-resilience.test.ts @@ -150,7 +150,7 @@ describe('bounded jittered retry', () => { }); describe('pool options', () => { - const base = { mode: 'cloud', connectionString: 'postgres://u:p@localhost:5432/db' } as const; + const base = { connectionString: 'postgres://u:p@localhost:5432/db' } as const; it('puts a client side ceiling on every query by default', () => { const options = poolOptions(base); @@ -206,7 +206,7 @@ describe.skipIf(!url)('postgres resilience against a real database', () => { // own throwaway schema per test => can't race the other postgres test files vitest runs in // parallel, and cleanup can't take anything else with it const schema = 'swaptest-heal'; - const driver = new PostgresDriver({ mode: 'cloud', connectionString: url! }); + const driver = new PostgresDriver({ connectionString: url! }); const pool = new pg.Pool({ connectionString: url! }); onTestFinished(async () => { await pool.query(`DROP SCHEMA IF EXISTS "${schema}" CASCADE`).catch(() => undefined); @@ -228,7 +228,6 @@ describe.skipIf(!url)('postgres resilience against a real database', () => { it('cancels a flush that blocks on a lock & keeps the pool usable', async () => { const schema = 'swaptest-lock'; const driver = new PostgresDriver({ - mode: 'cloud', connectionString: url!, pool: { statementTimeout: 400 }, }); diff --git a/test/reconnect.test.ts b/test/reconnect.test.ts new file mode 100644 index 0000000..1423027 --- /dev/null +++ b/test/reconnect.test.ts @@ -0,0 +1,156 @@ +/** + * @packageDocumentation + * 2.0 => `reconnect()` is one primitive for three jobs: restart a wedged connection, recover a lost + * one, and repoint to the other declared engine without moving data. + * + * The rules these lock down: pending writes are flushed before the old engine is torn down (same as + * `close()`), the exit-flush listener count doesn't grow per call, and a `reconnect()` that can't + * stand the new engine up — bad target, missing peer dep — leaves the connection you already had + * fully intact rather than dropping you to no engine at all. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it, vi, onTestFinished } from 'vitest'; +import { createDAL } from '../src/database/index.js'; +import { ConfigurationError, NotConnectedError } from '../src/database/errors.js'; +import { tempdir } from './helpers/tempdal.js'; +import { NOFLUSH } from './helpers/collector.js'; + +// same trick double-connect uses => make the pg driver fail the way a missing `pg` peer dep would, +// so we can prove reconnect stays on the live engine when the *target* engine can't be built, +// without uninstalling anything. only the one test that repoints to cloud imports this path +vi.mock('../src/database/drivers/postgres-drizzle.js', () => ({ + PostgresDriver: class { + constructor() { + const err = new Error("Cannot find package 'pg' imported from postgres-drizzle.js"); + (err as NodeJS.ErrnoException).code = 'ERR_MODULE_NOT_FOUND'; + throw err; + } + }, +})); + +describe('reconnect', () => { + it('throws NotConnectedError before connect()', async () => { + const db = createDAL(); + await expect(db.reconnect()).rejects.toThrow(NotConnectedError); + }); + + it('flushes pending writes before tearing the old engine down', async () => { + const dir = tempdir(); + const db = createDAL(); + onTestFinished(async () => { + await db.close().catch(() => undefined); + }); + + await db.connect({ db: 'local', local: { dataDir: dir }, collector: NOFLUSH }); + await db.schema('antinuke').table('settings').key('guild-1').set({ strict: true }); + expect(db.pendingWrites).toBe(1); + + // no arg => re-open the same engine. the buffered write has to be drained on the way out, not + // dropped with the collector it lived on + await db.reconnect(); + + expect(db.pendingWrites).toBe(0); + expect(fs.existsSync(path.join(dir, 'antinuke.db'))).toBe(true); + // and the fresh connection reads it back & still writes + expect(await db.schema('antinuke').table('settings').key('guild-1').get()).toEqual({ + 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, + }); + }); + + it('does not leak an exit-flush listener per call', async () => { + const db = createDAL(); + onTestFinished(async () => { + await db.close().catch(() => undefined); + }); + + const before = process.listenerCount('SIGTERM'); + + await db.connect({ db: 'local', local: { dataDir: tempdir() }, collector: NOFLUSH }); + await db.reconnect(); + await db.reconnect(); + + // one live collector => one listener, no matter how many times we reconnect + expect(process.listenerCount('SIGTERM')).toBe(before + 1); + + await db.close(); + expect(process.listenerCount('SIGTERM')).toBe(before); + }); + + it('reconnects silently => no "already connected" warn like connect()-over-connect does', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const db = createDAL(); + onTestFinished(async () => { + warn.mockRestore(); + await db.close().catch(() => undefined); + }); + + await db.connect({ db: 'local', local: { dataDir: tempdir() }, collector: NOFLUSH }); + await db.reconnect(); + + // a deliberate reconnect() is the sanctioned way to do this => it shouldn't nag + expect(warn).not.toHaveBeenCalled(); + }); + + it('leaves the live engine alone when the target has no config block', async () => { + const dir = tempdir(); + const db = createDAL(); + onTestFinished(async () => { + await db.close().catch(() => undefined); + }); + + // only a local block was declared => repointing to cloud is rejected before anything is torn + // down (validation runs ahead of building the replacement driver) + await db.connect({ db: 'local', local: { dataDir: dir }, collector: NOFLUSH }); + await db.schema('antinuke').table('settings').key('guild-1').set({ strict: true }); + + await expect(db.reconnect('cloud')).rejects.toThrow(ConfigurationError); + + // still on local, buffer included + expect(db.pendingWrites).toBe(1); + expect(await db.schema('antinuke').table('settings').key('guild-1').get()).toEqual({ + strict: true, + }); + }); + + it('leaves the live engine alone when the target engine fails to build', async () => { + const dir = tempdir(); + const db = createDAL(); + onTestFinished(async () => { + await db.close().catch(() => undefined); + }); + + // a cloud block is declared this time, so validation passes & the failure lands inside driver + // construction (the mocked pg ctor throws ERR_MODULE_NOT_FOUND) => that's exactly the spot the + // fail-safe ordering has to survive: the old local engine must still be live afterwards + await db.connect({ + db: 'local', + local: { dataDir: dir }, + cloud: { connectionString: 'postgres://ignored' }, + collector: NOFLUSH, + }); + await db.schema('antinuke').table('settings').key('guild-1').set({ strict: true }); + + const err = await db + .reconnect('cloud') + .then(() => null) + .catch((e: unknown) => e); + expect(err).toBeInstanceOf(ConfigurationError); + expect((err as ConfigurationError).message).toContain('pg'); + + // local engine untouched => buffer intact, reads & writes still work + expect(db.pendingWrites).toBe(1); + expect(await db.schema('antinuke').table('settings').key('guild-1').get()).toEqual({ + 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, + }); + }); +}); diff --git a/test/sqlite-config.test.ts b/test/sqlite-config.test.ts index aba29c2..fcaecf1 100644 --- a/test/sqlite-config.test.ts +++ b/test/sqlite-config.test.ts @@ -20,13 +20,15 @@ describe('sqlite busy_timeout config', () => { await expect( db.connect({ - db: { mode: 'local', dataDir: dir, busyTimeout: -1 }, + db: 'local', + local: { dataDir: dir, busyTimeout: -1 }, collector: { enabled: false }, }), ).rejects.toThrow(ConfigurationError); await expect( db.connect({ - db: { mode: 'local', dataDir: dir, busyTimeout: 1.5 }, + db: 'local', + local: { dataDir: dir, busyTimeout: 1.5 }, collector: { enabled: false }, }), ).rejects.toThrow(ConfigurationError); @@ -36,7 +38,8 @@ describe('sqlite busy_timeout config', () => { const dir = tempdir(); const db = createDAL(); await db.connect({ - db: { mode: 'local', dataDir: dir, busyTimeout: 1_000 }, + db: 'local', + local: { dataDir: dir, busyTimeout: 1_000 }, collector: { enabled: false }, }); @@ -49,7 +52,8 @@ describe('sqlite busy_timeout config', () => { const dir = tempdir(); const db = createDAL(); await db.connect({ - db: { mode: 'local', dataDir: dir, busyTimeout: 0 }, + db: 'local', + local: { dataDir: dir, busyTimeout: 0 }, collector: { enabled: false }, }); await db.close();