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 64a8064..efe42dd 100644
--- a/README.md
+++ b/README.md
@@ -1,59 +1,60 @@
-# sql-switch
+
+
+
-Universal hot-swappable database abstraction layer for Node.js. Run SQLite locally, PostgreSQL in production — the same fluent API covers both. Migrate your data between engines with one CLI command, or one function call.
+
+
+
+
+
+
-## Install
+
+ One fluent API for your data. Run SQLite while you build, PostgreSQL in production —
+ the same call works on both — then migrate between engines with a single command.
+
-```bash
-npm install sql-switch
-```
-
-Also published under the Creative-Softworks scope if you prefer the branded name — it re-exports
-this package unchanged, so pick whichever reads better to you:
+---
-```bash
-npm install @creative-softworks/sql-switch
-```
-
-The database drivers are **optional peer dependencies** — install only the one for the engine you
-run, so a SQLite-only app never pulls in `pg` and its build, and vice versa:
+## Quick start
```bash
-npm install better-sqlite3 # local mode
-npm install pg # cloud mode
+npm install sql-switch better-sqlite3
```
-Each driver is loaded lazily the first time you `connect()` in that mode, so importing the package
-never requires both engines to be present.
-
-## Quick start
-
```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 },
});
-// read
-const settings = await db.schema('antinuke').table('settings').key('guild_123').get();
-
-// write (queued, flushed every 3s in bulk)
+// write — queued in RAM, flushed in bulk every 3s
await db.schema('antinuke').table('settings').key('guild_123').set({ strict: true });
-// write immediately, bypassing the collector
+// read — a queued write is visible to the next get() on the same key
+const settings = await db.schema('antinuke').table('settings').key('guild_123').get();
+
+// need it on disk right now? bypass the collector
await db.schema('antinuke').table('settings').key('guild_123').set({ strict: true }).force();
```
-## Switch to PostgreSQL
+That is the whole surface: `schema → table → key → operation`. The same chain drives every engine.
+
+## Same code, production engine
+
+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 },
},
@@ -61,9 +62,42 @@ await db.connect({
});
```
-`statementTimeout` (default 30s, `0` disables) is the ceiling on a single operation. Without one a
-query that never answers holds a pool connection for the life of the process — `max` of those and
-every later read blocks with no error at all.
+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.
+
+> `statementTimeout` (default 30s, `0` disables) caps a single operation. Without one, a query that
+> never answers holds a pool connection for the life of the process — `max` of those and every later
+> read blocks with no error at all.
+
+The drivers are **optional peer dependencies**, loaded lazily the first time you `connect()` in that
+mode — a SQLite-only app never pulls in `pg`, and vice versa. Install just the one you run:
+
+```bash
+npm install better-sqlite3 # local mode
+npm install pg # cloud mode
+```
+
+Prefer the branded name? `@creative-softworks/sql-switch` re-exports this package unchanged.
+
+## How it works
+
+
+
+
+
+- **Write collector** — buffers writes in RAM and flushes them in bulk on an interval, collapsing
+ repeated writes to the same key inside the window. `.force()` bypasses it for an immediate write.
+- **Circuit breaker** — caps pending writes at 5000 keys and trips to read-only on a Postgres
+ outage instead of crashing, then heals itself once the database answers again.
+- **Exit flush** — `SIGINT`, `SIGTERM` and `beforeExit` all drain the buffer on the way out; the
+ library never calls `process.exit()` for you.
+- **Engine swap** — moves data both directions, a chunk at a time, journalled so an interrupted run
+ resumes deterministically.
## Enumerate, scan & convenience helpers
@@ -106,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
@@ -130,16 +165,13 @@ await db.connect({
## Engine swap
-Move your data between engines either from the terminal or from code.
+Move your data between engines from the terminal or from code.
### CLI
```bash
-# local SQLite → production PostgreSQL
-npm run db:engine-swap -- --up
-
-# production PostgreSQL → local SQLite
-npm run db:engine-swap -- --down
+npm run db:engine-swap -- --up # local SQLite → production PostgreSQL
+npm run db:engine-swap -- --down # production PostgreSQL → local SQLite
```
| Flag | Description |
@@ -152,9 +184,8 @@ npm run db:engine-swap -- --down
### From code
-Same migration, no terminal. Anything you leave out is filled in — `dataDir` defaults to
-`./data/databases`, `connectionString` to `process.env.DATABASE_URL`, and missing schemas,
-tables and directories are created on the target side.
+Anything you leave out is filled in — `dataDir` defaults to `./data/databases`, `connectionString`
+to `process.env.DATABASE_URL`, and missing schemas, tables and directories are created on the target.
```ts
import { engineSwap } from 'sql-switch';
@@ -168,9 +199,8 @@ const result = await engineSwap({
console.log(`${result.totalRows} rows across ${result.tables.length} tables`);
```
-Or swap a live DAL and keep using the same object. Pending writes are flushed and the open
-handles closed first, then it reconnects on the target engine with your existing collector
-settings:
+Or swap a live DAL and keep using the same object — pending writes are flushed and handles closed
+first, then it reconnects on the target engine with your existing collector settings:
```ts
await db.swapEngine({ direction: 'up', onConflict: 'overwrite' });
@@ -190,7 +220,7 @@ await engineSwap({
### What the migration guarantees
-| | |
+| Guarantee | Detail |
|---|---|
| Memory | Rows stream a chunk at a time in both directions — peak memory is one chunk, not one table. |
| Atomicity | Each table moves in its own transaction going up; going down the file is built as `.tmp` and renamed into place. |
@@ -225,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. |
@@ -232,8 +263,8 @@ if (result.skippedNames.length) console.warn('left alone:', result.skippedNames)
## Limits
-The library is built so the only hard wall you hit is the database running out of storage. The few
-non-storage constraints below are deliberate, so they're documented rather than left as surprises.
+The only hard wall you hit is the database running out of storage. The few non-storage constraints
+below are deliberate, so they're documented rather than left as surprises.
| Limit | Detail |
|-------|--------|
@@ -258,3 +289,8 @@ npm run docs:serve # serve /docs on http://localhost:3000
## Requirements
- Node.js >= 22.0.0 (tested on the active LTS / current lines, 22 and 24)
+
+## License
+
+[MIT](./LICENSE)
+
diff --git a/assets/readme/architecture.svg b/assets/readme/architecture.svg
new file mode 100644
index 0000000..49ff594
--- /dev/null
+++ b/assets/readme/architecture.svg
@@ -0,0 +1,75 @@
+
diff --git a/assets/readme/hero.svg b/assets/readme/hero.svg
new file mode 100644
index 0000000..2eaea73
--- /dev/null
+++ b/assets/readme/hero.svg
@@ -0,0 +1,73 @@
+
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();