@better-forge/backup 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.
package/src/restore.ts CHANGED
@@ -1,111 +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
- }
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 CHANGED
@@ -1,115 +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
- }
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 CHANGED
@@ -1,45 +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
- }
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
+ }
package/src/scheduler.ts CHANGED
@@ -1,39 +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
- }
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
+ }