Prepare worker and Discord snapshots through bounded catch-up
This commit is contained in:
@@ -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]!];
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user