@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/archive.ts ADDED
@@ -0,0 +1,114 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import crypto from 'node:crypto'
4
+
5
+ const HASH_SIZE = 32
6
+
7
+ export interface ArchiveEntry { name: string; source: string }
8
+
9
+ function safeName(name: string): string {
10
+ const normalized = name.replaceAll('\\', '/')
11
+ if (!normalized || normalized.startsWith('/') || normalized.includes('../') || normalized === '..') {
12
+ throw new Error(`Unsafe archive path: ${name}`)
13
+ }
14
+ return normalized
15
+ }
16
+
17
+ function sha256(file: string): Buffer {
18
+ const hash = crypto.createHash('sha256')
19
+ const fd = fs.openSync(file, 'r')
20
+ const buffer = Buffer.allocUnsafe(1024 * 1024)
21
+ try {
22
+ let count = 0
23
+ while ((count = fs.readSync(fd, buffer, 0, buffer.length, null)) > 0) hash.update(buffer.subarray(0, count))
24
+ } finally { fs.closeSync(fd) }
25
+ return hash.digest()
26
+ }
27
+
28
+ function copyBytes(input: number, output: number, size: number, hash?: crypto.Hash): void {
29
+ const buffer = Buffer.allocUnsafe(1024 * 1024)
30
+ let remaining = size
31
+ while (remaining > 0) {
32
+ const count = fs.readSync(input, buffer, 0, Math.min(buffer.length, remaining), null)
33
+ if (count === 0) throw new Error('Unexpected end of backup archive')
34
+ const chunk = buffer.subarray(0, count)
35
+ if (hash) hash.update(chunk)
36
+ fs.writeSync(output, chunk)
37
+ remaining -= count
38
+ }
39
+ }
40
+
41
+ function readExact(fd: number, size: number): Buffer {
42
+ const buffer = Buffer.alloc(size)
43
+ let offset = 0
44
+ while (offset < size) {
45
+ const count = fs.readSync(fd, buffer, offset, size - offset, null)
46
+ if (!count) throw new Error('Truncated backup archive')
47
+ offset += count
48
+ }
49
+ return buffer
50
+ }
51
+
52
+ /**
53
+ * The archive file format: a magic header, then for each file its name, size and SHA-256 followed by its bytes.
54
+ * Extraction checks every checksum and refuses names that would leave the destination. The `magic` is what identifies
55
+ * archives of one product, so an application keeps the one its existing archives start with.
56
+ */
57
+ export function createArchiveFormat(options: { magic: string }) {
58
+ const MAGIC = Buffer.from(options.magic)
59
+
60
+ function createArchive(output: string, entries: ArchiveEntry[]): void {
61
+ const fd = fs.openSync(output, 'wx', 0o600)
62
+ try {
63
+ fs.writeSync(fd, MAGIC)
64
+ for (const entry of entries) {
65
+ const name = Buffer.from(safeName(entry.name), 'utf8')
66
+ const stat = fs.statSync(entry.source)
67
+ if (!stat.isFile()) throw new Error(`Archive source is not a file: ${entry.source}`)
68
+ const header = Buffer.alloc(4 + 8 + HASH_SIZE)
69
+ header.writeUInt32BE(name.length, 0)
70
+ header.writeBigUInt64BE(BigInt(stat.size), 4)
71
+ sha256(entry.source).copy(header, 12)
72
+ fs.writeSync(fd, header)
73
+ fs.writeSync(fd, name)
74
+ const input = fs.openSync(entry.source, 'r')
75
+ try { copyBytes(input, fd, stat.size) } finally { fs.closeSync(input) }
76
+ }
77
+ fs.writeSync(fd, Buffer.alloc(4))
78
+ fs.fsyncSync(fd)
79
+ } finally { fs.closeSync(fd) }
80
+ }
81
+
82
+ function extractArchive(input: string, destination: string): string[] {
83
+ fs.mkdirSync(destination, { recursive: true, mode: 0o700 })
84
+ const root = path.resolve(destination)
85
+ const extracted: string[] = []
86
+ const fd = fs.openSync(input, 'r')
87
+ try {
88
+ if (!readExact(fd, MAGIC.length).equals(MAGIC)) throw new Error('Unsupported backup format')
89
+ for (let index = 0; index < 10000; index++) {
90
+ const nameLength = readExact(fd, 4).readUInt32BE(0)
91
+ if (nameLength === 0) return extracted
92
+ if (nameLength > 4096) throw new Error('Invalid archive entry name')
93
+ const metadata = readExact(fd, 8 + HASH_SIZE)
94
+ const sizeBig = metadata.readBigUInt64BE(0)
95
+ if (sizeBig > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error('Archive entry is too large')
96
+ const expectedHash = metadata.subarray(8)
97
+ const name = safeName(readExact(fd, nameLength).toString('utf8'))
98
+ const target = path.resolve(root, ...name.split('/'))
99
+ if (target !== root && !target.startsWith(`${root}${path.sep}`)) throw new Error('Archive path escapes destination')
100
+ fs.mkdirSync(path.dirname(target), { recursive: true })
101
+ const output = fs.openSync(target, 'wx', 0o600)
102
+ const hash = crypto.createHash('sha256')
103
+ try { copyBytes(fd, output, Number(sizeBig), hash) } finally { fs.closeSync(output) }
104
+ if (!crypto.timingSafeEqual(hash.digest(), expectedHash)) throw new Error(`Checksum mismatch: ${name}`)
105
+ extracted.push(name)
106
+ }
107
+ throw new Error('Archive contains too many entries')
108
+ } finally { fs.closeSync(fd) }
109
+ }
110
+
111
+ return { createArchive, extractArchive }
112
+ }
113
+
114
+ export type ArchiveFormat = ReturnType<typeof createArchiveFormat>
package/src/cli.ts ADDED
@@ -0,0 +1,62 @@
1
+ import path from 'node:path'
2
+ import type { BackupService } from './service.js'
3
+
4
+ function argument(argv: string[], name: string): string | undefined {
5
+ const index = argv.indexOf(name)
6
+ return index >= 0 ? argv[index + 1] : undefined
7
+ }
8
+
9
+ function fail(error: unknown): void {
10
+ console.error(error instanceof Error ? error.message : error)
11
+ process.exitCode = 1
12
+ }
13
+
14
+ export interface BackupCliOptions {
15
+ service: Pick<BackupService, 'createBackup' | 'validateBackup'>
16
+ /** The application's database handle: opened for the backup and closed after it. */
17
+ database: { open: () => void; close: () => void }
18
+ /** A backup file name as the usage line shows it (`<name>-backup-timestamp.backup`). */
19
+ exampleFile: string
20
+ }
21
+
22
+ /**
23
+ * The command line of `backup`: makes a backup (`--output <dir>` picks where) or, with `--verify <file>` /
24
+ * `--verify --input <file>`, checks one. Prints JSON; failures set a non-zero exit code.
25
+ */
26
+ export async function runBackupCli(argv: string[], options: BackupCliOptions): Promise<void> {
27
+ try {
28
+ const verifyMode = argv.includes('--verify')
29
+ const verify = argument(argv, '--input') ?? argument(argv, '--verify')
30
+ if (verifyMode) {
31
+ if (!verify || verify.startsWith('--')) throw new Error(`Usage: backup:verify -- --input <${options.exampleFile}>`)
32
+ const manifest = options.service.validateBackup(path.resolve(verify))
33
+ console.log(JSON.stringify({ valid: true, manifest }, null, 2))
34
+ return
35
+ }
36
+ options.database.open()
37
+ try {
38
+ const output = argument(argv, '--output')
39
+ console.log(JSON.stringify(await options.service.createBackup(output ? path.resolve(output) : undefined), null, 2))
40
+ } finally { options.database.close() }
41
+ } catch (error) { fail(error) }
42
+ }
43
+
44
+ export interface RestoreCliOptions {
45
+ restore: (input: string, extra: string) => Promise<unknown>
46
+ exampleFile: string
47
+ /** The one extra value the restore needs (for example the key the backup was made with). */
48
+ extra: { flag: string; placeholder: string }
49
+ }
50
+
51
+ /** The command line of `restore`: `--input <file>` and the extra flag are both required. */
52
+ export async function runRestoreCli(argv: string[], options: RestoreCliOptions): Promise<void> {
53
+ const input = argument(argv, '--input')
54
+ const extra = argument(argv, options.extra.flag)
55
+ if (!input || !extra) {
56
+ console.error(`Usage: restore --input <${options.exampleFile}> ${options.extra.flag} ${options.extra.placeholder}`)
57
+ process.exitCode = 1
58
+ return
59
+ }
60
+ try { console.log(JSON.stringify(await options.restore(path.resolve(input), extra), null, 2)) }
61
+ catch (error) { fail(error) }
62
+ }
package/src/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ export { createArchiveFormat, type ArchiveEntry, type ArchiveFormat } from './archive.js'
2
+ export {
3
+ collectFiles, createBackupService, DEFAULT_BACKUP_CONFIG, DEFAULT_BACKUP_STATUS, INTERRUPTED_BACKUP_ERROR,
4
+ type BackupConfig, type BackupInfo, type BackupManifest, type BackupService, type BackupServiceOptions, type BackupStatus, type ProcessOwner,
5
+ } from './service.js'
6
+ export { createRestore, replaceFile, type RestoreExtras, type RestoreOptions, type RestoreResult } from './restore.js'
7
+ export { createBackupRunner, spawnBackupWorker, type BackupJob, type BackupWorkerOptions } from './runner.js'
8
+ export { createBackupScheduler, scheduleExpression, type BackupSchedulerOptions } from './scheduler.js'
9
+ export { createBackupRoutes, type BackupRoutesOptions } from './routes.js'
10
+ export { runBackupCli, runRestoreCli, type BackupCliOptions, type RestoreCliOptions } from './cli.js'
package/src/restore.ts ADDED
@@ -0,0 +1,111 @@
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 type { BackupManifest, BackupService } from './service.js'
6
+
7
+ export interface RestoreResult { restoredAt: number; safetyBackup: string | null }
8
+
9
+ /** Puts `source` at `target` by copying beside it and renaming, so a failure never leaves half a file. */
10
+ export function replaceFile(source: string, target: string): void {
11
+ fs.mkdirSync(path.dirname(target), { recursive: true })
12
+ const incoming = `${target}.restore-incoming`
13
+ fs.copyFileSync(source, incoming)
14
+ fs.renameSync(incoming, target)
15
+ }
16
+
17
+ function removeSqliteSidecars(dbPath: string): void {
18
+ fs.rmSync(`${dbPath}-wal`, { force: true })
19
+ fs.rmSync(`${dbPath}-shm`, { force: true })
20
+ }
21
+
22
+ /** The files around the database that a restore replaces, and how to put the old ones back. */
23
+ export interface RestoreExtras {
24
+ /** Copies what is there now into `rollbackDir`. */
25
+ save: (rollbackDir: string) => void
26
+ /** Replaces what is there with what the backup holds (extracted into `extractedDir`). */
27
+ apply: (extractedDir: string) => void
28
+ /** Puts back what `save` kept (and removes what the failed restore created). */
29
+ rollback: (rollbackDir: string) => void
30
+ }
31
+
32
+ export interface RestoreOptions<Manifest extends BackupManifest> {
33
+ service: BackupService<Manifest>
34
+ name: string
35
+ /** How the application is called in the messages shown to its operator. */
36
+ displayName: string
37
+ databasePath: () => string
38
+ /** Throws unless the application is stopped. */
39
+ assertStopped: () => void
40
+ /** The application's own database handle: opened to take the safety backup and to migrate what was restored. */
41
+ database: { open: () => void; close: () => void; migrate: () => void }
42
+ /** Called once the restore is about to replace files; the flags it captures describe what existed before. */
43
+ extras: () => RestoreExtras
44
+ }
45
+
46
+ /**
47
+ * Restores a backup over a stopped application. A safety backup of what is there now is taken first, and a failure
48
+ * half way puts every replaced file back.
49
+ */
50
+ export function createRestore<Manifest extends BackupManifest>(options: RestoreOptions<Manifest>) {
51
+ const { service, database } = options
52
+
53
+ /**
54
+ * `prepare` runs before the backup is read (to set up the key it will need); `check` may refuse the backup by
55
+ * throwing, once its manifest is known.
56
+ */
57
+ return async function restoreBackup(
58
+ input: string,
59
+ hooks: { prepare?: () => void; check?: (manifest: Manifest) => void } = {},
60
+ ): Promise<RestoreResult> {
61
+ options.assertStopped()
62
+ hooks.prepare?.()
63
+ const manifest = service.validateBackup(input)
64
+ hooks.check?.(manifest)
65
+
66
+ const dbPath = options.databasePath()
67
+ if (fs.existsSync(dbPath)) {
68
+ const lockCheck = new DatabaseSync(dbPath)
69
+ try { lockCheck.exec('PRAGMA busy_timeout=0; BEGIN EXCLUSIVE; ROLLBACK') }
70
+ catch { lockCheck.close(); throw new Error(`Database is in use. Stop ${options.displayName} before restoring.`) }
71
+ lockCheck.close()
72
+ }
73
+
74
+ let safetyBackup: string | null = null
75
+ if (fs.existsSync(dbPath)) {
76
+ database.open()
77
+ try { safetyBackup = (await service.createBackup()).filename } finally { database.close() }
78
+ }
79
+
80
+ const temp = fs.mkdtempSync(path.join(os.tmpdir(), `${options.name}-restore-`))
81
+ const rollback = fs.mkdtempSync(path.join(os.tmpdir(), `${options.name}-rollback-`))
82
+ const hadDatabase = fs.existsSync(dbPath)
83
+ const extras = options.extras()
84
+ try {
85
+ service.extractArchive(input, temp)
86
+ if (fs.existsSync(dbPath)) fs.copyFileSync(dbPath, path.join(rollback, 'database.sqlite'))
87
+ extras.save(rollback)
88
+
89
+ try {
90
+ removeSqliteSidecars(dbPath)
91
+ replaceFile(path.join(temp, 'database.sqlite'), dbPath)
92
+ extras.apply(temp)
93
+ database.open()
94
+ database.migrate()
95
+ database.close()
96
+ } catch (error) {
97
+ database.close()
98
+ removeSqliteSidecars(dbPath)
99
+ const oldDb = path.join(rollback, 'database.sqlite')
100
+ if (fs.existsSync(oldDb)) replaceFile(oldDb, dbPath)
101
+ else if (!hadDatabase) fs.rmSync(dbPath, { force: true })
102
+ extras.rollback(rollback)
103
+ throw error
104
+ }
105
+ return { restoredAt: Date.now(), safetyBackup }
106
+ } finally {
107
+ fs.rmSync(temp, { recursive: true, force: true })
108
+ fs.rmSync(rollback, { recursive: true, force: true })
109
+ }
110
+ }
111
+ }
package/src/routes.ts ADDED
@@ -0,0 +1,115 @@
1
+ import type { FastifyInstance, FastifyRequest } from 'fastify'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import os from 'node:os'
5
+ import type { Readable } from 'node:stream'
6
+ import { pipeline } from 'node:stream/promises'
7
+ import type { Audit } from '@better-forge/audit'
8
+ import { DEFAULT_BACKUP_CONFIG, type BackupConfig, type BackupInfo, type BackupManifest, type BackupService } from './service.js'
9
+
10
+ /** The part of `@fastify/multipart` the upload route uses; the application registers the plugin. */
11
+ interface UploadedFile { file: Readable & { truncated?: boolean } }
12
+ type FileReader = (options: { limits: { fileSize: number } }) => Promise<UploadedFile | undefined>
13
+
14
+ export interface BackupRoutesOptions<Manifest extends BackupManifest> {
15
+ service: BackupService<Manifest>
16
+ audit: Audit
17
+ /** The signed-in caller of a request (the routes sit behind the application's own guards). */
18
+ identity: (req: FastifyRequest) => { userId: number; email: string }
19
+ /** Makes a backup (on a worker thread, in the application). */
20
+ createBackup: () => Promise<BackupInfo>
21
+ /** Verifies a backup file (on a worker thread, in the application). */
22
+ validateBackup: (input: string) => Promise<Manifest>
23
+ /** Called after the schedule changed, to apply it. */
24
+ restartScheduler: () => void
25
+ /** Fields added to the answer of a verification, next to the manifest (e.g. whether a key matches). */
26
+ describeManifest?: (manifest: Manifest) => Record<string, unknown>
27
+ }
28
+
29
+ /** Largest backup file the verification accepts. */
30
+ const MAX_VALIDATION_BYTES = 2 * 1024 * 1024 * 1024
31
+
32
+ function parseConfig(value: Partial<BackupConfig>): BackupConfig {
33
+ const config = { ...DEFAULT_BACKUP_CONFIG, ...value }
34
+ if (!['daily', 'weekly'].includes(config.frequency)) throw new Error('Invalid frequency')
35
+ if (!Number.isInteger(config.hour) || config.hour < 0 || config.hour > 23) throw new Error('Hour must be between 0 and 23')
36
+ if (!Number.isInteger(config.minute) || config.minute < 0 || config.minute > 59) throw new Error('Minute must be between 0 and 59')
37
+ if (!Number.isInteger(config.weekday) || config.weekday < 0 || config.weekday > 6) throw new Error('Weekday must be between 0 and 6')
38
+ if (!Number.isInteger(config.retention) || config.retention < 1 || config.retention > 365) throw new Error('Retention must be between 1 and 365')
39
+ return config
40
+ }
41
+
42
+ const messageOf = (error: unknown) => error instanceof Error ? error.message : String(error)
43
+
44
+ /** The admin routes of the backups: list, create, schedule, download, delete and verify an uploaded file. */
45
+ export function createBackupRoutes<Manifest extends BackupManifest>(options: BackupRoutesOptions<Manifest>) {
46
+ const { service, audit } = options
47
+ const actor = (req: FastifyRequest) => audit.actor(options.identity(req))
48
+
49
+ return async function backupRoutes(app: FastifyInstance) {
50
+ app.get('/', async () => ({ backups: service.listBackups(), config: service.readBackupConfig(), status: service.readBackupStatus() }))
51
+
52
+ app.post('/', async (req, reply) => {
53
+ try {
54
+ const result = await options.createBackup()
55
+ service.applyRetention(service.readBackupConfig().retention)
56
+ await audit.write(actor(req), 'create', 'backup', result.filename, result.filename)
57
+ return result
58
+ } catch (error) { return reply.code(409).send({ error: messageOf(error) }) }
59
+ })
60
+
61
+ app.put<{ Body: Partial<BackupConfig> }>('/config', async (req, reply) => {
62
+ try {
63
+ const config = parseConfig(req.body)
64
+ service.writeBackupConfig(config)
65
+ service.applyRetention(config.retention)
66
+ options.restartScheduler()
67
+ await audit.write(actor(req), 'update', 'backup-config', null, 'Backup schedule', {
68
+ enabled: config.enabled,
69
+ frequency: config.frequency,
70
+ hour: config.hour,
71
+ minute: config.minute,
72
+ weekday: config.weekday,
73
+ retention: config.retention,
74
+ })
75
+ return config
76
+ } catch (error) { return reply.code(400).send({ error: messageOf(error) }) }
77
+ })
78
+
79
+ app.get<{ Params: { filename: string } }>('/:filename/download', async (req, reply) => {
80
+ try {
81
+ const file = service.backupFile(req.params.filename)
82
+ if (!fs.existsSync(file)) return reply.code(404).send({ error: 'Backup not found' })
83
+ await audit.write(actor(req), 'allow', 'backup', req.params.filename, req.params.filename, { access: 'downloaded' })
84
+ reply.header('Content-Type', 'application/octet-stream')
85
+ reply.header('Content-Disposition', `attachment; filename="${path.basename(file)}"`)
86
+ return reply.send(fs.createReadStream(file))
87
+ } catch (error) { return reply.code(400).send({ error: messageOf(error) }) }
88
+ })
89
+
90
+ app.delete<{ Params: { filename: string }; Querystring: { confirm?: string } }>('/:filename', async (req, reply) => {
91
+ try {
92
+ if (req.query.confirm !== req.params.filename) return reply.code(400).send({ error: 'Type the exact backup filename to confirm deletion' })
93
+ const file = service.backupFile(req.params.filename)
94
+ if (!fs.existsSync(file)) return reply.code(404).send({ error: 'Backup not found' })
95
+ fs.rmSync(file)
96
+ await audit.write(actor(req), 'delete', 'backup', req.params.filename, req.params.filename)
97
+ return reply.code(204).send()
98
+ } catch (error) { return reply.code(400).send({ error: messageOf(error) }) }
99
+ })
100
+
101
+ app.post('/validate', async (req, reply) => {
102
+ const data = await (req as unknown as { file: FileReader }).file({ limits: { fileSize: MAX_VALIDATION_BYTES } })
103
+ if (!data) return reply.code(400).send({ error: 'No backup file' })
104
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'backup-upload-'))
105
+ const input = path.join(tempDir, 'backup.backup')
106
+ try {
107
+ await pipeline(data.file, fs.createWriteStream(input, { mode: 0o600 }))
108
+ if (data.file.truncated) throw new Error('Backup file exceeds the 2 GB validation limit')
109
+ const manifest = await options.validateBackup(input)
110
+ return { valid: true, manifest, ...options.describeManifest?.(manifest) }
111
+ } catch (error) { return reply.code(400).send({ error: messageOf(error) }) }
112
+ finally { fs.rmSync(tempDir, { recursive: true, force: true }) }
113
+ })
114
+ }
115
+ }
package/src/runner.ts ADDED
@@ -0,0 +1,45 @@
1
+ import { Worker } from 'node:worker_threads'
2
+ import type { BackupInfo, BackupManifest, BackupService } from './service.js'
3
+
4
+ export type BackupJob =
5
+ | { task: 'create'; outputDirectory?: string | undefined }
6
+ | { task: 'validate'; input: string }
7
+
8
+ export interface BackupWorkerOptions {
9
+ /** The worker script: the application's file that imports `@better-forge/backup/worker`. */
10
+ workerUrl: URL
11
+ /** The module the worker loads; it exports `createBackup` and `validateBackup`. */
12
+ moduleUrl: string
13
+ execArgv?: string[]
14
+ }
15
+
16
+ /** Runs one backup job on a worker thread; exposed with explicit URLs so tests can drive the real worker. */
17
+ export function spawnBackupWorker<T>(job: BackupJob, options: BackupWorkerOptions): Promise<T> {
18
+ return new Promise((resolve, reject) => {
19
+ const worker = new Worker(options.workerUrl, { workerData: { ...job, moduleUrl: options.moduleUrl }, execArgv: options.execArgv })
20
+ worker.once('message', (message: { result?: T; error?: string }) => message.error ? reject(new Error(message.error)) : resolve(message.result!))
21
+ worker.once('error', reject)
22
+ worker.once('exit', (code) => { if (code !== 0) reject(new Error(`Backup worker exited with code ${code}`)) })
23
+ })
24
+ }
25
+
26
+ /**
27
+ * Backups and verification off the event loop. With `inline` (development and tests, where the worker thread cannot
28
+ * load TypeScript) the same core runs in the calling thread instead.
29
+ */
30
+ export function createBackupRunner<Manifest extends BackupManifest>(options: BackupWorkerOptions & {
31
+ service: BackupService<Manifest>
32
+ inline: boolean
33
+ }) {
34
+ function run<T>(job: BackupJob, inline: () => Promise<T>): Promise<T> {
35
+ return options.inline ? inline() : spawnBackupWorker(job, options)
36
+ }
37
+
38
+ return {
39
+ createBackup: (outputDirectory?: string): Promise<BackupInfo> =>
40
+ run({ task: 'create', outputDirectory }, () => options.service.createBackup(outputDirectory)),
41
+ /** Extraction and the SQLite integrity check are synchronous, so keep them off the event loop. */
42
+ validateBackup: (input: string): Promise<Manifest> =>
43
+ run({ task: 'validate', input }, async () => options.service.validateBackup(input)),
44
+ }
45
+ }
@@ -0,0 +1,39 @@
1
+ import cron, { type ScheduledTask } from 'node-cron'
2
+ import type { BackupConfig } from './service.js'
3
+
4
+ export interface BackupSchedulerOptions {
5
+ readConfig: () => BackupConfig
6
+ /** Makes one scheduled backup (and prunes old ones). A rejection is logged, never thrown. */
7
+ run: (config: BackupConfig) => Promise<unknown>
8
+ /** Prefix of the lines it logs. */
9
+ logPrefix?: string
10
+ }
11
+
12
+ /** The cron expression of a schedule: every day, or one weekday, at the given time. */
13
+ export function scheduleExpression(config: BackupConfig): string {
14
+ return config.frequency === 'weekly' ? `${config.minute} ${config.hour} * * ${config.weekday}` : `${config.minute} ${config.hour} * * *`
15
+ }
16
+
17
+ /** Runs a backup on the schedule the configuration describes; `restart` picks up a changed configuration. */
18
+ export function createBackupScheduler(options: BackupSchedulerOptions) {
19
+ const prefix = options.logPrefix ?? '[backup]'
20
+ let task: ScheduledTask | null = null
21
+
22
+ function stop(): void {
23
+ task?.destroy()
24
+ task = null
25
+ }
26
+
27
+ function restart(): void {
28
+ stop()
29
+ const config = options.readConfig()
30
+ if (!config.enabled) return
31
+ const expression = scheduleExpression(config)
32
+ task = cron.schedule(expression, () => {
33
+ options.run(config).catch((error) => console.error(`${prefix} scheduled backup failed:`, error))
34
+ })
35
+ console.log(`${prefix} Scheduler started: ${expression}`)
36
+ }
37
+
38
+ return { restart, stop }
39
+ }