@better-forge/backup 0.0.0-stage → 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +12 -3
- package/dist/archive.d.ts +17 -0
- package/dist/archive.d.ts.map +1 -0
- package/dist/archive.js +132 -0
- package/dist/archive.js.map +1 -0
- package/dist/cli.d.ts +28 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +54 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/restore.d.ts +42 -0
- package/dist/restore.d.ts.map +1 -0
- package/dist/restore.js +89 -0
- package/dist/restore.js.map +1 -0
- package/dist/routes.d.ts +23 -0
- package/dist/routes.d.ts.map +1 -0
- package/dist/routes.js +111 -0
- package/dist/routes.js.map +1 -0
- package/dist/runner.d.ts +30 -0
- package/dist/runner.d.ts.map +1 -0
- package/dist/runner.js +26 -0
- package/dist/runner.js.map +1 -0
- package/dist/scheduler.d.ts +16 -0
- package/dist/scheduler.d.ts.map +1 -0
- package/dist/scheduler.js +27 -0
- package/dist/scheduler.js.map +1 -0
- package/dist/service.d.ts +79 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/service.js +244 -0
- package/dist/service.js.map +1 -0
- package/dist/worker.d.ts +2 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +12 -0
- package/dist/worker.js.map +1 -0
- package/package.json +57 -6
- package/src/archive.ts +114 -0
- package/src/cli.ts +62 -0
- package/src/index.ts +10 -0
- package/src/restore.ts +111 -0
- package/src/routes.ts +115 -0
- package/src/runner.ts +45 -0
- package/src/scheduler.ts +39 -0
- package/src/service.ts +250 -0
- 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) }))
|