@better-forge/backup 0.0.0-stage → 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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +11 -2
  3. package/dist/archive.d.ts +17 -0
  4. package/dist/archive.d.ts.map +1 -0
  5. package/dist/archive.js +132 -0
  6. package/dist/archive.js.map +1 -0
  7. package/dist/cli.d.ts +28 -0
  8. package/dist/cli.d.ts.map +1 -0
  9. package/dist/cli.js +54 -0
  10. package/dist/cli.js.map +1 -0
  11. package/dist/index.d.ts +8 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +8 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/restore.d.ts +42 -0
  16. package/dist/restore.d.ts.map +1 -0
  17. package/dist/restore.js +89 -0
  18. package/dist/restore.js.map +1 -0
  19. package/dist/routes.d.ts +23 -0
  20. package/dist/routes.d.ts.map +1 -0
  21. package/dist/routes.js +111 -0
  22. package/dist/routes.js.map +1 -0
  23. package/dist/runner.d.ts +30 -0
  24. package/dist/runner.d.ts.map +1 -0
  25. package/dist/runner.js +26 -0
  26. package/dist/runner.js.map +1 -0
  27. package/dist/scheduler.d.ts +16 -0
  28. package/dist/scheduler.d.ts.map +1 -0
  29. package/dist/scheduler.js +27 -0
  30. package/dist/scheduler.js.map +1 -0
  31. package/dist/service.d.ts +79 -0
  32. package/dist/service.d.ts.map +1 -0
  33. package/dist/service.js +244 -0
  34. package/dist/service.js.map +1 -0
  35. package/dist/worker.d.ts +2 -0
  36. package/dist/worker.d.ts.map +1 -0
  37. package/dist/worker.js +12 -0
  38. package/dist/worker.js.map +1 -0
  39. package/package.json +55 -4
  40. package/src/archive.ts +114 -0
  41. package/src/cli.ts +62 -0
  42. package/src/index.ts +10 -0
  43. package/src/restore.ts +111 -0
  44. package/src/routes.ts +115 -0
  45. package/src/runner.ts +45 -0
  46. package/src/scheduler.ts +39 -0
  47. package/src/service.ts +250 -0
  48. package/src/worker.ts +18 -0
