import { randomUUID } from 'node:crypto'; import { RateLimiter } from './auth/rate-limit'; import { loadConfig, type Config } from './config'; import { Ledger, type CompactionResult } from './ledger/ledger'; import { LOCK_PATH, WriterGuard } from './ledger/writer-guard'; import { HfBucketStore } from './storage/hf-bucket-store'; import { LocalFsStore } from './storage/local-fs-store'; import { PrefixedStore } from './storage/prefixed-store'; import type { BucketStore } from './storage/store'; import { UsersRepo } from './users/repository'; export interface CompactionStatus { lastRunAt: string | null; lastResult: CompactionResult | null; lastError: string | null; running: boolean; } export interface App { config: Config; store: BucketStore; users: UsersRepo; ledger: Ledger; guard: WriterGuard; loginLimiter: RateLimiter; /** Second login gate, keyed by client IP, so one host cannot spray many usernames. */ ipLimiter: RateLimiter; compaction: CompactionStatus; /** Aligns the ledger's read-only flag with the writer lock, logging any transition. */ syncReadOnly(): void; runCompaction(): Promise; } const BUILD_RETRY_AFTER_MS = 10_000; let instance: Promise | null = null; let failedAt = 0; /** Builds the singleton on first call; subsequent calls return the same promise. */ export function getApp(): Promise { if (instance && (failedAt === 0 || Date.now() - failedAt < BUILD_RETRY_AFTER_MS)) return instance; failedAt = 0; instance = build().catch((e) => { failedAt = Date.now(); throw e; }); return instance; } async function build(): Promise { const config = loadConfig(); const base: BucketStore = config.storage === 'hf' ? new HfBucketStore(config.hfBucket, config.hfToken) : new LocalFsStore(config.localDataDir); const store: BucketStore = config.hfPrefix ? new PrefixedStore(base, config.hfPrefix) : base; console.log( `[app] storage=${config.storage}${config.storage === 'hf' ? ` bucket=${config.hfBucket}` : ` dir=${config.localDataDir}`}${config.hfPrefix ? ` prefix=${config.hfPrefix}/` : ''}` ); const users = await UsersRepo.load(store); const guard = new WriterGuard(store, randomUUID()); const acquired = await guard.acquire(); if (!acquired) console.error('[app] another instance holds the writer lock; starting READ-ONLY until it goes stale'); try { const ledger = await Ledger.load(store); ledger.readOnly = !guard.canWrite; guard.start(); const compaction: CompactionStatus = { lastRunAt: null, lastResult: null, lastError: null, running: false }; let inFlight: Promise | null = null; const compactOnce = async (): Promise => { compaction.running = true; try { const result = await ledger.compact(); compaction.lastResult = result; compaction.lastError = null; return result; } catch (e) { compaction.lastError = e instanceof Error ? e.message : String(e); throw e; } finally { compaction.running = false; compaction.lastRunAt = new Date().toISOString(); } }; const app: App = { config, store, users, ledger, guard, loginLimiter: new RateLimiter(5, 60_000), ipLimiter: new RateLimiter(30, 60_000), compaction, syncReadOnly() { const readOnly = !guard.canWrite; if (readOnly === ledger.readOnly) return; ledger.readOnly = readOnly; console.log(readOnly ? '[app] entering READ-ONLY mode' : '[app] writer lock acquired, leaving read-only mode'); }, runCompaction() { inFlight ??= compactOnce().finally(() => { inFlight = null; }); return inFlight; } }; const timer = setInterval(() => { app.syncReadOnly(); if (ledger.readOnly) return; app.runCompaction().catch((e) => console.error('[compaction] failed', e)); }, config.compactionIntervalMs); timer.unref?.(); registerShutdown(app, timer); if (guard.canWrite && ledger.stats().pendingEvents > 0) { app.runCompaction().catch((e) => console.error('[compaction] boot compaction failed', e)); } console.log( `[app] ready: ${users.all().length} users, ${ledger.listExpenses().length} expenses, ${ledger.stats().pendingEvents} pending events` ); return app; } catch (e) { guard.stop(); throw e; } } let shutdownRegistered = false; /** * Hands the writer lock back on the way out so a redeploy does not have to wait for the * lock to go stale. adapter-node emits `sveltekit:shutdown` once the HTTP server is closed; * the signal handlers are a fallback for hosts that do not (e.g. `vite dev`). */ function registerShutdown(app: App, timer: ReturnType): void { if (shutdownRegistered) return; shutdownRegistered = true; let released: Promise | null = null; const release = (): Promise => { released ??= (async () => { clearInterval(timer); app.guard.stop(); if (!app.guard.canWrite) return; try { const bail = new Promise((_, reject) => { const t = setTimeout(() => reject(new Error('timed out')), 5_000); t.unref?.(); }); await Promise.race([app.store.delete([LOCK_PATH]), bail]); console.log('[app] released writer lock'); } catch (e) { console.warn('[app] could not release writer lock', e); } })(); return released; }; const emitter = process as NodeJS.EventEmitter; emitter.on('sveltekit:shutdown', () => void release()); for (const signal of ['SIGTERM', 'SIGINT'] as const) { process.once(signal, () => { // If nothing else listens for this signal our handler would otherwise swallow it. const sole = process.listenerCount(signal) === 0; release().finally(() => { if (sole) process.exit(0); }); }); } }