import { readFile, writeFile, mkdir, rename } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import path from 'node:path'; import { logger } from '@/lib/logger'; import type { SubscriptionRecord } from './types'; // JMAP subscriptions default to "no expiry" but servers may expire at will. // We evict mappings 30d past last activity; active devices re-register on // every app launch so real users never age out. const TTL_MS = 30 * 24 * 60 * 60 * 1000; // How long a verification code sits waiting for the client to poll it. const VERIFICATION_TTL_MS = 10 * 60 * 1000; function getPushDir(): string { return process.env.PUSH_DATA_DIR || path.join(process.cwd(), 'data', 'push'); } function subscriptionsPath(): string { return path.join(getPushDir(), 'subscriptions.json'); } interface SubscriptionsFile { records: Record; } class SubscriptionStore { private cache: Record = {}; private loaded = false; private writeQueue: Promise = Promise.resolve(); async load(): Promise { try { const raw = await readFile(subscriptionsPath(), 'utf-8'); const parsed = JSON.parse(raw) as SubscriptionsFile; this.cache = parsed.records ?? {}; this.evictExpired(); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { this.cache = {}; } else { logger.warn('push: failed to read subscriptions', { error: error instanceof Error ? error.message : 'Unknown error', }); this.cache = {}; } } this.loaded = true; } async ensureLoaded(): Promise { if (!this.loaded) await this.load(); } async get(id: string): Promise { await this.ensureLoaded(); const record = this.cache[id]; if (!record) return null; if (this.isExpired(record)) { delete this.cache[id]; await this.persist(); return null; } return record; } async put(id: string, record: SubscriptionRecord): Promise { await this.ensureLoaded(); this.cache[id] = record; await this.persist(); } async delete(id: string): Promise { await this.ensureLoaded(); if (id in this.cache) { delete this.cache[id]; await this.persist(); } } private isExpired(record: SubscriptionRecord): boolean { const age = Date.now() - (record.lastPushAt ?? record.createdAt); return age > TTL_MS; } private evictExpired(): void { for (const [id, record] of Object.entries(this.cache)) { if (this.isExpired(record)) delete this.cache[id]; } } private persist(): Promise { // Serialize writes so concurrent requests don't clobber each other. const next = this.writeQueue.then(() => this.flush()); this.writeQueue = next.catch(() => undefined); return next; } private async flush(): Promise { const dir = getPushDir(); if (!existsSync(dir)) { await mkdir(dir, { recursive: true }); } const target = subscriptionsPath(); const tmp = target + '.tmp'; const payload: SubscriptionsFile = { records: this.cache }; await writeFile(tmp, JSON.stringify(payload, null, 2), 'utf-8'); await rename(tmp, target); } } export const subscriptionStore = new SubscriptionStore(); export { VERIFICATION_TTL_MS };