Spaces:
Running
Running
| 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<CompactionResult>; | |
| } | |
| const BUILD_RETRY_AFTER_MS = 10_000; | |
| let instance: Promise<App> | null = null; | |
| let failedAt = 0; | |
| /** Builds the singleton on first call; subsequent calls return the same promise. */ | |
| export function getApp(): Promise<App> { | |
| 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<App> { | |
| 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<CompactionResult> | null = null; | |
| const compactOnce = async (): Promise<CompactionResult> => { | |
| 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<typeof setInterval>): void { | |
| if (shutdownRegistered) return; | |
| shutdownRegistered = true; | |
| let released: Promise<void> | null = null; | |
| const release = (): Promise<void> => { | |
| released ??= (async () => { | |
| clearInterval(timer); | |
| app.guard.stop(); | |
| if (!app.guard.canWrite) return; | |
| try { | |
| const bail = new Promise<never>((_, 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); | |
| }); | |
| }); | |
| } | |
| } | |