From 85376a54b3b1a0b32fbf6da7dd9c148b73bacee7 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Tue, 8 Sep 2026 16:13:48 +0200 Subject: [PATCH 1/4] Bound habit day and calendar reads to requested history --- src/habits/performance.test.ts | 23 +++++++++++++ src/habits/service.ts | 61 ++++++++++++++++++++++++++-------- 2 files changed, 71 insertions(+), 13 deletions(-) create mode 100644 src/habits/performance.test.ts diff --git a/src/habits/performance.test.ts b/src/habits/performance.test.ts new file mode 100644 index 0000000..e8979dd --- /dev/null +++ b/src/habits/performance.test.ts @@ -0,0 +1,23 @@ +import { expect, test } from 'bun:test'; +import { fixture } from './test-fixture'; +import { HabitService } from './service'; +import { publicUser } from '../auth'; +import { users } from '../db/schema'; +import { eq } from 'drizzle-orm'; + +test('single-day reads load only the selected latest snapshot and its tasks', async () => { + const f = fixture(); + try { + const habit = await f.json('/habits', 'POST', { name: 'Tasks', method: 'tasks', tasks: [{name:'Read'}] }, 201); + f.setTime('2027-09-04T12:00:00Z'); + await f.json('/today'); + const logs: string[] = []; + (f.db as any).session.logger = { logQuery(query: string) { logs.push(query); } }; + const service = new HabitService(f.db, publicUser(f.db.select().from(users).where(eq(users.id,'alice')).get()!), Date.parse('2027-09-04T12:00:00Z')); + const day = service.day(habit.id, '2027-09-04'); + expect(day.tasks).toHaveLength(1); + expect((service as any).recordsCache.get(habit.id).size).toBe(1); + expect((service as any).occurrencesCache.get(habit.id).size).toBe(1); + expect(logs.find(query => query.includes('task_occurrences'))).toContain('"habit_days"."date" >='); + } finally { f.close(); } +}); diff --git a/src/habits/service.ts b/src/habits/service.ts index 1239f98..3b2911b 100644 --- a/src/habits/service.ts +++ b/src/habits/service.ts @@ -1,4 +1,4 @@ -import { and, asc, desc, eq, inArray, lte, sql } from 'drizzle-orm'; +import { and, asc, desc, eq, inArray, lte, gte, gt, lt, sql } from 'drizzle-orm'; import type { AppDatabase } from '../auth'; import type { PublicUser } from '../shared/user'; import { habits, habitRevisions, habitDays, taskOccurrences, progressEvents, habitCalendarSettings, combinedCharts, users } from '../db/schema'; @@ -18,6 +18,8 @@ export class HabitService { private ownedCache = new Map(); private revisionCache = new Map(); private deadlineCache = new Map(); + private ranges = new Map(); + private revisionRanges = new Map(); private recordsCache = new Map>(); private occurrencesCache = new Map>(); private countCache = new Map>(); @@ -35,10 +37,23 @@ export class HabitService { if (!habit) throw missing(); this.ownedCache.set(id, habit); return habit; } + private loadRevisions(id: string, from: string, to: string) { + const first = this.db.select().from(habitRevisions).where(and(eq(habitRevisions.habitId, id), lte(habitRevisions.effectiveDate, from))).orderBy(desc(habitRevisions.effectiveDate), desc(habitRevisions.id)).get(); + const later = from === to ? [] : this.db.select().from(habitRevisions).where(and(eq(habitRevisions.habitId, id), gt(habitRevisions.effectiveDate, from), lte(habitRevisions.effectiveDate, to))).orderBy(desc(habitRevisions.effectiveDate), desc(habitRevisions.id)).all(); + this.revisionCache.set(id, first ? [...later, first] : later); + this.revisionRanges.set(id, { from, to }); + } revision(id: string, date = this.today): Revision | undefined { - let revisions = this.revisionCache.get(id); - if (!revisions) { revisions = this.db.select().from(habitRevisions).where(eq(habitRevisions.habitId, id)).orderBy(desc(habitRevisions.effectiveDate), desc(habitRevisions.id)).all(); this.revisionCache.set(id, revisions); } - return revisions.find(r => r.effectiveDate <= date); + const range = this.revisionRanges.get(id); + if (!range || date < range.from || date > range.to) this.loadRevisions(id, date, date); + return this.revisionCache.get(id)!.find(r => r.effectiveDate <= date); + } + private range(id: string, from: string, to = from) { + const old = this.ranges.get(id); + if (old && from >= old.from && to <= old.to) return; + this.ranges.set(id, { from, to }); + this.recordsCache.delete(id); this.occurrencesCache.delete(id); this.countCache.delete(id); + this.loadRevisions(id, from, to); } current(id: string) { const habit = this.owned(id); const revision = this.revision(id)!; @@ -51,7 +66,7 @@ export class HabitService { } list(includeArchived = false) { return this.db.select().from(habits).where(eq(habits.userId, this.user.id)).orderBy(asc(habits.createdAt), asc(habits.id)).all() - .map(h => this.current(h.id)).filter(h => includeArchived || !h.archived); + .map(h => { this.ownedCache.set(h.id, h); return this.current(h.id); }).filter(h => includeArchived || !h.archived); } create(input: ReturnType) { const id = crypto.randomUUID(); @@ -149,7 +164,7 @@ export class HabitService { }); } private invalidate(id: string) { - this.countCache.delete(id); this.revisionCache.delete(id); this.recordsCache.delete(id); this.occurrencesCache.delete(id); this.ownedCache.delete(id); + this.ranges.delete(id); this.revisionRanges.delete(id); this.countCache.delete(id); this.revisionCache.delete(id); this.recordsCache.delete(id); this.occurrencesCache.delete(id); this.ownedCache.delete(id); } private deadline(date: string) { let value = this.deadlineCache.get(date); @@ -160,7 +175,7 @@ export class HabitService { let records = this.recordsCache.get(id); if (!records) { records = new Map(); - for (const day of this.db.select().from(habitDays).where(eq(habitDays.habitId, id)).orderBy(asc(habitDays.id)).all()) records.set(day.date, day); + for (const day of this.db.select().from(habitDays).where(and(eq(habitDays.habitId, id), gte(habitDays.date, this.ranges.get(id)!.from), lte(habitDays.date, this.ranges.get(id)!.to), sql`${habitDays.id} = (SELECT MAX(d.id) FROM habit_days d WHERE d.habit_id = ${habitDays.habitId} AND d.date = ${habitDays.date})`)).all()) records.set(day.date, day); this.recordsCache.set(id, records); } return records; @@ -169,7 +184,7 @@ export class HabitService { let occurrences = this.occurrencesCache.get(id); if (!occurrences) { occurrences = new Map(); - const rows = this.db.select({ occurrence: taskOccurrences }).from(taskOccurrences).innerJoin(habitDays, eq(taskOccurrences.dayId, habitDays.id)).where(eq(habitDays.habitId, id)).orderBy(asc(taskOccurrences.id)).all(); + const rows = this.db.select({ occurrence: taskOccurrences }).from(taskOccurrences).innerJoin(habitDays, eq(taskOccurrences.dayId, habitDays.id)).where(and(eq(habitDays.habitId, id), gte(habitDays.date, this.ranges.get(id)!.from), lte(habitDays.date, this.ranges.get(id)!.to), sql`${habitDays.id} = (SELECT MAX(d.id) FROM habit_days d WHERE d.habit_id = ${habitDays.habitId} AND d.date = ${habitDays.date})`)).orderBy(asc(taskOccurrences.id)).all(); for (const { occurrence } of rows) { const entries = occurrences.get(occurrence.dayId) ?? []; entries.push(occurrence); occurrences.set(occurrence.dayId, entries); } this.occurrencesCache.set(id, occurrences); } @@ -206,10 +221,29 @@ export class HabitService { } private counts(id: string) { const cached = this.countCache.get(id); if (cached) return cached; - const rows = this.db.select({ day: habitDays, config: habitRevisions.config }).from(habitDays) - .innerJoin(habitRevisions, eq(habitDays.revisionId, habitRevisions.id)) - .where(and(eq(habitDays.habitId, id), sql`${habitDays.id} = (SELECT MAX(d.id) FROM habit_days d WHERE d.habit_id = ${habitDays.habitId} AND d.date = ${habitDays.date})`)) - .orderBy(asc(habitDays.date)).all(); + const range = this.ranges.get(id)!; + const latest = sql`${habitDays.id} = (SELECT MAX(d.id) FROM habit_days d WHERE d.habit_id = ${habitDays.habitId} AND d.date = ${habitDays.date})`; + const select = () => this.db.select({ day: habitDays, config: habitRevisions.config }).from(habitDays).innerJoin(habitRevisions, eq(habitDays.revisionId, habitRevisions.id)); + 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]; + if (first?.config.method === 'count' && first.config.carryPartialProgress && !first.day.countSet) { + let before = range.from; + const prefix: typeof rows = []; + let found = false; + while (!found) { + const page = select().where(and(eq(habitDays.habitId, id), lt(habitDays.date, before), latest)).orderBy(desc(habitDays.date)).limit(64).all(); + if (!page.length) break; + for (const entry of page) { + prefix.push(entry); + const { day, config } = entry; + if (config.method !== 'count' || config.archived || (scheduled(config.schedule, day.date) && (day.countSet || !config.carryPartialProgress))) { found = true; break; } + } + before = page.at(-1)!.day.date; + } + rows.unshift(...prefix.reverse()); + } const result = new Map(); let carry = 0; let source: string | null = null; for (const { day, config } of rows) { @@ -223,6 +257,7 @@ export class HabitService { } day(id: string, date: string) { this.owned(id); + this.range(id, date); const revision = this.revision(id, date); const record = this.records(id).get(date); const config = revision?.config; @@ -307,7 +342,7 @@ export class HabitService { } deleteChart(id: string) { this.chart(id); this.db.delete(combinedCharts).where(eq(combinedCharts.id, id)).run(); } calendar(ids: string[], from: string, to: string, settings: CalendarSettings, combined: boolean) { - ids.forEach(id => this.owned(id)); + ids.forEach(id => { this.owned(id); this.range(id, from, to); }); const days = []; for (let date = from; date <= to; date = addDays(date, 1)) { const details = ids.map(id => this.day(id, date)); From 4b8c7dff05c2566c06469f05041dc64a03d73462 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Tue, 8 Sep 2026 16:14:58 +0200 Subject: [PATCH 2/4] Add compact calendar responses and persisted habit change versions --- drizzle/0013_habit_calendar_revision.sql | 1 + drizzle/meta/0013_snapshot.json | 1225 ++++++++++++++++++++++ drizzle/meta/_journal.json | 9 +- src/db/schema.ts | 1 + src/habits/performance.test.ts | 23 + src/habits/routes.ts | 6 +- src/habits/service.ts | 19 +- 7 files changed, 1276 insertions(+), 8 deletions(-) create mode 100644 drizzle/0013_habit_calendar_revision.sql create mode 100644 drizzle/meta/0013_snapshot.json diff --git a/drizzle/0013_habit_calendar_revision.sql b/drizzle/0013_habit_calendar_revision.sql new file mode 100644 index 0000000..9139f88 --- /dev/null +++ b/drizzle/0013_habit_calendar_revision.sql @@ -0,0 +1 @@ +ALTER TABLE habits ADD COLUMN calendar_revision integer NOT NULL DEFAULT 0; diff --git a/drizzle/meta/0013_snapshot.json b/drizzle/meta/0013_snapshot.json new file mode 100644 index 0000000..b90571e --- /dev/null +++ b/drizzle/meta/0013_snapshot.json @@ -0,0 +1,1225 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "4c5b56f0-04cd-405a-afdd-8e4ca3793332", + "prevId": "39a7a2c2-c411-4e0a-ba41-9e4abdc8fbcf", + "tables": { + "combined_charts": { + "name": "combined_charts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "habit_ids": { + "name": "habit_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "settings": { + "name": "settings", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "combined_charts_user_idx": { + "name": "combined_charts_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "combined_charts_user_id_users_id_fk": { + "name": "combined_charts_user_id_users_id_fk", + "tableFrom": "combined_charts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "discord_controls": { + "name": "discord_controls", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "used": { + "name": "used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "discord_controls_expiry_idx": { + "name": "discord_controls_expiry_idx", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "discord_controls_user_id_users_id_fk": { + "name": "discord_controls_user_id_users_id_fk", + "tableFrom": "discord_controls", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "discord_deliveries": { + "name": "discord_deliveries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "image_hash": { + "name": "image_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message_url": { + "name": "message_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "discord_deliveries_user_id_users_id_fk": { + "name": "discord_deliveries_user_id_users_id_fk", + "tableFrom": "discord_deliveries", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "discord_receipts": { + "name": "discord_receipts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "response": { + "name": "response", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "discord_receipts_expiry_idx": { + "name": "discord_receipts_expiry_idx", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "discord_receipts_user_id_users_id_fk": { + "name": "discord_receipts_user_id_users_id_fk", + "tableFrom": "discord_receipts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "habit_calendar_settings": { + "name": "habit_calendar_settings", + "columns": { + "habit_id": { + "name": "habit_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "settings": { + "name": "settings", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "habit_calendar_settings_habit_id_habits_id_fk": { + "name": "habit_calendar_settings_habit_id_habits_id_fk", + "tableFrom": "habit_calendar_settings", + "tableTo": "habits", + "columnsFrom": [ + "habit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "habit_days": { + "name": "habit_days", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "habit_id": { + "name": "habit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision_id": { + "name": "revision_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ends_at": { + "name": "ends_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count_set": { + "name": "count_set", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "done": { + "name": "done", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "habit_days_lookup_idx": { + "name": "habit_days_lookup_idx", + "columns": [ + "habit_id", + "date", + "revision_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "habit_days_habit_id_habits_id_fk": { + "name": "habit_days_habit_id_habits_id_fk", + "tableFrom": "habit_days", + "tableTo": "habits", + "columnsFrom": [ + "habit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "habit_days_revision_id_habit_revisions_id_fk": { + "name": "habit_days_revision_id_habit_revisions_id_fk", + "tableFrom": "habit_days", + "tableTo": "habit_revisions", + "columnsFrom": [ + "revision_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "habit_revisions": { + "name": "habit_revisions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "habit_id": { + "name": "habit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "effective_date": { + "name": "effective_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "habit_revisions_date_idx": { + "name": "habit_revisions_date_idx", + "columns": [ + "habit_id", + "effective_date", + "id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "habit_revisions_habit_id_habits_id_fk": { + "name": "habit_revisions_habit_id_habits_id_fk", + "tableFrom": "habit_revisions", + "tableTo": "habits", + "columnsFrom": [ + "habit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "habits": { + "name": "habits", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_date": { + "name": "created_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "materialized_through": { + "name": "materialized_through", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "calendar_revision": { + "name": "calendar_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "habits_user_idx": { + "name": "habits_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "habits_user_id_users_id_fk": { + "name": "habits_user_id_users_id_fk", + "tableFrom": "habits", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "live_discord_cards": { + "name": "live_discord_cards", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message_url": { + "name": "message_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "nonce": { + "name": "nonce", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "image_hash": { + "name": "image_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dirty": { + "name": "dirty", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "retry_at": { + "name": "retry_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "live_discord_cards_user_id_users_id_fk": { + "name": "live_discord_cards_user_id_users_id_fk", + "tableFrom": "live_discord_cards", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "progress_events": { + "name": "progress_events", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "day_id": { + "name": "day_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "occurrence_id": { + "name": "occurrence_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "before": { + "name": "before", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "after": { + "name": "after", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "progress_events_day_idx": { + "name": "progress_events_day_idx", + "columns": [ + "day_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "progress_events_day_id_habit_days_id_fk": { + "name": "progress_events_day_id_habit_days_id_fk", + "tableFrom": "progress_events", + "tableTo": "habit_days", + "columnsFrom": [ + "day_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "reminder_deliveries": { + "name": "reminder_deliveries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slot": { + "name": "slot", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'*'" + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "retry_at": { + "name": "retry_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "reminder_deliveries_user_date_slot_idx": { + "name": "reminder_deliveries_user_date_slot_idx", + "columns": [ + "user_id", + "date", + "slot" + ], + "isUnique": true + } + }, + "foreignKeys": { + "reminder_deliveries_user_id_users_id_fk": { + "name": "reminder_deliveries_user_id_users_id_fk", + "tableFrom": "reminder_deliveries", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "reminder_settings": { + "name": "reminder_settings", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "time": { + "name": "time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'20:00'" + }, + "reminders": { + "name": "reminders", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quiet_start": { + "name": "quiet_start", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'22:00'" + }, + "quiet_end": { + "name": "quiet_end", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'08:00'" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "reminder_settings_user_id_users_id_fk": { + "name": "reminder_settings_user_id_users_id_fk", + "tableFrom": "reminder_settings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "reminder_worker_state": { + "name": "reminder_worker_state", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "blocked_until": { + "name": "blocked_until", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "sessions_expires_at_idx": { + "name": "sessions_expires_at_idx", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sharing_limits": { + "name": "sharing_limits", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "task_occurrences": { + "name": "task_occurrences", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "day_id": { + "name": "day_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "done": { + "name": "done", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "expired_at": { + "name": "expired_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "closed_at": { + "name": "closed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "task_occurrences_day_idx": { + "name": "task_occurrences_day_idx", + "columns": [ + "day_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "task_occurrences_day_id_habit_days_id_fk": { + "name": "task_occurrences_day_id_habit_days_id_fk", + "tableFrom": "task_occurrences", + "tableTo": "habit_days", + "columnsFrom": [ + "day_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "discord_id": { + "name": "discord_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "global_name": { + "name": "global_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_hash": { + "name": "avatar_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'UTC'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_discord_id_unique": { + "name": "users_discord_id_unique", + "columns": [ + "discord_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 4dde595..97d62ed 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -92,6 +92,13 @@ "when": 1788847978168, "tag": "0012_discord_commands", "breakpoints": true + }, + { + "idx": 13, + "version": "6", + "when": 1788860000000, + "tag": "0013_habit_calendar_revision", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/src/db/schema.ts b/src/db/schema.ts index 7e2cd37..04ba11c 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -33,6 +33,7 @@ export const discordDeliveries = sqliteTable("discord_deliveries", { // Revisions are append-only, including multiple edits on the same local date. export const habits = sqliteTable('habits', { id: text('id').primaryKey(), userId: text('user_id').notNull().references(() => users.id), + calendarRevision: integer('calendar_revision').notNull().default(0), createdDate: text('created_date').notNull(), materializedThrough: text('materialized_through'), createdAt: integer('created_at').notNull(), }, t => [index('habits_user_idx').on(t.userId)]); export const habitRevisions = sqliteTable('habit_revisions', { diff --git a/src/habits/performance.test.ts b/src/habits/performance.test.ts index e8979dd..c727950 100644 --- a/src/habits/performance.test.ts +++ b/src/habits/performance.test.ts @@ -21,3 +21,26 @@ test('single-day reads load only the selected latest snapshot and its tasks', as expect(logs.find(query => query.includes('task_occurrences'))).toContain('"habit_days"."date" >='); } finally { f.close(); } }); + +test('calendar summaries omit editor payloads and mutation versions cover historical changes', async () => { + const f = fixture(); + try { + const habit = await f.json('/habits', 'POST', { name: 'Tasks', method: 'tasks', tasks: [{name:'Read'}] }, 201); + const first = (await f.json('/today')).habits[0]; + expect((await f.json('/today')).habits[0].calendarRevision).toBe(first.calendarRevision); + f.setTime('2026-09-05T12:00:00Z'); + await f.json(`/habits/${habit.id}/days/2026-09-04/tasks/${first.tasks[0].taskId}`, 'PUT', {done:true}); + const changed = (await f.json('/today')).habits[0].calendarRevision; + expect(changed).toBeGreaterThan(first.calendarRevision); + await f.json(`/habits/${habit.id}/calendar-settings`, 'PUT', {mainColor:'#123456'}); + expect((await f.json('/today')).habits[0].calendarRevision).toBeGreaterThan(changed); + const url = `/habits/${habit.id}/calendar?from=2026-09-04&to=2026-09-05`; + const full = await f.json(url); + const compact = await f.json(url+'&compact=true'); + const detail = full.days[0].habits[0]; + const {tasks, requirements, ...summary} = detail; + expect(compact.days[0].habits[0]).toEqual(summary); + expect(compact.data).toEqual(full.data); + expect((await f.json(`/habits/${habit.id}/days/2026-09-04`)).tasks[0].done).toBe(true); + } finally { f.close(); } +}); diff --git a/src/habits/routes.ts b/src/habits/routes.ts index 61280a8..b8966d6 100644 --- a/src/habits/routes.ts +++ b/src/habits/routes.ts @@ -20,7 +20,7 @@ function date(c: Context) { return result.data; } function range(c: Context) { - const result = z.object({ from: dateSchema, to: dateSchema }).strict().safeParse(c.req.query()); + const result = z.object({ from: dateSchema, to: dateSchema, compact: z.enum(['true', 'false']).optional() }).strict().safeParse(c.req.query()); if (!result.success || result.data.from > result.data.to || dayNumber(result.data.to) - dayNumber(result.data.from) > 1829) throw new ApiError(422, 'Supply from and to dates in order, spanning at most 1830 days'); return result.data; } @@ -80,7 +80,7 @@ export function createHabitRoutes(db: AppDatabase, auth: ReturnType c.json(service(c).settings(id(c)))); 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)); + const { from, to, compact } = range(c); return c.json(service(c).calendar([id(c)], from, to, service(c).settings(id(c)), false, compact === 'true')); }); app.get('/today', c => { const s = service(c); const habits = s.list().map(h => s.day(h.id, s.today)); @@ -100,7 +100,7 @@ export function createHabitRoutes(db: AppDatabase, auth: ReturnType { service(c).deleteChart(id(c)); return c.body(null, 204); }); app.get('/charts/:id/calendar', c => { - const chart = service(c).chart(id(c)); const { from, to } = range(c); return c.json(service(c).calendar(chart.habitIds, from, to, chart.settings, true)); + const chart = service(c).chart(id(c)); const { from, to, compact } = range(c); return c.json(service(c).calendar(chart.habitIds, from, to, chart.settings, true, compact === 'true')); }); 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]); diff --git a/src/habits/service.ts b/src/habits/service.ts index 3b2911b..7b291bd 100644 --- a/src/habits/service.ts +++ b/src/habits/service.ts @@ -55,6 +55,10 @@ export class HabitService { this.recordsCache.delete(id); this.occurrencesCache.delete(id); this.countCache.delete(id); this.loadRevisions(id, from, to); } + private changed(id: string) { + this.db.update(habits).set({ calendarRevision: sql`${habits.calendarRevision} + 1` }).where(eq(habits.id, id)).run(); + this.ownedCache.delete(id); + } current(id: string) { const habit = this.owned(id); const revision = this.revision(id)!; const config = revision.config; @@ -62,7 +66,7 @@ export class HabitService { ? [{ code: 'no_tasks', taskId: null, message: 'Add a task to make this habit due.' }] : config.tasks.filter(task => !schedulesOverlap(config.schedule, task.schedule)) .map(task => ({ code: 'task_never_due', taskId: task.id, message: `Task \"${task.name}\" never coincides with the habit schedule.` })); - return { id: habit.id, ...config, warnings, createdDate: habit.createdDate, createdAt: habit.createdAt, revisionId: revision.id, effectiveDate: revision.effectiveDate }; + return { id: habit.id, calendarRevision: habit.calendarRevision, ...config, warnings, createdDate: habit.createdDate, createdAt: habit.createdAt, revisionId: revision.id, effectiveDate: revision.effectiveDate }; } list(includeArchived = false) { return this.db.select().from(habits).where(eq(habits.userId, this.user.id)).orderBy(asc(habits.createdAt), asc(habits.id)).all() @@ -130,6 +134,7 @@ export class HabitService { this.db.transaction(() => { this.db.insert(habitRevisions).values({ habitId: id, effectiveDate: this.today, config, createdAt: this.now }).run(); this.materialize(this.owned(id)); + this.changed(id); }); return this.current(id); } @@ -270,7 +275,7 @@ export class HabitService { const value = !due ? 0 : config!.method === 'count' ? counted?.value ?? 0 : config!.method === 'manual' ? Number(record?.done ?? false) : tasks.filter(t => t.done).length; const target = !config ? null : config.method === 'count' ? config.target : config.method === 'manual' ? 1 : tasks.length; const ratio = due ? Math.min(1, value / target!) : null; - return { date, habitId: id, name: config?.name ?? null, method: config?.method ?? null, revisionId: revision?.id ?? null, + return { date, habitId: id, calendarRevision: this.owned(id).calendarRevision, name: config?.name ?? null, method: config?.method ?? null, revisionId: revision?.id ?? null, timezone: record?.timezone ?? this.user.timezone, endsAt: record?.endsAt ?? null, due, future, status: future ? 'future' : !due ? 'not_due' : ratio === 1 ? 'complete' : value > 0 ? 'partial' : 'empty', value, target, carriedFrom: counted?.carriedFrom ?? null, loggedCount: record?.countSet ? record.count : null, unit: config?.method === 'count' ? config.unit : config?.method === 'tasks' ? 'tasks' : 'completion', @@ -291,6 +296,7 @@ export class HabitService { this.db.transaction(() => { 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.changed(id); }); this.invalidate(id); return this.day(id, date); @@ -302,6 +308,7 @@ export class HabitService { this.db.transaction(() => { this.db.insert(progressEvents).values({ dayId: record.id, occurrenceId: occurrence.id, before: { done: occurrence.done }, after: { done }, createdAt: this.now }).run(); this.db.update(taskOccurrences).set({ done, updatedAt: this.now }).where(eq(taskOccurrences.id, occurrence.id)).run(); + this.changed(id); }); this.invalidate(id); return this.day(id, date); @@ -325,6 +332,7 @@ export class HabitService { saveSettings(id: string, settings: CalendarSettings) { this.owned(id); this.db.insert(habitCalendarSettings).values({ habitId: id, settings }).onConflictDoUpdate({ target: habitCalendarSettings.habitId, set: { settings } }).run(); + this.changed(id); return settings; } chart(id: string) { @@ -341,7 +349,7 @@ export class HabitService { return this.chart(id); } deleteChart(id: string) { this.chart(id); this.db.delete(combinedCharts).where(eq(combinedCharts.id, id)).run(); } - calendar(ids: string[], from: string, to: string, settings: CalendarSettings, combined: boolean) { + calendar(ids: string[], from: string, to: string, settings: CalendarSettings, combined: boolean, compact = false) { ids.forEach(id => { this.owned(id); this.range(id, from, to); }); const days = []; for (let date = from; date <= to; date = addDays(date, 1)) { @@ -353,7 +361,10 @@ export class HabitService { const progress = shade(ratio, steps, settings, !combined && (settings.shadeCount === 'auto' || manual)); const future = date > this.today; days.push({ date, due: due.length, completed, ratio, future, status: future ? 'future' : ratio === null ? 'not_due' : ratio === 1 ? 'complete' : ratio > 0 ? 'partial' : 'empty', - shadeCount: steps, ...progress, color: future ? settings.futureColor : progress.color, habits: details }); + shadeCount: steps, ...progress, color: future ? settings.futureColor : progress.color, habits: details.map(detail => { + const { tasks, requirements, ...summary } = detail; + return { ...summary, tasks: compact ? undefined : tasks, requirements: compact ? undefined : requirements }; + }) }); } return { kind: combined ? 'combined' as const : 'individual' as const, from, to, today: this.today, timezone: this.user.timezone, settings, days, // Explicit colors and details are supplied for the client's Nivo colorScale/tooltip adapters. From 30dee557994e20408a0d346d4509c9858b8748b8 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Tue, 8 Sep 2026 16:16:04 +0200 Subject: [PATCH 3/4] Skip unchanged materialization and expire only open occurrences --- drizzle/0014_open_occurrences.sql | 1 + drizzle/meta/0014_snapshot.json | 1233 +++++++++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + src/db/schema.ts | 3 +- src/habits/performance.test.ts | 13 + src/habits/service.ts | 30 +- 6 files changed, 1275 insertions(+), 12 deletions(-) create mode 100644 drizzle/0014_open_occurrences.sql create mode 100644 drizzle/meta/0014_snapshot.json diff --git a/drizzle/0014_open_occurrences.sql b/drizzle/0014_open_occurrences.sql new file mode 100644 index 0000000..593a747 --- /dev/null +++ b/drizzle/0014_open_occurrences.sql @@ -0,0 +1 @@ +CREATE INDEX task_occurrences_open_idx ON task_occurrences (day_id) WHERE closed_at IS NULL; diff --git a/drizzle/meta/0014_snapshot.json b/drizzle/meta/0014_snapshot.json new file mode 100644 index 0000000..7a8a3e4 --- /dev/null +++ b/drizzle/meta/0014_snapshot.json @@ -0,0 +1,1233 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "0f63b205-bad6-46af-8d16-2abc60ffc211", + "prevId": "4c5b56f0-04cd-405a-afdd-8e4ca3793332", + "tables": { + "combined_charts": { + "name": "combined_charts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "habit_ids": { + "name": "habit_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "settings": { + "name": "settings", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "combined_charts_user_idx": { + "name": "combined_charts_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "combined_charts_user_id_users_id_fk": { + "name": "combined_charts_user_id_users_id_fk", + "tableFrom": "combined_charts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "discord_controls": { + "name": "discord_controls", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "used": { + "name": "used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "discord_controls_expiry_idx": { + "name": "discord_controls_expiry_idx", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "discord_controls_user_id_users_id_fk": { + "name": "discord_controls_user_id_users_id_fk", + "tableFrom": "discord_controls", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "discord_deliveries": { + "name": "discord_deliveries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "image_hash": { + "name": "image_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message_url": { + "name": "message_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "discord_deliveries_user_id_users_id_fk": { + "name": "discord_deliveries_user_id_users_id_fk", + "tableFrom": "discord_deliveries", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "discord_receipts": { + "name": "discord_receipts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "response": { + "name": "response", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "discord_receipts_expiry_idx": { + "name": "discord_receipts_expiry_idx", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "discord_receipts_user_id_users_id_fk": { + "name": "discord_receipts_user_id_users_id_fk", + "tableFrom": "discord_receipts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "habit_calendar_settings": { + "name": "habit_calendar_settings", + "columns": { + "habit_id": { + "name": "habit_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "settings": { + "name": "settings", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "habit_calendar_settings_habit_id_habits_id_fk": { + "name": "habit_calendar_settings_habit_id_habits_id_fk", + "tableFrom": "habit_calendar_settings", + "tableTo": "habits", + "columnsFrom": [ + "habit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "habit_days": { + "name": "habit_days", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "habit_id": { + "name": "habit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision_id": { + "name": "revision_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ends_at": { + "name": "ends_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count_set": { + "name": "count_set", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "done": { + "name": "done", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "habit_days_lookup_idx": { + "name": "habit_days_lookup_idx", + "columns": [ + "habit_id", + "date", + "revision_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "habit_days_habit_id_habits_id_fk": { + "name": "habit_days_habit_id_habits_id_fk", + "tableFrom": "habit_days", + "tableTo": "habits", + "columnsFrom": [ + "habit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "habit_days_revision_id_habit_revisions_id_fk": { + "name": "habit_days_revision_id_habit_revisions_id_fk", + "tableFrom": "habit_days", + "tableTo": "habit_revisions", + "columnsFrom": [ + "revision_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "habit_revisions": { + "name": "habit_revisions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "habit_id": { + "name": "habit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "effective_date": { + "name": "effective_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "habit_revisions_date_idx": { + "name": "habit_revisions_date_idx", + "columns": [ + "habit_id", + "effective_date", + "id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "habit_revisions_habit_id_habits_id_fk": { + "name": "habit_revisions_habit_id_habits_id_fk", + "tableFrom": "habit_revisions", + "tableTo": "habits", + "columnsFrom": [ + "habit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "habits": { + "name": "habits", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_date": { + "name": "created_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "materialized_through": { + "name": "materialized_through", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "calendar_revision": { + "name": "calendar_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "habits_user_idx": { + "name": "habits_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "habits_user_id_users_id_fk": { + "name": "habits_user_id_users_id_fk", + "tableFrom": "habits", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "live_discord_cards": { + "name": "live_discord_cards", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message_url": { + "name": "message_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "nonce": { + "name": "nonce", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "image_hash": { + "name": "image_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dirty": { + "name": "dirty", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "retry_at": { + "name": "retry_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "live_discord_cards_user_id_users_id_fk": { + "name": "live_discord_cards_user_id_users_id_fk", + "tableFrom": "live_discord_cards", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "progress_events": { + "name": "progress_events", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "day_id": { + "name": "day_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "occurrence_id": { + "name": "occurrence_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "before": { + "name": "before", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "after": { + "name": "after", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "progress_events_day_idx": { + "name": "progress_events_day_idx", + "columns": [ + "day_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "progress_events_day_id_habit_days_id_fk": { + "name": "progress_events_day_id_habit_days_id_fk", + "tableFrom": "progress_events", + "tableTo": "habit_days", + "columnsFrom": [ + "day_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "reminder_deliveries": { + "name": "reminder_deliveries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slot": { + "name": "slot", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'*'" + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "retry_at": { + "name": "retry_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "reminder_deliveries_user_date_slot_idx": { + "name": "reminder_deliveries_user_date_slot_idx", + "columns": [ + "user_id", + "date", + "slot" + ], + "isUnique": true + } + }, + "foreignKeys": { + "reminder_deliveries_user_id_users_id_fk": { + "name": "reminder_deliveries_user_id_users_id_fk", + "tableFrom": "reminder_deliveries", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "reminder_settings": { + "name": "reminder_settings", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "time": { + "name": "time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'20:00'" + }, + "reminders": { + "name": "reminders", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quiet_start": { + "name": "quiet_start", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'22:00'" + }, + "quiet_end": { + "name": "quiet_end", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'08:00'" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "reminder_settings_user_id_users_id_fk": { + "name": "reminder_settings_user_id_users_id_fk", + "tableFrom": "reminder_settings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "reminder_worker_state": { + "name": "reminder_worker_state", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "blocked_until": { + "name": "blocked_until", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "sessions_expires_at_idx": { + "name": "sessions_expires_at_idx", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sharing_limits": { + "name": "sharing_limits", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "task_occurrences": { + "name": "task_occurrences", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "day_id": { + "name": "day_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "done": { + "name": "done", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "expired_at": { + "name": "expired_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "closed_at": { + "name": "closed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "task_occurrences_day_idx": { + "name": "task_occurrences_day_idx", + "columns": [ + "day_id" + ], + "isUnique": false + }, + "task_occurrences_open_idx": { + "name": "task_occurrences_open_idx", + "columns": [ + "day_id" + ], + "isUnique": false, + "where": "\"task_occurrences\".\"closed_at\" IS NULL" + } + }, + "foreignKeys": { + "task_occurrences_day_id_habit_days_id_fk": { + "name": "task_occurrences_day_id_habit_days_id_fk", + "tableFrom": "task_occurrences", + "tableTo": "habit_days", + "columnsFrom": [ + "day_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "discord_id": { + "name": "discord_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "global_name": { + "name": "global_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_hash": { + "name": "avatar_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'UTC'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_discord_id_unique": { + "name": "users_discord_id_unique", + "columns": [ + "discord_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 97d62ed..af9b7b6 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -99,6 +99,13 @@ "when": 1788860000000, "tag": "0013_habit_calendar_revision", "breakpoints": true + }, + { + "idx": 14, + "version": "6", + "when": 1788860000001, + "tag": "0014_open_occurrences", + "breakpoints": true } ] } diff --git a/src/db/schema.ts b/src/db/schema.ts index 04ba11c..20053d9 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,3 +1,4 @@ +import { sql } from 'drizzle-orm'; import { index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"; export const users = sqliteTable("users", { @@ -48,7 +49,7 @@ export const habitDays = sqliteTable('habit_days', { export const taskOccurrences = sqliteTable('task_occurrences', { id: text('id').primaryKey(), dayId: integer('day_id').notNull().references(() => habitDays.id), taskId: text('task_id').notNull(), name: text('name').notNull(), done: integer('done', { mode: 'boolean' }).notNull().default(false), expiredAt: integer('expired_at'), closedAt: integer('closed_at'), updatedAt: integer('updated_at').notNull(), -}, t => [index('task_occurrences_day_idx').on(t.dayId)]); +}, t => [index('task_occurrences_day_idx').on(t.dayId), index('task_occurrences_open_idx').on(t.dayId).where(sql`${t.closedAt} IS NULL`)]); export const progressEvents = sqliteTable('progress_events', { id: integer('id').primaryKey({ autoIncrement: true }), dayId: integer('day_id').notNull().references(() => habitDays.id), occurrenceId: text('occurrence_id'), before: text('before', { mode: 'json' }).$type().notNull(), after: text('after', { mode: 'json' }).$type().notNull(), createdAt: integer('created_at').notNull(), diff --git a/src/habits/performance.test.ts b/src/habits/performance.test.ts index c727950..9421e09 100644 --- a/src/habits/performance.test.ts +++ b/src/habits/performance.test.ts @@ -44,3 +44,16 @@ test('calendar summaries omit editor payloads and mutation versions cover histor expect((await f.json(`/habits/${habit.id}/days/2026-09-04`)).tasks[0].done).toBe(true); } finally { f.close(); } }); + +test('unchanged dashboard and calendar reads write no rows after synchronization', async () => { + const f = fixture(); + try { + const habit = await f.json('/habits', 'POST', {name:'Tasks', method:'tasks', tasks:[{name:'Read'}]}, 201); + f.setTime('2027-09-04T12:00:00Z'); + await f.json('/today'); + const before = (f.sqlite.query('SELECT total_changes() AS n').get() as {n:number}).n; + await f.json('/today'); + await f.json(`/habits/${habit.id}/calendar?from=2027-09-01&to=2027-09-04&compact=true`); + expect((f.sqlite.query('SELECT total_changes() AS n').get() as {n:number}).n).toBe(before); + } finally {f.close();} +}); diff --git a/src/habits/service.ts b/src/habits/service.ts index 7b291bd..bd63456 100644 --- a/src/habits/service.ts +++ b/src/habits/service.ts @@ -1,4 +1,4 @@ -import { and, asc, desc, eq, inArray, lte, gte, gt, lt, sql } from 'drizzle-orm'; +import { and, asc, desc, eq, inArray, lte, gte, gt, lt, or, isNull, sql } from 'drizzle-orm'; import type { AppDatabase } from '../auth'; import type { PublicUser } from '../shared/user'; import { habits, habitRevisions, habitDays, taskOccurrences, progressEvents, habitCalendarSettings, combinedCharts, users } from '../db/schema'; @@ -133,7 +133,7 @@ export class HabitService { this.owned(id); this.db.transaction(() => { this.db.insert(habitRevisions).values({ habitId: id, effectiveDate: this.today, config, createdAt: this.now }).run(); - this.materialize(this.owned(id)); + this.materialize(this.owned(id), this.today, true); this.changed(id); }); return this.current(id); @@ -157,17 +157,23 @@ export class HabitService { } sync() { this.db.transaction(() => { - for (const habit of this.db.select().from(habits).where(eq(habits.userId, this.user.id)).all()) this.materialize(habit); - // Record the original deadline even after downtime. Only active day snapshots expire. + for (const habit of this.pending()) this.materialize(habit); + // Start with the small partial index of open occurrences rather than + // revisiting every historical snapshot on every unchanged request. this.db.run(sql`UPDATE task_occurrences SET expired_at = CASE WHEN done = 0 THEN COALESCE(expired_at, (SELECT ends_at FROM habit_days WHERE id = task_occurrences.day_id)) ELSE expired_at END, closed_at = (SELECT ends_at FROM habit_days WHERE id = task_occurrences.day_id) - WHERE closed_at IS NULL AND day_id IN ( - SELECT d.id FROM habit_days d JOIN habits h ON h.id = d.habit_id - WHERE h.user_id = ${this.user.id} AND d.ends_at <= ${this.now} + WHERE id IN ( + SELECT o.id FROM task_occurrences o INDEXED BY task_occurrences_open_idx + CROSS JOIN habit_days d ON d.id = o.day_id + CROSS JOIN habits h ON h.id = d.habit_id + WHERE o.closed_at IS NULL AND h.user_id = ${this.user.id} AND d.ends_at <= ${this.now} AND d.id = (SELECT MAX(d2.id) FROM habit_days d2 WHERE d2.habit_id = d.habit_id AND d2.date = d.date))`); }); } + private pending() { + return this.db.select().from(habits).where(and(eq(habits.userId, this.user.id), or(isNull(habits.materializedThrough), lt(habits.materializedThrough, this.today)))).all(); + } private invalidate(id: string) { this.ranges.delete(id); this.revisionRanges.delete(id); this.countCache.delete(id); this.revisionCache.delete(id); this.recordsCache.delete(id); this.occurrencesCache.delete(id); this.ownedCache.delete(id); } @@ -195,9 +201,11 @@ export class HabitService { } return occurrences; } - private materialize(habit: Habit) { - let date = habit.materializedThrough ?? habit.createdDate; - while (date <= this.today) { + private materialize(habit: Habit, through = this.today, force = false) { + let date = force ? through : habit.materializedThrough ? addDays(habit.materializedThrough, 1) : habit.createdDate; + if (date > through) return; + this.loadRevisions(habit.id, date, through); + while (date <= through) { const revision = this.revision(habit.id, date)!; const existing = this.db.select().from(habitDays).where(and(eq(habitDays.habitId, habit.id), eq(habitDays.date, date))).orderBy(desc(habitDays.id)).get(); if (!existing || existing.revisionId !== revision.id) { @@ -222,7 +230,7 @@ export class HabitService { } date = addDays(date, 1); } - this.db.update(habits).set({ materializedThrough: this.today }).where(eq(habits.id, habit.id)).run(); + this.db.update(habits).set({ materializedThrough: through }).where(eq(habits.id, habit.id)).run(); } private counts(id: string) { const cached = this.countCache.get(id); if (cached) return cached; From 31e4f3b2eff0d2777b2c7f26c25eca0b089372e8 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Tue, 8 Sep 2026 16:18:09 +0200 Subject: [PATCH 4/4] Yield bounded catch-up batches behind per-user readiness queues --- src/account/routes.ts | 7 +++-- src/habits/performance.test.ts | 26 +++++++++++++++++ src/habits/routes.ts | 5 ++-- src/habits/service.ts | 53 ++++++++++++++++++++++++++++++---- 4 files changed, 80 insertions(+), 11 deletions(-) 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 = [];