Files
skills/docs/persistence/database/db/src/connection/singleton.md
2026-05-29 08:20:56 +01:00

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
  • DatabaseConfigSchema validation before connect is called
  • Wraps connect failures as DatabaseError with DB.CONNECTION_FAILED
  • closeSingleton — graceful disconnect + registry removal (no-op for unknown names)
  • closeAllSingletons — drains the entire registry; collects disconnect errors into AggregateError
  • Injectable registry parameter — full test isolation without global state
  • AbortSignal threading via DbOperationOptions

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.