Merge worker performance improvements

This commit is contained in:
syntaxbullet
2026-09-08 16:35:45 +02:00
20 changed files with 477 additions and 74 deletions

View File

@@ -1,5 +1,7 @@
import { Database } from "bun:sqlite"; import { Database } from "bun:sqlite";
import { databasePath } from "../src/db/config"; import { databasePath } from "../src/db/config";
import { backupConfig, createBackup } from "../src/ops/backups"; 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(); } try { console.log(createBackup(db, backupConfig(databasePath))); } finally { db.close(); }

View File

@@ -0,0 +1,18 @@
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 {
// 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);
console.log(JSON.stringify({ timestamp: latestBackupTime(config), created: true }));
} finally { db.close(); }
}

View File

@@ -55,7 +55,7 @@ export function commandGraphicAction(c: Context, action: string, data: ControlDa
]; ];
const reply = message(c, content, [row(...controls)], false); const reply = message(c, content, [row(...controls)], false);
c.defer(async () => { 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) } }; } 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 } : {} }; return { type: 5, data: c.interaction.guild_id ? { flags: 64 } : {} };

View File

@@ -1,3 +1,4 @@
import { renderImage } from '../rendering/queue';
import { createCanvas } from '@napi-rs/canvas'; import { createCanvas } from '@napi-rs/canvas';
import '../sharing/live-card-image'; import '../sharing/live-card-image';
import { themePalette, type Theme } from '../lib/theme'; 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 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 }); 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. */ /** 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.'); 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 { width, height } = commandCardSize(data), canvas = createCanvas(width, height), ctx = canvas.getContext('2d');
const p = themePalette[theme]; 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); rect(64, height - 92, 1072, 1, p.rule);
text(data.note, 64, height - 48, 21, 860, p.muted); text(data.note, 64, height - 48, 21, 860, p.muted);
text(`${data.page + 1} / ${data.pages}`, 1035, height - 48, 21, 100, 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; }
} }

View File

@@ -1,3 +1,4 @@
import { renderImage } from '../rendering/queue';
import { createCanvas } from '@napi-rs/canvas'; import { createCanvas } from '@napi-rs/canvas';
// Import the existing renderer's bundled font registration and data contract. // Import the existing renderer's bundled font registration and data contract.
import { type LiveImageData } from '../sharing/live-card-image'; 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 }; export const progressImageSize = { width: 1200, height: 860 };
/** Focused, chronological 30-day view; constant font/cell sizes at any habit count. */ /** 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]; 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.'); 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); 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(item.label, x + 36, 772, 21, 230, palette.muted);
}); });
text('A little, every day.', 64, 822, 27, 1072, palette.muted, true); 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; }
} }

View File

@@ -112,3 +112,17 @@ test('snapshot quotas are shared with website operations and new controls cannot
f.setDiscordTime('2026-09-04T12:16:00Z'); f.setDiscordTime('2026-09-04T12:16:00Z');
expect((await click(preview, 'Choose public destination')).data.content).toContain('expired'); 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);
});

View File

@@ -1,6 +1,6 @@
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { renderProgressImage } from './progress-image'; import { renderProgressImage } from './progress-image';
import { ApiError } from '../habits/service'; import { ApiError, HabitService } from '../habits/service';
import { liveCardData } from '../sharing/live-card-image'; import { liveCardData } from '../sharing/live-card-image';
import type { createSharingRoutes } from '../sharing/routes'; import type { createSharingRoutes } from '../sharing/routes';
import type { createLiveDiscord } from '../sharing/live-discord'; import type { createLiveDiscord } from '../sharing/live-discord';
@@ -14,9 +14,11 @@ export type ProgressOperations = {
export type DeferredMessage = { message: Message; image?: Uint8Array; imageDescription?: string }; export type DeferredMessage = { message: Message; image?: Uint8Array; imageDescription?: string };
const fingerprint = (value: unknown) => createHash('sha256').update(JSON.stringify(value)).digest('hex'); const fingerprint = (value: unknown) => createHash('sha256').update(JSON.stringify(value)).digest('hex');
function imageData(c: Context, data: ControlData) { 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) { if (data.habitId) {
const habits = c.service.list(); const habits = snapshot.list();
const index = habits.findIndex(h => h.id === data.habitId); const index = habits.findIndex(h => h.id === data.habitId);
if (index < 0) throw new ApiError(409, 'This habit changed. Run /progress again.'); if (index < 0) throw new ApiError(409, 'This habit changed. Run /progress again.');
result.habits = [result.habits[index]!]; result.habits = [result.habits[index]!];
@@ -24,7 +26,7 @@ function imageData(c: Context, data: ControlData) {
return { ...result, avatarUrl: null }; return { ...result, avatarUrl: null };
} }
async function render(c: Context, data: ControlData, source = imageData(c, data)) { 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 output(reply: Reply, image?: Uint8Array): DeferredMessage { return { message: reply.data as Message, image }; }
function defer(c: Context, job: () => Promise<DeferredMessage>): Reply { function defer(c: Context, job: () => Promise<DeferredMessage>): Reply {
@@ -87,7 +89,7 @@ export function progressAction(c: Context, action: string, data: ControlData): R
const source = imageData(c, data); const source = imageData(c, data);
const image = await render(c, data, source); 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.'); 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)); 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'); const state = action === 'progress.pause' ? await ops.live.pause(c.service.user.id) : await ops.live.start(c.service.user, data.theme ?? 'light');

View File

@@ -54,15 +54,17 @@ export function createDiscordRoutes(db: AppDatabase, config: CommandConfig, now
// Help needs no account creation, habit synchronization, or saved UI state. // 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)); if (i.type === 2 && i.data?.name === 'help') return c.json(help(!!i.guild_id, config.origin));
let pending: (() => Promise<DeferredMessage>) | undefined; let pending: (() => Promise<DeferredMessage>) | undefined;
const reply = db.transaction((): Reply => { // 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 existing = db.select().from(users).where(eq(users.discordId, profile.id)).get();
const userId = existing?.id ?? crypto.randomUUID(); 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(); 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(); const receipt = db.select().from(discordReceipts).where(eq(discordReceipts.id, i.id)).get();
if (receipt && receipt.expiresAt > now()) return receipt.response; 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 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 }; 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; let result: Reply;
try { 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(); db.insert(discordReceipts).values({ id: i.id, userId, response: result, expiresAt: now() + 86400000 }).run();
return result; return result;
}); }));
if (pending) { if (pending) {
const work = pending; const work = pending;
const job = async () => { const job = async () => {

View File

@@ -15,6 +15,7 @@ if (process.env.NODE_ENV !== "production") {
} }
const backups = process.env.NODE_ENV === "production" ? startBackups(sqlite, backupConfig(databasePath)) : undefined; 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()); 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; 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;

View File

@@ -1,6 +1,6 @@
import { expect, test } from "bun:test"; import { expect, test } from "bun:test";
import { Database } from "bun:sqlite"; 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 { join } from "node:path";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { fixture } from "../habits/test-fixture"; import { fixture } from "../habits/test-fixture";
@@ -35,6 +35,7 @@ test("WAL snapshots restore committed progress, revoke sessions, replicate, and
expect(readdirSync(config.replica!)).toHaveLength(2); expect(readdirSync(config.replica!)).toHaveLength(2);
expect(readdirSync(config.directory)).toContain("keep.txt"); expect(readdirSync(config.directory)).toContain("keep.txt");
const runner = startBackups(f.sqlite, config); const runner = startBackups(f.sqlite, config);
await runner.ready;
expect(runner.state.failed).toBe(false); expect(runner.state.lastSuccess).toBeGreaterThan(0); runner.stop(); expect(runner.state.failed).toBe(false); expect(runner.state.lastSuccess).toBeGreaterThan(0); runner.stop();
} finally { f.close(); rmSync(directory, { recursive: true, force: true }); } } finally { f.close(); rmSync(directory, { recursive: true, force: true }); }
}); });
@@ -71,3 +72,78 @@ 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 }); expect(f.sqlite.query('SELECT count(*) AS n FROM sessions').get()).toEqual({ n: 2 });
} finally { f.close(); rmSync(directory, { recursive: true, force: true }); } } 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<typeof startBackups> | 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<void>(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<number>(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();
});
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<typeof startBackups> | 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 }); }
});

View File

@@ -1,3 +1,4 @@
import { fileURLToPath } from "node:url";
import { Database } from "bun:sqlite"; import { Database } from "bun:sqlite";
import { chmodSync, closeSync, constants, copyFileSync, existsSync, fsyncSync, linkSync, mkdirSync, openSync, readdirSync, renameSync, rmSync, statSync, unlinkSync } from "node:fs"; 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"; import { dirname, join, resolve } from "node:path";
@@ -82,18 +83,40 @@ export function restoreBackup(source: string, destination: string) {
} finally { if (existsSync(temporary)) unlinkSync(temporary); } } finally { if (existsSync(temporary)) unlinkSync(temporary); }
return destination; return destination;
} }
export function startBackups(database: Database, config: BackupConfig) { /** Runs all filesystem and SQLite backup work in a separate Bun process. */
const state = { enabled: config.enabled, lastSuccess: 0, failed: false }; export async function runScheduledBackup(database: string, config: BackupConfig): Promise<number> {
if (!config.enabled) return { state, stop() {} }; const source = new URL("../../scripts/scheduled-backup.ts", import.meta.url);
const tick = () => { const built = new URL("../scripts/scheduled-backup.ts", import.meta.url);
try { const script = fileURLToPath(existsSync(source) ? source : built);
const latest = latestBackupTime(config); const child = Bun.spawn([process.execPath, script, resolve(database), JSON.stringify(config)], {
if (latest && Date.now() - latest < config.intervalMs) { state.lastSuccess = latest; state.failed = false; return; } stdout: "pipe", stderr: "ignore",
createBackup(database, config); state.lastSuccess = Date.now(); state.failed = false; });
console.log(JSON.stringify({ event: "backup_completed", timestamp: new Date().toISOString() })); const [output, status] = await Promise.all([new Response(child.stdout).text(), child.exited]);
} catch { state.failed = true; console.error(JSON.stringify({ event: "backup_failed", timestamp: new Date().toISOString() })); } if (status !== 0) throw new Error("Scheduled backup process failed");
}; const { timestamp, created } = JSON.parse(output) as { timestamp: number; created: boolean };
tick(); if (!Number.isFinite(timestamp) || timestamp <= 0) throw new Error("Scheduled backup returned an invalid timestamp");
const timer = setInterval(tick, 60000); timer.unref(); if (created) console.log(JSON.stringify({ event: "backup_completed", timestamp: new Date(timestamp).toISOString() }));
return { state, stop: () => clearInterval(timer) }; return timestamp;
}
export function startBackups(database: Database | string, config: BackupConfig, run = runScheduledBackup) {
const state = { enabled: config.enabled, lastSuccess: 0, failed: false };
let running: Promise<void> | undefined;
let stopped = false;
const tick = (): Promise<void> => {
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); } };
} }

