Merge worker performance improvements
This commit is contained in:
@@ -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(); }
|
||||
|
||||
18
scripts/scheduled-backup.ts
Normal file
18
scripts/scheduled-backup.ts
Normal 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(); }
|
||||
}
|
||||
@@ -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 } : {} };
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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]!];
|
||||
@@ -24,7 +26,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<DeferredMessage>): Reply {
|
||||
@@ -87,7 +89,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');
|
||||
|
||||
@@ -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<DeferredMessage>) | 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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
@@ -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,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 });
|
||||
} 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 }); }
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
@@ -82,18 +83,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<number> {
|
||||
const source = new URL("../../scripts/scheduled-backup.ts", import.meta.url);
|
||||
const built = new URL("../scripts/scheduled-backup.ts", import.meta.url);
|
||||
const script = fileURLToPath(existsSync(source) ? source : built);
|
||||
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<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); } };
|
||||
}
|
||||
|
||||
@@ -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,55 @@ 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<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(); }
|
||||
});
|
||||
|
||||
@@ -22,22 +22,42 @@ 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 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 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;
|
||||
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;
|
||||
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 = due(); if (!initial) continue;
|
||||
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 => {
|
||||
// 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();
|
||||
@@ -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;
|
||||
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 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.
|
||||
|
||||
27
src/rendering/queue.test.ts
Normal file
27
src/rendering/queue.test.ts
Normal 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
42
src/rendering/queue.ts
Normal 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);
|
||||
}
|
||||
38
src/sharing/avatar-cache.test.ts
Normal file
38
src/sharing/avatar-cache.test.ts
Normal 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();
|
||||
});
|
||||
61
src/sharing/avatar-cache.ts
Normal file
61
src/sharing/avatar-cache.ts
Normal 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();
|
||||
@@ -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 { 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";
|
||||
@@ -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();
|
||||
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;
|
||||
@@ -47,18 +54,15 @@ 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');
|
||||
const colors = themePalette[theme];
|
||||
let avatar: Awaited<ReturnType<typeof loadImage>> | 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();
|
||||
};
|
||||
@@ -132,5 +136,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; }
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { RenderBusyError } from '../rendering/queue';
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { Hono } from "hono";
|
||||
@@ -7,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";
|
||||
@@ -64,7 +65,7 @@ export function createLiveDiscord(db: AppDatabase, auth: ReturnType<typeof creat
|
||||
const delay = Math.max(current.retryAt ?? 0, channelRetryAt) - now();
|
||||
if (delay > 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) {
|
||||
@@ -81,7 +82,7 @@ export function createLiveDiscord(db: AppDatabase, auth: ReturnType<typeof creat
|
||||
guildId = channel.guild_id;
|
||||
} 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; }
|
||||
// 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<typeof creat
|
||||
clearTimeout(timers.get(id)); timers.delete(id);
|
||||
const existing = running.get(id);
|
||||
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.' }); }
|
||||
}).finally(() => running.delete(id));
|
||||
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('/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(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));
|
||||
});
|
||||
|
||||
@@ -78,17 +78,19 @@ export function createSharingRoutes(db: AppDatabase, auth: ReturnType<typeof cre
|
||||
|
||||
app.post("/preview", async c => {
|
||||
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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user