auto-snapshots.ts252 lines · main
1import { newId, ValidationError } from '@briven/shared';
2import { and, asc, eq, lte } from 'drizzle-orm';
3
4import { getDb } from '../db/client.js';
5import {
6 autoSnapshotFrequency,
7 projectAutoSnapshotSettings,
8 type AutoSnapshotFrequency,
9 type AutoSnapshotRunStatus,
10 type ProjectAutoSnapshotSettings,
11} from '../db/schema.js';
12
13/**
14 * Automatic scheduled snapshots — the control-plane settings + scheduling
15 * logic behind Briven's auto save-points. One row per project in
16 * `project_auto_snapshot_settings`; the worker (workers/auto-snapshot.ts)
17 * claims due rows, calls createSnapshot({ auto: true }), then prunes auto
18 * snapshots beyond retentionCount. Manual snapshots are never touched.
19 *
20 * Cadence is intentionally simple (daily / twice-daily) rather than a full
21 * cron — non-coders pick "once a day" or "twice a day", and we anchor the
22 * runs to fixed UTC hours so they're predictable and don't drift.
23 */
24
25/** UTC hours each frequency fires at. Twice-daily = ~02:00 and ~14:00. */
26const DAILY_HOURS = [2];
27const TWICE_DAILY_HOURS = [2, 14];
28
29const MIN_RETENTION = 1;
30const MAX_RETENTION = 90;
31const MAX_ERROR_LEN = 500;
32
33function hoursFor(frequency: AutoSnapshotFrequency): number[] {
34 return frequency === 'twice_daily' ? TWICE_DAILY_HOURS : DAILY_HOURS;
35}
36
37/**
38 * Next fire time strictly after `from`, anchored to the frequency's fixed
39 * UTC hours. Walks today's slots, then tomorrow's first slot, so the result
40 * is always in the future regardless of when called.
41 */
42export function nextAutoRunAfter(frequency: AutoSnapshotFrequency, from: Date): Date {
43 const hours = hoursFor(frequency);
44 for (const h of hours) {
45 const candidate = new Date(from);
46 candidate.setUTCHours(h, 0, 0, 0);
47 if (candidate.getTime() > from.getTime()) return candidate;
48 }
49 // All of today's slots are in the past — take tomorrow's first slot.
50 const next = new Date(from);
51 next.setUTCDate(next.getUTCDate() + 1);
52 next.setUTCHours(hours[0] ?? 2, 0, 0, 0);
53 return next;
54}
55
56export interface AutoSnapshotSettingsView {
57 readonly enabled: boolean;
58 readonly frequency: AutoSnapshotFrequency;
59 readonly retentionCount: number;
60 readonly nextRunAt: string | null;
61 readonly lastRunAt: string | null;
62 readonly lastRunStatus: AutoSnapshotRunStatus | null;
63 readonly lastRunError: string | null;
64}
65
66/** The default a project gets before it has ever configured auto-snapshots. */
67const DISABLED_DEFAULTS: AutoSnapshotSettingsView = {
68 enabled: false,
69 frequency: 'daily',
70 retentionCount: 7,
71 nextRunAt: null,
72 lastRunAt: null,
73 lastRunStatus: null,
74 lastRunError: null,
75};
76
77function toView(row: ProjectAutoSnapshotSettings): AutoSnapshotSettingsView {
78 return {
79 enabled: row.enabled,
80 frequency: row.frequency,
81 retentionCount: row.retentionCount,
82 nextRunAt: row.nextRunAt ? row.nextRunAt.toISOString() : null,
83 lastRunAt: row.lastRunAt ? row.lastRunAt.toISOString() : null,
84 lastRunStatus: row.lastRunStatus ?? null,
85 lastRunError: row.lastRunError ?? null,
86 };
87}
88
89/**
90 * Read a project's auto-snapshot settings. Returns disabled defaults when
91 * the project has never configured them (no row yet) so the dashboard
92 * always has something to render.
93 */
94export async function getAutoSnapshotSettings(
95 projectId: string,
96): Promise<AutoSnapshotSettingsView> {
97 const db = getDb();
98 const rows = await db
99 .select()
100 .from(projectAutoSnapshotSettings)
101 .where(eq(projectAutoSnapshotSettings.projectId, projectId))
102 .limit(1);
103 const row = rows[0];
104 return row ? toView(row) : DISABLED_DEFAULTS;
105}
106
107export interface UpdateAutoSnapshotInput {
108 enabled: boolean;
109 frequency: AutoSnapshotFrequency;
110 retentionCount: number;
111 updatedBy: string | null;
112}
113
114function validateInput(input: UpdateAutoSnapshotInput): void {
115 if (typeof input.enabled !== 'boolean') {
116 throw new ValidationError('enabled must be true or false');
117 }
118 if (!autoSnapshotFrequency.includes(input.frequency)) {
119 throw new ValidationError(`frequency must be one of: ${autoSnapshotFrequency.join(', ')}`);
120 }
121 if (
122 !Number.isInteger(input.retentionCount) ||
123 input.retentionCount < MIN_RETENTION ||
124 input.retentionCount > MAX_RETENTION
125 ) {
126 throw new ValidationError(
127 `retentionCount must be a whole number between ${MIN_RETENTION} and ${MAX_RETENTION}`,
128 );
129 }
130}
131
132/**
133 * Create or update a project's auto-snapshot settings (upsert on
134 * project_id). When enabled, next_run_at is set to the next slot for the
135 * chosen frequency so the worker picks it up; when disabled, next_run_at is
136 * still kept current so re-enabling doesn't back-fire a stale run.
137 */
138export async function upsertAutoSnapshotSettings(
139 projectId: string,
140 input: UpdateAutoSnapshotInput,
141): Promise<AutoSnapshotSettingsView> {
142 validateInput(input);
143 const db = getDb();
144 const now = new Date();
145 const nextRunAt = nextAutoRunAfter(input.frequency, now);
146
147 const inserted = await db
148 .insert(projectAutoSnapshotSettings)
149 .values({
150 id: newId('as'),
151 projectId,
152 enabled: input.enabled,
153 frequency: input.frequency,
154 retentionCount: input.retentionCount,
155 nextRunAt,
156 createdBy: input.updatedBy,
157 })
158 .onConflictDoUpdate({
159 target: projectAutoSnapshotSettings.projectId,
160 set: {
161 enabled: input.enabled,
162 frequency: input.frequency,
163 retentionCount: input.retentionCount,
164 // Recompute the next slot from the (possibly changed) frequency so a
165 // user toggling settings never leaves a stale next_run_at behind.
166 nextRunAt,
167 updatedAt: now,
168 },
169 })
170 .returning();
171 const row = inserted[0];
172 if (!row) throw new Error('auto-snapshot settings upsert returned no row');
173 return toView(row);
174}
175
176/** A due auto-snapshot settings row, projected for the worker. */
177export interface DueAutoSnapshot {
178 readonly id: string;
179 readonly projectId: string;
180 readonly frequency: AutoSnapshotFrequency;
181 readonly retentionCount: number;
182 readonly nextRunAt: Date;
183}
184
185/**
186 * Claim auto-snapshot rows that are due: enabled and next_run_at <= now.
187 * Optimistic claim (plain SELECT, no row lock) — the worker advances
188 * next_run_at via recordAutoSnapshotResult guarded by the pre-claim value,
189 * so two workers can't double-fire the same project.
190 */
191export async function claimDueAutoSnapshots(
192 now: Date,
193 limit: number,
194): Promise<DueAutoSnapshot[]> {
195 const db = getDb();
196 const rows = await db
197 .select({
198 id: projectAutoSnapshotSettings.id,
199 projectId: projectAutoSnapshotSettings.projectId,
200 frequency: projectAutoSnapshotSettings.frequency,
201 retentionCount: projectAutoSnapshotSettings.retentionCount,
202 nextRunAt: projectAutoSnapshotSettings.nextRunAt,
203 })
204 .from(projectAutoSnapshotSettings)
205 .where(
206 and(
207 eq(projectAutoSnapshotSettings.enabled, true),
208 lte(projectAutoSnapshotSettings.nextRunAt, now),
209 ),
210 )
211 .orderBy(asc(projectAutoSnapshotSettings.nextRunAt))
212 .limit(limit);
213 return rows;
214}
215
216/**
217 * Record the outcome of an auto-snapshot run and advance next_run_at. The
218 * UPDATE is guarded by the claimed next_run_at so a second worker that
219 * claimed the same row loses the race (0 rows updated) and skips silently.
220 * Returns true when this caller won the race and the row was advanced.
221 */
222export async function recordAutoSnapshotResult(input: {
223 settingsId: string;
224 claimedNextRunAt: Date;
225 newNextRunAt: Date;
226 ranAt: Date;
227 status: AutoSnapshotRunStatus;
228 errorMessage?: string;
229}): Promise<boolean> {
230 const db = getDb();
231 const result = await db
232 .update(projectAutoSnapshotSettings)
233 .set({
234 nextRunAt: input.newNextRunAt,
235 lastRunAt: input.ranAt,
236 lastRunStatus: input.status,
237 lastRunError: input.errorMessage ? input.errorMessage.slice(0, MAX_ERROR_LEN) : null,
238 updatedAt: input.ranAt,
239 })
240 .where(
241 and(
242 eq(projectAutoSnapshotSettings.id, input.settingsId),
243 // Guard: only advance if next_run_at is still the value we claimed.
244 eq(projectAutoSnapshotSettings.nextRunAt, input.claimedNextRunAt),
245 ),
246 )
247 .returning({ id: projectAutoSnapshotSettings.id });
248 return result.length > 0;
249}
250
251// Exported for the worker + tests.
252export const _internals = { nextAutoRunAfter, DAILY_HOURS, TWICE_DAILY_HOURS };