Spreadsheet-UX op een echte relationele engine

main

Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.

5.1 kB Ruw bestand
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);
    }
  }
}