@meith/backup 0.34.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.md ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jordan Harrison and the Meith contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@meith/backup",
3
+ "version": "0.34.0",
4
+ "license": "MIT",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/meith-dev/meith.git",
8
+ "directory": "packages/backup"
9
+ },
10
+ "type": "module",
11
+ "main": "./src/index.ts",
12
+ "types": "./src/index.ts",
13
+ "files": [
14
+ "src",
15
+ "!src/**/*.test.*",
16
+ "!src/**/*.type-test.*"
17
+ ],
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "dependencies": {
22
+ "@aws-sdk/client-s3": "3.1111.0",
23
+ "@aws-sdk/s3-request-presigner": "3.1111.0",
24
+ "@meith/core": "0.34.0",
25
+ "@meith/upgrade": "0.34.0"
26
+ }
27
+ }
package/src/archive.ts ADDED
@@ -0,0 +1,132 @@
1
+ import path from 'node:path'
2
+
3
+ import { ValidationError } from '@meith/core'
4
+
5
+ import { run } from './postgres-client'
6
+
7
+ export interface RestoreLimits {
8
+ readonly archiveBytes: number
9
+ readonly members: number
10
+ readonly memberBytes: number
11
+ readonly expandedBytes: number
12
+ }
13
+
14
+ const RESTORE_LIMIT_DEFAULTS: RestoreLimits = {
15
+ archiveBytes: 2 * 1024 * 1024 * 1024,
16
+ members: 100_000,
17
+ memberBytes: 1024 * 1024 * 1024,
18
+ expandedBytes: 8 * 1024 * 1024 * 1024,
19
+ }
20
+
21
+ function positiveInteger(value: string | undefined, variable: string, fallback: number): number {
22
+ if (value === undefined || value === '') return fallback
23
+ if (!/^\d+$/.test(value)) {
24
+ throw new ValidationError(`${variable} must be a positive integer number of bytes or members.`)
25
+ }
26
+ const parsed = Number(value)
27
+ if (!Number.isSafeInteger(parsed) || parsed < 1) {
28
+ throw new ValidationError(`${variable} must be a positive integer number of bytes or members.`)
29
+ }
30
+ return parsed
31
+ }
32
+
33
+ export function restoreLimits(
34
+ environment: Readonly<Record<string, string | undefined>>,
35
+ ): RestoreLimits {
36
+ return {
37
+ archiveBytes: positiveInteger(
38
+ environment.MEITH_RESTORE_MAX_ARCHIVE_BYTES,
39
+ 'MEITH_RESTORE_MAX_ARCHIVE_BYTES',
40
+ RESTORE_LIMIT_DEFAULTS.archiveBytes,
41
+ ),
42
+ members: positiveInteger(
43
+ environment.MEITH_RESTORE_MAX_MEMBERS,
44
+ 'MEITH_RESTORE_MAX_MEMBERS',
45
+ RESTORE_LIMIT_DEFAULTS.members,
46
+ ),
47
+ memberBytes: positiveInteger(
48
+ environment.MEITH_RESTORE_MAX_MEMBER_BYTES,
49
+ 'MEITH_RESTORE_MAX_MEMBER_BYTES',
50
+ RESTORE_LIMIT_DEFAULTS.memberBytes,
51
+ ),
52
+ expandedBytes: positiveInteger(
53
+ environment.MEITH_RESTORE_MAX_EXPANDED_BYTES,
54
+ 'MEITH_RESTORE_MAX_EXPANDED_BYTES',
55
+ RESTORE_LIMIT_DEFAULTS.expandedBytes,
56
+ ),
57
+ }
58
+ }
59
+
60
+ export interface ArchiveMember {
61
+ readonly name: string
62
+ readonly type: string
63
+ readonly size: number
64
+ }
65
+
66
+ function normalizedArchiveName(name: string): string | undefined {
67
+ if (name === '' || name.includes('\\') || name.includes('\0')) return undefined
68
+ const withoutDirectoryMarker = name.endsWith('/') ? name.slice(0, -1) : name
69
+ if (withoutDirectoryMarker === '.') return '.'
70
+ const normalized = withoutDirectoryMarker.replace(/^\.\//, '')
71
+ if (normalized === '' || path.posix.isAbsolute(normalized)) return undefined
72
+ const parts = normalized.split('/')
73
+ if (parts.some((part) => part === '' || part === '.' || part === '..')) return undefined
74
+ return normalized
75
+ }
76
+
77
+ function memberSize(fields: readonly string[]): number {
78
+ return Number(fields[1]?.includes('/') ? fields[2] : fields[4])
79
+ }
80
+
81
+ export function validateArchiveListing(
82
+ namesOutput: string,
83
+ verboseOutput: string,
84
+ limits: RestoreLimits,
85
+ allowedTypes: ReadonlySet<string>,
86
+ ): readonly ArchiveMember[] {
87
+ const names = namesOutput === '' ? [] : namesOutput.replace(/\n$/, '').split('\n')
88
+ const verbose = verboseOutput === '' ? [] : verboseOutput.replace(/\n$/, '').split('\n')
89
+ if (names.length !== verbose.length) {
90
+ throw new ValidationError('The archive has malformed member names.')
91
+ }
92
+ if (names.length > limits.members) {
93
+ throw new ValidationError(`The archive has more than ${limits.members} members.`)
94
+ }
95
+
96
+ const seen = new Set<string>()
97
+ let expandedBytes = 0
98
+ return names.map((rawName, index) => {
99
+ const name = normalizedArchiveName(rawName)
100
+ if (name === undefined || seen.has(name)) {
101
+ throw new ValidationError(`The archive contains an unsafe or duplicate member: ${rawName}`)
102
+ }
103
+ seen.add(name)
104
+
105
+ const fields = verbose[index]?.trim().split(/\s+/) ?? []
106
+ const type = fields[0]?.[0] ?? ''
107
+ const size = memberSize(fields)
108
+ if (!allowedTypes.has(type) || !Number.isSafeInteger(size) || size < 0) {
109
+ throw new ValidationError(`The archive contains an unsupported member: ${name}`)
110
+ }
111
+ if (size > limits.memberBytes) {
112
+ throw new ValidationError(`The archive member ${name} exceeds the per-member size limit.`)
113
+ }
114
+ expandedBytes += size
115
+ if (!Number.isSafeInteger(expandedBytes) || expandedBytes > limits.expandedBytes) {
116
+ throw new ValidationError('The archive exceeds the expanded-size limit.')
117
+ }
118
+ return { name, type, size }
119
+ })
120
+ }
121
+
122
+ export async function inspectArchive(
123
+ archive: string,
124
+ limits: RestoreLimits,
125
+ allowedTypes: ReadonlySet<string>,
126
+ ): Promise<readonly ArchiveMember[]> {
127
+ const [names, verbose] = await Promise.all([
128
+ run('tar', ['tzf', archive]),
129
+ run('tar', ['tvzf', archive]),
130
+ ])
131
+ return validateArchiveListing(names, verbose, limits, allowedTypes)
132
+ }
package/src/bundle.ts ADDED
@@ -0,0 +1,129 @@
1
+ import path from 'node:path'
2
+
3
+ import { ValidationError } from '@meith/core'
4
+
5
+ export type FilestoreDriver = 'local' | 's3' | 'blob'
6
+
7
+ export type UploadsMode = 'include' | 'skip'
8
+
9
+ export interface BackupManifest {
10
+ readonly format: 1
11
+ readonly createdAt: string
12
+ readonly version: string
13
+ readonly filestore: FilestoreDriver
14
+ readonly uploads: 'included' | 'skipped'
15
+ readonly bucket?: string
16
+ readonly skippedKeys?: readonly string[]
17
+ }
18
+
19
+ export const BUNDLE_NAME_PATTERN =
20
+ /^meith-backup-(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})Z\.tar\.gz$/
21
+
22
+ export function isBundleName(name: string): boolean {
23
+ return BUNDLE_NAME_PATTERN.test(name)
24
+ }
25
+
26
+ export function bundleName(at: Date): string {
27
+ const stamp = at
28
+ .toISOString()
29
+ .replace(/\.\d+Z$/, 'Z')
30
+ .replaceAll(':', '-')
31
+ return `meith-backup-${stamp}.tar.gz`
32
+ }
33
+
34
+ export function bundleTakenAt(name: string): Date | null {
35
+ const match = BUNDLE_NAME_PATTERN.exec(name)
36
+ if (match === null) return null
37
+ const at = new Date(`${match[1]}T${match[2]}:${match[3]}:${match[4]}Z`)
38
+ return Number.isNaN(at.getTime()) ? null : at
39
+ }
40
+
41
+ export function resolveUploadsMode(driver: FilestoreDriver, flag: string | undefined): UploadsMode {
42
+ if (flag === undefined || flag === 'auto') return driver === 's3' ? 'skip' : 'include'
43
+ if (flag === 'include' || flag === 'skip') return flag
44
+ throw new ValidationError(`--uploads must be "include" or "skip", got "${flag}".`)
45
+ }
46
+
47
+ export function parseManifest(raw: string): BackupManifest {
48
+ let parsed: unknown
49
+ try {
50
+ parsed = JSON.parse(raw)
51
+ } catch {
52
+ throw new ValidationError('The bundle manifest is not valid JSON.')
53
+ }
54
+
55
+ const manifest = parsed as Partial<BackupManifest>
56
+ if (manifest.format !== 1) {
57
+ throw new ValidationError(
58
+ `This bundle declares format ${JSON.stringify(manifest.format)}; this build restores format 1.`,
59
+ )
60
+ }
61
+ if (manifest.uploads !== 'included' && manifest.uploads !== 'skipped') {
62
+ throw new ValidationError('The bundle manifest does not say whether uploads are included.')
63
+ }
64
+ if (typeof manifest.createdAt !== 'string' || typeof manifest.version !== 'string') {
65
+ throw new ValidationError('The bundle manifest is missing createdAt or version.')
66
+ }
67
+ if (
68
+ manifest.filestore !== 'local' &&
69
+ manifest.filestore !== 's3' &&
70
+ manifest.filestore !== 'blob'
71
+ ) {
72
+ throw new ValidationError('The bundle manifest does not name a known file driver.')
73
+ }
74
+
75
+ const skippedKeys = manifest.skippedKeys
76
+ if (
77
+ skippedKeys !== undefined &&
78
+ (!Array.isArray(skippedKeys) || skippedKeys.some((key) => typeof key !== 'string'))
79
+ ) {
80
+ throw new ValidationError('The bundle manifest lists skipped objects in a form it cannot read.')
81
+ }
82
+
83
+ return {
84
+ format: 1,
85
+ createdAt: manifest.createdAt,
86
+ version: manifest.version,
87
+ filestore: manifest.filestore,
88
+ uploads: manifest.uploads,
89
+ ...(typeof manifest.bucket === 'string' ? { bucket: manifest.bucket } : {}),
90
+ ...(skippedKeys === undefined || skippedKeys.length === 0 ? {} : { skippedKeys }),
91
+ }
92
+ }
93
+
94
+ const CONTENT_TYPES: ReadonlyMap<string, string> = new Map([
95
+ ['.avif', 'image/avif'],
96
+ ['.gif', 'image/gif'],
97
+ ['.jpeg', 'image/jpeg'],
98
+ ['.jpg', 'image/jpeg'],
99
+ ['.png', 'image/png'],
100
+ ['.svg', 'image/svg+xml'],
101
+ ['.webp', 'image/webp'],
102
+ ])
103
+
104
+ export function contentTypeFor(key: string): string {
105
+ return CONTENT_TYPES.get(path.extname(key).toLowerCase()) ?? 'application/octet-stream'
106
+ }
107
+
108
+ export function formatBytes(size: number): string {
109
+ let value = size
110
+ let unit = 'B'
111
+ for (const next of ['KiB', 'MiB', 'GiB', 'TiB']) {
112
+ if (value < 1024) break
113
+ value /= 1024
114
+ unit = next
115
+ }
116
+ return unit === 'B' ? `${value} B` : `${value.toFixed(value >= 10 ? 0 : 1)} ${unit}`
117
+ }
118
+
119
+ const SKIPPED_KEYS_LISTED = 10
120
+
121
+ export function skippedKeyLines(keys: readonly string[]): readonly string[] {
122
+ const shown = keys.slice(0, SKIPPED_KEYS_LISTED)
123
+ return [
124
+ ...shown.map((key) => ` ${JSON.stringify(key)}`),
125
+ ...(keys.length > shown.length
126
+ ? [` …and ${keys.length - shown.length} more, listed in the bundle's manifest.json.`]
127
+ : []),
128
+ ]
129
+ }
@@ -0,0 +1,10 @@
1
+ export type BackupCapability = 'available' | 'fixture' | 'serverless'
2
+
3
+ export function backupCapability(environment: {
4
+ readonly DATA_SOURCE?: string | undefined
5
+ readonly VERCEL?: string | undefined
6
+ }): BackupCapability {
7
+ if (environment.DATA_SOURCE !== 'postgres') return 'fixture'
8
+ if (environment.VERCEL !== undefined && environment.VERCEL !== '') return 'serverless'
9
+ return 'available'
10
+ }
package/src/create.ts ADDED
@@ -0,0 +1,316 @@
1
+ import { chmod, mkdir, mkdtemp, open, readdir, rm, stat, writeFile } from 'node:fs/promises'
2
+ import { tmpdir } from 'node:os'
3
+ import path from 'node:path'
4
+
5
+ import { ValidationError } from '@meith/core'
6
+
7
+ import {
8
+ type BackupManifest,
9
+ bundleName,
10
+ type FilestoreDriver,
11
+ formatBytes,
12
+ isBundleName,
13
+ type UploadsMode,
14
+ } from './bundle'
15
+ import type { BackupDestination } from './destination'
16
+ import { postgresClientEnvironment, run } from './postgres-client'
17
+ import { type RetentionPolicy, retentionCandidates } from './retention'
18
+ import { drainStoreToDirectory, type ListableStore } from './uploads'
19
+
20
+ export interface BackupLog {
21
+ info(line: string): void
22
+ warn(line: string): void
23
+ }
24
+
25
+ export const SILENT_LOG: BackupLog = { info: () => undefined, warn: () => undefined }
26
+
27
+ export interface BackupSource {
28
+ readonly databaseUrl: string
29
+ readonly databaseVariable: string
30
+ readonly version: string
31
+ readonly filestore: FilestoreDriver
32
+ readonly uploadsDir: string
33
+ readonly objectStore?:
34
+ | { readonly store: ListableStore; readonly origin: string; readonly bucket?: string }
35
+ | undefined
36
+ }
37
+
38
+ export interface BackupTarget {
39
+ readonly out?: string | undefined
40
+ readonly dir?: string | undefined
41
+ readonly destination?: BackupDestination | undefined
42
+ readonly retention: RetentionPolicy
43
+ }
44
+
45
+ export interface CreateBackupInput {
46
+ readonly source: BackupSource
47
+ readonly target: BackupTarget
48
+ readonly uploads: UploadsMode
49
+ readonly now?: Date | undefined
50
+ readonly log?: BackupLog | undefined
51
+ readonly translateWriteError?: ((error: unknown, destination: string) => never) | undefined
52
+ }
53
+
54
+ export interface BackupOutcome {
55
+ readonly path: string
56
+ readonly name: string
57
+ readonly size: number
58
+ readonly createdAt: Date
59
+ readonly uploads: 'included' | 'skipped'
60
+ readonly skippedKeys: readonly string[]
61
+ readonly shipped: string | null
62
+ readonly prunedLocal: readonly string[]
63
+ readonly prunedRemote: readonly string[]
64
+ }
65
+
66
+ export interface WrittenBundle {
67
+ readonly name: string
68
+ readonly size: number
69
+ readonly uploads: 'included' | 'skipped'
70
+ readonly skippedKeys: readonly string[]
71
+ }
72
+
73
+ export class BackupShippingError extends Error {
74
+ constructor(
75
+ cause: unknown,
76
+ readonly bundle: WrittenBundle,
77
+ ) {
78
+ super(
79
+ `${bundle.name} was written but not shipped: ${
80
+ cause instanceof Error ? cause.message : String(cause)
81
+ }`,
82
+ )
83
+ this.name = 'BackupShippingError'
84
+ }
85
+ }
86
+
87
+ export async function reserveBackupDestination(destination: string): Promise<void> {
88
+ const file = await open(destination, 'wx', 0o600)
89
+ await file.close()
90
+ }
91
+
92
+ export async function claimBackupDestination(
93
+ destination: string,
94
+ translateWriteError?: (error: unknown, destination: string) => never,
95
+ ): Promise<void> {
96
+ try {
97
+ await reserveBackupDestination(destination)
98
+ } catch (error) {
99
+ if ((error as NodeJS.ErrnoException | undefined)?.code === 'EEXIST') {
100
+ throw new ValidationError(
101
+ `backup will not write over ${destination}: something is already there. Move it aside ` +
102
+ 'or pass a different --out. A previous run killed part-way through can leave an ' +
103
+ 'empty or truncated bundle at the path it had claimed; that file is not a backup ' +
104
+ 'and is safe to delete.',
105
+ )
106
+ }
107
+ if (translateWriteError !== undefined) translateWriteError(error, destination)
108
+ throw error
109
+ }
110
+ }
111
+
112
+ interface StagedUploads {
113
+ readonly uploads: 'included' | 'skipped'
114
+ readonly skippedKeys: readonly string[]
115
+ }
116
+
117
+ async function stageLocalUploads(
118
+ stage: string,
119
+ uploadsDir: string,
120
+ log: BackupLog,
121
+ ): Promise<StagedUploads> {
122
+ const exists = await stat(uploadsDir).then(
123
+ (info) => info.isDirectory(),
124
+ () => false,
125
+ )
126
+ if (!exists) {
127
+ log.info(`No uploads directory at ${uploadsDir}; the bundle carries none.`)
128
+ return { uploads: 'skipped', skippedKeys: [] }
129
+ }
130
+
131
+ await run('tar', ['czf', path.join(stage, 'uploads.tar.gz'), '-C', uploadsDir, '.'])
132
+ return { uploads: 'included', skippedKeys: [] }
133
+ }
134
+
135
+ async function stageObjectStoreUploads(
136
+ stage: string,
137
+ store: ListableStore,
138
+ origin: string,
139
+ log: BackupLog,
140
+ ): Promise<StagedUploads> {
141
+ const dir = path.join(stage, 'uploads')
142
+ await mkdir(dir, { recursive: true })
143
+
144
+ const { pulled, skipped } = await drainStoreToDirectory(store, dir, (line) => log.warn(line))
145
+
146
+ if (pulled === 0) {
147
+ log.info(`Found no objects in ${origin}; the bundle carries no uploads.`)
148
+ await rm(dir, { recursive: true, force: true })
149
+ return { uploads: 'skipped', skippedKeys: skipped }
150
+ }
151
+
152
+ await run('tar', ['czf', path.join(stage, 'uploads.tar.gz'), '-C', dir, '.'])
153
+ await rm(dir, { recursive: true, force: true })
154
+ log.info(`Pulled ${pulled} object(s) from ${origin}.`)
155
+ return { uploads: 'included', skippedKeys: skipped }
156
+ }
157
+
158
+ async function stageUploads(
159
+ stage: string,
160
+ source: BackupSource,
161
+ mode: UploadsMode,
162
+ log: BackupLog,
163
+ ): Promise<StagedUploads> {
164
+ if (mode === 'skip') return { uploads: 'skipped', skippedKeys: [] }
165
+
166
+ if (source.filestore === 'local') return stageLocalUploads(stage, source.uploadsDir, log)
167
+
168
+ if (source.objectStore === undefined) {
169
+ throw new ValidationError(
170
+ `The uploads live in a ${source.filestore} store and no store was given to read them from.`,
171
+ )
172
+ }
173
+ return stageObjectStoreUploads(stage, source.objectStore.store, source.objectStore.origin, log)
174
+ }
175
+
176
+ export async function createBackup(input: CreateBackupInput): Promise<BackupOutcome> {
177
+ const log = input.log ?? SILENT_LOG
178
+ const { source, target } = input
179
+ if (target.out !== undefined && target.dir !== undefined) {
180
+ throw new ValidationError('--out and --dir are two answers to one question; pass one.')
181
+ }
182
+
183
+ const now = input.now ?? new Date()
184
+ const name = bundleName(now)
185
+ if (target.dir !== undefined) await mkdir(path.resolve(target.dir), { recursive: true })
186
+ const out = path.resolve(
187
+ target.dir === undefined ? (target.out ?? name) : path.join(target.dir, name),
188
+ )
189
+
190
+ const stage = await mkdtemp(path.join(tmpdir(), 'meith-backup-'))
191
+ let destinationCreated = false
192
+ try {
193
+ await claimBackupDestination(out, input.translateWriteError)
194
+ destinationCreated = true
195
+
196
+ log.info(
197
+ source.databaseVariable === 'DIRECT_DATABASE_URL'
198
+ ? 'Dumping the database over DIRECT_DATABASE_URL…'
199
+ : 'Dumping the database…',
200
+ )
201
+ const databaseEnvironment = postgresClientEnvironment(
202
+ source.databaseUrl,
203
+ source.databaseVariable,
204
+ )
205
+ await run(
206
+ 'pg_dump',
207
+ ['--format=custom', '--no-owner', '--no-privileges', '--file', path.join(stage, 'db.dump')],
208
+ databaseEnvironment,
209
+ )
210
+
211
+ const { uploads, skippedKeys } = await stageUploads(stage, source, input.uploads, log)
212
+
213
+ const bucket = source.objectStore?.bucket
214
+ const manifest: BackupManifest = {
215
+ format: 1,
216
+ createdAt: now.toISOString(),
217
+ version: source.version,
218
+ filestore: source.filestore,
219
+ uploads,
220
+ ...(bucket === undefined ? {} : { bucket }),
221
+ ...(skippedKeys.length === 0 ? {} : { skippedKeys }),
222
+ }
223
+ await writeFile(path.join(stage, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`)
224
+
225
+ const members = ['manifest.json', 'db.dump']
226
+ if (uploads === 'included') members.push('uploads.tar.gz')
227
+ await run('tar', ['czf', out, '-C', stage, ...members])
228
+ await chmod(out, 0o600)
229
+
230
+ const size = (await stat(out)).size
231
+ destinationCreated = false
232
+ log.info(
233
+ `Wrote ${out} (${formatBytes(size)}): the database dump${
234
+ uploads === 'included' ? ' and the uploads' : ', no uploads'
235
+ }.`,
236
+ )
237
+ if (uploads === 'skipped' && source.filestore === 's3' && input.uploads !== 'include') {
238
+ log.info(
239
+ 'The S3 bucket was not pulled — it has its own backup story. ' +
240
+ 'Run with --uploads include for a bundle that carries every object.',
241
+ )
242
+ }
243
+ if (uploads === 'skipped' && input.uploads === 'skip') {
244
+ log.info('Restoring this bundle gives a board whose posts have broken images.')
245
+ }
246
+
247
+ let shipped: string | null = null
248
+ let prunedRemote: readonly string[] = []
249
+ if (target.destination !== undefined) {
250
+ try {
251
+ await target.destination.putFile(name, out, size)
252
+ shipped = target.destination.description
253
+ log.info(`Shipped ${name} to ${shipped}.`)
254
+ prunedRemote = await target.destination.prune(target.retention, now)
255
+ } catch (error) {
256
+ throw new BackupShippingError(error, { name, size, uploads, skippedKeys })
257
+ }
258
+ if (prunedRemote.length > 0) {
259
+ log.info(
260
+ `Pruned ${prunedRemote.length} bundle(s) there beyond the retention policy: ` +
261
+ `${prunedRemote.join(', ')}.`,
262
+ )
263
+ }
264
+ } else {
265
+ log.info(
266
+ 'Copy the bundle off this machine: a backup on the server is a backup of the ' +
267
+ 'thing most likely to fail.',
268
+ )
269
+ }
270
+
271
+ let prunedLocal: readonly string[] = []
272
+ if (target.dir !== undefined) {
273
+ const dir = path.resolve(target.dir)
274
+ prunedLocal = retentionCandidates(await readdir(dir), target.retention, now)
275
+ for (const staleName of prunedLocal) await rm(path.join(dir, staleName), { force: true })
276
+ if (prunedLocal.length > 0) {
277
+ log.info(
278
+ `Pruned ${prunedLocal.length} bundle(s) in ${dir} beyond the retention policy: ` +
279
+ `${prunedLocal.join(', ')}.`,
280
+ )
281
+ }
282
+ }
283
+
284
+ return {
285
+ path: out,
286
+ name,
287
+ size,
288
+ createdAt: now,
289
+ uploads,
290
+ skippedKeys,
291
+ shipped,
292
+ prunedLocal,
293
+ prunedRemote,
294
+ }
295
+ } finally {
296
+ if (destinationCreated) await rm(out, { force: true })
297
+ await rm(stage, { recursive: true, force: true })
298
+ }
299
+ }
300
+
301
+ export interface LocalBundle {
302
+ readonly name: string
303
+ readonly size: number
304
+ }
305
+
306
+ export async function localBundles(dir: string): Promise<readonly LocalBundle[]> {
307
+ const names = await readdir(dir).catch((error: NodeJS.ErrnoException) => {
308
+ if (error.code === 'ENOENT') return []
309
+ throw error
310
+ })
311
+ const bundles: LocalBundle[] = []
312
+ for (const name of names.filter((entry) => isBundleName(entry)).sort()) {
313
+ bundles.push({ name, size: (await stat(path.join(dir, name))).size })
314
+ }
315
+ return bundles
316
+ }