diff --git a/src/account/routes.ts b/src/account/routes.ts index 5f64031..8c8c5ea 100644 --- a/src/account/routes.ts +++ b/src/account/routes.ts @@ -8,9 +8,8 @@ import { HabitService } from "../habits/service"; export function createAccountRoutes(db: AppDatabase, auth: ReturnType, now: () => number) { const app = new Hono(); app.use("*", auth.requireAuth); - app.get("/export", c => { + app.get("/export", c => HabitService.withPrepared(db, c.get("user"), now, () => { const user = c.get("user"); - const service = new HabitService(db, user, now()); service.sync(); const data = db.transaction(tx => { const owned = tx.select({ id: schema.habits.id }).from(schema.habits).where(eq(schema.habits.userId, user.id)); const days = tx.select({ id: schema.habitDays.id }).from(schema.habitDays).where(inArray(schema.habitDays.habitId, owned)); @@ -31,11 +30,12 @@ export function createAccountRoutes(db: AppDatabase, auth: ReturnType { const body = await c.req.json().catch(() => null); if (body?.confirmation !== "DELETE") return c.json({ error: "Type DELETE to confirm account deletion." }, 422); const userId = c.get("user").id; + return HabitService.withPrepared(db, c.get("user"), now, () => { db.transaction(tx => { const owned = tx.select({ id: schema.habits.id }).from(schema.habits).where(eq(schema.habits.userId, userId)); const days = tx.select({ id: schema.habitDays.id }).from(schema.habitDays).where(inArray(schema.habitDays.habitId, owned)); @@ -50,6 +50,7 @@ export function createAccountRoutes(db: AppDatabase, auth: ReturnType { + const f = fixture(); + try { + const habit = await f.json('/habits', 'POST', {name:'Many tasks', method:'tasks', tasks:Array.from({length:50}, (_,i)=>({name:`Task ${i}`}))}, 201); + f.setTime('2027-09-04T12:00:00Z'); + let complete = false; + const dashboard = f.json('/today').then(value => {complete=true; return value;}); + await new Promise(resolve=>setTimeout(resolve,0)); + expect(complete).toBe(false); + // Another user's request can complete while Alice is still catching up. + expect((await f.request('/today','GET',undefined,'b')).status).toBe(200); + expect(complete).toBe(false); + // The actual date can advance while catch-up yields; readiness revalidates it. + f.setTime('2027-09-05T12:00:00Z'); + const edit = f.json(`/habits/${habit.id}`, 'PATCH', {name:'Renamed'}); + const [today, changed] = await Promise.all([dashboard, edit]); + expect(today.date).toBe('2027-09-05'); + expect(today.habits[0].tasks).toHaveLength(50); + expect(changed.name).toBe('Renamed'); + const through = f.sqlite.query('SELECT materialized_through AS date FROM habits WHERE id=?').get(habit.id) as {date:string}; + expect(through.date).toBe('2027-09-05'); + const day = await f.json(`/habits/${habit.id}/days/2026-09-05`); + expect(day.tasks.every((task:any)=>task.expiredAt!==null && task.closedAt!==null)).toBe(true); + } finally {f.close();} +}); diff --git a/src/habits/routes.ts b/src/habits/routes.ts index b8966d6..3da963c 100644 --- a/src/habits/routes.ts +++ b/src/habits/routes.ts @@ -41,8 +41,9 @@ export function createHabitRoutes(db: AppDatabase, auth: ReturnType { + c.set('service', service); await next(); + }); }); if (response) c.res = response; }; if (!['GET', 'HEAD', 'OPTIONS'].includes(c.req.method)) { const response = await origin(c, proceed); if (response) c.res = response; diff --git a/src/habits/service.ts b/src/habits/service.ts index bd63456..deec55f 100644 --- a/src/habits/service.ts +++ b/src/habits/service.ts @@ -13,7 +13,45 @@ export type Habit = typeof habits.$inferSelect; export type Revision = typeof habitRevisions.$inferSelect; export type HabitDay = typeof habitDays.$inferSelect; +// Each database has its own per-user readiness queue. No transaction spans a yield. +const preparationQueues = new WeakMap>>(); export class HabitService { + static async withPrepared(db: AppDatabase, user: PublicUser, now: () => number, run: (service: HabitService) => T | Promise): Promise { + let queues = preparationQueues.get(db); + if (!queues) { queues = new Map(); preparationQueues.set(db, queues); } + const previous = queues.get(user.id); + let release!: () => void; + const ticket = new Promise(resolve => { release = resolve; }); + queues.set(user.id, ticket); + if (previous) await previous; + try { + let sliceStarted = performance.now(); + while (true) { + // Re-read timezone, date and owned records after every scheduling yield. + const service = new HabitService(db, user, now()); + const habit = service.pending()[0]; + if (!habit) { + service.sync(); + return await run(service); + } + const start = habit.materializedThrough ? addDays(habit.materializedThrough, 1) : habit.createdDate; + const through = [addDays(start, 7), service.today].sort()[0]!; + db.transaction(() => service.materialize(habit, through)); + if (performance.now() - sliceStarted >= 8) { + await new Promise(resolve => setTimeout(resolve, 0)); + sliceStarted = performance.now(); + } + } + } finally { + release(); + if (queues.get(user.id) === ticket) queues.delete(user.id); + } + } + /** Ready read snapshot. Use withPrepared for read-modify-write handlers. */ + static prepare(db: AppDatabase, user: PublicUser, now: () => number) { + return HabitService.withPrepared(db, user, now, service => service); + } + readonly today: string; private ownedCache = new Map(); private revisionCache = new Map(); @@ -25,7 +63,9 @@ export class HabitService { private countCache = new Map>(); 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; + const currentUser = db.select({ timezone: users.timezone }).from(users).where(eq(users.id, user.id)).get(); + if (!currentUser) throw missing(); + const timezone = currentUser.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; @@ -221,11 +261,12 @@ export class HabitService { .innerJoin(habitDays, eq(taskOccurrences.dayId, habitDays.id)) .where(and(eq(habitDays.habitId, habit.id), eq(habitDays.date, date))) .orderBy(desc(habitDays.id)).all().map(row => row.occurrence) : []; - for (const task of revision.config.tasks.filter(t => scheduled(t.schedule, date))) { + const occurrences = revision.config.tasks.filter(t => scheduled(t.schedule, date)).map(task => { const old = previous.find(t => t.taskId === task.id); - this.db.insert(taskOccurrences).values({ id: crypto.randomUUID(), dayId: day.id, taskId: task.id, name: task.name, - done: old?.done ?? false, expiredAt: old ? old.expiredAt : day.endsAt <= this.now ? day.endsAt : null, closedAt: old?.closedAt ?? (day.endsAt <= this.now ? day.endsAt : null), updatedAt: old?.updatedAt ?? this.now }).run(); - } + return { id: crypto.randomUUID(), dayId: day.id, taskId: task.id, name: task.name, + done: old?.done ?? false, expiredAt: old ? old.expiredAt : day.endsAt <= this.now ? day.endsAt : null, closedAt: old?.closedAt ?? (day.endsAt <= this.now ? day.endsAt : null), updatedAt: old?.updatedAt ?? this.now }; + }); + if (occurrences.length) this.db.insert(taskOccurrences).values(occurrences).run(); } } date = addDays(date, 1); @@ -240,7 +281,7 @@ export class HabitService { const rows = select().where(and(eq(habitDays.habitId, id), gte(habitDays.date, range.from), lte(habitDays.date, range.to), latest)).orderBy(asc(habitDays.date)).all(); // Only carry-enabled ranges need a prefix. Walk backwards in bounded pages // until a logged value or requirements reset establishes the starting state. - const first = rows[0]; + const first = rows.find(({day, config}) => config.method !== 'count' || config.archived || scheduled(config.schedule, day.date)); if (first?.config.method === 'count' && first.config.carryPartialProgress && !first.day.countSet) { let before = range.from; const prefix: typeof rows = [];