Spaces:
Running
Running
File size: 5,706 Bytes
4bb4f90 e830ad4 4bb4f90 52ec2ea 4bb4f90 e830ad4 4bb4f90 6d928e9 4bb4f90 6d928e9 4bb4f90 6d928e9 4bb4f90 6d928e9 4bb4f90 6d928e9 4bb4f90 6d928e9 4bb4f90 6d928e9 4bb4f90 6d928e9 d0c44b2 6d928e9 4bb4f90 6d928e9 e830ad4 6d928e9 4bb4f90 52ec2ea e830ad4 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 | 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);
});
});
}
}
|