package/src/service.ts ADDED
@@ -0,0 +1,250 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import os from 'node:os'
4
+ import { DatabaseSync } from 'node:sqlite'
5
+ import { createArchiveFormat, type ArchiveEntry } from './archive.js'
6
+
7
+ /** What every backup records about itself; an application adds its own fields to the manifest. */
8
+ export interface BackupManifest {
9
+ formatVersion: number
10
+ createdAt: number
11
+ appVersion: string
12
+ databaseIntegrity: 'ok'
13
+ }
14
+
15
+ export interface BackupInfo { filename: string; size: number; createdAt: number }
16
+ export interface BackupConfig { enabled: boolean; frequency: 'daily' | 'weekly'; hour: number; minute: number; weekday: number; retention: number }
17
+ export interface BackupStatus { state: 'idle' | 'running' | 'success' | 'error'; lastStartedAt: number | null; lastCompletedAt: number | null; lastFilename: string | null; lastError: string | null }
18
+
19
+ export const DEFAULT_BACKUP_CONFIG: BackupConfig = { enabled: false, frequency: 'daily', hour: 2, minute: 0, weekday: 0, retention: 7 }
20
+ export const DEFAULT_BACKUP_STATUS: BackupStatus = { state: 'idle', lastStartedAt: null, lastCompletedAt: null, lastFilename: null, lastError: null }
21
+
22
+ export const INTERRUPTED_BACKUP_ERROR = 'Backup was interrupted before it finished'
23
+
24
+ /** Who holds the backup lock: enough to tell whether that process may still be running. */
25
+ export interface ProcessOwner { pid: number; processStartedAt: number }
26
+
27
+ export interface BackupServiceOptions<Manifest extends BackupManifest> {
28
+ /** Names the files of this product: `<name>-backup-<time>.backup`, and the temporary directories. */
29
+ name: string
30
+ /** The first bytes of the archive; they identify archives of this product. */
31
+ magic: string
32
+ /** Version of the archive layout; an archive of another version is refused. */
33
+ formatVersion: number
34
+ /** Where backups, their schedule and their status are kept. */
35
+ backupDir: () => string
36
+ /** The SQLite database to back up. */
37
+ databasePath: () => string
38
+ appVersion: () => string
39
+ processOwner: {
40
+ current: () => ProcessOwner
41
+ isAlive: (owner: Partial<ProcessOwner>) => boolean
42
+ }
43
+ /** What goes into the archive besides the database, and the manifest fields that describe it. */
44
+ extras: () => { entries: ArchiveEntry[]; manifest: Omit<Manifest, keyof BackupManifest> }
45
+ }
46
+
47
+ /** Lists the files below `root` as archive entries named `<prefix>/<relative path>`. */
48
+ export function collectFiles(root: string, prefix: string): ArchiveEntry[] {
49
+ if (!fs.existsSync(root)) return []
50
+ const result: ArchiveEntry[] = []
51
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
52
+ const source = path.join(root, entry.name)
53
+ const name = `${prefix}/${entry.name}`
54
+ if (entry.isDirectory()) result.push(...collectFiles(source, name))
55
+ else if (entry.isFile()) result.push({ name, source })
56
+ }
57
+ return result
58
+ }
59
+
60
+ const LOCK_NAME = '.backup.lock'
61
+ /** A lock that cannot be parsed is only trusted briefly: it is either being written or left by an older version. */
62
+ const UNREADABLE_LOCK_GRACE_MS = 60_000
63
+ /** Hard ceiling, even for an owner that still looks alive (e.g. a stuck worker thread in this process). */
64
+ const MAX_LOCK_AGE_MS = 24 * 60 * 60 * 1000
65
+
66
+ interface BackupLockOwner extends ProcessOwner { startedAt: number }
67
+
68
+ function quoteSql(value: string): string { return `'${value.replaceAll("'", "''")}'` }
69
+
70
+ /** Closes the handle even when the file is not SQLite, so temp-dir cleanup never hits a locked file. */
71
+ function checkIntegrity(file: string): void {
72
+ const checkDb = new DatabaseSync(file, { readOnly: true })
73
+ let result: string
74
+ try {
75
+ result = (checkDb.prepare('PRAGMA integrity_check').get() as { integrity_check: string }).integrity_check
76
+ } finally {
77
+ checkDb.close()
78
+ }
79
+ if (result !== 'ok') throw new Error(`SQLite integrity check failed: ${result}`)
80
+ }
81
+
82
+ /**
83
+ * Backups of a SQLite database and the files around it: the archive, its verification, the lock that keeps two
84
+ * backups apart, the schedule and status files, and retention. All state is in the returned closure.
85
+ */
86
+ export function createBackupService<Manifest extends BackupManifest = BackupManifest>(options: BackupServiceOptions<Manifest>) {
87
+ const { createArchive, extractArchive } = createArchiveFormat({ magic: options.magic })
88
+ const { backupDir } = options
89
+ const filePattern = new RegExp(`^${options.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}-backup-\\d+\\.backup$`)
90
+ let operation: Promise<unknown> | null = null
91
+
92
+ const configFile = () => path.join(backupDir(), 'config.json')
93
+ const statusFile = () => path.join(backupDir(), 'status.json')
94
+ const lockFile = () => path.join(backupDir(), LOCK_NAME)
95
+
96
+ function readBackupConfig(): BackupConfig {
97
+ try { return { ...DEFAULT_BACKUP_CONFIG, ...JSON.parse(fs.readFileSync(configFile(), 'utf8')) as Partial<BackupConfig> } }
98
+ catch { return { ...DEFAULT_BACKUP_CONFIG } }
99
+ }
100
+
101
+ function writeBackupConfig(config: BackupConfig): void {
102
+ fs.mkdirSync(backupDir(), { recursive: true })
103
+ fs.writeFileSync(configFile(), JSON.stringify(config, null, 2), { mode: 0o600 })
104
+ }
105
+
106
+ function readStoredBackupStatus(): BackupStatus {
107
+ try { return { ...DEFAULT_BACKUP_STATUS, ...JSON.parse(fs.readFileSync(statusFile(), 'utf8')) as Partial<BackupStatus> } }
108
+ catch { return { ...DEFAULT_BACKUP_STATUS } }
109
+ }
110
+
111
+ /** A 'running' status without a live lock owner means the backup process died mid-run. */
112
+ function readBackupStatus(): BackupStatus {
113
+ const status = readStoredBackupStatus()
114
+ if (status.state !== 'running' || isBackupLockHeld()) return status
115
+ return { ...status, state: 'error', lastError: INTERRUPTED_BACKUP_ERROR }
116
+ }
117
+
118
+ function writeBackupStatus(status: BackupStatus): void {
119
+ fs.mkdirSync(backupDir(), { recursive: true })
120
+ fs.writeFileSync(statusFile(), JSON.stringify(status, null, 2), { mode: 0o600 })
121
+ }
122
+
123
+ async function exclusive<T>(work: () => Promise<T>): Promise<T> {
124
+ if (operation) throw new Error('Another backup operation is already running')
125
+ const current = work()
126
+ operation = current
127
+ try { return await current } finally { operation = null }
128
+ }
129
+
130
+ /** True while some live backup owns the lock; a crashed owner's lock reads as free. */
131
+ function isBackupLockHeld(): boolean {
132
+ const lock = lockFile()
133
+ let stat: fs.Stats
134
+ try { stat = fs.statSync(lock) } catch { return false }
135
+ const age = Date.now() - stat.mtimeMs
136
+ if (age >= MAX_LOCK_AGE_MS) return false
137
+ let owner: Partial<BackupLockOwner>
138
+ try { owner = JSON.parse(fs.readFileSync(lock, 'utf8')) as Partial<BackupLockOwner> }
139
+ catch { return age < UNREADABLE_LOCK_GRACE_MS }
140
+ return options.processOwner.isAlive(owner)
141
+ }
142
+
143
+ function acquireBackupLock(): () => void {
144
+ const lock = lockFile()
145
+ const owner: BackupLockOwner = { ...options.processOwner.current(), startedAt: Date.now() }
146
+ const create = () => fs.writeFileSync(lock, JSON.stringify(owner), { flag: 'wx', mode: 0o600 })
147
+ try { create() }
148
+ catch (error) {
149
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
150
+ if (isBackupLockHeld()) throw new Error('Another backup operation is already running', { cause: error })
151
+ fs.rmSync(lock, { force: true })
152
+ try { create() }
153
+ catch (retryError) {
154
+ if ((retryError as NodeJS.ErrnoException).code === 'EEXIST') throw new Error('Another backup operation is already running', { cause: retryError })
155
+ throw retryError
156
+ }
157
+ }
158
+ return () => fs.rmSync(lock, { force: true })
159
+ }
160
+
161
+ async function createBackup(outputDirectory = backupDir()): Promise<BackupInfo> {
162
+ return exclusive(async () => {
163
+ fs.mkdirSync(outputDirectory, { recursive: true })
164
+ fs.mkdirSync(backupDir(), { recursive: true })
165
+ // Lock first: a rejected concurrent attempt must not overwrite the running backup's status.
166
+ const releaseLock = acquireBackupLock()
167
+ const startedAt = Date.now()
168
+ let temp: string | null = null
169
+ let partial: string | null = null
170
+ try {
171
+ writeBackupStatus({ ...readStoredBackupStatus(), state: 'running', lastStartedAt: startedAt, lastError: null })
172
+ temp = fs.mkdtempSync(path.join(os.tmpdir(), `${options.name}-backup-`))
173
+ const snapshot = path.join(temp, 'database.sqlite')
174
+ const sourceDb = new DatabaseSync(options.databasePath())
175
+ try { sourceDb.exec(`VACUUM INTO ${quoteSql(snapshot)}`) } finally { sourceDb.close() }
176
+ checkIntegrity(snapshot)
177
+
178
+ const extras = options.extras()
179
+ const manifest = {
180
+ formatVersion: options.formatVersion,
181
+ createdAt: Date.now(),
182
+ appVersion: options.appVersion(),
183
+ databaseIntegrity: 'ok',
184
+ ...extras.manifest,
185
+ } as Manifest
186
+ const manifestPath = path.join(temp, 'manifest.json')
187
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2))
188
+ const entries: ArchiveEntry[] = [
189
+ { name: 'manifest.json', source: manifestPath },
190
+ { name: 'database.sqlite', source: snapshot },
191
+ ...extras.entries,
192
+ ]
193
+ const filename = `${options.name}-backup-${manifest.createdAt}.backup`
194
+ partial = path.join(outputDirectory, `${filename}.partial`)
195
+ const target = path.join(outputDirectory, filename)
196
+ createArchive(partial, entries)
197
+ fs.renameSync(partial, target)
198
+ const stat = fs.statSync(target)
199
+ writeBackupStatus({ state: 'success', lastStartedAt: startedAt, lastCompletedAt: Date.now(), lastFilename: filename, lastError: null })
200
+ return { filename, size: stat.size, createdAt: manifest.createdAt }
201
+ } catch (error) {
202
+ try {
203
+ writeBackupStatus({ ...readStoredBackupStatus(), state: 'error', lastStartedAt: startedAt, lastCompletedAt: Date.now(), lastError: error instanceof Error ? error.message : String(error) })
204
+ } catch { /* keep the original failure */ }
205
+ throw error
206
+ } finally {
207
+ if (partial) fs.rmSync(partial, { force: true })
208
+ if (temp) fs.rmSync(temp, { recursive: true, force: true })
209
+ releaseLock()
210
+ }
211
+ })
212
+ }
213
+
214
+ function validateBackup(input: string): Manifest {
215
+ const temp = fs.mkdtempSync(path.join(os.tmpdir(), `${options.name}-verify-`))
216
+ try {
217
+ const entries = extractArchive(input, temp)
218
+ if (!entries.includes('manifest.json') || !entries.includes('database.sqlite')) throw new Error('Backup is missing required files')
219
+ const manifest = JSON.parse(fs.readFileSync(path.join(temp, 'manifest.json'), 'utf8')) as Manifest
220
+ if (manifest.formatVersion !== options.formatVersion) throw new Error(`Unsupported backup version: ${manifest.formatVersion}`)
221
+ checkIntegrity(path.join(temp, 'database.sqlite'))
222
+ return manifest
223
+ } finally { fs.rmSync(temp, { recursive: true, force: true }) }
224
+ }
225
+
226
+ function listBackups(): BackupInfo[] {
227
+ if (!fs.existsSync(backupDir())) return []
228
+ return fs.readdirSync(backupDir()).filter((name) => filePattern.test(name)).map((filename) => {
229
+ const stat = fs.statSync(path.join(backupDir(), filename))
230
+ return { filename, size: stat.size, createdAt: stat.mtimeMs }
231
+ }).sort((a, b) => b.createdAt - a.createdAt)
232
+ }
233
+
234
+ function backupFile(filename: string): string {
235
+ if (!filePattern.test(filename)) throw new Error('Invalid backup filename')
236
+ return path.join(backupDir(), filename)
237
+ }
238
+
239
+ function applyRetention(keep: number): void {
240
+ for (const item of listBackups().slice(Math.max(1, keep))) fs.rmSync(backupFile(item.filename), { force: true })
241
+ }
242
+
243
+ return {
244
+ createBackup, validateBackup, listBackups, backupFile, applyRetention,
245
+ readBackupConfig, writeBackupConfig, readBackupStatus, isBackupLockHeld,
246
+ extractArchive, createArchive,
247
+ }
248
+ }
249
+
250
+ export type BackupService<Manifest extends BackupManifest = BackupManifest> = ReturnType<typeof createBackupService<Manifest>>
package/src/worker.ts ADDED
@@ -0,0 +1,18 @@
1
+ import { parentPort, workerData } from 'node:worker_threads'
2
+
3
+ /** The functions the loaded module must export. */
4
+ interface BackupModule {
5
+ createBackup: (outputDirectory?: string) => Promise<unknown>
6
+ validateBackup: (input: string) => unknown
7
+ }
8
+
9
+ // Entry of the backup worker thread: import this file from the application's worker script. It loads the module the
10
+ // job names, runs the job and posts the result (or the error message) back.
11
+ const backup = await import(workerData.moduleUrl as string) as BackupModule
12
+ const job = workerData as { task?: 'create' | 'validate'; outputDirectory?: string; input?: string }
13
+ const work = job.task === 'validate'
14
+ ? Promise.resolve().then(() => backup.validateBackup(job.input!))
15
+ : backup.createBackup(job.outputDirectory)
16
+ work
17
+ .then((result) => parentPort?.postMessage({ result }))
18
+ .catch((error: unknown) => parentPort?.postMessage({ error: error instanceof Error ? error.message : String(error) }))