fix(api): serialize dated writes after request body parsing

This commit is contained in:
syntaxbullet
2026-09-04 09:49:00 +02:00
parent 18a1af9dfc
commit adb1cf43c1
3 changed files with 82 additions and 21 deletions

View File

@@ -49,3 +49,54 @@ test('expired occurrences preserve original expiry through repeated historical c
await tick(h, true); expect((await detail(h.id)).tasks[0].expiredAt).toBe(expired);
await tick(h, false); expect((await detail(h.id)).tasks[0].expiredAt).toBe(expired);
});
test('overlapping patches preserve unrelated changes', async () => {
const h = await f.json('/habits', 'POST', { name: 'Original', method: 'count', target: 8 }, 201);
await Promise.all([
f.json(`/habits/${h.id}`, 'PATCH', { name: 'Renamed' }),
f.json(`/habits/${h.id}`, 'PATCH', { target: 10 }),
]);
expect(await f.json(`/habits/${h.id}`)).toMatchObject({ name: 'Renamed', target: 10 });
});
test('count correction audit records inherited progress rather than the raw zero default', async () => {
const h = await f.json('/habits', 'POST', { name: 'Carry', method: 'count', target: 8, carryPartialProgress: true }, 201);
await f.json(`/habits/${h.id}/days/2026-09-04/progress`, 'PUT', { count: 7 });
f.setTime('2026-09-05T12:00Z');
await f.json(`/habits/${h.id}/days/2026-09-05/progress`, 'PUT', { count: 8 });
const audit = await f.json(`/habits/${h.id}/days/2026-09-05/audit`);
expect(audit.records[0].events[0].before.count).toBe(7);
});
function delayedPatch(path: string, input: object) {
let controller!: ReadableStreamDefaultController<Uint8Array>;
let began!: () => void;
const started = new Promise<void>(resolve => { began = resolve; });
const encoded = new TextEncoder().encode(JSON.stringify(input));
const stream = new ReadableStream<Uint8Array>({ start(value) { controller = value; }, pull() { began(); } });
const response = f.app.request(`${f.origin}/api${path}`, { method: 'PATCH', body: stream,
headers: { Cookie: `minabot_session=${'a'.repeat(43)}`, Origin: f.origin, 'Content-Type': 'application/json', 'Content-Length': String(encoded.length) } });
return { started, response, finish() { controller.enqueue(encoded); controller.close(); } };
}
test('a request streaming across midnight applies configuration on its actual write date', async () => {
const h = await taskHabit(); f.setTime('2026-09-04T21:59:59Z');
const pending = delayedPatch(`/habits/${h.id}`, { name: 'Tomorrow' }); await pending.started;
f.setTime('2026-09-04T22:01Z'); pending.finish();
expect((await pending.response).status).toBe(200);
expect((await f.json(`/habits/${h.id}`)).effectiveDate).toBe('2026-09-05');
expect((await detail(h.id)).name).toBe('Routine');
});
test('a streaming request observes timezone changes completed before its write', async () => {
const h = await taskHabit();
const pending = delayedPatch(`/habits/${h.id}`, { name: 'After travel' }); await pending.started;
await f.json('/me', 'PATCH', { timezone: 'Pacific/Kiritimati' }); pending.finish();
expect((await pending.response).status).toBe(200);
expect((await f.json(`/habits/${h.id}`)).effectiveDate).toBe('2026-09-05');
expect((await f.json(`/habits/${h.id}/days/2026-09-05`)).timezone).toBe('Pacific/Kiritimati');
expect((await detail(h.id)).name).toBe('Routine');
});
test('task occurrences retain definition order across dates and revisions', async () => {
const h = await f.json('/habits', 'POST', { name: 'Order', method: 'tasks', tasks: [{ name: 'First' }, { name: 'Second' }, { name: 'Third' }] }, 201);
for (let i = 0; i < 3; i++) f.sqlite.query('UPDATE task_occurrences SET id = ? WHERE task_id = ?').run(String(3 - i), h.tasks[i].id);
expect((await detail(h.id)).tasks.map((t: any) => t.name)).toEqual(['First', 'Second', 'Third']);
await f.json(`/habits/${h.id}`, 'PATCH', { name: 'Rename' });
f.setTime('2026-09-05T12:00Z');
expect((await f.json(`/habits/${h.id}/days/2026-09-05`)).tasks.map((t: any) => t.name)).toEqual(['First', 'Second', 'Third']);
});

View File