View File

@@ -1,4 +1,4 @@
import { expect, test } from "bun:test"; import { expect, test, spyOn } from "bun:test";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { fixture } from "../habits/test-fixture"; import { fixture } from "../habits/test-fixture";
import { reminderDeliveries, reminderSettings } from "../db/schema"; import { reminderDeliveries, reminderSettings } from "../db/schema";
@@ -213,3 +213,55 @@ test("messages include names only for the applicable reminders in the delivery b
expect(JSON.parse(sent[1]!).allowed_mentions.parse).toEqual([]); expect(JSON.parse(sent[1]!).allowed_mentions.parse).toEqual([]);
} finally { f.close(); } } 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<typeof spyOn> | 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(); }
});
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(); }
});
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(); }
});

View File

@@ -22,22 +22,42 @@ export async function sendDueReminders(db: AppDatabase, config: ReminderConfig,
// Retain recent delivery history and deduplication records without unbounded growth. // Retain recent delivery history and deduplication records without unbounded growth.
db.delete(reminderDeliveries).where(lt(reminderDeliveries.updatedAt, now() - 90 * 86400000)).run(); db.delete(reminderDeliveries).where(lt(reminderDeliveries.updatedAt, now() - 90 * 86400000)).run();
for (const { user } of rows) { for (const { user } of rows) {
function due() { function eligible(checkDeliveries = false) {
const currentUser = db.select().from(users).where(eq(users.id, user.id)).get(); 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(); const prefs = db.select().from(reminderSettings).where(eq(reminderSettings.userId, user.id)).get();
if (!currentUser || !prefs?.enabled) return null; if (!currentUser || !prefs?.enabled) return null;
const timestamp = now(); const clock = localClock(timestamp, currentUser.timezone); const timestamp = now(); const clock = localClock(timestamp, currentUser.timezone);
if (inQuietHours(clock, prefs.quietStart, prefs.quietEnd)) return null; 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 (!times.length) 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); if (checkDeliveries) {
service.sync(); const previous = db.select().from(reminderDeliveries).where(and(eq(reminderDeliveries.userId, user.id), eq(reminderDeliveries.date, date))).all();
// Avoid sending an artificial "today" after a backwards timezone change. // This is only a cheap eligibility hint. The transaction below still owns claims.
if (service.today !== localDate(timestamp, currentUser.timezone)) return null; // Let stale pending records reach its recovery step before skipping the user.
const remaining = service.list().filter(habit => { const day = service.day(habit.id, service.today); return day.due && !day.complete; }).length; const stalePending = previous.some(row => row.status === 'pending' && row.updatedAt < timestamp - 60000);
return remaining ? { date: service.today, remaining, timezone: currentUser.timezone, times, entries: entriesForDate(prefs, service.today) } : null; 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 initial = due(); if (!initial) continue; }
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 => readDue(service, checkDeliveries));
}
const initial = await due(true); if (!initial) continue;
const claims = db.transaction(tx => { const claims = db.transaction(tx => {
// A crashed/aborted send must not block later times indefinitely. // 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(); tx.update(reminderDeliveries).set({ status: "uncertain" }).where(and(eq(reminderDeliveries.userId, user.id), eq(reminderDeliveries.status, "pending"), lt(reminderDeliveries.updatedAt, now() - 60000))).run();
@@ -75,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; 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; } 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. // A user may opt out or finish a habit while Discord opens the DM channel.
const latest = 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; } 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)))]; 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. // Bound the label prefix so the count and link always fit Discord's message limit.

