auto-snapshots.ts252 lines · main
| 1 | import { newId, ValidationError } from '@briven/shared'; |
| 2 | import { and, asc, eq, lte } from 'drizzle-orm'; |
| 3 | |
| 4 | import { getDb } from '../db/client.js'; |
| 5 | import { |
| 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. */ |
| 26 | const DAILY_HOURS = [2]; |
| 27 | const TWICE_DAILY_HOURS = [2, 14]; |
| 28 | |
| 29 | const MIN_RETENTION = 1; |
| 30 | const MAX_RETENTION = 90; |
| 31 | const MAX_ERROR_LEN = 500; |
| 32 | |
| 33 | function 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 | */ |
| 42 | export 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 | |
| 56 | export 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. */ |
| 67 | const 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 | |
| 77 | function 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 | */ |
| 94 | export 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 | |
| 107 | export interface UpdateAutoSnapshotInput { |
| 108 | enabled: boolean; |
| 109 | frequency: AutoSnapshotFrequency; |
| 110 | retentionCount: number; |
| 111 | updatedBy: string | null; |
| 112 | } |
| 113 | |
| 114 | function 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 | */ |
| 138 | export 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. */ |
| 177 | export 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 | */ |
| 191 | export 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 | */ |
| 222 | export 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. |
| 252 | export const _internals = { nextAutoRunAfter, DAILY_HOURS, TWICE_DAILY_HOURS }; |