4.2 KiB
Connection Singletons
(no submodules)
Purpose
getSingleton, closeSingleton, hasSingleton, and closeAllSingletons provide a lightweight singleton registry for DatabaseAdapter instances. The primary purpose is to avoid opening multiple connections to the same database within a single process — getSingleton is idempotent: the first call creates and connects the adapter, subsequent calls with the same name return the cached reference without reconnecting.
By accepting an optional registry parameter (a plain Map<string, DatabaseAdapter>), the helpers are safe for test isolation: each test suite can pass its own private map and have zero cross-test bleed. In production, omitting registry uses a module-level default map, replicating the familiar global-singleton pattern without requiring any global state management from callers.
Features
- Idempotent
getSingleton— second call with same name returns cached adapter DatabaseConfigSchemavalidation beforeconnectis called- Wraps
connectfailures asDatabaseErrorwithDB.CONNECTION_FAILED closeSingleton— graceful disconnect + registry removal (no-op for unknown names)closeAllSingletons— drains the entire registry; collects disconnect errors intoAggregateError- Injectable
registryparameter — full test isolation without global state AbortSignalthreading viaDbOperationOptions
Usage: Basic Example
import { getSingleton, closeSingleton } from "@suaveplan/db";
import { SqliteAdapter } from "@suaveplan/db-sqlite";
// Get or create the default "app" connection
const db = await getSingleton("app", () => new SqliteAdapter({ filename: "./app.db" }));
const rows = await db.query<{ id: number }>("SELECT id FROM users");
// Clean up at process exit
await closeSingleton("app");
Usage: Advanced Example
import {
getSingleton,
closeAllSingletons,
hasSingleton,
} from "@suaveplan/db";
import type { DatabaseAdapter } from "@suaveplan/db";
// Test-isolated registry — no cross-test bleed
const testRegistry = new Map<string, DatabaseAdapter>();
beforeEach(async () => {
await getSingleton(
"test-db",
() => new SqliteAdapter({ filename: ":memory:" }),
undefined,
undefined,
testRegistry
);
});
afterEach(async () => {
await closeAllSingletons(undefined, testRegistry);
});
it("has singleton after setup", () => {
expect(hasSingleton("test-db", testRegistry)).toBe(true);
});
API Reference
getSingleton
getSingleton(
name: string,
factory: () => DatabaseAdapter,
config?: Partial<DatabaseConfig>,
opts?: DbOperationOptions,
registry?: Map<string, DatabaseAdapter>
): Promise<DatabaseAdapter>
Returns the cached adapter for name, or creates one via factory, validates config, connects it, and caches it. Throws DatabaseError(DB.CONNECTION_FAILED) if connect fails.
closeSingleton
closeSingleton(
name: string,
opts?: DbOperationOptions,
registry?: Map<string, DatabaseAdapter>
): Promise<void>
Calls disconnect on the named adapter and removes it from the registry. No-op if name is not registered.
hasSingleton
hasSingleton(name: string, registry?: Map<string, DatabaseAdapter>): boolean
Returns true if an adapter is registered under name.
closeAllSingletons
closeAllSingletons(
opts?: DbOperationOptions,
registry?: Map<string, DatabaseAdapter>
): Promise<void>
Clears the registry immediately, then disconnects all adapters concurrently. If one or more disconnects fail, an AggregateError is thrown after all attempts complete.
Implementation Notes
The registry is cleared before disconnect is called in closeAllSingletons. This ensures that even if a disconnect rejects, the registry is empty and a subsequent getSingleton call will create a fresh adapter rather than returning a half-closed one. The AggregateError is thrown after all disconnect attempts so callers get complete failure information rather than a short-circuit on the first error.
Config validation uses DatabaseConfigSchema.parse (not .safeParse) so invalid config throws a ZodError immediately — fail-fast at the boundary is preferable to a cryptic driver-level error.