/** * StoreManager - Multi-tenant LevelDB store management * * Manages lazy loading and cleanup of per-database LevelDB stores. * Each database gets its own isolated store, opened on-demand and * closed after idle timeout. * * This is a generic implementation. Applications provide custom database ID * parsing and path resolution via hooks in StoreManagerConfig. */ import { StoreEventEmitter } from '@quereus/store'; import { LevelDBStore } from '@quereus/plugin-leveldb'; import { type SyncManager } from '@quereus/sync'; export interface StoreEntry { databaseId: string; store: LevelDBStore; syncManager: SyncManager; storeEvents: StoreEventEmitter; refCount: number; lastAccess: number; /** True if no local data existed before this store was opened. */ isNew?: boolean; } /** * Context passed to store hooks for auth-aware decisions. */ export interface StoreContext { /** The raw auth token (e.g., JWT) */ token?: string; /** User ID from authentication */ userId?: string; /** Additional metadata from authentication */ metadata?: Record; } /** * Hooks for customizing store manager behavior. * Apps can provide these to implement custom database ID handling. */ export interface StoreManagerHooks { /** * Resolve a database ID to a storage path relative to dataDir. * @param databaseId The database identifier (any string) * @param context Optional auth context for auth-aware path resolution * @returns The storage path relative to dataDir * @default Returns sanitized databaseId (replaces unsafe chars) */ resolveStoragePath?: (databaseId: string, context?: StoreContext) => string; /** * Validate a database ID. * @param databaseId The database identifier to validate * @param context Optional auth context for auth-aware validation * @returns True if valid, false otherwise * @default Returns true for non-empty strings */ isValidDatabaseId?: (databaseId: string, context?: StoreContext) => boolean; } export interface StoreManagerConfig { /** Base directory for all database stores */ dataDir: string; /** Maximum number of stores to keep open (LRU eviction) */ maxOpenStores: number; /** Idle timeout in ms before closing a store with refCount=0 */ idleTimeoutMs: number; /** Interval for cleanup checks */ cleanupIntervalMs: number; /** Sync config passed to createSyncModule */ syncConfig?: { retentionHorizonMs?: number; batchSize?: number; }; /** Hooks for customizing behavior */ hooks?: StoreManagerHooks; /** Called when a new store is created (no pre-existing local data). Used for S3 restore. */ onStoreCreated?: (entry: StoreEntry) => Promise; /** Idle time (ms) before a closed store's local directory is eligible for disk eviction. 0 = disabled. */ diskEvictionIdleMs?: number; /** Called to confirm a closed store can be safely evicted from disk. Return true to proceed with deletion. */ onEvictStore?: (databaseId: string) => Promise; } /** * Manages multiple LevelDB stores for multi-tenant sync. */ export declare class StoreManager { private readonly config; private readonly resolveStoragePath; private readonly isValidDatabaseId; private readonly stores; private readonly pendingOpens; /** In-flight closes keyed by databaseId. An acquire awaits this before opening a fresh handle. */ private readonly pendingCloses; private readonly onStoreCreated?; /** Tracks closed stores eligible for disk eviction: databaseId → { storagePath, closedAt } */ private readonly closedStores; private readonly diskEvictionIdleMs; private readonly onEvictStore?; private cleanupTimer; private shutdownPromise; private _shuttingDown; constructor(config?: Partial); /** * Start the store manager (begins cleanup interval). */ start(): void; /** * Get or open a store for a database. Increments refCount. * Uses pendingOpens to prevent concurrent open+restore for the same databaseId, * and awaits pendingCloses so an in-flight close of the same key fully finishes * before we vend or re-open a handle (see closeStore for the serialization invariant). * @param databaseId The database identifier * @param context Optional auth context for auth-aware path resolution */ acquire(databaseId: string, context?: StoreContext): Promise; /** * Release a store reference. Decrements refCount. */ release(databaseId: string): void; /** * Release a pin taken by {@link acquireIfOpen}, without refreshing * `lastAccess` — see the NOTE there for why background sweeps must not count * as access. */ releasePin(databaseId: string): void; private decRef; /** * Check if a store is currently open. */ isOpen(databaseId: string): boolean; /** * Get an open store without acquiring (for read-only checks). */ get(databaseId: string): StoreEntry | undefined; /** * Snapshot of the database IDs currently open. A plain array copy, so the * caller can await between entries without tripping over concurrent * open/close mutating the live map. */ openDatabaseIds(): string[]; /** * Pin an already-open store, or return undefined if it is not open. Unlike * {@link acquire} this never opens (or re-opens) a store — it is for * background work that should touch only what is already resident, e.g. the * periodic sync-maintenance sweep. * * Synchronous by design: the refCount bump lands in the same synchronous * section as the `stores.get`, which makes it atomic with respect to * `closeStore`'s equally-synchronous "guard then delete" section (see the * serialization invariant on {@link closeStore}). So the store cannot be * closed out from under a caller that got an entry back, and a caller that * lost the race gets undefined rather than a half-torn-down handle. * * NOTE: deliberately does NOT touch `lastAccess`. A maintenance sweep is not * user access; refreshing the timestamp on every pass would keep otherwise-idle * stores permanently above the idle-close threshold. */ acquireIfOpen(databaseId: string): StoreEntry | undefined; /** * Get count of open stores. */ get openCount(): number; /** * Get count of closed stores pending disk eviction. */ get evictionCandidateCount(): number; /** * Check if a database ID is valid. * @param databaseId The database identifier * @param context Optional auth context for auth-aware validation */ validateDatabaseId(databaseId: string, context?: StoreContext): boolean; /** * Shutdown all stores. */ shutdown(): Promise; /** * Open a store and run the onStoreCreated callback if the store is new. * On callback failure, closes the store and rethrows. */ private openAndRestore; private openStore; /** * Cleanup idle stores with refCount=0 past timeout. */ private cleanup; /** * Evict closed stores from local disk if they've been idle long enough * and the eviction callback confirms safety (e.g. data is durable in S3). */ private evictFromDisk; /** * Evict least recently used store (with refCount=0). */ private evictLRU; /** * Close a specific store. * * Serialization invariant: the close decision and the removal from this.stores * happen in ONE synchronous section (no await between the refCount guard and the * stores.delete). JS is single-threaded, so that section is atomic w.r.t. any * racing acquire: * - acquire's sync section ran first → it bumped refCount, the guard here bails, * the entry stays live. * - this sync section runs first → the entry is gone from this.stores and the * in-flight close is registered in pendingCloses BEFORE we await close(); a * later acquire finds no entry, awaits pendingCloses, then opens a fresh handle * (correct for LevelDB's single-open lock — old handle fully closed first). */ private closeStore; } //# sourceMappingURL=store-manager.d.ts.map