Skip unchanged materialization and expire only open occurrences
This commit is contained in:
1
drizzle/0014_open_occurrences.sql
Normal file
1
drizzle/0014_open_occurrences.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
CREATE INDEX task_occurrences_open_idx ON task_occurrences (day_id) WHERE closed_at IS NULL;
|
||||||
1233
drizzle/meta/0014_snapshot.json
Normal file
1233
drizzle/meta/0014_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -99,6 +99,13 @@
|
|||||||
"when": 1788860000000,
|
"when": 1788860000000,
|
||||||
"tag": "0013_habit_calendar_revision",
|
"tag": "0013_habit_calendar_revision",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 14,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1788860000001,
|
||||||
|
"tag": "0014_open_occurrences",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { sql } from 'drizzle-orm';
|
||||||
import { index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
|
import { index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
|
||||||
|
|
||||||
export const users = sqliteTable("users", {
|
export const users = sqliteTable("users", {
|
||||||
@@ -48,7 +49,7 @@ export const habitDays = sqliteTable('habit_days', {
|
|||||||
export const taskOccurrences = sqliteTable('task_occurrences', {
|
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(),
|
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(),
|
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', {
|
export const progressEvents = sqliteTable('progress_events', {
|
||||||
id: integer('id').primaryKey({ autoIncrement: true }), dayId: integer('day_id').notNull().references(() => habitDays.id), occurrenceId: text('occurrence_id'),
|
id: integer('id').primaryKey({ autoIncrement: true }), dayId: integer('day_id').notNull().references(() => habitDays.id), occurrenceId: text('occurrence_id'),
|
||||||
before: text('before', { mode: 'json' }).$type<unknown>().notNull(), after: text('after', { mode: 'json' }).$type<unknown>().notNull(), createdAt: integer('created_at').notNull(),
|
before: text('before', { mode: 'json' }).$type<unknown>().notNull(), after: text('after', { mode: 'json' }).$type<unknown>().notNull(), createdAt: integer('created_at').notNull(),
|
||||||
|
|||||||
@@ -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);
|
expect((await f.json(`/habits/${habit.id}/days/2026-09-04`)).tasks[0].done).toBe(true);
|
||||||
} finally { f.close(); }
|
} 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();}
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 { AppDatabase } from '../auth';
|
||||||
import type { PublicUser } from '../shared/user';
|
import type { PublicUser } from '../shared/user';
|
||||||
import { habits, habitRevisions, habitDays, taskOccurrences, progressEvents, habitCalendarSettings, combinedCharts, users } from '../db/schema';
|
import { habits, habitRevisions, habitDays, taskOccurrences, progressEvents, habitCalendarSettings, combinedCharts, users } from '../db/schema';
|
||||||
@@ -133,7 +133,7 @@ export class HabitService {
|
|||||||
this.owned(id);
|
this.owned(id);
|
||||||
this.db.transaction(() => {
|
this.db.transaction(() => {
|
||||||
this.db.insert(habitRevisions).values({ habitId: id, effectiveDate: this.today, config, createdAt: this.now }).run();
|
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);
|
this.changed(id);
|
||||||
});
|
});
|
||||||
return this.current(id);
|
return this.current(id);
|
||||||
@@ -157,17 +157,23 @@ export class HabitService {
|
|||||||
}
|
}
|
||||||
sync() {
|
sync() {
|
||||||
this.db.transaction(() => {
|
this.db.transaction(() => {
|
||||||
for (const habit of this.db.select().from(habits).where(eq(habits.userId, this.user.id)).all()) this.materialize(habit);
|
for (const habit of this.pending()) this.materialize(habit);
|
||||||
// Record the original deadline even after downtime. Only active day snapshots expire.
|
// 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
|
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,
|
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)
|
closed_at = (SELECT ends_at FROM habit_days WHERE id = task_occurrences.day_id)
|
||||||
WHERE closed_at IS NULL AND day_id IN (
|
WHERE id IN (
|
||||||
SELECT d.id FROM habit_days d JOIN habits h ON h.id = d.habit_id
|
SELECT o.id FROM task_occurrences o INDEXED BY task_occurrences_open_idx
|
||||||
WHERE h.user_id = ${this.user.id} AND d.ends_at <= ${this.now}
|
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))`);
|
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) {
|
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);
|
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;
|
return occurrences;
|
||||||
}
|
}
|
||||||
private materialize(habit: Habit) {
|
private materialize(habit: Habit, through = this.today, force = false) {
|
||||||
let date = habit.materializedThrough ?? habit.createdDate;
|
let date = force ? through : habit.materializedThrough ? addDays(habit.materializedThrough, 1) : habit.createdDate;
|
||||||
while (date <= this.today) {
|
if (date > through) return;
|
||||||
|
this.loadRevisions(habit.id, date, through);
|
||||||
|
while (date <= through) {
|
||||||
const revision = this.revision(habit.id, date)!;
|
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();
|
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) {
|
if (!existing || existing.revisionId !== revision.id) {
|
||||||
@@ -222,7 +230,7 @@ export class HabitService {
|
|||||||
}
|
}
|
||||||
date = addDays(date, 1);
|
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) {
|
private counts(id: string) {
|
||||||
const cached = this.countCache.get(id); if (cached) return cached;
|
const cached = this.countCache.get(id); if (cached) return cached;
|
||||||
|
|||||||
Reference in New Issue
Block a user