From b0950cb4b44706ac9be344c3209d8bdfda375e16 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Tue, 8 Sep 2026 16:12:53 +0200 Subject: [PATCH 1/8] Run scheduled backups in an isolated subprocess --- scripts/scheduled-backup.ts | 16 ++++++++++++ src/index.ts | 1 + src/ops/backups.test.ts | 45 +++++++++++++++++++++++++++++++++ src/ops/backups.ts | 50 ++++++++++++++++++++++++++----------- 4 files changed, 98 insertions(+), 14 deletions(-) create mode 100644 scripts/scheduled-backup.ts diff --git a/scripts/scheduled-backup.ts b/scripts/scheduled-backup.ts new file mode 100644 index 0000000..83150ea --- /dev/null +++ b/scripts/scheduled-backup.ts @@ -0,0 +1,16 @@ +import { Database } from 'bun:sqlite'; +import { createBackup, latestBackupTime, type BackupConfig } from '../src/ops/backups'; + +const path = process.argv[2]; +if (!path || !process.argv[3]) throw new Error('Expected database path and backup configuration'); +const config = JSON.parse(process.argv[3]) as BackupConfig; +const latest = latestBackupTime(config); +if (latest && Date.now() - latest < config.intervalMs) console.log(JSON.stringify({ timestamp: latest, created: false })); +else { + const db = new Database(path, { readonly: true, strict: true }); + try { + db.exec('PRAGMA busy_timeout=5000'); + createBackup(db, config); + console.log(JSON.stringify({ timestamp: latestBackupTime(config), created: true })); + } finally { db.close(); } +} diff --git a/src/index.ts b/src/index.ts index be1e836..1eca478 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,7 @@ if (process.env.NODE_ENV !== "production") { } const backups = process.env.NODE_ENV === "production" ? startBackups(sqlite, backupConfig(databasePath)) : undefined; +await backups?.ready; if (import.meta.hot) import.meta.hot.dispose(() => backups?.stop()); const reminders = process.env.NODE_ENV === "production" ? startReminders(db, { token: readDiscordSharingConfig().token, origin: readAuthConfig().origin, commandsAvailable: /^[a-f\d]{64}$/i.test(process.env.DISCORD_PUBLIC_KEY ?? "") && Boolean(readAuthConfig().clientId) }) : undefined; diff --git a/src/ops/backups.test.ts b/src/ops/backups.test.ts index b9bd3ea..dc29f83 100644 --- a/src/ops/backups.test.ts +++ b/src/ops/backups.test.ts @@ -35,6 +35,7 @@ test("WAL snapshots restore committed progress, revoke sessions, replicate, and expect(readdirSync(config.replica!)).toHaveLength(2); expect(readdirSync(config.directory)).toContain("keep.txt"); const runner = startBackups(f.sqlite, config); + await runner.ready; expect(runner.state.failed).toBe(false); expect(runner.state.lastSuccess).toBeGreaterThan(0); runner.stop(); } finally { f.close(); rmSync(directory, { recursive: true, force: true }); } }); @@ -71,3 +72,47 @@ test("backup and restore CLIs work against a live database without overwriting i expect(f.sqlite.query('SELECT count(*) AS n FROM sessions').get()).toEqual({ n: 2 }); } finally { f.close(); rmSync(directory, { recursive: true, force: true }); } }); + +test('scheduled backups yield to the event loop, retain WAL data, and verify replicas', async () => { + const directory = mkdtempSync(join(tmpdir(), 'minabot-async-backup-')); + const path = join(directory, 'live.sqlite'), f = fixture(path); + let runner: ReturnType | undefined; + try { + f.sqlite.exec('PRAGMA journal_mode=WAL'); + await f.json('/habits', 'POST', { name: 'Async snapshot', method: 'manual' }, 201); + const config = backupConfig(path, { BACKUP_REPLICA_DIR: 'replica' }); + mkdirSync(config.replica!); + let yielded = false; + const timer = new Promise(resolve => setTimeout(() => { yielded = true; resolve(); }, 0)); + runner = startBackups(path, config); + expect(runner.state.lastSuccess).toBe(0); + await runner.ready; + expect(yielded).toBe(true); await timer; + expect(runner.state.failed).toBe(false); + expect(runner.state.lastSuccess).toBeGreaterThan(0); + const snapshots = readdirSync(config.directory).filter(file => file.endsWith('.sqlite')); + expect(snapshots).toHaveLength(1); + const check = new Database(join(config.replica!, snapshots[0]!), { readonly: true }); + try { expect(check.query('SELECT count(*) AS n FROM habits').get()).toEqual({ n: 1 }); } finally { check.close(); } + await runner.tick(); + expect(readdirSync(config.directory).filter(file => file.endsWith('.sqlite'))).toHaveLength(1); + } finally { runner?.stop(); f.close(); rmSync(directory, { recursive: true, force: true }); } +}); + +test('scheduled backup ticks coalesce, expose failure and recovery, and stop admitting work', async () => { + const config = backupConfig('/tmp/test-only.sqlite', {}); + let calls = 0, release!: (value: number) => void; + const run = async () => { calls++; return new Promise(resolve => { release = resolve; }); }; + const runner = startBackups('/tmp/test-only.sqlite', config, run); + expect(runner.tick()).toBe(runner.ready); + expect(calls).toBe(1); + release(123); await runner.ready; + expect(runner.state.lastSuccess).toBe(123); + const second = runner.tick(); expect(calls).toBe(2); + runner.stop(); release(456); await second; + await runner.tick(); expect(calls).toBe(2); + const failed = startBackups('/tmp/test-only.sqlite', config, async () => { throw new Error('test failure'); }); + await failed.ready; expect(failed.state.failed).toBe(true); failed.stop(); + const disabled = startBackups('/tmp/test-only.sqlite', { ...config, enabled: false }, run); + await disabled.ready; expect(calls).toBe(2); disabled.stop(); +}); diff --git a/src/ops/backups.ts b/src/ops/backups.ts index 1e1176c..8a2bc12 100644 --- a/src/ops/backups.ts +++ b/src/ops/backups.ts @@ -82,18 +82,40 @@ export function restoreBackup(source: string, destination: string) { } finally { if (existsSync(temporary)) unlinkSync(temporary); } return destination; } -export function startBackups(database: Database, config: BackupConfig) { - const state = { enabled: config.enabled, lastSuccess: 0, failed: false }; - if (!config.enabled) return { state, stop() {} }; - const tick = () => { - try { - const latest = latestBackupTime(config); - if (latest && Date.now() - latest < config.intervalMs) { state.lastSuccess = latest; state.failed = false; return; } - createBackup(database, config); state.lastSuccess = Date.now(); state.failed = false; - console.log(JSON.stringify({ event: "backup_completed", timestamp: new Date().toISOString() })); - } catch { state.failed = true; console.error(JSON.stringify({ event: "backup_failed", timestamp: new Date().toISOString() })); } - }; - tick(); - const timer = setInterval(tick, 60000); timer.unref(); - return { state, stop: () => clearInterval(timer) }; +/** Runs all filesystem and SQLite backup work in a separate Bun process. */ +export async function runScheduledBackup(database: string, config: BackupConfig): Promise { + const source = new URL("../../scripts/scheduled-backup.ts", import.meta.url); + const built = new URL("../scripts/scheduled-backup.ts", import.meta.url); + const script = (existsSync(source) ? source : built).pathname; + const child = Bun.spawn([process.execPath, script, resolve(database), JSON.stringify(config)], { + stdout: "pipe", stderr: "ignore", + }); + const [output, status] = await Promise.all([new Response(child.stdout).text(), child.exited]); + if (status !== 0) throw new Error("Scheduled backup process failed"); + const { timestamp, created } = JSON.parse(output) as { timestamp: number; created: boolean }; + if (!Number.isFinite(timestamp) || timestamp <= 0) throw new Error("Scheduled backup returned an invalid timestamp"); + if (created) console.log(JSON.stringify({ event: "backup_completed", timestamp: new Date(timestamp).toISOString() })); + return timestamp; +} +export function startBackups(database: Database | string, config: BackupConfig, run = runScheduledBackup) { + const state = { enabled: config.enabled, lastSuccess: 0, failed: false }; + let running: Promise | undefined; + let stopped = false; + const tick = (): Promise => { + if (stopped || !config.enabled) return Promise.resolve(); + if (running) return running; + running = (async () => { + try { + const path = typeof database === 'string' ? database : database.filename; + if (!path || path === ':memory:') throw new Error('Scheduled backups require a file database'); + state.lastSuccess = await run(path, config); state.failed = false; + } catch { state.failed = true; console.error(JSON.stringify({ event: "backup_failed", timestamp: new Date().toISOString() })); } + })().finally(() => { running = undefined; }); + return running; + }; + // Callers await readiness before accepting traffic, matching the original startup check. + const ready = tick(); + const timer = config.enabled ? setInterval(() => { void tick(); }, 60000) : undefined; + timer?.unref(); + return { state, ready, tick, stop: () => { stopped = true; clearInterval(timer); } }; } From 17190219fa04255d081144c48c4561a1e7d0a950 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Tue, 8 Sep 2026 16:13:44 +0200 Subject: [PATCH 2/8] Skip reminder habit evaluation for completed delivery slots --- src/reminders/worker.test.ts | 20 +++++++++++++++++++- src/reminders/worker.ts | 18 +++++++++++++++--- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/src/reminders/worker.test.ts b/src/reminders/worker.test.ts index 5df7cb1..d72e2e2 100644 --- a/src/reminders/worker.test.ts +++ b/src/reminders/worker.test.ts @@ -1,4 +1,4 @@ -import { expect, test } from "bun:test"; +import { expect, test, spyOn } from "bun:test"; import { eq } from "drizzle-orm"; import { fixture } from "../habits/test-fixture"; import { reminderDeliveries, reminderSettings } from "../db/schema"; @@ -213,3 +213,21 @@ test("messages include names only for the applicable reminders in the delivery b expect(JSON.parse(sent[1]!).allowed_mentions.parse).toEqual([]); } finally { f.close(); } }); + + +test('delivered slots skip habit evaluation while later slots and retries remain eligible', async () => { + const { f } = await setup(); const sent: string[] = []; + const { HabitService } = await import('../habits/service'); + let sync: ReturnType | undefined; + try { + f.db.update(reminderSettings).set({ quietStart: '00:00', quietEnd: '00:00', reminders: [ + { type: 'date', date: '2026-09-04', time: '10:00' }, { type: 'date', date: '2026-09-04', time: '12:00' }, + ] }).run(); + const tick = (clock: string) => sendDueReminders(f.db, config, transport(sent), () => Date.parse(`2026-09-04T${clock}:00Z`)); + await tick('08:00'); expect(sent).toHaveLength(1); + sync = spyOn(HabitService.prototype, 'sync'); + for (let i = 0; i < 5; i++) await tick('08:30'); + expect(sync).not.toHaveBeenCalled(); expect(sent).toHaveLength(1); + await tick('10:00'); expect(sync).toHaveBeenCalled(); expect(sent).toHaveLength(2); + } finally { sync?.mockRestore(); f.close(); } +}); diff --git a/src/reminders/worker.ts b/src/reminders/worker.ts index b987ec3..86ac3d3 100644 --- a/src/reminders/worker.ts +++ b/src/reminders/worker.ts @@ -22,14 +22,26 @@ export async function sendDueReminders(db: AppDatabase, config: ReminderConfig, // Retain recent delivery history and deduplication records without unbounded growth. db.delete(reminderDeliveries).where(lt(reminderDeliveries.updatedAt, now() - 90 * 86400000)).run(); for (const { user } of rows) { - function due() { + function due(checkDeliveries = false) { const currentUser = db.select().from(users).where(eq(users.id, user.id)).get(); const prefs = db.select().from(reminderSettings).where(eq(reminderSettings.userId, user.id)).get(); if (!currentUser || !prefs?.enabled) return null; const timestamp = now(); const clock = localClock(timestamp, currentUser.timezone); if (inQuietHours(clock, prefs.quietStart, prefs.quietEnd)) return null; - const times = timesForDate(prefs, localDate(timestamp, currentUser.timezone)).filter(time => time <= clock); + const date = localDate(timestamp, currentUser.timezone); + const times = timesForDate(prefs, date).filter(time => time <= clock); if (!times.length) return null; + if (checkDeliveries) { + const previous = db.select().from(reminderDeliveries).where(and(eq(reminderDeliveries.userId, user.id), eq(reminderDeliveries.date, date))).all(); + // This is only a cheap eligibility hint. The transaction below still owns claims. + // Let stale pending records reach its recovery step before skipping the user. + const stalePending = previous.some(row => row.status === 'pending' && row.updatedAt < timestamp - 60000); + if (!stalePending) { + const legacy = previous.find(row => row.slot === '*'); + const claimable = (prior: typeof previous[number] | undefined) => !prior || (prior.status === 'deferred' && (prior.retryAt ?? 0) <= timestamp); + if (previous.some(row => row.status === 'pending') || (legacy ? !claimable(legacy) : !times.some(slot => claimable(previous.find(row => row.slot === slot))))) return null; + } + } const service = new HabitService(db, { id: currentUser.id, discordId: currentUser.discordId, username: currentUser.username, displayName: currentUser.globalName ?? currentUser.username, avatarUrl: null, timezone: currentUser.timezone }, timestamp); service.sync(); // Avoid sending an artificial "today" after a backwards timezone change. @@ -37,7 +49,7 @@ export async function sendDueReminders(db: AppDatabase, config: ReminderConfig, const remaining = service.list().filter(habit => { const day = service.day(habit.id, service.today); return day.due && !day.complete; }).length; return remaining ? { date: service.today, remaining, timezone: currentUser.timezone, times, entries: entriesForDate(prefs, service.today) } : null; } - const initial = due(); if (!initial) continue; + const initial = due(true); if (!initial) continue; const claims = db.transaction(tx => { // A crashed/aborted send must not block later times indefinitely. tx.update(reminderDeliveries).set({ status: "uncertain" }).where(and(eq(reminderDeliveries.userId, user.id), eq(reminderDeliveries.status, "pending"), lt(reminderDeliveries.updatedAt, now() - 60000))).run(); From e3e07404f84812db04fddf69a7fbb6508582915d Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Tue, 8 Sep 2026 16:15:08 +0200 Subject: [PATCH 3/8] Cache bounded Discord avatars and coalesce downloads --- src/sharing/avatar-cache.test.ts | 38 ++++++++++++++++++++ src/sharing/avatar-cache.ts | 61 ++++++++++++++++++++++++++++++++ src/sharing/live-card-image.ts | 11 ++---- 3 files changed, 102 insertions(+), 8 deletions(-) create mode 100644 src/sharing/avatar-cache.test.ts create mode 100644 src/sharing/avatar-cache.ts diff --git a/src/sharing/avatar-cache.test.ts b/src/sharing/avatar-cache.test.ts new file mode 100644 index 0000000..9309ddd --- /dev/null +++ b/src/sharing/avatar-cache.test.ts @@ -0,0 +1,38 @@ +import { expect, test } from 'bun:test'; +import { createCanvas } from '@napi-rs/canvas'; +import { createAvatarCache } from './avatar-cache'; + +test('avatars share in-flight downloads and invalidate by URL or expiry', async () => { + const png = await createCanvas(16, 16).encode('png'); + let calls = 0, timestamp = 0; + const request = Object.assign(async () => { calls++; return new Response(new Uint8Array(png)); }, { preconnect: fetch.preconnect }); + const avatar = createAvatarCache({ request, now: () => timestamp, successTtlMs: 100 }); + const [one, two] = await Promise.all([avatar('avatar/hash1'), avatar('avatar/hash1')]); + expect(one).not.toBeNull(); expect(one).toBe(two); expect(calls).toBe(1); + expect(await avatar('avatar/hash1')).toBe(one); expect(calls).toBe(1); + await avatar('avatar/hash2'); expect(calls).toBe(2); + timestamp = 100; await avatar('avatar/hash1'); expect(calls).toBe(3); +}); + +test('avatar cache evicts least recently used images and retries negative entries after their TTL', async () => { + const png = await createCanvas(16, 16).encode('png'); + let calls = 0, timestamp = 0, fail = false; + const request = Object.assign(async () => { calls++; return fail ? new Response(null, { status: 503 }) : new Response(new Uint8Array(png)); }, { preconnect: fetch.preconnect }); + const avatar = createAvatarCache({ request, now: () => timestamp, maxEntries: 2, failureTtlMs: 10 }); + await avatar('a'); await avatar('b'); await avatar('a'); await avatar('c'); + expect(calls).toBe(3); await avatar('b'); expect(calls).toBe(4); + fail = true; + expect(await avatar('bad')).toBeNull(); expect(await avatar('bad')).toBeNull(); expect(calls).toBe(5); + timestamp = 10; fail = false; + expect(await avatar('bad')).not.toBeNull(); expect(calls).toBe(6); +}); + +test('avatar downloads have a concurrency bound and oversized decoded images use the fallback', async () => { + let release!: (response: Response) => void, calls = 0; + const request = Object.assign(async () => { calls++; return new Promise(resolve => { release = resolve; }); }, { preconnect: fetch.preconnect }); + const avatar = createAvatarCache({ request, maxPending: 1 }); + const first = avatar('one'), duplicate = avatar('one'); + expect(await avatar('two')).toBeNull(); expect(calls).toBe(1); + release(new Response(new Uint8Array(await createCanvas(257, 1).encode('png')))); + expect(await first).toBeNull(); expect(await duplicate).toBeNull(); +}); diff --git a/src/sharing/avatar-cache.ts b/src/sharing/avatar-cache.ts new file mode 100644 index 0000000..5cf7a1d --- /dev/null +++ b/src/sharing/avatar-cache.ts @@ -0,0 +1,61 @@ +import { loadImage } from '@napi-rs/canvas'; + +async function boundedBytes(response: Response) { + const reader = response.body?.getReader(); + if (!reader) return null; + const chunks: Uint8Array[] = []; let size = 0; + try { + while (true) { + const { value, done } = await reader.read(); + if (done) return Buffer.concat(chunks); + size += value.byteLength; + if (size > 1024 * 1024) { await reader.cancel(); return null; } + chunks.push(value); + } + } finally { reader.releaseLock(); } +} + +type Avatar = Awaited>; +type Options = { + request?: typeof fetch; + now?: () => number; + maxEntries?: number; + maxPending?: number; + successTtlMs?: number; + failureTtlMs?: number; +}; +/** Hash-based Discord URLs invalidate naturally; bound both cached images and downloads. */ +export function createAvatarCache({ request = ((url, init) => fetch(url, init)) as typeof fetch, now = Date.now, maxEntries = 128, maxPending = 4, successTtlMs = 3600000, failureTtlMs = 30000 }: Options = {}) { + const cache = new Map(); + const pending = new Map>(); + return async (url: string): Promise => { + const cached = cache.get(url); + if (cached && cached.expires > now()) { + cache.delete(url); cache.set(url, cached); + return cached.image; + } + cache.delete(url); + const existing = pending.get(url); + if (existing) return existing; + if (pending.size >= maxPending) return null; + const job = (async () => { + let image: Avatar | null = null; + try { + const response = await request(url, { redirect: 'error', signal: AbortSignal.timeout(3000) }); + if (response.ok) { + const bytes = await boundedBytes(response); + if (bytes) { + const decoded = await loadImage(Buffer.from(bytes)); + if (decoded.width <= 256 && decoded.height <= 256) image = decoded; + } + } + } catch { /* A brief negative cache keeps an unavailable CDN out of the hot path. */ } + cache.set(url, { image, expires: now() + (image ? successTtlMs : failureTtlMs) }); + while (cache.size > maxEntries) cache.delete(cache.keys().next().value!); + return image; + })().finally(() => { pending.delete(url); }); + pending.set(url, job); + return job; + }; +} +export const cachedAvatar = createAvatarCache(); diff --git a/src/sharing/live-card-image.ts b/src/sharing/live-card-image.ts index e3535bd..60921a8 100644 --- a/src/sharing/live-card-image.ts +++ b/src/sharing/live-card-image.ts @@ -1,4 +1,5 @@ -import { createCanvas, GlobalFonts, loadImage } from "@napi-rs/canvas"; +import { cachedAvatar } from './avatar-cache'; +import { createCanvas, GlobalFonts } from "@napi-rs/canvas"; import { existsSync } from "node:fs"; import { eq } from "drizzle-orm"; import type { AppDatabase } from "../auth"; @@ -52,13 +53,7 @@ export async function renderLiveCard(data: LiveImageData, theme: Theme) { const canvas = createCanvas(layout.width, layout.height); const ctx = canvas.getContext('2d'); const colors = themePalette[theme]; - let avatar: Awaited> | null = null; - if (data.avatarUrl) { - try { - const response = await fetch(data.avatarUrl, { redirect: 'error', signal: AbortSignal.timeout(3000) }); - if (response.ok) avatar = await loadImage(Buffer.from(await response.arrayBuffer())); - } catch { /* Match the snapshot card's neutral avatar fallback. */ } - } + const avatar = data.avatarUrl ? await cachedAvatar(data.avatarUrl) : null; const rect = (x: number, y: number, w: number, h: number, color: string, radius = 0) => { ctx.fillStyle = color; ctx.beginPath(); ctx.roundRect(x, y, Math.max(0, w), Math.max(0, h), radius); ctx.fill(); }; From ba049efdc8cd730f2ebefe110d084e768eaf3b79 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Tue, 8 Sep 2026 16:18:05 +0200 Subject: [PATCH 4/8] Bound and deduplicate server image rendering per account --- src/discord/command-graphics.ts | 2 +- src/discord/command-image.ts | 9 +++++-- src/discord/progress-image.ts | 9 +++++-- src/discord/progress.ts | 4 ++-- src/rendering/queue.test.ts | 27 +++++++++++++++++++++ src/rendering/queue.ts | 42 +++++++++++++++++++++++++++++++++ src/sharing/live-card-image.ts | 9 +++++-- src/sharing/live-discord.ts | 11 ++++++--- 8 files changed, 101 insertions(+), 12 deletions(-) create mode 100644 src/rendering/queue.test.ts create mode 100644 src/rendering/queue.ts diff --git a/src/discord/command-graphics.ts b/src/discord/command-graphics.ts index 128f34c..cc966e0 100644 --- a/src/discord/command-graphics.ts +++ b/src/discord/command-graphics.ts @@ -55,7 +55,7 @@ export function commandGraphicAction(c: Context, action: string, data: ControlDa ]; const reply = message(c, content, [row(...controls)], false); c.defer(async () => { - try { return { message: reply.data as import('./types').Message, image: await renderCommandCard(model, theme), imageDescription: `${model.title}. ${model.summary}. ${model.subtitle}. Page ${model.page + 1} of ${model.pages}.` }; } + try { return { message: reply.data as import('./types').Message, image: await renderCommandCard(model, theme, c.service.user.id), imageDescription: `${model.title}. ${model.summary}. ${model.subtitle}. Page ${model.page + 1} of ${model.pages}.` }; } catch { return { message: { ...(reply.data as import('./types').Message), content: `The graphic could not be rendered.\n${content}`.slice(0, 2000) } }; } }); return { type: 5, data: c.interaction.guild_id ? { flags: 64 } : {} }; diff --git a/src/discord/command-image.ts b/src/discord/command-image.ts index ea8282c..dac5e0d 100644 --- a/src/discord/command-image.ts +++ b/src/discord/command-image.ts @@ -1,3 +1,4 @@ +import { renderImage } from '../rendering/queue'; import { createCanvas } from '@napi-rs/canvas'; import '../sharing/live-card-image'; import { themePalette, type Theme } from '../lib/theme'; @@ -7,7 +8,10 @@ export type CardRow = { label: string; detail: string; value?: string; ratio?: n export type CommandCard = { title: string; owner: string; subtitle: string; summary: string; note: string; rows: CardRow[]; page: number; pages: number; history?: { date: string; ratio: number | null; future: boolean; color: string }[] }; export const commandCardSize = (data: CommandCard) => ({ width: 1200, height: 350 + Math.max(1, data.rows.length) * 116 + (data.history ? 164 : 0) + 100 }); /** Fixed-size rows and text keep every page readable, including very large accounts. */ -export async function renderCommandCard(data: CommandCard, theme: Theme = 'light') { +export function renderCommandCard(data: CommandCard, theme: Theme = 'light', userId = 'unscoped') { + return renderImage('command', userId, data, theme, () => draw(data, theme)); +} +async function draw(data: CommandCard, theme: Theme) { if (data.rows.length > 6 || (data.history && data.history.length > 14)) throw new Error('Paginate command cards before rendering.'); const { width, height } = commandCardSize(data), canvas = createCanvas(width, height), ctx = canvas.getContext('2d'); const p = themePalette[theme]; @@ -61,5 +65,6 @@ export async function renderCommandCard(data: CommandCard, theme: Theme = 'light rect(64, height - 92, 1072, 1, p.rule); text(data.note, 64, height - 48, 21, 860, p.muted); text(`${data.page + 1} / ${data.pages}`, 1035, height - 48, 21, 100, p.muted); - return new Uint8Array(await canvas.encode('png')); + try { return new Uint8Array(await canvas.encode('png')); } + finally { canvas.width = 1; canvas.height = 1; } } diff --git a/src/discord/progress-image.ts b/src/discord/progress-image.ts index c00822a..6aa35d5 100644 --- a/src/discord/progress-image.ts +++ b/src/discord/progress-image.ts @@ -1,3 +1,4 @@ +import { renderImage } from '../rendering/queue'; import { createCanvas } from '@napi-rs/canvas'; // Import the existing renderer's bundled font registration and data contract. import { type LiveImageData } from '../sharing/live-card-image'; @@ -8,7 +9,10 @@ import { cardDate } from '../lib/progress-card'; export const progressImageSize = { width: 1200, height: 860 }; /** Focused, chronological 30-day view; constant font/cell sizes at any habit count. */ -export async function renderProgressImage(data: LiveImageData, theme: Theme = 'light') { +export function renderProgressImage(data: LiveImageData, theme: Theme = 'light', userId = 'unscoped') { + return renderImage('progress', userId, data, theme, () => draw(data, theme)); +} +async function draw(data: LiveImageData, theme: Theme) { const habit = data.habits[0]; if (!habit || data.habits.length !== 1 || habit.days.length !== 30) throw new Error('Choose one habit and a 30-day range.'); const canvas = createCanvas(progressImageSize.width, progressImageSize.height); @@ -62,5 +66,6 @@ export async function renderProgressImage(data: LiveImageData, theme: Theme = 'l text(item.label, x + 36, 772, 21, 230, palette.muted); }); text('A little, every day.', 64, 822, 27, 1072, palette.muted, true); - return new Uint8Array(await canvas.encode('png')); + try { return new Uint8Array(await canvas.encode('png')); } + finally { canvas.width = 1; canvas.height = 1; } } diff --git a/src/discord/progress.ts b/src/discord/progress.ts index f1bb0a1..56eac98 100644 --- a/src/discord/progress.ts +++ b/src/discord/progress.ts @@ -24,7 +24,7 @@ function imageData(c: Context, data: ControlData) { return { ...result, avatarUrl: null }; } async function render(c: Context, data: ControlData, source = imageData(c, data)) { - return renderProgressImage(source, data.theme ?? 'light'); + return renderProgressImage(source, data.theme ?? 'light', c.service.user.id); } function output(reply: Reply, image?: Uint8Array): DeferredMessage { return { message: reply.data as Message, image }; } function defer(c: Context, job: () => Promise): Reply { @@ -87,7 +87,7 @@ export function progressAction(c: Context, action: string, data: ControlData): R const source = imageData(c, data); const image = await render(c, data, source); if (fingerprint(imageData(c, data)) !== data.expected) throw new ApiError(409, 'Progress changed during rendering. Open a fresh preview.'); - const delivery = await ops.sharing.send(c.service.user, data.deliveryId!, new File([image], 'mina-habits-progress.png', { type: 'image/png' })); + const delivery = await ops.sharing.send(c.service.user, data.deliveryId!, new File([new Uint8Array(image)], 'mina-habits-progress.png', { type: 'image/png' })); return output(message(c, delivery.status === 'sent' ? `Snapshot shared.${delivery.messageUrl ? ` [Open snapshot](${delivery.messageUrl})` : ''}` : 'Discord could not confirm delivery. Check the destination before creating another snapshot; this action will not be retried.', [], false)); } const state = action === 'progress.pause' ? await ops.live.pause(c.service.user.id) : await ops.live.start(c.service.user, data.theme ?? 'light'); diff --git a/src/rendering/queue.test.ts b/src/rendering/queue.test.ts new file mode 100644 index 0000000..272b113 --- /dev/null +++ b/src/rendering/queue.test.ts @@ -0,0 +1,27 @@ +import { expect, test } from 'bun:test'; +import { createRenderQueue, RenderBusyError } from './queue'; + +const result = new Uint8Array([1, 2, 3]); +test('render queue bounds global concurrency and shares only the same owner and content', async () => { + const queue = createRenderQueue({ concurrency: 2, maxPending: 4, maxPerUser: 2 }); + let active = 0, peak = 0, calls = 0; + const run = async () => { calls++; active++; peak = Math.max(peak, active); await Bun.sleep(5); active--; return result; }; + const first = queue('alice', 'one', run); + expect(queue('alice', 'one', run)).toBe(first); + const all = [first, queue('alice', 'two', run), queue('bob', 'one', run), queue('carol', 'one', run)]; + await Promise.all(all); expect(calls).toBe(4); expect(peak).toBe(2); + expect(await queue('alice', 'one', run)).toBe(result); expect(calls).toBe(5); +}); + +test('render admission enforces per-user and global capacity and recovers after failures', async () => { + const queue = createRenderQueue({ concurrency: 1, maxPending: 2, maxPerUser: 1 }); + let release!: () => void; + const first = queue('alice', 'a', async () => { await new Promise(resolve => { release = resolve; }); throw new Error('render failed'); }); + const failure = first.catch(error => error); + await expect(queue('alice', 'b', async () => result)).rejects.toBeInstanceOf(RenderBusyError); + const second = queue('bob', 'b', async () => result); + await expect(queue('carol', 'c', async () => result)).rejects.toBeInstanceOf(RenderBusyError); + while (!release) await Bun.sleep(1); + release(); expect((await failure).message).toBe('render failed'); await second; + expect(await queue('alice', 'a', async () => result)).toBe(result); +}); diff --git a/src/rendering/queue.ts b/src/rendering/queue.ts new file mode 100644 index 0000000..49b8e23 --- /dev/null +++ b/src/rendering/queue.ts @@ -0,0 +1,42 @@ +import { createHash } from 'node:crypto'; +import { ApiError } from '../habits/service'; + +export class RenderBusyError extends ApiError { + constructor() { super(429, 'Image rendering is busy. Try again shortly.'); } +} +type Job = { run: () => Promise; resolve: (image: Uint8Array) => void; reject: (reason: unknown) => void }; +/** One process-wide admission boundary for every server canvas renderer. */ +export function createRenderQueue({ concurrency = 2, maxPending = 16, maxPerUser = 2 } = {}) { + const pending = new Map>(); + const owners = new Map(); + const waiting: Job[] = []; + let active = 0; + function pump() { + while (active < concurrency && waiting.length) { + const job = waiting.shift()!; active++; + // Yield before drawing so a burst of callers cannot monopolize this turn. + setTimeout(() => { + void Promise.resolve().then(job.run).then(job.resolve, job.reject).finally(() => { active--; pump(); }); + }, 0); + } + } + return (userId: string, key: string, run: Job['run']): Promise => { + const scopedKey = JSON.stringify([userId, key]); + const existing = pending.get(scopedKey); + if (existing) return existing; + if (pending.size >= maxPending || (owners.get(userId) ?? 0) >= maxPerUser) return Promise.reject(new RenderBusyError()); + owners.set(userId, (owners.get(userId) ?? 0) + 1); + const work = new Promise((resolve, reject) => { waiting.push({ run, resolve, reject }); }).finally(() => { + pending.delete(scopedKey); + const count = owners.get(userId)! - 1; + if (count) owners.set(userId, count); else owners.delete(userId); + }); + pending.set(scopedKey, work); pump(); + return work; + }; +} +const schedule = createRenderQueue(); +export function renderImage(kind: string, userId: string, data: unknown, theme: string, run: Job['run']) { + const hash = createHash('sha256').update(JSON.stringify([kind, data, theme])).digest('hex'); + return schedule(userId, hash, run); +} diff --git a/src/sharing/live-card-image.ts b/src/sharing/live-card-image.ts index 60921a8..1dab9c4 100644 --- a/src/sharing/live-card-image.ts +++ b/src/sharing/live-card-image.ts @@ -1,3 +1,4 @@ +import { renderImage } from '../rendering/queue'; import { cachedAvatar } from './avatar-cache'; import { createCanvas, GlobalFonts } from "@napi-rs/canvas"; import { existsSync } from "node:fs"; @@ -48,7 +49,10 @@ export function liveCardLayout(count: number) { } /** A deterministic 16:9 PNG. Every active habit receives a panel; none are paginated away. */ -export async function renderLiveCard(data: LiveImageData, theme: Theme) { +export function renderLiveCard(data: LiveImageData, theme: Theme, userId = 'unscoped') { + return renderImage('live', userId, data, theme, () => draw(data, theme)); +} +async function draw(data: LiveImageData, theme: Theme) { const layout = liveCardLayout(data.habits.length); const canvas = createCanvas(layout.width, layout.height); const ctx = canvas.getContext('2d'); @@ -127,5 +131,6 @@ export async function renderLiveCard(data: LiveImageData, theme: Theme) { rect(64, 1010, 1792, 1, colors.rule); text('Totals cover this entire period', 64, 1050, 28, 1200, colors.muted); ctx.textAlign = 'right'; text(`${data.habits.length} active habits`, 1856, 1050, 28, 536, colors.muted); - return canvas.encode('png'); + try { return new Uint8Array(await canvas.encode('png')); } + finally { canvas.width = 1; canvas.height = 1; } } diff --git a/src/sharing/live-discord.ts b/src/sharing/live-discord.ts index abe18c3..7a0d738 100644 --- a/src/sharing/live-discord.ts +++ b/src/sharing/live-discord.ts @@ -1,3 +1,4 @@ +import { RenderBusyError } from '../rendering/queue'; import { createHash, randomBytes } from "node:crypto"; import { eq } from "drizzle-orm"; import { Hono } from "hono"; @@ -81,7 +82,7 @@ export function createLiveDiscord(db: AppDatabase, auth: ReturnType 4 * 1024 * 1024) { update(id, { status: 'error', error: 'The rendered image exceeds the 4 MB sharing limit.' }); return; } // A stop or account deletion during image generation cancels the outbound edit. if (stopped || !['active', 'sending'].includes(row(id)?.status ?? '')) return; @@ -139,7 +140,11 @@ export function createLiveDiscord(db: AppDatabase, auth: ReturnType deliver(id)).catch(() => { + const work = queue.then(() => deliver(id)).catch(error => { + if (error instanceof RenderBusyError && !stopped && ['active', 'sending'].includes(row(id)?.status ?? '')) { + update(id, { dirty: true, retryAt: now() + 1000, error: 'Image rendering is busy; update queued.' }); + schedule(id, 1000); return; + } if (!stopped && row(id)) { update(id, { status: 'error', error: 'Could not render or update the card. Try resuming updates.' }); } }).finally(() => running.delete(id)); queue = work; running.set(id, work); return work; @@ -152,7 +157,7 @@ export function createLiveDiscord(db: AppDatabase, auth: ReturnType c.json(state(c.get('user').id))); app.get('/preview', async c => { const theme = c.req.query('theme') === 'dark' ? 'dark' : 'light'; - const image = await renderLiveCard(liveCardData(db, c.get('user').id, now()), theme); + const image = await renderLiveCard(liveCardData(db, c.get('user').id, now()), theme, c.get('user').id); c.header('Content-Type', 'image/png'); c.header('Content-Disposition', 'inline; filename="minabot-live-progress.png"'); return c.body(new Uint8Array(image)); }); From c3b52e8fe1e50b7175eee174c1006ad59a952d22 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Tue, 8 Sep 2026 16:18:05 +0200 Subject: [PATCH 5/8] Resolve backup subprocess paths with URL decoding --- src/ops/backups.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ops/backups.ts b/src/ops/backups.ts index 8a2bc12..ea0d77a 100644 --- a/src/ops/backups.ts +++ b/src/ops/backups.ts @@ -1,3 +1,4 @@ +import { fileURLToPath } from "node:url"; import { Database } from "bun:sqlite"; import { chmodSync, closeSync, constants, copyFileSync, existsSync, fsyncSync, linkSync, mkdirSync, openSync, readdirSync, renameSync, rmSync, statSync, unlinkSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; @@ -86,7 +87,7 @@ export function restoreBackup(source: string, destination: string) { export async function runScheduledBackup(database: string, config: BackupConfig): Promise { const source = new URL("../../scripts/scheduled-backup.ts", import.meta.url); const built = new URL("../scripts/scheduled-backup.ts", import.meta.url); - const script = (existsSync(source) ? source : built).pathname; + const script = fileURLToPath(existsSync(source) ? source : built); const child = Bun.spawn([process.execPath, script, resolve(database), JSON.stringify(config)], { stdout: "pipe", stderr: "ignore", }); From 149ec36c77cd4a36196a3a405a50c4322ef4f173 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Tue, 8 Sep 2026 16:21:37 +0200 Subject: [PATCH 6/8] Prepare worker and Discord snapshots through bounded catch-up --- src/discord/progress.test.ts | 14 ++++++++ src/discord/progress.ts | 8 +++-- src/discord/routes.ts | 16 ++++++---- src/reminders/worker.test.ts | 14 ++++++++ src/reminders/worker.ts | 58 +++++++++++++++++++--------------- src/sharing/live-card-image.ts | 13 +++++--- src/sharing/live-discord.ts | 6 ++-- src/sharing/routes.ts | 24 +++++++------- 8 files changed, 99 insertions(+), 54 deletions(-) diff --git a/src/discord/progress.test.ts b/src/discord/progress.test.ts index 1e7c711..c18497e 100644 --- a/src/discord/progress.test.ts +++ b/src/discord/progress.test.ts @@ -112,3 +112,17 @@ test('snapshot quotas are shared with website operations and new controls cannot f.setDiscordTime('2026-09-04T12:16:00Z'); expect((await click(preview, 'Choose public destination')).data.content).toContain('expired'); }); + +test('a website edit during a deferred public share invalidates the prepared snapshot', async () => { + const env = setup(); + const habit = await f.json('/habits', 'POST', { name: 'Reading', method: 'manual' }, 201); + const panel = await f.run({ data: { name: 'progress' } }); + await click(panel, 'Preview a habit', false, [habit.id]); const preview = await env.drain(); + await click(preview, 'Choose public destination'); const confirmation = await env.drain(); + await click(confirmation, 'Share snapshot publicly'); + const delivery = env.drain(); + await f.json(`/habits/${habit.id}/days/2026-09-04/progress`, 'PUT', { done: true }); + const result = await delivery; + expect(result.data.content).toContain('changed'); + expect(env.sends).toHaveLength(0); +}); diff --git a/src/discord/progress.ts b/src/discord/progress.ts index 56eac98..236453c 100644 --- a/src/discord/progress.ts +++ b/src/discord/progress.ts @@ -1,6 +1,6 @@ import { createHash } from 'node:crypto'; import { renderProgressImage } from './progress-image'; -import { ApiError } from '../habits/service'; +import { ApiError, HabitService } from '../habits/service'; import { liveCardData } from '../sharing/live-card-image'; import type { createSharingRoutes } from '../sharing/routes'; import type { createLiveDiscord } from '../sharing/live-discord'; @@ -14,9 +14,11 @@ export type ProgressOperations = { export type DeferredMessage = { message: Message; image?: Uint8Array; imageDescription?: string }; const fingerprint = (value: unknown) => createHash('sha256').update(JSON.stringify(value)).digest('hex'); function imageData(c: Context, data: ControlData) { - const result = liveCardData(c.db, c.service.user.id, c.now); + // Preparation happened before dispatch. Fresh read caches detect edits across deferred awaits. + const snapshot = new HabitService(c.db, c.service.user, c.now); + const result = liveCardData(c.db, c.service.user.id, c.now, snapshot); if (data.habitId) { - const habits = c.service.list(); + const habits = snapshot.list(); const index = habits.findIndex(h => h.id === data.habitId); if (index < 0) throw new ApiError(409, 'This habit changed. Run /progress again.'); result.habits = [result.habits[index]!]; diff --git a/src/discord/routes.ts b/src/discord/routes.ts index 1905933..c213d33 100644 --- a/src/discord/routes.ts +++ b/src/discord/routes.ts @@ -54,15 +54,17 @@ export function createDiscordRoutes(db: AppDatabase, config: CommandConfig, now // Help needs no account creation, habit synchronization, or saved UI state. if (i.type === 2 && i.data?.name === 'help') return c.json(help(!!i.guild_id, config.origin)); let pending: (() => Promise) | undefined; - const reply = db.transaction((): Reply => { - const existing = db.select().from(users).where(eq(users.discordId, profile.id)).get(); - const userId = existing?.id ?? crypto.randomUUID(); - if (!existing) db.insert(users).values({ id: userId, discordId: profile.id, username: profile.username, globalName: profile.global_name, avatarHash: profile.avatar, createdAt: now(), updatedAt: now() }).run(); + // Resolve the account before queueing catch-up; no transaction is held across yields. + const existing = db.select().from(users).where(eq(users.discordId, profile.id)).get(); + const userId = existing?.id ?? crypto.randomUUID(); + if (!existing) db.insert(users).values({ id: userId, discordId: profile.id, username: profile.username, globalName: profile.global_name, avatarHash: profile.avatar, createdAt: now(), updatedAt: now() }).run(); + const receipt = db.select().from(discordReceipts).where(eq(discordReceipts.id, i.id)).get(); + if (receipt && receipt.expiresAt > now()) return c.json(receipt.response); + const owner = publicUser(db.select().from(users).where(eq(users.id, userId)).get()!); + const reply = await HabitService.withPrepared(db, owner, now, service => db.transaction((): Reply => { const receipt = db.select().from(discordReceipts).where(eq(discordReceipts.id, i.id)).get(); if (receipt && receipt.expiresAt > now()) return receipt.response; - const user = publicUser(db.select().from(users).where(eq(users.id, userId)).get()!); const controls = new ControlStore(db, userId, now()); controls.prune(); - const service = new HabitService(db, user, now()); service.sync(); const ctx: Context = { db, service, controls, interaction: i, now: now(), origin: config.origin, remindersAvailable: config.remindersAvailable ?? false, changed: () => changed(userId), progress: dispatch.progress, defer: i.token ? job => { pending = job; } : undefined }; let result: Reply; try { @@ -83,7 +85,7 @@ export function createDiscordRoutes(db: AppDatabase, config: CommandConfig, now } db.insert(discordReceipts).values({ id: i.id, userId, response: result, expiresAt: now() + 86400000 }).run(); return result; - }); + })); if (pending) { const work = pending; const job = async () => { diff --git a/src/reminders/worker.test.ts b/src/reminders/worker.test.ts index d72e2e2..279175f 100644 --- a/src/reminders/worker.test.ts +++ b/src/reminders/worker.test.ts @@ -231,3 +231,17 @@ test('delivered slots skip habit evaluation while later slots and retries remain await tick('10:00'); expect(sync).toHaveBeenCalled(); expect(sent).toHaveLength(2); } finally { sync?.mockRestore(); f.close(); } }); + +test('reminder catch-up yields and rechecks opt-out before claiming or sending', async () => { + const f = fixture(); let requests = 0; + try { + await f.json('/habits', 'POST', { name: 'Backlog', method: 'tasks', tasks: Array.from({ length: 50 }, (_, i) => ({ name: `Task ${i}` })) }, 201); + f.db.insert(reminderSettings).values({ userId: 'alice', ...defaultReminder, enabled: true, updatedAt: 0 }).run(); + const work = sendDueReminders(f.db, config, async () => { requests++; throw new Error('No sends expected'); }, () => Date.parse('2027-09-04T18:00:00Z')); + await new Promise(resolve => setTimeout(resolve, 0)); + f.db.update(reminderSettings).set({ enabled: false }).run(); + await work; + expect(requests).toBe(0); + expect(f.db.select().from(reminderDeliveries).all()).toHaveLength(0); + } finally { f.close(); } +}); diff --git a/src/reminders/worker.ts b/src/reminders/worker.ts index 86ac3d3..db55b83 100644 --- a/src/reminders/worker.ts +++ b/src/reminders/worker.ts @@ -22,34 +22,40 @@ export async function sendDueReminders(db: AppDatabase, config: ReminderConfig, // Retain recent delivery history and deduplication records without unbounded growth. db.delete(reminderDeliveries).where(lt(reminderDeliveries.updatedAt, now() - 90 * 86400000)).run(); for (const { user } of rows) { - function due(checkDeliveries = false) { - const currentUser = db.select().from(users).where(eq(users.id, user.id)).get(); - const prefs = db.select().from(reminderSettings).where(eq(reminderSettings.userId, user.id)).get(); - if (!currentUser || !prefs?.enabled) return null; - const timestamp = now(); const clock = localClock(timestamp, currentUser.timezone); - if (inQuietHours(clock, prefs.quietStart, prefs.quietEnd)) return null; - const date = localDate(timestamp, currentUser.timezone); - const times = timesForDate(prefs, date).filter(time => time <= clock); - if (!times.length) return null; - if (checkDeliveries) { - const previous = db.select().from(reminderDeliveries).where(and(eq(reminderDeliveries.userId, user.id), eq(reminderDeliveries.date, date))).all(); - // This is only a cheap eligibility hint. The transaction below still owns claims. - // Let stale pending records reach its recovery step before skipping the user. - const stalePending = previous.some(row => row.status === 'pending' && row.updatedAt < timestamp - 60000); - if (!stalePending) { - const legacy = previous.find(row => row.slot === '*'); - const claimable = (prior: typeof previous[number] | undefined) => !prior || (prior.status === 'deferred' && (prior.retryAt ?? 0) <= timestamp); - if (previous.some(row => row.status === 'pending') || (legacy ? !claimable(legacy) : !times.some(slot => claimable(previous.find(row => row.slot === slot))))) return null; + async function due(checkDeliveries = false) { + function eligible() { + const currentUser = db.select().from(users).where(eq(users.id, user.id)).get(); + const prefs = db.select().from(reminderSettings).where(eq(reminderSettings.userId, user.id)).get(); + if (!currentUser || !prefs?.enabled) return null; + const timestamp = now(); const clock = localClock(timestamp, currentUser.timezone); + if (inQuietHours(clock, prefs.quietStart, prefs.quietEnd)) return null; + const date = localDate(timestamp, currentUser.timezone); + const times = timesForDate(prefs, date).filter(time => time <= clock); + if (!times.length) return null; + if (checkDeliveries) { + const previous = db.select().from(reminderDeliveries).where(and(eq(reminderDeliveries.userId, user.id), eq(reminderDeliveries.date, date))).all(); + // This is only a cheap eligibility hint. The transaction below still owns claims. + // Let stale pending records reach its recovery step before skipping the user. + const stalePending = previous.some(row => row.status === 'pending' && row.updatedAt < timestamp - 60000); + if (!stalePending) { + const legacy = previous.find(row => row.slot === '*'); + const claimable = (prior: typeof previous[number] | undefined) => !prior || (prior.status === 'deferred' && (prior.retryAt ?? 0) <= timestamp); + if (previous.some(row => row.status === 'pending') || (legacy ? !claimable(legacy) : !times.some(slot => claimable(previous.find(row => row.slot === slot))))) return null; + } } + return { currentUser, prefs, date, times }; } - const service = new HabitService(db, { id: currentUser.id, discordId: currentUser.discordId, username: currentUser.username, displayName: currentUser.globalName ?? currentUser.username, avatarUrl: null, timezone: currentUser.timezone }, timestamp); - service.sync(); - // Avoid sending an artificial "today" after a backwards timezone change. - if (service.today !== localDate(timestamp, currentUser.timezone)) return null; - const remaining = service.list().filter(habit => { const day = service.day(habit.id, service.today); return day.due && !day.complete; }).length; - return remaining ? { date: service.today, remaining, timezone: currentUser.timezone, times, entries: entriesForDate(prefs, service.today) } : null; + const initial = eligible(); if (!initial) return null; + const owner = initial.currentUser; + return HabitService.withPrepared(db, { id: owner.id, discordId: owner.discordId, username: owner.username, displayName: owner.globalName ?? owner.username, avatarUrl: null, timezone: owner.timezone }, now, service => { + // Revalidate preferences and local date after bounded catch-up may have yielded. + const latest = eligible(); + if (!latest || service.today !== latest.date || service.user.timezone !== latest.currentUser.timezone) return null; + const remaining = service.list().filter(habit => { const day = service.day(habit.id, service.today); return day.due && !day.complete; }).length; + return remaining ? { date: service.today, remaining, timezone: service.user.timezone, times: latest.times, entries: entriesForDate(latest.prefs, service.today) } : null; + }); } - const initial = due(true); if (!initial) continue; + const initial = await due(true); if (!initial) continue; const claims = db.transaction(tx => { // A crashed/aborted send must not block later times indefinitely. tx.update(reminderDeliveries).set({ status: "uncertain" }).where(and(eq(reminderDeliveries.userId, user.id), eq(reminderDeliveries.status, "pending"), lt(reminderDeliveries.updatedAt, now() - 60000))).run(); @@ -87,7 +93,7 @@ export async function sendDueReminders(db: AppDatabase, config: ReminderConfig, const destination = channel.ok ? await channel.json().catch(() => null) as { id?: string } | null : null; if (!destination?.id || !/^\d{17,20}$/.test(destination.id)) { update("failed"); continue; } // A user may opt out or finish a habit while Discord opens the DM channel. - const latest = due(); + const latest = await due(); if (!latest || latest.date !== initial.date || !claims.some(claim => claim.slot === "*" || latest.times.includes(claim.slot))) { update("skipped"); continue; } const names = [...new Set(latest.entries.filter(entry => claims.some(claim => claim.slot === "*" ? latest.times.includes(entry.time) : claim.slot === entry.time)).map(entry => entry.name?.trim()).filter((name): name is string => Boolean(name)))]; // Bound the label prefix so the count and link always fit Discord's message limit. diff --git a/src/sharing/live-card-image.ts b/src/sharing/live-card-image.ts index 1dab9c4..992a541 100644 --- a/src/sharing/live-card-image.ts +++ b/src/sharing/live-card-image.ts @@ -3,7 +3,7 @@ import { cachedAvatar } from './avatar-cache'; import { createCanvas, GlobalFonts } from "@napi-rs/canvas"; import { existsSync } from "node:fs"; import { eq } from "drizzle-orm"; -import type { AppDatabase } from "../auth"; +import { publicUser, type AppDatabase } from "../auth"; import { users } from "../db/schema"; import { HabitService, ApiError } from "../habits/service"; import { addDays } from "../habits/calendar"; @@ -25,11 +25,16 @@ for (const [file, family] of [ } } -export function liveCardData(db: AppDatabase, userId: string, now: number) { +export async function loadLiveCardData(db: AppDatabase, userId: string, now: () => number) { + const user = db.select().from(users).where(eq(users.id, userId)).get(); + if (!user) throw new ApiError(404, 'Account unavailable.'); + return HabitService.withPrepared(db, publicUser(user), now, service => liveCardData(db, userId, service.now, service)); +} +export function liveCardData(db: AppDatabase, userId: string, now: number, prepared?: HabitService) { const user = db.select().from(users).where(eq(users.id, userId)).get(); if (!user) throw new ApiError(404, "Account unavailable."); - const service = new HabitService(db, { ...user, displayName: user.globalName ?? user.username, avatarUrl: null }, now); - service.sync(); + const service = prepared ?? new HabitService(db, { ...user, displayName: user.globalName ?? user.username, avatarUrl: null }, now); + if (!prepared) service.sync(); const from = addDays(service.today, -29); const avatarUrl = /^\d{17,20}$/.test(user.discordId) && /^(?:a_)?[a-f0-9]{32}$/i.test(user.avatarHash ?? '') ? `https://cdn.discordapp.com/avatars/${user.discordId}/${user.avatarHash}.png?size=128` : null; diff --git a/src/sharing/live-discord.ts b/src/sharing/live-discord.ts index 7a0d738..c54de1c 100644 --- a/src/sharing/live-discord.ts +++ b/src/sharing/live-discord.ts @@ -8,7 +8,7 @@ import type { AppDatabase, AuthEnv, createAuth } from "../auth"; import { liveDiscordCards, users } from "../db/schema"; import { ApiError } from "../habits/service"; import { endOfDay, localDate } from "../habits/calendar"; -import { liveCardData, renderLiveCard } from "./live-card-image"; +import { loadLiveCardData, renderLiveCard } from "./live-card-image"; import type { PublicUser } from "../shared/user"; import type { DiscordSharingConfig } from "./config"; import type { DiscordFetch } from "./routes"; @@ -65,7 +65,7 @@ export function createLiveDiscord(db: AppDatabase, auth: ReturnType 0) { schedule(id, delay); return; } update(id, { dirty: false, retryAt: null }); - const data = liveCardData(db, id, now()); + const data = await loadLiveCardData(db, id, now); // Hash the rendered content, excluding timestamps and private task details. const hash = createHash('sha256').update(JSON.stringify({ data, theme: current.theme, renderer: 3, content: liveMessageText })).digest('hex'); if (current.messageId && current.imageHash === hash) { @@ -157,7 +157,7 @@ export function createLiveDiscord(db: AppDatabase, auth: ReturnType c.json(state(c.get('user').id))); app.get('/preview', async c => { const theme = c.req.query('theme') === 'dark' ? 'dark' : 'light'; - const image = await renderLiveCard(liveCardData(db, c.get('user').id, now()), theme, c.get('user').id); + const image = await renderLiveCard(await loadLiveCardData(db, c.get('user').id, now), theme, c.get('user').id); c.header('Content-Type', 'image/png'); c.header('Content-Disposition', 'inline; filename="minabot-live-progress.png"'); return c.body(new Uint8Array(image)); }); diff --git a/src/sharing/routes.ts b/src/sharing/routes.ts index 0bf81b8..2425405 100644 --- a/src/sharing/routes.ts +++ b/src/sharing/routes.ts @@ -78,17 +78,19 @@ export function createSharingRoutes(db: AppDatabase, auth: ReturnType { const input = await json(c.req.raw, shareInput); - const service = new HabitService(db, c.get("user"), now()); service.sync(); - if (input.to > service.today) throw new ApiError(422, "The end date cannot be after today."); - const result: ShareData = { from: input.from, to: input.to, timezone: service.user.timezone, habits: input.habitIds.map(id => { - const habit = service.current(id); - const chart = service.calendar([id], input.from, input.to, service.settings(id), false); - return { name: habit.name, color: chart.settings.mainColor, - legend: { empty: chart.settings.emptyColor, complete: chart.settings.mainColor, - partial: chart.days.some(d => d.shadeCount > 1) ? [0.25, 0.5, 0.75].map(ratio => shade(ratio, 4, chart.settings, false).color) : [] }, - completed: chart.days.filter(d => d.completed > 0).length, scheduled: chart.days.filter(d => d.due > 0).length, - days: chart.days.map(d => ({ date: d.date, color: d.color, due: d.due > 0, complete: d.completed > 0, ratio: d.ratio })) }; - }) }; + const result = await HabitService.withPrepared(db, c.get("user"), now, service => { + if (input.to > service.today) throw new ApiError(422, "The end date cannot be after today."); + const result: ShareData = { from: input.from, to: input.to, timezone: service.user.timezone, habits: input.habitIds.map(id => { + const habit = service.current(id); + const chart = service.calendar([id], input.from, input.to, service.settings(id), false); + return { name: habit.name, color: chart.settings.mainColor, + legend: { empty: chart.settings.emptyColor, complete: chart.settings.mainColor, + partial: chart.days.some(d => d.shadeCount > 1) ? [0.25, 0.5, 0.75].map(ratio => shade(ratio, 4, chart.settings, false).color) : [] }, + completed: chart.days.filter(d => d.completed > 0).length, scheduled: chart.days.filter(d => d.due > 0).length, + days: chart.days.map(d => ({ date: d.date, color: d.color, due: d.due > 0, complete: d.completed > 0, ratio: d.ratio })) }; + }) }; + return result; + }); result.botAvatarUrl = await botAvatarUrl(); return c.json(result); }); From 27beb011445b96765781c99bd75e56a61b4d0b12 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Tue, 8 Sep 2026 16:24:33 +0200 Subject: [PATCH 7/8] Revalidate reminders synchronously after readiness yields --- src/reminders/worker.test.ts | 20 +++++++++++ src/reminders/worker.ts | 65 +++++++++++++++++++----------------- 2 files changed, 55 insertions(+), 30 deletions(-) diff --git a/src/reminders/worker.test.ts b/src/reminders/worker.test.ts index 279175f..d42795e 100644 --- a/src/reminders/worker.test.ts +++ b/src/reminders/worker.test.ts @@ -245,3 +245,23 @@ test('reminder catch-up yields and rechecks opt-out before claiming or sending', expect(f.db.select().from(reminderDeliveries).all()).toHaveLength(0); } finally { f.close(); } }); + +test('an opt-out queued at readiness completion prevents the final reminder POST', async () => { + const { f } = await setup(); let posts = 0, evaluations = 0; + const { HabitService } = await import('../habits/service'); + const original = HabitService.withPrepared; + const prepare = spyOn(HabitService, 'withPrepared').mockImplementation((db, user, now, run) => original(db, user, now, service => { + const result = run(service); + if (++evaluations === 2) queueMicrotask(() => f.db.update(reminderSettings).set({ enabled: false }).run()); + return result; + })); + try { + await sendDueReminders(f.db, config, async url => { + if (!url.endsWith('/users/@me/channels')) posts++; + return Response.json({ id: '223456789012345678' }); + }, () => Date.parse('2026-09-04T18:00:00Z')); + expect(evaluations).toBe(2); + expect(posts).toBe(0); + expect(f.db.select().from(reminderDeliveries).get()?.status).toBe('skipped'); + } finally { prepare.mockRestore(); f.close(); } +}); diff --git a/src/reminders/worker.ts b/src/reminders/worker.ts index db55b83..06be856 100644 --- a/src/reminders/worker.ts +++ b/src/reminders/worker.ts @@ -22,38 +22,40 @@ export async function sendDueReminders(db: AppDatabase, config: ReminderConfig, // Retain recent delivery history and deduplication records without unbounded growth. db.delete(reminderDeliveries).where(lt(reminderDeliveries.updatedAt, now() - 90 * 86400000)).run(); for (const { user } of rows) { - async function due(checkDeliveries = false) { - function eligible() { - const currentUser = db.select().from(users).where(eq(users.id, user.id)).get(); - const prefs = db.select().from(reminderSettings).where(eq(reminderSettings.userId, user.id)).get(); - if (!currentUser || !prefs?.enabled) return null; - const timestamp = now(); const clock = localClock(timestamp, currentUser.timezone); - if (inQuietHours(clock, prefs.quietStart, prefs.quietEnd)) return null; - const date = localDate(timestamp, currentUser.timezone); - const times = timesForDate(prefs, date).filter(time => time <= clock); - if (!times.length) return null; - if (checkDeliveries) { - const previous = db.select().from(reminderDeliveries).where(and(eq(reminderDeliveries.userId, user.id), eq(reminderDeliveries.date, date))).all(); - // This is only a cheap eligibility hint. The transaction below still owns claims. - // Let stale pending records reach its recovery step before skipping the user. - const stalePending = previous.some(row => row.status === 'pending' && row.updatedAt < timestamp - 60000); - if (!stalePending) { - const legacy = previous.find(row => row.slot === '*'); - const claimable = (prior: typeof previous[number] | undefined) => !prior || (prior.status === 'deferred' && (prior.retryAt ?? 0) <= timestamp); - if (previous.some(row => row.status === 'pending') || (legacy ? !claimable(legacy) : !times.some(slot => claimable(previous.find(row => row.slot === slot))))) return null; - } + function eligible(checkDeliveries = false) { + const currentUser = db.select().from(users).where(eq(users.id, user.id)).get(); + const prefs = db.select().from(reminderSettings).where(eq(reminderSettings.userId, user.id)).get(); + if (!currentUser || !prefs?.enabled) return null; + const timestamp = now(); const clock = localClock(timestamp, currentUser.timezone); + if (inQuietHours(clock, prefs.quietStart, prefs.quietEnd)) return null; + const date = localDate(timestamp, currentUser.timezone); + const times = timesForDate(prefs, date).filter(time => time <= clock); + if (!times.length) return null; + if (checkDeliveries) { + const previous = db.select().from(reminderDeliveries).where(and(eq(reminderDeliveries.userId, user.id), eq(reminderDeliveries.date, date))).all(); + // This is only a cheap eligibility hint. The transaction below still owns claims. + // Let stale pending records reach its recovery step before skipping the user. + const stalePending = previous.some(row => row.status === 'pending' && row.updatedAt < timestamp - 60000); + if (!stalePending) { + const legacy = previous.find(row => row.slot === '*'); + const claimable = (prior: typeof previous[number] | undefined) => !prior || (prior.status === 'deferred' && (prior.retryAt ?? 0) <= timestamp); + if (previous.some(row => row.status === 'pending') || (legacy ? !claimable(legacy) : !times.some(slot => claimable(previous.find(row => row.slot === slot))))) return null; } - return { currentUser, prefs, date, times }; } - const initial = eligible(); if (!initial) return null; + return { currentUser, prefs, date, times }; + } + function readDue(prepared?: HabitService, checkDeliveries = false) { + const latest = eligible(checkDeliveries); if (!latest) return null; + const owner = latest.currentUser; + const service = prepared ?? new HabitService(db, { id: owner.id, discordId: owner.discordId, username: owner.username, displayName: owner.globalName ?? owner.username, avatarUrl: null, timezone: owner.timezone }, now()); + if (service.today !== latest.date || service.user.timezone !== owner.timezone) return null; + const remaining = service.list().filter(habit => { const day = service.day(habit.id, service.today); return day.due && !day.complete; }).length; + return remaining ? { date: service.today, remaining, timezone: service.user.timezone, times: latest.times, entries: entriesForDate(latest.prefs, service.today) } : null; + } + async function due(checkDeliveries = false) { + const initial = eligible(checkDeliveries); if (!initial) return null; const owner = initial.currentUser; - return HabitService.withPrepared(db, { id: owner.id, discordId: owner.discordId, username: owner.username, displayName: owner.globalName ?? owner.username, avatarUrl: null, timezone: owner.timezone }, now, service => { - // Revalidate preferences and local date after bounded catch-up may have yielded. - const latest = eligible(); - if (!latest || service.today !== latest.date || service.user.timezone !== latest.currentUser.timezone) return null; - const remaining = service.list().filter(habit => { const day = service.day(habit.id, service.today); return day.due && !day.complete; }).length; - return remaining ? { date: service.today, remaining, timezone: service.user.timezone, times: latest.times, entries: entriesForDate(latest.prefs, service.today) } : null; - }); + return HabitService.withPrepared(db, { id: owner.id, discordId: owner.discordId, username: owner.username, displayName: owner.globalName ?? owner.username, avatarUrl: null, timezone: owner.timezone }, now, service => readDue(service, checkDeliveries)); } const initial = await due(true); if (!initial) continue; const claims = db.transaction(tx => { @@ -93,7 +95,10 @@ export async function sendDueReminders(db: AppDatabase, config: ReminderConfig, const destination = channel.ok ? await channel.json().catch(() => null) as { id?: string } | null : null; if (!destination?.id || !/^\d{17,20}$/.test(destination.id)) { update("failed"); continue; } // A user may opt out or finish a habit while Discord opens the DM channel. - const latest = await due(); + const prepared = await due(); + // Releasing readiness can run a queued edit before this continuation. Re-read + // synchronously, with fresh caches, and invoke POST without another yield. + const latest = prepared ? readDue() : null; if (!latest || latest.date !== initial.date || !claims.some(claim => claim.slot === "*" || latest.times.includes(claim.slot))) { update("skipped"); continue; } const names = [...new Set(latest.entries.filter(entry => claims.some(claim => claim.slot === "*" ? latest.times.includes(entry.time) : claim.slot === entry.time)).map(entry => entry.name?.trim()).filter((name): name is string => Boolean(name)))]; // Bound the label prefix so the count and link always fit Discord's message limit. From 7c7a048b326a86051152b6488b2ec115184a01b9 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Tue, 8 Sep 2026 16:31:58 +0200 Subject: [PATCH 8/8] Initialize cold WAL bookkeeping for subprocess snapshots --- scripts/backup.ts | 4 +++- scripts/scheduled-backup.ts | 4 +++- src/ops/backups.test.ts | 33 ++++++++++++++++++++++++++++++++- 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/scripts/backup.ts b/scripts/backup.ts index a0396be..b6ceb90 100644 --- a/scripts/backup.ts +++ b/scripts/backup.ts @@ -1,5 +1,7 @@ import { Database } from "bun:sqlite"; import { databasePath } from "../src/db/config"; import { backupConfig, createBackup } from "../src/ops/backups"; -const db = new Database(databasePath, { readonly: true, strict: true }); +// SQLite needs writable WAL bookkeeping when the first snapshot opens a cold database. +// Only snapshot SQL runs here; never create a missing source file. +const db = new Database(databasePath, { readwrite: true, create: false, strict: true }); try { console.log(createBackup(db, backupConfig(databasePath))); } finally { db.close(); } diff --git a/scripts/scheduled-backup.ts b/scripts/scheduled-backup.ts index 83150ea..47ac3fb 100644 --- a/scripts/scheduled-backup.ts +++ b/scripts/scheduled-backup.ts @@ -7,7 +7,9 @@ const config = JSON.parse(process.argv[3]) as BackupConfig; const latest = latestBackupTime(config); if (latest && Date.now() - latest < config.intervalMs) console.log(JSON.stringify({ timestamp: latest, created: false })); else { - const db = new Database(path, { readonly: true, strict: true }); + // SQLite needs writable WAL bookkeeping when the first snapshot opens a cold database. + // Only snapshot SQL runs here; never create a missing source file. + const db = new Database(path, { readwrite: true, create: false, strict: true }); try { db.exec('PRAGMA busy_timeout=5000'); createBackup(db, config); diff --git a/src/ops/backups.test.ts b/src/ops/backups.test.ts index dc29f83..96a6385 100644 --- a/src/ops/backups.test.ts +++ b/src/ops/backups.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test"; import { Database } from "bun:sqlite"; -import { mkdtempSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { fixture } from "../habits/test-fixture"; @@ -116,3 +116,34 @@ test('scheduled backup ticks coalesce, expose failure and recovery, and stop adm const disabled = startBackups('/tmp/test-only.sqlite', { ...config, enabled: false }, run); await disabled.ready; expect(calls).toBe(2); disabled.stop(); }); + + +test('the first scheduled snapshot initializes cold WAL bookkeeping without changing source rows', async () => { + const directory = mkdtempSync(join(tmpdir(), 'minabot-cold-wal-')); + const path = join(directory, 'live.sqlite'), f = fixture(path); + f.sqlite.exec('PRAGMA journal_mode=DELETE'); f.close(); + const db = new Database(path, { readwrite: true, create: false, strict: true }); + let runner: ReturnType | undefined; + try { + db.exec('PRAGMA journal_mode=WAL'); + expect(existsSync(`${path}-shm`)).toBe(false); + runner = startBackups(db, backupConfig(path, {})); + await runner.ready; + expect(runner.state.failed).toBe(false); + expect(runner.state.lastSuccess).toBeGreaterThan(0); + expect(db.query('SELECT count(*) AS n FROM users').get()).toEqual({ n: 2 }); + expect(db.query('SELECT count(*) AS n FROM sessions').get()).toEqual({ n: 2 }); + expect(db.query('SELECT total_changes() AS n').get()).toEqual({ n: 0 }); + } finally { runner?.stop(); db.close(); rmSync(directory, { recursive: true, force: true }); } +}); + +test('the scheduled snapshot never creates a missing source database', async () => { + const directory = mkdtempSync(join(tmpdir(), 'minabot-missing-source-')); + const path = join(directory, 'missing.sqlite'); + const runner = startBackups(path, backupConfig(path, {})); + try { + await runner.ready; + expect(runner.state.failed).toBe(true); + expect(existsSync(path)).toBe(false); + } finally { runner.stop(); rmSync(directory, { recursive: true, force: true }); } +});