@@ -5,11 +5,11 @@ import type { AppDatabase, AuthEnv, createAuth } from '../auth';
import { HabitService, ApiError } from './service';
import { calendarSettingsSchema, chartInput, chartPatch, dateSchema, doneInput, habitInput, habitPatch, progressInput, taskInput, taskPatch, timezoneInput } from './contracts';
import { dayNumber } from './calendar';
type Env = AuthEnv & { Variables: { service: HabitService } };
async function body<T extends z.ZodType>(c: Context<Env>, schema: T): Promise<z.output<T>> {
if (!c.req.header('Content-Type')?.toLowerCase().startsWith('application/json')) throw new ApiError(400, 'Expected application/json');
let value: unknown;
try { value = await c.req.json(); } catch { throw new ApiError(400, 'Malformed JSON'); }
type Env = AuthEnv & { Variables: { service: HabitService; payload: { value: unknown; malformed: boolean } } };
function body<T extends z.ZodType>(c: Context<Env>, schema: T): z.output<T> {
if (c.req.header('Content-Type')?.split(';')[0]?.trim().toLowerCase() !== 'application/json') throw new ApiError(400, 'Expected application/json');
const { value, malformed } = c.get('payload');
if (malformed) throw new ApiError(400, 'Malformed JSON');
const result = schema.safeParse(value);
if (!result.success) throw new ApiError(422, result.error.issues.map(i => `${i.path.join('.') || 'body'}: ${i.message}`).join('; '));
return result.data;
@@ -34,6 +34,13 @@ export function createHabitRoutes(db: AppDatabase, auth: ReturnType<typeof creat
if (!protectedPath(c.req.path, c.req.method)) return next();
return authenticate(c, async () => {
const proceed = async () => { const response = await limit(c, async () => {
// Finish all asynchronous body reads before taking a dated database snapshot.
// The handlers below then read, validate and write synchronously in one turn.
const payload: Env['Variables']['payload'] = { value: undefined, malformed: false };
if (['POST', 'PATCH', 'PUT'].includes(c.req.method)) {
try { payload.value = await c.req.json(); } catch { payload.malformed = true; }
}
c.set('payload', payload);
const service = new HabitService(db, c.get('user'), now());
service.sync(); c.set('service', service); await next();
}); if (response) c.res = response; };
@@ -49,28 +56,28 @@ export function createHabitRoutes(db: AppDatabase, auth: ReturnType<typeof creat
if (!q.success) throw new ApiError(422, 'archived must be true or false');
return c.json({ habits: service(c).list(q.data.archived === 'true') });
});
app.post('/habits', async c => {
const habit = service(c).create(await body(c, habitInput));
app.post('/habits', c => {
const habit = service(c).create(body(c, habitInput));
c.header('Location', `/api/habits/${habit.id}`); return c.json(habit, 201);
});
app.get('/habits/:id', c => c.json(service(c).current(id(c))));
app.patch('/habits/:id', async c => c.json(service(c).edit(id(c), await body(c, habitPatch))));
app.patch('/habits/:id', c => c.json(service(c).edit(id(c), body(c, habitPatch))));
app.delete('/habits/:id', c => { service(c).edit(id(c), { archived: true }); return c.body(null, 204); });
app.get('/habits/:id/history', c => c.json({ revisions: service(c).history(id(c)) }));
app.get('/habits/:id/tasks', c => c.json({ tasks: service(c).tasks(id(c)) }));
app.post('/habits/:id/tasks', async c => c.json(service(c).changeTask(id(c), undefined, await body(c, taskInput)), 201));
app.post('/habits/:id/tasks', c => c.json(service(c).changeTask(id(c), undefined, body(c, taskInput)), 201));
app.get('/habits/:id/tasks/:taskId', c => {
const task = service(c).tasks(id(c)).find(t => t.id === c.req.param('taskId'));
if (!task) throw new ApiError(404, 'Not found'); return c.json(task);
});
app.patch('/habits/:id/tasks/:taskId', async c => c.json(service(c).changeTask(id(c), c.req.param('taskId'), await body(c, taskPatch))));
app.patch('/habits/:id/tasks/:taskId', c => c.json(service(c).changeTask(id(c), c.req.param('taskId'), body(c, taskPatch))));
app.delete('/habits/:id/tasks/:taskId', c => { service(c).changeTask(id(c), c.req.param('taskId'), null); return c.body(null, 204); });
app.get('/habits/:id/days/:date', c => c.json(service(c).day(id(c), date(c))));
app.get('/habits/:id/days/:date/audit', c => c.json({ records: service(c).audit(id(c), date(c)) }));
app.put('/habits/:id/days/:date/progress', async c => c.json(service(c).progress(id(c), date(c), await body(c, progressInput))));
app.put('/habits/:id/days/:date/tasks/:taskId', async c => c.json(service(c).completeTask(id(c), date(c), c.req.param('taskId'), (await body(c, doneInput)).done)));
app.put('/habits/:id/days/:date/progress', c => c.json(service(c).progress(id(c), date(c), body(c, progressInput))));
app.put('/habits/:id/days/:date/tasks/:taskId', c => c.json(service(c).completeTask(id(c), date(c), c.req.param('taskId'), (body(c, doneInput)).done)));
app.get('/habits/:id/calendar-settings', c => c.json(service(c).settings(id(c))));
app.put('/habits/:id/calendar-settings', async c => c.json(service(c).saveSettings(id(c), await body(c, calendarSettingsSchema))));
app.put('/habits/:id/calendar-settings', c => c.json(service(c).saveSettings(id(c), body(c, calendarSettingsSchema))));
app.get('/habits/:id/calendar', c => {
const { from, to } = range(c); return c.json(service(c).calendar([id(c)], from, to, service(c).settings(id(c)), false));
});
@@ -82,12 +89,12 @@ export function createHabitRoutes(db: AppDatabase, auth: ReturnType<typeof creat
const s = service(c); const day = date(c); return c.json({ date: day, habits: s.list(true).map(h => s.day(h.id, day)) });
});
app.get('/charts', c => c.json({ charts: service(c).charts() }));
app.post('/charts', async c => {
const chart = service(c).saveChart(await body(c, chartInput)); c.header('Location', `/api/charts/${chart.id}`); return c.json(chart, 201);
app.post('/charts', c => {
const chart = service(c).saveChart(body(c, chartInput)); c.header('Location', `/api/charts/${chart.id}`); return c.json(chart, 201);
});
app.get('/charts/:id', c => c.json(service(c).chart(id(c))));
app.patch('/charts/:id', async c => {
const patch = await body(c, chartPatch); const old = service(c).chart(id(c));
app.patch('/charts/:id', c => {
const patch = body(c, chartPatch); const old = service(c).chart(id(c));
return c.json(service(c).saveChart({ name: patch.name ?? old.name, habitIds: patch.habitIds ?? old.habitIds, settings: patch.settings ?? old.settings }, id(c)));
});
app.delete('/charts/:id', c => { service(c).deleteChart(id(c)); return c.body(null, 204); });
@@ -97,6 +104,6 @@ export function createHabitRoutes(db: AppDatabase, auth: ReturnType<typeof creat
app.get('/charts/:id/days/:date', c => {
const chart = service(c).chart(id(c)); const day = date(c); return c.json(service(c).calendar(chart.habitIds, day, day, chart.settings, true).days[0]);
});
app.patch('/me', async c => c.json(service(c).setTimezone((await body(c, timezoneInput)).timezone)));
app.patch('/me', c => c.json(service(c).setTimezone((body(c, timezoneInput)).timezone)));
return app;
}

View File

@@ -22,9 +22,12 @@ export class HabitService {
private occurrencesCache = new Map<string, Map<number, (typeof taskOccurrences.$inferSelect)[]>>();
private countCache = new Map<string, Map<string, { value: number; carriedFrom: string | null }>>();
constructor(readonly db: AppDatabase, readonly user: PublicUser, readonly now: number) {
// A different request may update the timezone while this request's body streams in.
const timezone = db.select({ timezone: users.timezone }).from(users).where(eq(users.id, user.id)).get()!.timezone;
this.user = { ...user, timezone };
// Travel west must never reopen or rewrite earlier requirements.
const latest = db.select({ date: habits.materializedThrough }).from(habits).where(eq(habits.userId, user.id)).orderBy(desc(habits.materializedThrough)).get()?.date;
this.today = [localDate(now, user.timezone), latest ?? ''].sort().at(-1)!;
this.today = [localDate(now, this.user.timezone), latest ?? ''].sort().at(-1)!;
}
owned(id: string): Habit {
const cached = this.ownedCache.get(id); if (cached) return cached;
@@ -193,7 +196,7 @@ export class HabitService {
const config = revision?.config;
const future = date > this.today;
const tasks = config?.method === 'tasks' && !config.archived && scheduled(config.schedule, date)
? record ? this.occurrences(id).get(record.id) ?? []
? record ? config.tasks.flatMap(task => (this.occurrences(id).get(record.id) ?? []).filter(occurrence => occurrence.taskId === task.id))
: config.tasks.filter(t => scheduled(t.schedule, date)).map(t => ({ id: null, taskId: t.id, name: t.name, done: false, expiredAt: null, closedAt: null, updatedAt: null })) : [];
const due = !!config && !config.archived && scheduled(config.schedule, date) && (config.method !== 'tasks' || tasks.length > 0);
const counted = config?.method === 'count' && !future ? this.counts(id).get(date) : undefined;
@@ -219,7 +222,7 @@ export class HabitService {
if (detail.method === 'count' ? !('count' in input) : !('done' in input)) throw new ApiError(422, 'Input does not match the method on this date');
this.countCache.delete(id);
this.db.transaction(() => {
this.db.insert(progressEvents).values({ dayId: record.id, before: { count: record.count, done: record.done }, after: input, createdAt: this.now }).run();
this.db.insert(progressEvents).values({ dayId: record.id, before: { count: detail.value, done: record.done, loggedCount: record.countSet ? record.count : null, carriedFrom: detail.carriedFrom }, after: input, createdAt: this.now }).run();
this.db.update(habitDays).set('count' in input ? { ...input, countSet: true } : input).where(eq(habitDays.id, record.id)).run();
});
this.invalidate(id);