@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 +21 -0
- package/package.json +27 -0
- package/src/archive.ts +132 -0
- package/src/bundle.ts +129 -0
- package/src/capability.ts +10 -0
- package/src/create.ts +316 -0
- package/src/destination.ts +449 -0
- package/src/index.ts +105 -0
- package/src/postgres-client.ts +141 -0
- package/src/restore.ts +332 -0
- package/src/retention.ts +45 -0
- package/src/runs.ts +60 -0
- package/src/schedule.ts +75 -0
- package/src/uploads.ts +75 -0
- package/src/webdav.ts +247 -0
package/src/restore.ts
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import { copyFile, mkdir, mkdtemp, readdir, readFile, rm, stat } from 'node:fs/promises'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
|
|
5
|
+
import type { FileStore } from '@meith/core'
|
|
6
|
+
import { ValidationError } from '@meith/core'
|
|
7
|
+
import { compareVersions } from '@meith/upgrade'
|
|
8
|
+
|
|
9
|
+
import { inspectArchive, type RestoreLimits } from './archive'
|
|
10
|
+
import { type BackupManifest, parseManifest } from './bundle'
|
|
11
|
+
import { type BackupLog, SILENT_LOG } from './create'
|
|
12
|
+
import { postgresClientEnvironment, run } from './postgres-client'
|
|
13
|
+
import { uploadDirectoryToStore } from './uploads'
|
|
14
|
+
|
|
15
|
+
export type RestoreTargetMode = 'empty-database' | 'reset-schema'
|
|
16
|
+
|
|
17
|
+
export interface RestoreTarget {
|
|
18
|
+
readonly url: string
|
|
19
|
+
readonly variable: string
|
|
20
|
+
readonly mode: RestoreTargetMode
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type RestoreUploadsPlan =
|
|
24
|
+
| { readonly mode: 'skip' }
|
|
25
|
+
| { readonly mode: 'directory'; readonly dir: string }
|
|
26
|
+
| {
|
|
27
|
+
readonly mode: 'store'
|
|
28
|
+
readonly store: { put: FileStore['put'] }
|
|
29
|
+
readonly description: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface RestoreInput {
|
|
33
|
+
readonly bundle: string
|
|
34
|
+
readonly target: RestoreTarget
|
|
35
|
+
readonly codeVersion: string
|
|
36
|
+
readonly migrate: (url: string) => Promise<number>
|
|
37
|
+
readonly uploads: RestoreUploadsPlan
|
|
38
|
+
readonly limits: RestoreLimits
|
|
39
|
+
readonly log?: BackupLog | undefined
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface RestoreOutcome {
|
|
43
|
+
readonly manifest: BackupManifest
|
|
44
|
+
readonly migrationsApplied: number
|
|
45
|
+
readonly posts: number
|
|
46
|
+
readonly uploads: 'restored' | 'pushed' | 'skipped' | 'none'
|
|
47
|
+
readonly pushed: number
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function versionRefusal(manifestVersion: string, codeVersion: string): string | null {
|
|
51
|
+
let order: number
|
|
52
|
+
try {
|
|
53
|
+
order = compareVersions(manifestVersion, codeVersion)
|
|
54
|
+
} catch {
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
57
|
+
if (order <= 0) return null
|
|
58
|
+
return (
|
|
59
|
+
`This bundle was taken by version ${manifestVersion} and this build is ${codeVersion}. ` +
|
|
60
|
+
'Migrations are forward-only, so a newer dump cannot be restored into older code: ' +
|
|
61
|
+
'deploy that version or newer first, then restore.'
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function validateUploadsArchive(stage: string, limits: RestoreLimits): Promise<void> {
|
|
66
|
+
const members = await inspectArchive(
|
|
67
|
+
path.join(stage, 'uploads.tar.gz'),
|
|
68
|
+
limits,
|
|
69
|
+
new Set(['-', 'd']),
|
|
70
|
+
)
|
|
71
|
+
for (const member of members) {
|
|
72
|
+
if (member.name === '.' && member.type !== 'd') {
|
|
73
|
+
throw new ValidationError('The uploads archive root is not a directory.')
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function refuseNonEmptyDirectory(dir: string): Promise<void> {
|
|
79
|
+
const existing = await readdir(dir).catch((error: NodeJS.ErrnoException) => {
|
|
80
|
+
if (error.code === 'ENOENT') return undefined
|
|
81
|
+
throw error
|
|
82
|
+
})
|
|
83
|
+
if (existing !== undefined && existing.length > 0) {
|
|
84
|
+
throw new ValidationError(
|
|
85
|
+
`${dir} is not empty. Empty it first, or on the command line restore the uploads into ` +
|
|
86
|
+
'a fresh directory with --uploads-dir, the same way the database goes into a fresh ' +
|
|
87
|
+
'database.',
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function extractUploads(
|
|
93
|
+
stage: string,
|
|
94
|
+
dir: string,
|
|
95
|
+
limits: RestoreLimits,
|
|
96
|
+
log: BackupLog,
|
|
97
|
+
): Promise<void> {
|
|
98
|
+
await validateUploadsArchive(stage, limits)
|
|
99
|
+
await refuseNonEmptyDirectory(dir)
|
|
100
|
+
await mkdir(dir, { recursive: true })
|
|
101
|
+
await run('tar', ['xzf', path.join(stage, 'uploads.tar.gz'), '-C', dir])
|
|
102
|
+
log.info(`Restored the uploads into ${dir}.`)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function pushUploadsToStore(
|
|
106
|
+
stage: string,
|
|
107
|
+
limits: RestoreLimits,
|
|
108
|
+
store: { put: FileStore['put'] },
|
|
109
|
+
description: string,
|
|
110
|
+
log: BackupLog,
|
|
111
|
+
): Promise<number> {
|
|
112
|
+
await validateUploadsArchive(stage, limits)
|
|
113
|
+
const dir = path.join(stage, 'uploads-extract')
|
|
114
|
+
await mkdir(dir, { recursive: true })
|
|
115
|
+
await run('tar', ['xzf', path.join(stage, 'uploads.tar.gz'), '-C', dir])
|
|
116
|
+
|
|
117
|
+
const pushed = await uploadDirectoryToStore(store, dir)
|
|
118
|
+
log.info(`Uploaded ${pushed} object(s) to ${description}.`)
|
|
119
|
+
return pushed
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const FAIL_INHERITED_RUNS_SQL =
|
|
123
|
+
"update backup_runs set status = 'failed', finished_at = now(), heartbeat_at = now(), " +
|
|
124
|
+
"error = 'The backup was interrupted: the dump this board was restored from caught it " +
|
|
125
|
+
"mid-flight.' where status in ('queued', 'running') returning id"
|
|
126
|
+
|
|
127
|
+
const RELEASE_INHERITED_LEASES_SQL =
|
|
128
|
+
'update tasks set locked_until = null where locked_until is not null returning key'
|
|
129
|
+
|
|
130
|
+
async function tableExists(name: string, databaseEnvironment: NodeJS.ProcessEnv): Promise<boolean> {
|
|
131
|
+
const output = await run(
|
|
132
|
+
'psql',
|
|
133
|
+
['-tAc', `select to_regclass('public.${name}') is not null`],
|
|
134
|
+
databaseEnvironment,
|
|
135
|
+
)
|
|
136
|
+
return output.trim() === 't'
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function returnedRows(output: string): number {
|
|
140
|
+
return output.split('\n').filter((line) => line.trim() !== '').length
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function failInheritedRuns(databaseEnvironment: NodeJS.ProcessEnv): Promise<number> {
|
|
144
|
+
if (await tableExists('tasks', databaseEnvironment)) {
|
|
145
|
+
await run('psql', ['-tAc', RELEASE_INHERITED_LEASES_SQL], databaseEnvironment)
|
|
146
|
+
}
|
|
147
|
+
if (!(await tableExists('backup_runs', databaseEnvironment))) return 0
|
|
148
|
+
return returnedRows(await run('psql', ['-tAc', FAIL_INHERITED_RUNS_SQL], databaseEnvironment))
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const RESET_SCHEMA_SQL = [
|
|
152
|
+
'drop schema if exists drizzle cascade;',
|
|
153
|
+
'drop schema if exists public cascade;',
|
|
154
|
+
'create schema public;',
|
|
155
|
+
].join('\n')
|
|
156
|
+
|
|
157
|
+
async function prepareTarget(
|
|
158
|
+
target: RestoreTarget,
|
|
159
|
+
databaseEnvironment: NodeJS.ProcessEnv,
|
|
160
|
+
log: BackupLog,
|
|
161
|
+
): Promise<void> {
|
|
162
|
+
const tables = (
|
|
163
|
+
await run(
|
|
164
|
+
'psql',
|
|
165
|
+
['-tAc', "select count(*) from information_schema.tables where table_schema = 'public'"],
|
|
166
|
+
databaseEnvironment,
|
|
167
|
+
)
|
|
168
|
+
).trim()
|
|
169
|
+
|
|
170
|
+
if (target.mode === 'empty-database') {
|
|
171
|
+
if (tables !== '0') {
|
|
172
|
+
throw new ValidationError(
|
|
173
|
+
`The target database already holds ${tables} table(s). Restore into a new, ` +
|
|
174
|
+
'empty database — a restore over a live board is how a bad backup becomes ' +
|
|
175
|
+
'two lost boards.',
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const members = (
|
|
182
|
+
await run(
|
|
183
|
+
'psql',
|
|
184
|
+
[
|
|
185
|
+
'-tAc',
|
|
186
|
+
"select case when to_regclass('public.users') is null then 0 " +
|
|
187
|
+
'else (select count(*) from users) end',
|
|
188
|
+
],
|
|
189
|
+
databaseEnvironment,
|
|
190
|
+
)
|
|
191
|
+
).trim()
|
|
192
|
+
if (members !== '0') {
|
|
193
|
+
throw new ValidationError(
|
|
194
|
+
`The target database holds ${members} member account(s). The installer only restores ` +
|
|
195
|
+
'over an empty, uninstalled board.',
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (tables !== '0') {
|
|
200
|
+
log.info(`Dropping the empty schema (${tables} table(s)) before the restore…`)
|
|
201
|
+
}
|
|
202
|
+
await run('psql', ['-v', 'ON_ERROR_STOP=1', '-q'], databaseEnvironment, RESET_SCHEMA_SQL)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export async function restoreBackup(input: RestoreInput): Promise<RestoreOutcome> {
|
|
206
|
+
const log = input.log ?? SILENT_LOG
|
|
207
|
+
const databaseEnvironment = postgresClientEnvironment(input.target.url, input.target.variable)
|
|
208
|
+
|
|
209
|
+
const bundleInfo = await stat(input.bundle).catch(() => undefined)
|
|
210
|
+
if (bundleInfo === undefined || !bundleInfo.isFile()) {
|
|
211
|
+
throw new ValidationError(`No such bundle: ${input.bundle}`)
|
|
212
|
+
}
|
|
213
|
+
if (bundleInfo.size > input.limits.archiveBytes) {
|
|
214
|
+
throw new ValidationError('The backup bundle exceeds MEITH_RESTORE_MAX_ARCHIVE_BYTES.')
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const stage = await mkdtemp(path.join(tmpdir(), 'meith-restore-'))
|
|
218
|
+
try {
|
|
219
|
+
const stagedBundle = path.join(stage, 'bundle.tar.gz')
|
|
220
|
+
await copyFile(path.resolve(input.bundle), stagedBundle)
|
|
221
|
+
const members = await inspectArchive(stagedBundle, input.limits, new Set(['-']))
|
|
222
|
+
const possibleMembers = new Set(['manifest.json', 'db.dump', 'uploads.tar.gz'])
|
|
223
|
+
if (members.some((member) => !possibleMembers.has(member.name))) {
|
|
224
|
+
throw new ValidationError('The backup bundle contains an unexpected member.')
|
|
225
|
+
}
|
|
226
|
+
await run('tar', ['xzf', stagedBundle, '-C', stage, 'manifest.json'])
|
|
227
|
+
const manifest = parseManifest(await readFile(path.join(stage, 'manifest.json'), 'utf8'))
|
|
228
|
+
const expectedMembers = new Set(['manifest.json', 'db.dump'])
|
|
229
|
+
if (manifest.uploads === 'included') expectedMembers.add('uploads.tar.gz')
|
|
230
|
+
if (
|
|
231
|
+
members.length !== expectedMembers.size ||
|
|
232
|
+
members.some((member) => !expectedMembers.has(member.name))
|
|
233
|
+
) {
|
|
234
|
+
throw new ValidationError('The backup bundle members do not match its manifest.')
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const refusal = versionRefusal(manifest.version, input.codeVersion)
|
|
238
|
+
if (refusal !== null) throw new ValidationError(refusal)
|
|
239
|
+
|
|
240
|
+
const restoreMembers = ['db.dump']
|
|
241
|
+
if (manifest.uploads === 'included') restoreMembers.push('uploads.tar.gz')
|
|
242
|
+
await run('tar', ['xzf', stagedBundle, '-C', stage, ...restoreMembers])
|
|
243
|
+
if (manifest.uploads === 'included') {
|
|
244
|
+
await validateUploadsArchive(stage, input.limits)
|
|
245
|
+
if (input.uploads.mode === 'directory') await refuseNonEmptyDirectory(input.uploads.dir)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
await prepareTarget(input.target, databaseEnvironment, log)
|
|
249
|
+
|
|
250
|
+
log.info(`Restoring the backup taken ${manifest.createdAt} (version ${manifest.version})…`)
|
|
251
|
+
await run(
|
|
252
|
+
'pg_restore',
|
|
253
|
+
[
|
|
254
|
+
'--no-owner',
|
|
255
|
+
'--no-privileges',
|
|
256
|
+
'--dbname',
|
|
257
|
+
databaseEnvironment.PGDATABASE ?? '',
|
|
258
|
+
path.join(stage, 'db.dump'),
|
|
259
|
+
],
|
|
260
|
+
databaseEnvironment,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
let step = 'applying the migrations the dump predates'
|
|
264
|
+
let migrationsApplied = 0
|
|
265
|
+
let posts = 0
|
|
266
|
+
let uploads: RestoreOutcome['uploads'] = 'none'
|
|
267
|
+
let pushed = 0
|
|
268
|
+
try {
|
|
269
|
+
migrationsApplied = await input.migrate(input.target.url)
|
|
270
|
+
log.info(
|
|
271
|
+
migrationsApplied === 0
|
|
272
|
+
? 'Migrations: nothing to do — the dump matches this build.'
|
|
273
|
+
: `Migrations: applied ${migrationsApplied} migration(s) the dump predates.`,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
const interrupted = await failInheritedRuns(databaseEnvironment)
|
|
277
|
+
if (interrupted > 0) {
|
|
278
|
+
log.info(`Marked ${interrupted} backup run(s) the dump caught mid-flight as failed.`)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
posts = Number(
|
|
282
|
+
(await run('psql', ['-tAc', 'select count(*) from posts'], databaseEnvironment)).trim(),
|
|
283
|
+
)
|
|
284
|
+
log.info(`The restored board holds ${posts} post(s).`)
|
|
285
|
+
|
|
286
|
+
step = 'putting the uploads back'
|
|
287
|
+
if (manifest.uploads === 'included') {
|
|
288
|
+
if (input.uploads.mode === 'skip') {
|
|
289
|
+
uploads = 'skipped'
|
|
290
|
+
} else if (input.uploads.mode === 'store') {
|
|
291
|
+
pushed = await pushUploadsToStore(
|
|
292
|
+
stage,
|
|
293
|
+
input.limits,
|
|
294
|
+
input.uploads.store,
|
|
295
|
+
input.uploads.description,
|
|
296
|
+
log,
|
|
297
|
+
)
|
|
298
|
+
uploads = 'pushed'
|
|
299
|
+
} else {
|
|
300
|
+
await extractUploads(stage, input.uploads.dir, input.limits, log)
|
|
301
|
+
uploads = 'restored'
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
} catch (error) {
|
|
305
|
+
throw new ValidationError(
|
|
306
|
+
`The database was restored from ${path.basename(input.bundle)}, but ${step} failed: ${
|
|
307
|
+
error instanceof Error ? error.message : String(error)
|
|
308
|
+
} The board is installed and serving the restored database. ` +
|
|
309
|
+
(step === 'putting the uploads back'
|
|
310
|
+
? 'Unpack uploads.tar.gz from the bundle into the uploads location by hand.'
|
|
311
|
+
: 'Fix the cause and run `meith migrate`.'),
|
|
312
|
+
)
|
|
313
|
+
}
|
|
314
|
+
if (manifest.uploads !== 'included') {
|
|
315
|
+
log.info(
|
|
316
|
+
manifest.filestore === 's3'
|
|
317
|
+
? `This bundle carries no uploads — they live in the S3 bucket${
|
|
318
|
+
manifest.bucket === undefined ? '' : ` (${manifest.bucket})`
|
|
319
|
+
}.`
|
|
320
|
+
: manifest.filestore === 'blob'
|
|
321
|
+
? 'This bundle carries no uploads, and a Vercel Blob store is not ' +
|
|
322
|
+
'something you can copy out by hand. Take another backup with ' +
|
|
323
|
+
'--uploads include while the old board still exists.'
|
|
324
|
+
: 'This bundle carries no uploads.',
|
|
325
|
+
)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return { manifest, migrationsApplied, posts, uploads, pushed }
|
|
329
|
+
} finally {
|
|
330
|
+
await rm(stage, { recursive: true, force: true })
|
|
331
|
+
}
|
|
332
|
+
}
|
package/src/retention.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { ValidationError } from '@meith/core'
|
|
2
|
+
|
|
3
|
+
import { bundleTakenAt, isBundleName } from './bundle'
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_KEEP = 7
|
|
6
|
+
|
|
7
|
+
export interface RetentionPolicy {
|
|
8
|
+
readonly keep: number
|
|
9
|
+
readonly keepDays?: number | undefined
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function resolveKeep(flag: string | undefined): number {
|
|
13
|
+
if (flag === undefined) return DEFAULT_KEEP
|
|
14
|
+
if (!/^\d+$/.test(flag) || Number(flag) < 1 || !Number.isSafeInteger(Number(flag))) {
|
|
15
|
+
throw new ValidationError(`--keep must be a whole number of bundles, 1 or more, got "${flag}".`)
|
|
16
|
+
}
|
|
17
|
+
return Number(flag)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function pruneCandidates(names: readonly string[], keep: number): readonly string[] {
|
|
21
|
+
return retentionCandidates(names, { keep })
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function retentionCandidates(
|
|
25
|
+
names: readonly string[],
|
|
26
|
+
policy: RetentionPolicy,
|
|
27
|
+
now: Date = new Date(),
|
|
28
|
+
): readonly string[] {
|
|
29
|
+
const bundles = names
|
|
30
|
+
.filter((name) => isBundleName(name))
|
|
31
|
+
.sort()
|
|
32
|
+
.reverse()
|
|
33
|
+
const beyondCount = new Set(bundles.slice(Math.max(1, policy.keep)))
|
|
34
|
+
|
|
35
|
+
const keepDays = policy.keepDays ?? 0
|
|
36
|
+
if (keepDays > 0) {
|
|
37
|
+
const cutoff = now.getTime() - keepDays * 24 * 60 * 60 * 1000
|
|
38
|
+
for (const name of bundles.slice(1)) {
|
|
39
|
+
const takenAt = bundleTakenAt(name)
|
|
40
|
+
if (takenAt !== null && takenAt.getTime() < cutoff) beyondCount.add(name)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return bundles.filter((name) => beyondCount.has(name))
|
|
45
|
+
}
|
package/src/runs.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export type BackupTrigger = 'manual' | 'schedule' | 'upgrade' | 'cli'
|
|
2
|
+
|
|
3
|
+
export type BackupRunStatus = 'queued' | 'running' | 'done' | 'incomplete' | 'failed'
|
|
4
|
+
|
|
5
|
+
export interface BackupRunRecord {
|
|
6
|
+
readonly id: number
|
|
7
|
+
readonly trigger: BackupTrigger
|
|
8
|
+
readonly status: BackupRunStatus
|
|
9
|
+
readonly requestedByUserId: number | null
|
|
10
|
+
readonly requestedAt: Date
|
|
11
|
+
readonly startedAt: Date | null
|
|
12
|
+
readonly finishedAt: Date | null
|
|
13
|
+
readonly heartbeatAt: Date | null
|
|
14
|
+
readonly bundleName: string | null
|
|
15
|
+
readonly sizeBytes: number | null
|
|
16
|
+
readonly uploads: 'included' | 'skipped' | null
|
|
17
|
+
readonly shipped: boolean
|
|
18
|
+
readonly skippedKeys: number
|
|
19
|
+
readonly error: string | null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface BackupRunFinish {
|
|
23
|
+
readonly status: 'done' | 'incomplete' | 'failed'
|
|
24
|
+
readonly finishedAt: Date
|
|
25
|
+
readonly bundleName?: string | null | undefined
|
|
26
|
+
readonly sizeBytes?: number | null | undefined
|
|
27
|
+
readonly uploads?: 'included' | 'skipped' | null | undefined
|
|
28
|
+
readonly shipped?: boolean | undefined
|
|
29
|
+
readonly skippedKeys?: number | undefined
|
|
30
|
+
readonly error?: string | null | undefined
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface BackupRunRepository {
|
|
34
|
+
enqueue(input: {
|
|
35
|
+
readonly trigger: BackupTrigger
|
|
36
|
+
readonly requestedByUserId?: number | null | undefined
|
|
37
|
+
readonly now: Date
|
|
38
|
+
}): Promise<{ readonly id: number; readonly queued: boolean }>
|
|
39
|
+
|
|
40
|
+
claimNext(now: Date): Promise<BackupRunRecord | null>
|
|
41
|
+
|
|
42
|
+
heartbeat(id: number, now: Date): Promise<void>
|
|
43
|
+
|
|
44
|
+
finish(id: number, outcome: BackupRunFinish): Promise<void>
|
|
45
|
+
|
|
46
|
+
active(now: Date, staleBefore: Date): Promise<BackupRunRecord | null>
|
|
47
|
+
|
|
48
|
+
recent(limit: number): Promise<readonly BackupRunRecord[]>
|
|
49
|
+
|
|
50
|
+
lastScheduledAt(): Promise<Date | null>
|
|
51
|
+
|
|
52
|
+
failInterrupted(now: Date, staleBefore: Date): Promise<number>
|
|
53
|
+
|
|
54
|
+
record(input: {
|
|
55
|
+
readonly trigger: BackupTrigger
|
|
56
|
+
readonly requestedByUserId?: number | null | undefined
|
|
57
|
+
readonly startedAt: Date
|
|
58
|
+
readonly outcome: BackupRunFinish
|
|
59
|
+
}): Promise<void>
|
|
60
|
+
}
|
package/src/schedule.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
export type BackupFrequency = 'off' | 'daily' | 'weekly'
|
|
2
|
+
|
|
3
|
+
export interface BackupSchedule {
|
|
4
|
+
readonly frequency: BackupFrequency
|
|
5
|
+
readonly hour: number
|
|
6
|
+
readonly minute: number
|
|
7
|
+
readonly weekday: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const SCHEDULE_TIME_PATTERN = /^([01]\d|2[0-3]):([0-5]\d)$/
|
|
11
|
+
|
|
12
|
+
export function parseScheduleTime(value: string): { hour: number; minute: number } | null {
|
|
13
|
+
const match = SCHEDULE_TIME_PATTERN.exec(value.trim())
|
|
14
|
+
if (match === null) return null
|
|
15
|
+
return { hour: Number(match[1]), minute: Number(match[2]) }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function formatScheduleTime(schedule: { hour: number; minute: number }): string {
|
|
19
|
+
return `${String(schedule.hour).padStart(2, '0')}:${String(schedule.minute).padStart(2, '0')}`
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const DAY_MS = 24 * 60 * 60 * 1000
|
|
23
|
+
|
|
24
|
+
function slotOnDay(day: Date, schedule: BackupSchedule): Date {
|
|
25
|
+
return new Date(
|
|
26
|
+
Date.UTC(
|
|
27
|
+
day.getUTCFullYear(),
|
|
28
|
+
day.getUTCMonth(),
|
|
29
|
+
day.getUTCDate(),
|
|
30
|
+
schedule.hour,
|
|
31
|
+
schedule.minute,
|
|
32
|
+
),
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function latestSlotAtOrBefore(schedule: BackupSchedule, now: Date): Date | null {
|
|
37
|
+
if (schedule.frequency === 'off') return null
|
|
38
|
+
|
|
39
|
+
for (let back = 0; back < 8; back += 1) {
|
|
40
|
+
const day = new Date(now.getTime() - back * DAY_MS)
|
|
41
|
+
if (schedule.frequency === 'weekly' && day.getUTCDay() !== schedule.weekday) continue
|
|
42
|
+
const slot = slotOnDay(day, schedule)
|
|
43
|
+
if (slot.getTime() <= now.getTime()) return slot
|
|
44
|
+
}
|
|
45
|
+
return null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function nextSlotAfter(schedule: BackupSchedule, from: Date): Date | null {
|
|
49
|
+
if (schedule.frequency === 'off') return null
|
|
50
|
+
|
|
51
|
+
for (let ahead = 0; ahead < 8; ahead += 1) {
|
|
52
|
+
const day = new Date(from.getTime() + ahead * DAY_MS)
|
|
53
|
+
if (schedule.frequency === 'weekly' && day.getUTCDay() !== schedule.weekday) continue
|
|
54
|
+
const slot = slotOnDay(day, schedule)
|
|
55
|
+
if (slot.getTime() > from.getTime()) return slot
|
|
56
|
+
}
|
|
57
|
+
return null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function scheduledBackupDue(
|
|
61
|
+
schedule: BackupSchedule,
|
|
62
|
+
input: {
|
|
63
|
+
readonly now: Date
|
|
64
|
+
readonly lastTickAt: Date | null
|
|
65
|
+
readonly lastScheduledAt: Date | null
|
|
66
|
+
},
|
|
67
|
+
): Date | null {
|
|
68
|
+
const slot = latestSlotAtOrBefore(schedule, input.now)
|
|
69
|
+
if (slot === null) return null
|
|
70
|
+
if (input.lastTickAt === null || slot.getTime() <= input.lastTickAt.getTime()) return null
|
|
71
|
+
if (input.lastScheduledAt !== null && input.lastScheduledAt.getTime() >= slot.getTime()) {
|
|
72
|
+
return null
|
|
73
|
+
}
|
|
74
|
+
return slot
|
|
75
|
+
}
|
package/src/uploads.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { type FileStore, unusableKeyReason } from '@meith/core'
|
|
5
|
+
|
|
6
|
+
import { contentTypeFor } from './bundle'
|
|
7
|
+
|
|
8
|
+
export interface ListableStore {
|
|
9
|
+
listKeys(): AsyncGenerator<string>
|
|
10
|
+
get(key: string): Promise<Uint8Array | undefined>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface DrainedStore {
|
|
14
|
+
readonly pulled: number
|
|
15
|
+
readonly skipped: readonly string[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function drainStoreToDirectory(
|
|
19
|
+
store: ListableStore,
|
|
20
|
+
dir: string,
|
|
21
|
+
warn: (line: string) => void = () => undefined,
|
|
22
|
+
): Promise<DrainedStore> {
|
|
23
|
+
let pulled = 0
|
|
24
|
+
const skipped: string[] = []
|
|
25
|
+
|
|
26
|
+
for await (const key of store.listKeys()) {
|
|
27
|
+
const target = path.resolve(dir, key)
|
|
28
|
+
if (target !== dir && !target.startsWith(dir + path.sep)) {
|
|
29
|
+
warn(`Skipping the object at ${JSON.stringify(key)}: its key escapes ${dir}.`)
|
|
30
|
+
skipped.push(key)
|
|
31
|
+
continue
|
|
32
|
+
}
|
|
33
|
+
const unusable = unusableKeyReason(key)
|
|
34
|
+
if (unusable !== undefined) {
|
|
35
|
+
warn(`Skipping the object at ${JSON.stringify(key)}: its key ${unusable}.`)
|
|
36
|
+
skipped.push(key)
|
|
37
|
+
continue
|
|
38
|
+
}
|
|
39
|
+
const body = await store.get(key)
|
|
40
|
+
if (body === undefined) continue
|
|
41
|
+
await mkdir(path.dirname(target), { recursive: true })
|
|
42
|
+
await writeFile(target, body)
|
|
43
|
+
pulled++
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return { pulled, skipped }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function walk(dir: string): Promise<readonly string[]> {
|
|
50
|
+
const files: string[] = []
|
|
51
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
52
|
+
const full = path.join(dir, entry.name)
|
|
53
|
+
if (entry.isDirectory()) files.push(...(await walk(full)))
|
|
54
|
+
else if (entry.isFile()) files.push(full)
|
|
55
|
+
}
|
|
56
|
+
return files
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function uploadDirectoryToStore(
|
|
60
|
+
store: { put: FileStore['put'] },
|
|
61
|
+
dir: string,
|
|
62
|
+
): Promise<number> {
|
|
63
|
+
let pushed = 0
|
|
64
|
+
|
|
65
|
+
for (const file of await walk(dir)) {
|
|
66
|
+
const key = path.relative(dir, file).split(path.sep).join('/')
|
|
67
|
+
await store.put(key, await readFile(file), {
|
|
68
|
+
contentType: contentTypeFor(key),
|
|
69
|
+
visibility: 'public',
|
|
70
|
+
})
|
|
71
|
+
pushed++
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return pushed
|
|
75
|
+
}
|