Spreadsheet-UX op een echte relationele engine
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
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<string, Entry>();
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);
}
}
}