clone

a self-hosted sync node for lofi apps: jazz sync over iroh, ticket-gated, with no static ip or cloud needed

main
lofi-node / src / ticket-activity.ts
ticket-activity.ts 2.9K
1// Last-seen activity per ticket, persisted in a daemon-owned sidecar so the2// CLI can answer "is that phone still using this ticket?" without IPC. A3// separate file — never tickets.json — because tickets.json is CLI-written4// and mtime-hot-reloaded by the daemon: a second writer would race the CLI's5// read-modify-write and thrash the reload throttle. Writes are throttled and6// dirty-only; last-seen is operational telemetry where minute granularity is7// plenty.89interface ActivityFile {10 v: 1;11 seen: Record<string, string>;12}1314const FLUSH_INTERVAL_MS = 60_000;1516/** Daemon-owned last-seen tracking; file-backed when a dataDir is given. */17export class TicketActivity {18 #path: string | null;19 #seen = new Map<string, string>();20 #dirty = false;21 #lastFlush = 0;22 #inFlight: Promise<void> = Promise.resolve();2324 private constructor(path: string | null) {25 this.#path = path;26 }2728 static async load(dataDir?: string): Promise<TicketActivity> {29 const activity = new TicketActivity(dataDir ? `${dataDir}/ticket-activity.json` : null);30 if (activity.#path) {31 try {32 const parsed = JSON.parse(await Deno.readTextFile(activity.#path)) as ActivityFile;33 if (parsed.v === 1) {34 for (const [id, at] of Object.entries(parsed.seen)) activity.#seen.set(id, at);35 }36 } catch {37 // Missing or unreadable sidecar: telemetry starts fresh.38 }39 }40 return activity;41 }4243 /** Record activity on a ticket now; flushes at most once per interval. */44 note(ticketId: string): void {45 this.#seen.set(ticketId, new Date().toISOString());46 this.#dirty = true;47 if (Date.now() - this.#lastFlush >= FLUSH_INTERVAL_MS) {48 this.flush().catch(() => {});49 }50 }5152 /** The last recorded activity for a ticket, if any. */53 lastSeen(ticketId: string): string | undefined {54 return this.#seen.get(ticketId);55 }5657 /** Write the sidecar when dirty; called on interval hits and shutdown.58 * Writes are serialized, and a flush resolves only after every write59 * scheduled before it has landed. */60 flush(): Promise<void> {61 this.#inFlight = this.#inFlight.then(() => this.#write());62 return this.#inFlight;63 }6465 async #write(): Promise<void> {66 if (!this.#dirty || !this.#path) return;67 this.#dirty = false;68 this.#lastFlush = Date.now();69 const file: ActivityFile = { v: 1, seen: Object.fromEntries(this.#seen) };70 const tmp = `${this.#path}.${Deno.pid}.tmp`;71 await Deno.writeTextFile(tmp, JSON.stringify(file, null, 2) + "\n");72 await Deno.rename(tmp, this.#path);73 }74}7576/** Read-only sidecar view for the CLI (a separate process from the daemon). */77export async function readTicketActivity(dataDir: string): Promise<Map<string, string>> {78 try {79 const parsed = JSON.parse(80 await Deno.readTextFile(`${dataDir}/ticket-activity.json`),81 ) as ActivityFile;82 if (parsed.v !== 1) return new Map();83 return new Map(Object.entries(parsed.seen));84 } catch {85 return new Map();86 }87}