import { rmSync } from 'node:fs'; import path from 'node:path'; import { closeDatabase, openDatabase, type Db } from '../db/driver.js'; import type { WorkspaceRecord } from '../control/workspaces.js'; /** * The pool of open workspace databases. * * Database-per-tenant means one SQLite file — and one open handle — per * customer. Handles are memoised because opening one runs the migration check, * and because the metadata cache that makes requests cheap lives on the handle. * * Two things stop the pool growing without bound: a cap on how many stay open, * and eviction of ones nobody has touched recently. Neither may ever close a * handle a request is still holding, which is what the reference count is for: * better-sqlite3 is synchronous, but a handler that awaits between two queries * (hashing a password, say) yields long enough for the sweep timer to fire. */ export interface RegistryOptions { /** Directory holding workspace databases named by workspace id. */ directory: string; /** Handles kept open at once. Beyond this, the least recently used idle one goes. */ maxOpen?: number; /** Close a handle nobody has used for this long. */ idleMs?: number; } interface Entry { db: Db; file: string; lastUsed: number; /** Requests currently holding this handle. Never evicted above zero. */ inUse: number; } export class TenantRegistry { private readonly entries = new Map(); private readonly directory: string; private readonly maxOpen: number; private readonly idleMs: number; constructor(options: RegistryOptions) { this.directory = options.directory; this.maxOpen = options.maxOpen ?? 64; this.idleMs = options.idleMs ?? 10 * 60_000; } /** Absolute path of a workspace's database file. */ fileFor(workspace: WorkspaceRecord): string { return path.isAbsolute(workspace.dbFile) ? workspace.dbFile : path.join(this.directory, workspace.dbFile); } /** * Open (or reuse) a workspace's database and take a reference to it. * * Every caller must pair this with `release`. Routes get that for free from * the tenant plugin's request hooks. */ acquire(workspace: WorkspaceRecord): Db { const existing = this.entries.get(workspace.id); if (existing) { existing.lastUsed = Date.now(); existing.inUse++; return existing.db; } const file = this.fileFor(workspace); const db = openDatabase({ file }); this.entries.set(workspace.id, { db, file, lastUsed: Date.now(), inUse: 1 }); // Opening one more may put us over the cap; make room straight away rather // than waiting for the next sweep. this.enforceCap(); return db; } release(workspaceId: string): void { const entry = this.entries.get(workspaceId); if (!entry) return; entry.lastUsed = Date.now(); if (entry.inUse > 0) entry.inUse--; } /** Close a workspace's handle. In-flight requests keep theirs until they finish. */ close(workspaceId: string): void { const entry = this.entries.get(workspaceId); if (!entry) return; this.entries.delete(workspaceId); closeDatabase(entry.db); } /** Close every handle. Called on shutdown. */ closeAll(): void { for (const [id] of this.entries) this.close(id); } /** * Close handles nobody has touched for `idleMs`. Safe to call on a timer. * * The override exists so a caller can reclaim everything idle right now — * useful before a backup, and the only way to assert in a test that a * finished request really did give its reference back. */ sweep(idleMs: number = this.idleMs): number { const cutoff = Date.now() - idleMs; let closed = 0; for (const [id, entry] of [...this.entries]) { if (entry.inUse > 0 || entry.lastUsed > cutoff) continue; this.close(id); closed++; } return closed; } /** * Delete a workspace's database and everything WAL leaves beside it. * * The handle is closed first; on Windows an open handle would make the * unlink fail outright. */ destroy(workspace: WorkspaceRecord): void { this.close(workspace.id); const file = this.fileFor(workspace); for (const suffix of ['', '-wal', '-shm']) { rmSync(`${file}${suffix}`, { force: true }); } } get openCount(): number { return this.entries.size; } /** Test seam: is this workspace's handle currently open? */ isOpen(workspaceId: string): boolean { return this.entries.has(workspaceId); } /** * Evict the least recently used idle handle until the pool fits the cap. * * If every handle is in use the pool is allowed over the cap: refusing the * request would be worse than holding one more file open for a moment. */ private enforceCap(): void { while (this.entries.size > this.maxOpen) { let oldest: { id: string; lastUsed: number } | null = null; for (const [id, entry] of this.entries) { if (entry.inUse > 0) continue; if (!oldest || entry.lastUsed < oldest.lastUsed) oldest = { id, lastUsed: entry.lastUsed }; } if (!oldest) return; this.close(oldest.id); } } }