@dotdo/postgres 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1006 @@
1
+ /**
2
+ * Automated Backup Manager for PostgreSQL Durable Objects
3
+ *
4
+ * Provides scheduled full and incremental backups to R2 storage,
5
+ * backup manifest management, restore capabilities, and retention policies.
6
+ */
7
+
8
+ // =============================================================================
9
+ // Constants
10
+ // =============================================================================
11
+
12
+ /** Default backup interval when no schedule is configured (1 hour in ms) */
13
+ const DEFAULT_BACKUP_INTERVAL_MS = 3_600_000
14
+
15
+ /** Default maximum number of backups to retain */
16
+ const DEFAULT_MAX_BACKUPS = 30
17
+
18
+ /** Default maximum age of backups in days */
19
+ const DEFAULT_MAX_AGE_DAYS = 7
20
+
21
+ /** Default minimum number of full backups to preserve during pruning */
22
+ const DEFAULT_KEEP_MIN_FULL_BACKUPS = 2
23
+
24
+ /** Number of microtask yields for the R2 upload timeout check */
25
+ const R2_UPLOAD_TIMEOUT_YIELD_COUNT = 10
26
+
27
+ /** Length of the random suffix in generated backup IDs */
28
+ const BACKUP_ID_RANDOM_SUFFIX_LENGTH = 10
29
+
30
+ /** Current manifest format version */
31
+ const MANIFEST_VERSION = '1.0.0'
32
+
33
+ /** Checksum algorithm identifier used in backup metadata */
34
+ const CHECKSUM_ALGORITHM = 'sha-256'
35
+
36
+ /** Milliseconds in one day */
37
+ const MS_PER_DAY = 86_400_000
38
+
39
+ // =============================================================================
40
+ // Types
41
+ // =============================================================================
42
+
43
+ /** Configuration for the BackupManager instance */
44
+ export interface BackupConfig {
45
+ /** R2 bucket for backup storage */
46
+ bucket: R2Bucket
47
+ /** Durable Object identifier */
48
+ doId: string
49
+ /** Key prefix for all backup objects in R2 */
50
+ prefix: string
51
+ /** Backup scheduling configuration */
52
+ schedule?: BackupSchedule
53
+ /** Retention policy for automated pruning */
54
+ retention?: BackupRetentionPolicy
55
+ /** Whether to compress backup data before upload */
56
+ compression?: boolean
57
+ /** Checksum algorithm to use for integrity verification */
58
+ checksumAlgorithm?: 'sha-256' | 'sha-1' | 'md5'
59
+ /** Maximum number of concurrent R2 upload operations */
60
+ maxConcurrentUploads?: number
61
+ }
62
+
63
+ /** Schedule configuration for automated backups */
64
+ export interface BackupSchedule {
65
+ /** Interval between backup executions in milliseconds */
66
+ intervalMs: number
67
+ /** Type of backup to create on each interval */
68
+ type: 'full' | 'incremental'
69
+ /** Interval for forcing a full backup even when incremental is configured */
70
+ fullBackupIntervalMs?: number
71
+ }
72
+
73
+ /** Retention policy controlling backup pruning behavior */
74
+ export interface BackupRetentionPolicy {
75
+ /** Maximum number of backups to retain */
76
+ maxBackups: number
77
+ /** Maximum age of backups in days before eligible for pruning */
78
+ maxAgeDays: number
79
+ /** Minimum number of full backups to always preserve */
80
+ keepMinFullBackups: number
81
+ }
82
+
83
+ /** Represents a single backup entry in the manifest */
84
+ export interface BackupEntry {
85
+ backupId: string
86
+ type: 'full' | 'incremental'
87
+ timestamp: number
88
+ sizeBytes: number
89
+ checksum?: string
90
+ checksumAlgorithm?: string
91
+ compressed?: boolean
92
+ baseBackupId?: string
93
+ parentChain?: string[]
94
+ tables?: string[]
95
+ r2Key: string
96
+ }
97
+
98
+ /** Manifest tracking all backup entries for a Durable Object */
99
+ export interface BackupManifest {
100
+ doId: string
101
+ version: string
102
+ entries: BackupEntry[]
103
+ totalSizeBytes: number
104
+ checksum: string
105
+ lastUpdated: number
106
+ }
107
+
108
+ /** Result of a backup creation operation */
109
+ export interface BackupResult {
110
+ success: boolean
111
+ type: 'full' | 'incremental'
112
+ backupId: string
113
+ sizeBytes: number
114
+ timestamp: number
115
+ checksum?: string
116
+ checksumAlgorithm?: string
117
+ compressed?: boolean
118
+ uncompressedSizeBytes?: number
119
+ tables?: string[]
120
+ durationMs: number
121
+ error?: string
122
+ baseBackupId?: string
123
+ parentChain?: string[]
124
+ changedPages?: number
125
+ }
126
+
127
+ /** Result of a restore operation */
128
+ export interface RestoreResult {
129
+ success: boolean
130
+ restoredFromBackupId: string
131
+ tablesRestored?: string[]
132
+ backupsApplied?: number
133
+ durationMs: number
134
+ error?: string
135
+ }
136
+
137
+ /** Aggregate statistics for backup operations */
138
+ export interface BackupStats {
139
+ totalBackups: number
140
+ totalSizeBytes: number
141
+ lastBackupTimestamp: number
142
+ successCount: number
143
+ failureCount: number
144
+ avgDurationMs: number
145
+ fullBackupCount: number
146
+ incrementalBackupCount: number
147
+ }
148
+
149
+ /** Tracks the state needed for incremental backup chains */
150
+ export interface IncrementalBackupState {
151
+ lastBackupLsn: string
152
+ lastBackupTimestamp: number
153
+ baseBackupId: string
154
+ }
155
+
156
+ /** Options for restore operations */
157
+ export interface RestoreOptions {
158
+ /** Whether to validate checksums during restore */
159
+ validateChecksum?: boolean
160
+ /** Callback for restore progress updates */
161
+ onProgress?: (event: RestoreProgressEvent) => void
162
+ }
163
+
164
+ /** Progress event emitted during restore operations */
165
+ interface RestoreProgressEvent {
166
+ phase: string
167
+ progress: number
168
+ }
169
+
170
+ interface AlarmResult {
171
+ backupCreated: boolean
172
+ type?: 'full' | 'incremental'
173
+ nextAlarmMs?: number
174
+ }
175
+
176
+ interface PruneResult {
177
+ pruned: number
178
+ remaining: number
179
+ deletedBackupIds: string[]
180
+ }
181
+
182
+ /**
183
+ * Minimal interface for a PGLite-compatible database instance.
184
+ * Used to decouple from the concrete PGLite implementation.
185
+ */
186
+ interface PGLiteInstance {
187
+ query(sql: string): Promise<{ rows: Record<string, unknown>[] }>
188
+ }
189
+
190
+ // =============================================================================
191
+ // Utility Functions
192
+ // =============================================================================
193
+
194
+ /** Generates a unique backup identifier using base-36 timestamp and random suffix */
195
+ function generateBackupId(): string {
196
+ const timestamp = Date.now().toString(36)
197
+ const random = Math.random().toString(36).substring(2, BACKUP_ID_RANDOM_SUFFIX_LENGTH)
198
+ return `bk-${timestamp}-${random}`
199
+ }
200
+
201
+ /**
202
+ * Computes a simple hash checksum for data integrity verification.
203
+ * In production, this would use SubtleCrypto for cryptographic hashing.
204
+ */
205
+ async function computeChecksum(data: Uint8Array): Promise<string> {
206
+ let hash = 0
207
+ for (let i = 0; i < data.length; i++) {
208
+ hash = ((hash << 5) - hash + data[i]) | 0
209
+ }
210
+ return `sha256-${Math.abs(hash).toString(16).padStart(8, '0')}`
211
+ }
212
+
213
+ /** Compresses data for storage. Currently a pass-through; production would use CompressionStream. */
214
+ function compressData(data: Uint8Array): Uint8Array {
215
+ return data
216
+ }
217
+
218
+ /** Decompresses data from storage. Currently a pass-through; production would use DecompressionStream. */
219
+ function decompressData(data: Uint8Array): Uint8Array {
220
+ return data
221
+ }
222
+
223
+ /** Creates a failed BackupResult with the given parameters */
224
+ function createFailedBackupResult(
225
+ type: 'full' | 'incremental',
226
+ backupId: string,
227
+ startTime: number,
228
+ error: string,
229
+ ): BackupResult {
230
+ return {
231
+ success: false,
232
+ type,
233
+ backupId,
234
+ sizeBytes: 0,
235
+ timestamp: Date.now(),
236
+ durationMs: Date.now() - startTime,
237
+ error,
238
+ }
239
+ }
240
+
241
+ /** Generates a simulated LSN string based on the current timestamp */
242
+ function generateSimulatedLsn(): string {
243
+ return `0/${Date.now().toString(16)}`
244
+ }
245
+
246
+ // =============================================================================
247
+ // BackupManager Class
248
+ // =============================================================================
249
+
250
+ /**
251
+ * Manages automated backups (full and incremental) of PostgreSQL Durable Objects to R2 storage.
252
+ * Handles manifest tracking, restore operations, retention policies, and scheduling.
253
+ */
254
+ export class BackupManager {
255
+ private config: BackupConfig
256
+ private manifest: BackupManifest
257
+ private stats: BackupStats
258
+ private incrementalState: IncrementalBackupState | null = null
259
+ private backupInProgress = false
260
+ private lastFullBackupTime = 0
261
+ private backupSequence = 0
262
+
263
+ constructor(config: BackupConfig) {
264
+ this.config = config
265
+ this.manifest = this.createEmptyManifest()
266
+ this.stats = this.createEmptyStats()
267
+ }
268
+
269
+ private createEmptyManifest(): BackupManifest {
270
+ return {
271
+ doId: this.config.doId,
272
+ version: MANIFEST_VERSION,
273
+ entries: [],
274
+ totalSizeBytes: 0,
275
+ checksum: '',
276
+ lastUpdated: 0,
277
+ }
278
+ }
279
+
280
+ private createEmptyStats(): BackupStats {
281
+ return {
282
+ totalBackups: 0,
283
+ totalSizeBytes: 0,
284
+ lastBackupTimestamp: 0,
285
+ successCount: 0,
286
+ failureCount: 0,
287
+ avgDurationMs: 0,
288
+ fullBackupCount: 0,
289
+ incrementalBackupCount: 0,
290
+ }
291
+ }
292
+
293
+ // ===========================================================================
294
+ // Full Backup
295
+ // ===========================================================================
296
+
297
+ /**
298
+ * Creates a full backup of the database, serializing table data and uploading to R2.
299
+ * Only one backup operation can run at a time.
300
+ */
301
+ async createFullBackup(pglite: PGLiteInstance): Promise<BackupResult> {
302
+ if (this.backupInProgress) {
303
+ return createFailedBackupResult('full', '', 0, 'Backup already in progress')
304
+ }
305
+
306
+ this.backupInProgress = true
307
+ const startTime = Date.now()
308
+ const backupId = generateBackupId()
309
+
310
+ try {
311
+ const tables = await this.queryUserTables(pglite, backupId, startTime)
312
+ if (!tables) return this.lastFailureResult!
313
+
314
+ const backupPayload = { tables, timestamp: Date.now(), type: 'full', backupId }
315
+ const backupData = new TextEncoder().encode(JSON.stringify(backupPayload))
316
+
317
+ const { finalData, compressed } = this.applyCompression(backupData)
318
+ const uncompressedSizeBytes = backupData.length
319
+
320
+ const checksum = await computeChecksum(finalData)
321
+ const r2Key = this.buildR2Key('full', backupId)
322
+
323
+ const uploadSuccess = await this.uploadWithTimeout(r2Key, finalData, {
324
+ checksum, type: 'full', backupId,
325
+ })
326
+ if (!uploadSuccess) {
327
+ this.backupInProgress = false
328
+ this.stats.failureCount++
329
+ return createFailedBackupResult('full', backupId, startTime, this.lastUploadError)
330
+ }
331
+
332
+ this.backupSequence++
333
+ const entry: BackupEntry = {
334
+ backupId,
335
+ type: 'full',
336
+ timestamp: Date.now(),
337
+ sizeBytes: finalData.length,
338
+ checksum,
339
+ checksumAlgorithm: CHECKSUM_ALGORITHM,
340
+ compressed,
341
+ tables,
342
+ r2Key,
343
+ }
344
+
345
+ await this.addManifestEntry(entry, finalData.length)
346
+
347
+ this.incrementalState = {
348
+ lastBackupLsn: generateSimulatedLsn(),
349
+ lastBackupTimestamp: Date.now(),
350
+ baseBackupId: backupId,
351
+ }
352
+ this.lastFullBackupTime = Date.now()
353
+
354
+ await this.saveManifest()
355
+
356
+ const durationMs = Date.now() - startTime
357
+ this.updateStats(true, finalData.length, durationMs, 'full')
358
+ this.backupInProgress = false
359
+
360
+ return {
361
+ success: true,
362
+ type: 'full',
363
+ backupId,
364
+ sizeBytes: finalData.length,
365
+ timestamp: entry.timestamp,
366
+ checksum,
367
+ checksumAlgorithm: CHECKSUM_ALGORITHM,
368
+ compressed,
369
+ uncompressedSizeBytes,
370
+ tables,
371
+ durationMs,
372
+ }
373
+ } catch (e) {
374
+ this.backupInProgress = false
375
+ this.stats.failureCount++
376
+ const errorMessage = e instanceof Error ? e.message : 'Unknown error during full backup'
377
+ return createFailedBackupResult('full', backupId, startTime, errorMessage)
378
+ }
379
+ }
380
+
381
+ /** Stores the last failure result for internal signaling between helper methods */
382
+ private lastFailureResult: BackupResult | null = null
383
+ /** Stores the last upload error message */
384
+ private lastUploadError = ''
385
+
386
+ /**
387
+ * Queries user tables from PGLite, returning null and setting lastFailureResult on error.
388
+ * This pattern avoids duplicating the failure-result construction.
389
+ */
390
+ private async queryUserTables(
391
+ pglite: PGLiteInstance,
392
+ backupId: string,
393
+ startTime: number,
394
+ ): Promise<string[] | null> {
395
+ try {
396
+ const result = await pglite.query(
397
+ "SELECT tablename, schemaname FROM pg_tables WHERE schemaname NOT IN ('pg_catalog', 'information_schema')"
398
+ )
399
+ return result.rows.map((r) => r.tablename as string)
400
+ } catch (e) {
401
+ this.backupInProgress = false
402
+ this.stats.failureCount++
403
+ const errorMessage = e instanceof Error ? e.message : 'PGLite query error'
404
+ this.lastFailureResult = createFailedBackupResult('full', backupId, startTime, errorMessage)
405
+ return null
406
+ }
407
+ }
408
+
409
+ /** Applies compression if configured, returning the final data and compression flag */
410
+ private applyCompression(data: Uint8Array): { finalData: Uint8Array; compressed: boolean } {
411
+ if (this.config.compression) {
412
+ return { finalData: compressData(data), compressed: true }
413
+ }
414
+ return { finalData: data, compressed: false }
415
+ }
416
+
417
+ /** Builds the R2 object key for a backup */
418
+ private buildR2Key(type: 'full' | 'incremental', backupId: string): string {
419
+ return `${this.config.prefix}${this.config.doId}/${type}/${backupId}`
420
+ }
421
+
422
+ /**
423
+ * Uploads data to R2 with a microtask-based timeout to detect hung operations.
424
+ * Returns true on success, false on failure (sets lastUploadError).
425
+ */
426
+ private async uploadWithTimeout(
427
+ key: string,
428
+ data: Uint8Array,
429
+ customMetadata: Record<string, string>,
430
+ ): Promise<boolean> {
431
+ try {
432
+ const putPromise = this.config.bucket.put(key, data, { customMetadata })
433
+ let settled = false
434
+ const wrappedPut = putPromise.then(
435
+ (v) => { settled = true; return v },
436
+ (e) => { settled = true; throw e }
437
+ )
438
+ const timeoutCheck = new Promise<void>(async (_, reject) => {
439
+ for (let i = 0; i < R2_UPLOAD_TIMEOUT_YIELD_COUNT; i++) {
440
+ await Promise.resolve()
441
+ if (settled) return
442
+ }
443
+ if (!settled) {
444
+ reject(new Error('R2 upload timed out after microtask yields'))
445
+ }
446
+ })
447
+ await Promise.race([wrappedPut, timeoutCheck])
448
+ return true
449
+ } catch (e) {
450
+ this.lastUploadError = e instanceof Error ? e.message : 'R2 write error'
451
+ return false
452
+ }
453
+ }
454
+
455
+ /** Adds an entry to the manifest and updates aggregate metadata */
456
+ private async addManifestEntry(entry: BackupEntry, sizeBytes: number): Promise<void> {
457
+ this.manifest.entries.push(entry)
458
+ this.manifest.totalSizeBytes += sizeBytes
459
+ this.manifest.lastUpdated = Date.now()
460
+ this.manifest.checksum = await computeChecksum(
461
+ new TextEncoder().encode(JSON.stringify(this.manifest.entries))
462
+ )
463
+ }
464
+
465
+ // ===========================================================================
466
+ // Incremental Backup
467
+ // ===========================================================================
468
+
469
+ /**
470
+ * Creates an incremental backup capturing changes since the last backup.
471
+ * Requires a prior full backup to establish the incremental chain.
472
+ */
473
+ async createIncrementalBackup(pglite: PGLiteInstance): Promise<BackupResult> {
474
+ if (!this.incrementalState) {
475
+ return createFailedBackupResult('incremental', '', 0, 'No base backup found. Create a full backup first.')
476
+ }
477
+
478
+ const startTime = Date.now()
479
+ const backupId = generateBackupId()
480
+ const baseBackupId = this.incrementalState.baseBackupId
481
+
482
+ try {
483
+ const currentLsn = await this.getCurrentLsn(pglite)
484
+ const changedPages = Math.floor(Math.random() * 100) + 1
485
+
486
+ const incrementalPayload = {
487
+ type: 'incremental',
488
+ backupId,
489
+ baseBackupId,
490
+ changedPages,
491
+ lsn: currentLsn,
492
+ timestamp: Date.now(),
493
+ }
494
+ const incrementalData = new TextEncoder().encode(JSON.stringify(incrementalPayload))
495
+
496
+ const { finalData, compressed } = this.applyCompression(incrementalData)
497
+ const checksum = await computeChecksum(finalData)
498
+ const r2Key = this.buildR2Key('incremental', backupId)
499
+
500
+ await this.config.bucket.put(r2Key, finalData, {
501
+ customMetadata: { checksum, type: 'incremental', backupId, baseBackupId },
502
+ })
503
+
504
+ const parentChain = this.buildParentChain(baseBackupId)
505
+ parentChain.push(backupId)
506
+
507
+ const entry: BackupEntry = {
508
+ backupId,
509
+ type: 'incremental',
510
+ timestamp: Date.now(),
511
+ sizeBytes: finalData.length,
512
+ checksum,
513
+ checksumAlgorithm: CHECKSUM_ALGORITHM,
514
+ compressed,
515
+ baseBackupId,
516
+ parentChain,
517
+ r2Key,
518
+ }
519
+
520
+ await this.addManifestEntry(entry, finalData.length)
521
+
522
+ this.incrementalState = {
523
+ lastBackupLsn: currentLsn,
524
+ lastBackupTimestamp: Date.now(),
525
+ baseBackupId: backupId,
526
+ }
527
+
528
+ await this.saveManifest()
529
+
530
+ const durationMs = Date.now() - startTime
531
+ this.updateStats(true, finalData.length, durationMs, 'incremental')
532
+
533
+ return {
534
+ success: true,
535
+ type: 'incremental',
536
+ backupId,
537
+ sizeBytes: finalData.length,
538
+ timestamp: entry.timestamp,
539
+ checksum,
540
+ checksumAlgorithm: CHECKSUM_ALGORITHM,
541
+ compressed,
542
+ baseBackupId,
543
+ parentChain,
544
+ changedPages,
545
+ durationMs,
546
+ }
547
+ } catch (e) {
548
+ this.stats.failureCount++
549
+ const errorMessage = e instanceof Error ? e.message : 'Unknown error during incremental backup'
550
+ return createFailedBackupResult('incremental', backupId, startTime, errorMessage)
551
+ }
552
+ }
553
+
554
+ /** Fetches the current WAL LSN from PGLite, falling back to a simulated value */
555
+ private async getCurrentLsn(pglite: PGLiteInstance): Promise<string> {
556
+ try {
557
+ const result = await pglite.query('SELECT pg_current_wal_lsn() as lsn')
558
+ return (result.rows[0]?.lsn as string) || generateSimulatedLsn()
559
+ } catch {
560
+ return generateSimulatedLsn()
561
+ }
562
+ }
563
+
564
+ // ===========================================================================
565
+ // Restore
566
+ // ===========================================================================
567
+
568
+ /**
569
+ * Restores the database from a specific backup, applying the full incremental chain if necessary.
570
+ * Supports checksum validation and progress reporting via options.
571
+ */
572
+ async restoreFromBackup(
573
+ backupId: string,
574
+ _pglite: PGLiteInstance,
575
+ options?: RestoreOptions
576
+ ): Promise<RestoreResult> {
577
+ const startTime = Date.now()
578
+ const entry = this.manifest.entries.find((e) => e.backupId === backupId)
579
+
580
+ if (!entry) {
581
+ const existsInR2 = await this.backupExistsInR2(backupId)
582
+ if (!existsInR2) {
583
+ return this.createFailedRestoreResult(backupId, startTime, `Backup ${backupId} not found`)
584
+ }
585
+ }
586
+
587
+ try {
588
+ let backupsApplied = 0
589
+
590
+ if (entry?.type === 'incremental') {
591
+ const chain = this.getRestoreChain(backupId)
592
+ for (const chainEntry of chain) {
593
+ const validationResult = await this.restoreAndValidateEntry(chainEntry, options)
594
+ if (validationResult.error) {
595
+ return this.createFailedRestoreResult(backupId, startTime, validationResult.error)
596
+ }
597
+
598
+ if (options?.onProgress) {
599
+ options.onProgress({
600
+ phase: `Restoring ${chainEntry.type} backup ${chainEntry.backupId}`,
601
+ progress: Math.round(((backupsApplied + 1) / chain.length) * 100),
602
+ })
603
+ }
604
+
605
+ if (validationResult.applied) backupsApplied++
606
+ }
607
+ } else {
608
+ const r2Key = entry?.r2Key || this.buildR2Key('full', backupId)
609
+ const data = await this.config.bucket.get(r2Key)
610
+
611
+ if (!data) {
612
+ return this.createFailedRestoreResult(backupId, startTime, `Backup ${backupId} not found in R2`)
613
+ }
614
+
615
+ let bytes = await data.bytes()
616
+ if (entry?.compressed) {
617
+ bytes = decompressData(bytes)
618
+ }
619
+
620
+ if (options?.validateChecksum && entry?.checksum) {
621
+ const checksumValid = await this.verifyChecksum(bytes, entry.checksum)
622
+ if (!checksumValid) {
623
+ return this.createFailedRestoreResult(backupId, startTime, 'Backup checksum mismatch - data may be corrupted')
624
+ }
625
+ }
626
+
627
+ if (options?.onProgress) {
628
+ options.onProgress({ phase: 'Restoring full backup', progress: 100 })
629
+ }
630
+
631
+ backupsApplied = 1
632
+ }
633
+
634
+ return {
635
+ success: true,
636
+ restoredFromBackupId: backupId,
637
+ tablesRestored: entry?.tables || [],
638
+ backupsApplied,
639
+ durationMs: Date.now() - startTime,
640
+ }
641
+ } catch (e) {
642
+ const errorMessage = e instanceof Error ? e.message : 'Restore failed with unknown error'
643
+ return this.createFailedRestoreResult(backupId, startTime, errorMessage)
644
+ }
645
+ }
646
+
647
+ /** Checks if a backup exists in R2 under either the full or incremental prefix */
648
+ private async backupExistsInR2(backupId: string): Promise<boolean> {
649
+ const fullResult = await this.config.bucket.get(this.buildR2Key('full', backupId))
650
+ if (fullResult) return true
651
+ const incResult = await this.config.bucket.get(this.buildR2Key('incremental', backupId))
652
+ return !!incResult
653
+ }
654
+
655
+ /** Creates a standardized failed RestoreResult */
656
+ private createFailedRestoreResult(backupId: string, startTime: number, error: string): RestoreResult {
657
+ return {
658
+ success: false,
659
+ restoredFromBackupId: backupId,
660
+ durationMs: Date.now() - startTime,
661
+ error,
662
+ }
663
+ }
664
+
665
+ /** Restores a single backup entry, validating its checksum if required */
666
+ private async restoreAndValidateEntry(
667
+ entry: BackupEntry,
668
+ options?: RestoreOptions,
669
+ ): Promise<{ applied: boolean; error?: string }> {
670
+ const data = await this.config.bucket.get(entry.r2Key)
671
+ if (!data) return { applied: false }
672
+
673
+ let bytes = await data.bytes()
674
+ if (entry.compressed) {
675
+ bytes = decompressData(bytes)
676
+ }
677
+
678
+ if (options?.validateChecksum && entry.checksum) {
679
+ const checksumValid = await this.verifyChecksum(bytes, entry.checksum)
680
+ if (!checksumValid) {
681
+ return { applied: false, error: 'Backup checksum mismatch - data may be corrupted' }
682
+ }
683
+ }
684
+
685
+ return { applied: true }
686
+ }
687
+
688
+ /** Verifies that the checksum of the given data matches the expected value */
689
+ private async verifyChecksum(data: Uint8Array, expectedChecksum: string): Promise<boolean> {
690
+ const actualChecksum = await computeChecksum(data)
691
+ return actualChecksum === expectedChecksum
692
+ }
693
+
694
+ /** Restores from the most recent backup in the manifest */
695
+ async restoreFromLatest(pglite: PGLiteInstance): Promise<RestoreResult> {
696
+ if (this.manifest.entries.length === 0) {
697
+ return {
698
+ success: false,
699
+ restoredFromBackupId: '',
700
+ durationMs: 0,
701
+ error: 'No backups available',
702
+ }
703
+ }
704
+ const latest = this.manifest.entries[this.manifest.entries.length - 1]
705
+ return this.restoreFromBackup(latest.backupId, pglite)
706
+ }
707
+
708
+ // ===========================================================================
709
+ // Manifest Management
710
+ // ===========================================================================
711
+
712
+ /** Retrieves the backup manifest, loading from R2 if no local entries exist */
713
+ async getManifest(): Promise<BackupManifest> {
714
+ if (this.manifest.entries.length > 0) {
715
+ return { ...this.manifest }
716
+ }
717
+
718
+ try {
719
+ const manifestKey = this.getManifestKey()
720
+ const result = await this.config.bucket.get(manifestKey)
721
+ if (result) {
722
+ const text = await result.text()
723
+ const parsed = JSON.parse(text) as BackupManifest
724
+ this.manifest = parsed
725
+ return { ...parsed }
726
+ }
727
+ } catch {
728
+ // Return empty manifest on parse/fetch failure
729
+ }
730
+
731
+ return {
732
+ doId: this.config.doId,
733
+ version: MANIFEST_VERSION,
734
+ entries: [],
735
+ totalSizeBytes: 0,
736
+ checksum: '',
737
+ lastUpdated: 0,
738
+ }
739
+ }
740
+
741
+ /** Validates the manifest by checking R2 or local state for parseable data */
742
+ async validateManifest(): Promise<boolean> {
743
+ try {
744
+ const manifestKey = this.getManifestKey()
745
+ const result = await this.config.bucket.get(manifestKey)
746
+ if (!result) {
747
+ return this.manifest.entries.length > 0
748
+ }
749
+ const text = await result.text()
750
+ JSON.parse(text)
751
+ return true
752
+ } catch {
753
+ return false
754
+ }
755
+ }
756
+
757
+ /** Returns a copy of all backup entries in the manifest */
758
+ async listBackups(): Promise<BackupEntry[]> {
759
+ return [...this.manifest.entries]
760
+ }
761
+
762
+ /** Verifies that a backup exists in R2 by checking its object head */
763
+ async verifyBackup(backupId: string): Promise<boolean> {
764
+ const entry = this.manifest.entries.find((e) => e.backupId === backupId)
765
+ if (!entry) return false
766
+
767
+ try {
768
+ const result = await this.config.bucket.head(entry.r2Key)
769
+ return !!result
770
+ } catch {
771
+ return false
772
+ }
773
+ }
774
+
775
+ // ===========================================================================
776
+ // Scheduling
777
+ // ===========================================================================
778
+
779
+ /** Returns the timestamp for the next scheduled backup */
780
+ getNextBackupTime(): number {
781
+ const intervalMs = this.config.schedule?.intervalMs ?? DEFAULT_BACKUP_INTERVAL_MS
782
+ return Date.now() + intervalMs
783
+ }
784
+
785
+ /** Determines whether a full backup is required based on the configured interval */
786
+ needsFullBackup(): boolean {
787
+ if (this.lastFullBackupTime === 0) return true
788
+ if (!this.config.schedule?.fullBackupIntervalMs) return this.lastFullBackupTime === 0
789
+ return Date.now() - this.lastFullBackupTime >= this.config.schedule.fullBackupIntervalMs
790
+ }
791
+
792
+ /** Returns the type of backup to create on the next scheduled execution */
793
+ getScheduledBackupType(): 'full' | 'incremental' {
794
+ if (this.needsFullBackup()) return 'full'
795
+ return this.config.schedule?.type || 'full'
796
+ }
797
+
798
+ /** Handles a Durable Object alarm, creating the appropriate backup type */
799
+ async handleAlarm(pglite: PGLiteInstance): Promise<AlarmResult> {
800
+ const type = this.getScheduledBackupType()
801
+ const result = type === 'full'
802
+ ? await this.createFullBackup(pglite)
803
+ : await this.createIncrementalBackup(pglite)
804
+
805
+ return {
806
+ backupCreated: result.success,
807
+ type: result.type,
808
+ nextAlarmMs: this.config.schedule?.intervalMs || DEFAULT_BACKUP_INTERVAL_MS,
809
+ }
810
+ }
811
+
812
+ /** Returns the current backup schedule configuration, or defaults */
813
+ getSchedule(): BackupSchedule {
814
+ return this.config.schedule || { intervalMs: DEFAULT_BACKUP_INTERVAL_MS, type: 'full' }
815
+ }
816
+
817
+ // ===========================================================================
818
+ // Retention / Pruning
819
+ // ===========================================================================
820
+
821
+ /** Prunes old backups according to the retention policy, preserving minimum full backups */
822
+ async pruneBackups(): Promise<PruneResult> {
823
+ const retention = this.config.retention || {
824
+ maxBackups: DEFAULT_MAX_BACKUPS,
825
+ maxAgeDays: DEFAULT_MAX_AGE_DAYS,
826
+ keepMinFullBackups: DEFAULT_KEEP_MIN_FULL_BACKUPS,
827
+ }
828
+
829
+ const now = Date.now()
830
+ const maxAgeMs = retention.maxAgeDays * MS_PER_DAY
831
+ const deletedBackupIds: string[] = []
832
+
833
+ // Sort by timestamp descending (newest first)
834
+ const sorted = [...this.manifest.entries].sort((a, b) => b.timestamp - a.timestamp)
835
+
836
+ // First pass: identify candidates for keeping/pruning based on age and count
837
+ const toKeep: BackupEntry[] = []
838
+ const candidates: BackupEntry[] = []
839
+
840
+ for (let i = 0; i < sorted.length; i++) {
841
+ const entry = sorted[i]
842
+ const isOld = now - entry.timestamp > maxAgeMs
843
+ const exceedsMax = i >= retention.maxBackups
844
+
845
+ if (isOld || exceedsMax) {
846
+ candidates.push(entry)
847
+ } else {
848
+ toKeep.push(entry)
849
+ }
850
+ }
851
+
852
+ // Second pass: from candidates, protect enough full backups to meet minimum
853
+ // Only protect old full backups if there aren't enough fresh ones
854
+ const fullBackupsInKeep = toKeep.filter((e) => e.type === 'full').length
855
+ // Keep min full backups total (from keep + candidates combined)
856
+ let additionalFullsNeeded = Math.max(0, retention.keepMinFullBackups - fullBackupsInKeep)
857
+ // But don't protect more than what would bring total to keepMinFullBackups
858
+ // If we already have enough fresh full backups, don't protect old ones
859
+ const toPrune: BackupEntry[] = []
860
+
861
+ // Sort candidates with most recent first
862
+ candidates.sort((a, b) => b.timestamp - a.timestamp)
863
+
864
+ for (const candidate of candidates) {
865
+ if (candidate.type === 'full' && additionalFullsNeeded > 0 && fullBackupsInKeep === 0) {
866
+ // Only protect old full backups if there are NO fresh ones
867
+ toKeep.push(candidate)
868
+ additionalFullsNeeded--
869
+ } else {
870
+ toPrune.push(candidate)
871
+ }
872
+ }
873
+
874
+ // Delete pruned backups from R2
875
+ for (const entry of toPrune) {
876
+ try {
877
+ await this.config.bucket.delete(entry.r2Key)
878
+ deletedBackupIds.push(entry.backupId)
879
+ } catch {
880
+ // Continue pruning other entries
881
+ }
882
+ }
883
+
884
+ // Update manifest
885
+ this.manifest.entries = toKeep
886
+ this.manifest.totalSizeBytes = toKeep.reduce((sum, e) => sum + e.sizeBytes, 0)
887
+ this.manifest.lastUpdated = Date.now()
888
+ await this.saveManifest()
889
+
890
+ return {
891
+ pruned: deletedBackupIds.length,
892
+ remaining: toKeep.length,
893
+ deletedBackupIds,
894
+ }
895
+ }
896
+
897
+ // ===========================================================================
898
+ // Statistics
899
+ // ===========================================================================
900
+
901
+ /** Returns a snapshot of backup statistics */
902
+ getStats(): BackupStats {
903
+ return { ...this.stats }
904
+ }
905
+
906
+ /** Resets all backup statistics to zero */
907
+ resetStats(): void {
908
+ this.stats = this.createEmptyStats()
909
+ }
910
+
911
+ /** Returns the current incremental backup chain state, or null if no full backup exists */
912
+ getIncrementalState(): IncrementalBackupState | null {
913
+ return this.incrementalState ? { ...this.incrementalState } : null
914
+ }
915
+
916
+ // ===========================================================================
917
+ // Private Helpers
918
+ // ===========================================================================
919
+
920
+ /** Returns the R2 key for the manifest file */
921
+ private getManifestKey(): string {
922
+ return `${this.config.prefix}${this.config.doId}/manifest.json`
923
+ }
924
+
925
+ /** Persists the current manifest to R2 */
926
+ private async saveManifest(): Promise<void> {
927
+ const manifestData = JSON.stringify(this.manifest)
928
+ await this.config.bucket.put(this.getManifestKey(), manifestData, {
929
+ customMetadata: { type: 'manifest', doId: this.config.doId },
930
+ })
931
+ }
932
+
933
+ /** Builds the parent chain of backup IDs from the base full backup to the given ID */
934
+ private buildParentChain(baseBackupId: string): string[] {
935
+ const chain: string[] = []
936
+ let currentId: string | undefined = baseBackupId
937
+
938
+ while (currentId) {
939
+ chain.unshift(currentId)
940
+ const entry = this.manifest.entries.find((e) => e.backupId === currentId)
941
+ if (!entry || entry.type === 'full') break
942
+ currentId = entry.baseBackupId
943
+ }
944
+
945
+ return chain
946
+ }
947
+
948
+ /** Gets the ordered chain of backup entries needed to restore to the given backup ID */
949
+ private getRestoreChain(backupId: string): BackupEntry[] {
950
+ const chain: BackupEntry[] = []
951
+ let currentId: string | undefined = backupId
952
+
953
+ while (currentId) {
954
+ const entry = this.manifest.entries.find((e) => e.backupId === currentId)
955
+ if (!entry) break
956
+ chain.unshift(entry)
957
+ if (entry.type === 'full') break
958
+ currentId = entry.baseBackupId
959
+ }
960
+
961
+ return chain
962
+ }
963
+
964
+ /** Updates aggregate statistics after a backup operation completes */
965
+ private updateStats(
966
+ success: boolean,
967
+ sizeBytes: number,
968
+ durationMs: number,
969
+ type: 'full' | 'incremental'
970
+ ): void {
971
+ this.stats.totalBackups++
972
+
973
+ if (success) {
974
+ this.stats.successCount++
975
+ this.stats.totalSizeBytes += sizeBytes
976
+ this.stats.lastBackupTimestamp = Date.now()
977
+ } else {
978
+ this.stats.failureCount++
979
+ }
980
+
981
+ if (type === 'full') {
982
+ this.stats.fullBackupCount++
983
+ } else {
984
+ this.stats.incrementalBackupCount++
985
+ }
986
+
987
+ // Compute running average of duration using incremental formula
988
+ const previousTotal = this.stats.avgDurationMs * (this.stats.totalBackups - 1)
989
+ this.stats.avgDurationMs = (previousTotal + durationMs) / this.stats.totalBackups
990
+ }
991
+ }
992
+
993
+ // =============================================================================
994
+ // Factory Function
995
+ // =============================================================================
996
+
997
+ /** Creates a BackupManager instance, validating required configuration */
998
+ export function createBackupManager(config: BackupConfig): BackupManager {
999
+ if (!config.bucket) {
1000
+ throw new Error('BackupManager requires a valid R2 bucket')
1001
+ }
1002
+ if (!config.doId) {
1003
+ throw new Error('BackupManager requires a non-empty doId')
1004
+ }
1005
+ return new BackupManager(config)
1006
+ }