View File

@@ -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<void>(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);
});

42
src/rendering/queue.ts Normal file
View File

@@ -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<Uint8Array>; 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<string, Promise<Uint8Array>>();
const owners = new Map<string, number>();
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<Uint8Array> => {
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<Uint8Array>((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);
}

View File

@@ -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<Response>(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();
});

View File

@@ -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<ReturnType<typeof loadImage>>;
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<string, { image: Avatar | null; expires: number }>();
const pending = new Map<string, Promise<Avatar | null>>();
return async (url: string): Promise<Avatar | null> => {
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();

View File

@@ -1,7 +1,9 @@
import { createCanvas, GlobalFonts, loadImage } from "@napi-rs/canvas"; import { renderImage } from '../rendering/queue';
import { cachedAvatar } from './avatar-cache';
import { createCanvas, GlobalFonts } from "@napi-rs/canvas";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import type { AppDatabase } from "../auth"; import { publicUser, type AppDatabase } from "../auth";
import { users } from "../db/schema"; import { users } from "../db/schema";
import { HabitService, ApiError } from "../habits/service"; import { HabitService, ApiError } from "../habits/service";
import { addDays } from "../habits/calendar"; import { addDays } from "../habits/calendar";
@@ -23,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(); const user = db.select().from(users).where(eq(users.id, userId)).get();
if (!user) throw new ApiError(404, "Account unavailable."); if (!user) throw new ApiError(404, "Account unavailable.");
const service = new HabitService(db, { ...user, displayName: user.globalName ?? user.username, avatarUrl: null }, now); const service = prepared ?? new HabitService(db, { ...user, displayName: user.globalName ?? user.username, avatarUrl: null }, now);
service.sync(); if (!prepared) service.sync();
const from = addDays(service.today, -29); const from = addDays(service.today, -29);
const avatarUrl = /^\d{17,20}$/.test(user.discordId) && /^(?:a_)?[a-f0-9]{32}$/i.test(user.avatarHash ?? '') 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; ? `https://cdn.discordapp.com/avatars/${user.discordId}/${user.avatarHash}.png?size=128` : null;
@@ -47,18 +54,15 @@ export function liveCardLayout(count: number) {
} }
/** A deterministic 16:9 PNG. Every active habit receives a panel; none are paginated away. */ /** 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 layout = liveCardLayout(data.habits.length);
const canvas = createCanvas(layout.width, layout.height); const canvas = createCanvas(layout.width, layout.height);
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
const colors = themePalette[theme]; const colors = themePalette[theme];
let avatar: Awaited<ReturnType<typeof loadImage>> | null = null; const avatar = data.avatarUrl ? await cachedAvatar(data.avatarUrl) : 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 rect = (x: number, y: number, w: number, h: number, color: string, radius = 0) => { 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(); ctx.fillStyle = color; ctx.beginPath(); ctx.roundRect(x, y, Math.max(0, w), Math.max(0, h), radius); ctx.fill();
}; };
@@ -132,5 +136,6 @@ export async function renderLiveCard(data: LiveImageData, theme: Theme) {
rect(64, 1010, 1792, 1, colors.rule); rect(64, 1010, 1792, 1, colors.rule);
text('Totals cover this entire period', 64, 1050, 28, 1200, colors.muted); 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); 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; }
} }

View File

@@ -1,3 +1,4 @@
import { RenderBusyError } from '../rendering/queue';
import { createHash, randomBytes } from "node:crypto"; import { createHash, randomBytes } from "node:crypto";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { Hono } from "hono"; import { Hono } from "hono";
@@ -7,7 +8,7 @@ import type { AppDatabase, AuthEnv, createAuth } from "../auth";
import { liveDiscordCards, users } from "../db/schema"; import { liveDiscordCards, users } from "../db/schema";
import { ApiError } from "../habits/service"; import { ApiError } from "../habits/service";
import { endOfDay, localDate } from "../habits/calendar"; 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 { PublicUser } from "../shared/user";
import type { DiscordSharingConfig } from "./config"; import type { DiscordSharingConfig } from "./config";
import type { DiscordFetch } from "./routes"; import type { DiscordFetch } from "./routes";
@@ -64,7 +65,7 @@ export function createLiveDiscord(db: AppDatabase, auth: ReturnType<typeof creat
const delay = Math.max(current.retryAt ?? 0, channelRetryAt) - now(); const delay = Math.max(current.retryAt ?? 0, channelRetryAt) - now();
if (delay > 0) { schedule(id, delay); return; } if (delay > 0) { schedule(id, delay); return; }
update(id, { dirty: false, retryAt: null }); 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. // 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'); const hash = createHash('sha256').update(JSON.stringify({ data, theme: current.theme, renderer: 3, content: liveMessageText })).digest('hex');
if (current.messageId && current.imageHash === hash) { if (current.messageId && current.imageHash === hash) {
@@ -81,7 +82,7 @@ export function createLiveDiscord(db: AppDatabase, auth: ReturnType<typeof creat
guildId = channel.guild_id; guildId = channel.guild_id;
} catch { update(id, { status: 'error', error: 'Could not reach the Discord channel. Try again.' }); return; } } catch { update(id, { status: 'error', error: 'Could not reach the Discord channel. Try again.' }); return; }
} }
const png = await renderLiveCard(data, current.theme); const png = await renderLiveCard(data, current.theme, id);
if (png.byteLength > 4 * 1024 * 1024) { update(id, { status: 'error', error: 'The rendered image exceeds the 4 MB sharing limit.' }); return; } if (png.byteLength > 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. // A stop or account deletion during image generation cancels the outbound edit.
if (stopped || !['active', 'sending'].includes(row(id)?.status ?? '')) return; if (stopped || !['active', 'sending'].includes(row(id)?.status ?? '')) return;
@@ -139,7 +140,11 @@ export function createLiveDiscord(db: AppDatabase, auth: ReturnType<typeof creat
clearTimeout(timers.get(id)); timers.delete(id); clearTimeout(timers.get(id)); timers.delete(id);
const existing = running.get(id); const existing = running.get(id);
if (existing) return existing; if (existing) return existing;
const work = queue.then(() => 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.' }); } if (!stopped && row(id)) { update(id, { status: 'error', error: 'Could not render or update the card. Try resuming updates.' }); }
}).finally(() => running.delete(id)); }).finally(() => running.delete(id));
queue = work; running.set(id, work); return work; queue = work; running.set(id, work); return work;
@@ -152,7 +157,7 @@ export function createLiveDiscord(db: AppDatabase, auth: ReturnType<typeof creat
app.get('/', c => c.json(state(c.get('user').id))); app.get('/', c => c.json(state(c.get('user').id)));
app.get('/preview', async c => { app.get('/preview', async c => {
const theme = c.req.query('theme') === 'dark' ? 'dark' : 'light'; 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(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"'); c.header('Content-Type', 'image/png'); c.header('Content-Disposition', 'inline; filename="minabot-live-progress.png"');
return c.body(new Uint8Array(image)); return c.body(new Uint8Array(image));
}); });

View File

@@ -78,7 +78,7 @@ export function createSharingRoutes(db: AppDatabase, auth: ReturnType<typeof cre
app.post("/preview", async c => { app.post("/preview", async c => {
const input = await json(c.req.raw, shareInput); const input = await json(c.req.raw, shareInput);
const service = new HabitService(db, c.get("user"), now()); service.sync(); 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."); 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 result: ShareData = { from: input.from, to: input.to, timezone: service.user.timezone, habits: input.habitIds.map(id => {
const habit = service.current(id); const habit = service.current(id);
@@ -89,6 +89,8 @@ export function createSharingRoutes(db: AppDatabase, auth: ReturnType<typeof cre
completed: chart.days.filter(d => d.completed > 0).length, scheduled: chart.days.filter(d => d.due > 0).length, 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 })) }; 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(); result.botAvatarUrl = await botAvatarUrl();
return c.json(result); return c.json(result);
}); });