@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.
@@ -0,0 +1,449 @@
1
+ import { createReadStream, createWriteStream } from 'node:fs'
2
+ import { pipeline } from 'node:stream/promises'
3
+
4
+ import {
5
+ DeleteObjectCommand,
6
+ GetObjectCommand,
7
+ ListObjectsV2Command,
8
+ PutObjectCommand,
9
+ S3Client,
10
+ } from '@aws-sdk/client-s3'
11
+ import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
12
+
13
+ import { ConfigurationError, ValidationError } from '@meith/core'
14
+
15
+ import { isBundleName } from './bundle'
16
+ import { type RetentionPolicy, retentionCandidates } from './retention'
17
+ import { WebDavBackupDestination } from './webdav'
18
+
19
+ export interface RemoteBundle {
20
+ readonly name: string
21
+ readonly size: number
22
+ }
23
+
24
+ export interface RemoteBundleBody {
25
+ readonly body: ReadableStream<Uint8Array>
26
+ readonly size: number | null
27
+ }
28
+
29
+ export interface BackupDestination {
30
+ readonly description: string
31
+ list(): Promise<readonly RemoteBundle[]>
32
+ putFile(name: string, filePath: string, size: number): Promise<void>
33
+ getToFile(name: string, outPath: string): Promise<void>
34
+ open(name: string): Promise<RemoteBundleBody | null>
35
+ delete(name: string): Promise<void>
36
+ prune(policy: RetentionPolicy, now?: Date): Promise<readonly string[]>
37
+ downloadUrl?(name: string, expiresInSeconds: number): Promise<string>
38
+ }
39
+
40
+ export interface S3DestinationConfig {
41
+ readonly kind: 's3'
42
+ readonly bucket: string
43
+ readonly region: string
44
+ readonly accessKeyId: string
45
+ readonly secretAccessKey: string
46
+ readonly endpoint?: string | undefined
47
+ readonly prefix?: string | undefined
48
+ }
49
+
50
+ export interface WebDavDestinationConfig {
51
+ readonly kind: 'webdav'
52
+ readonly url: string
53
+ readonly username: string
54
+ readonly password: string
55
+ }
56
+
57
+ export type BackupDestinationConfig = S3DestinationConfig | WebDavDestinationConfig
58
+
59
+ export type BackupDestinationKind = BackupDestinationConfig['kind']
60
+
61
+ export type BackupDestinationSource = 'environment' | 'board' | 'none'
62
+
63
+ export interface BackupDestinationResolution {
64
+ readonly source: BackupDestinationSource
65
+ readonly config: BackupDestinationConfig | null
66
+ readonly problem: string | null
67
+ }
68
+
69
+ export const BACKUP_DESTINATION_KEYS = [
70
+ 'BACKUP_S3_BUCKET',
71
+ 'BACKUP_S3_REGION',
72
+ 'BACKUP_S3_ACCESS_KEY_ID',
73
+ 'BACKUP_S3_SECRET_ACCESS_KEY',
74
+ ] as const
75
+
76
+ export const BACKUP_WEBDAV_KEYS = [
77
+ 'BACKUP_WEBDAV_URL',
78
+ 'BACKUP_WEBDAV_USERNAME',
79
+ 'BACKUP_WEBDAV_PASSWORD',
80
+ ] as const
81
+
82
+ export type BackupDestinationEnvironment = {
83
+ readonly [K in
84
+ | (typeof BACKUP_DESTINATION_KEYS)[number]
85
+ | (typeof BACKUP_WEBDAV_KEYS)[number]
86
+ | 'BACKUP_S3_ENDPOINT'
87
+ | 'BACKUP_S3_PREFIX']?: string | undefined
88
+ }
89
+
90
+ export function usableWebDavUrl(value: string): string | null {
91
+ let url: URL
92
+ try {
93
+ url = new URL(value.trim())
94
+ } catch {
95
+ return null
96
+ }
97
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') return null
98
+ if (url.search !== '' || url.hash !== '') return null
99
+ return url.href.endsWith('/') ? url.href : `${url.href}/`
100
+ }
101
+
102
+ function webDavFromEnv(
103
+ environment: BackupDestinationEnvironment,
104
+ ): WebDavDestinationConfig | undefined {
105
+ const url = environment.BACKUP_WEBDAV_URL
106
+ const username = environment.BACKUP_WEBDAV_USERNAME ?? ''
107
+ const password = environment.BACKUP_WEBDAV_PASSWORD ?? ''
108
+ if (url === undefined || url === '') {
109
+ if (username !== '' || password !== '') {
110
+ throw new ConfigurationError(
111
+ 'BACKUP_WEBDAV_USERNAME or BACKUP_WEBDAV_PASSWORD is set without BACKUP_WEBDAV_URL.',
112
+ )
113
+ }
114
+ return undefined
115
+ }
116
+ const usable = usableWebDavUrl(url)
117
+ if (usable === null) {
118
+ throw new ConfigurationError(
119
+ 'BACKUP_WEBDAV_URL must be an http:// or https:// address of a collection, ' +
120
+ 'with no query string.',
121
+ )
122
+ }
123
+ if ((username === '') !== (password === '')) {
124
+ throw new ConfigurationError(
125
+ 'BACKUP_WEBDAV_USERNAME and BACKUP_WEBDAV_PASSWORD are set together, or not at all.',
126
+ )
127
+ }
128
+ return { kind: 'webdav', url: usable, username, password }
129
+ }
130
+
131
+ function normalisePrefix(raw: string | undefined): string | undefined {
132
+ const prefix = raw?.replace(/^\/+|\/+$/g, '')
133
+ if (prefix === undefined || prefix === '') return undefined
134
+ if (
135
+ prefix.split('/').some((segment) => !/^[\w!.*'()-]+$/.test(segment) || /^\.+$/.test(segment))
136
+ ) {
137
+ throw new ConfigurationError(
138
+ 'The backup prefix must be one or more path segments of unreserved characters.',
139
+ )
140
+ }
141
+ return prefix
142
+ }
143
+
144
+ export function backupDestinationFromEnv(
145
+ environment: BackupDestinationEnvironment,
146
+ ): BackupDestinationConfig | undefined {
147
+ const set = BACKUP_DESTINATION_KEYS.filter(
148
+ (key) => environment[key] !== undefined && environment[key] !== '',
149
+ )
150
+ if (set.length === 0) return webDavFromEnv(environment)
151
+ if (set.length < BACKUP_DESTINATION_KEYS.length) {
152
+ const missing = BACKUP_DESTINATION_KEYS.filter((key) => !set.includes(key))
153
+ throw new ConfigurationError(
154
+ `An off-site backup destination is partly configured: ${set.join(', ')} without ` +
155
+ `${missing.join(', ')}. Set all four, or none.`,
156
+ )
157
+ }
158
+
159
+ if (webDavFromEnv(environment) !== undefined) {
160
+ throw new ConfigurationError(
161
+ 'Both BACKUP_S3_* and BACKUP_WEBDAV_* are set. A board ships its bundles to one ' +
162
+ 'destination; unset one of the two.',
163
+ )
164
+ }
165
+
166
+ return {
167
+ kind: 's3',
168
+ bucket: environment.BACKUP_S3_BUCKET as string,
169
+ region: environment.BACKUP_S3_REGION as string,
170
+ accessKeyId: environment.BACKUP_S3_ACCESS_KEY_ID as string,
171
+ secretAccessKey: environment.BACKUP_S3_SECRET_ACCESS_KEY as string,
172
+ endpoint: environment.BACKUP_S3_ENDPOINT || undefined,
173
+ prefix: normalisePrefix(environment.BACKUP_S3_PREFIX),
174
+ }
175
+ }
176
+
177
+ export interface BackupDestinationSettings {
178
+ readonly kind: 'none' | 's3' | 'webdav'
179
+ readonly bucket: string
180
+ readonly region: string
181
+ readonly accessKeyId: string
182
+ readonly secretAccessKey: string
183
+ readonly endpoint: string
184
+ readonly prefix: string
185
+ readonly webdavUrl: string
186
+ readonly webdavUsername: string
187
+ readonly webdavPassword: string
188
+ }
189
+
190
+ function webDavFromSettings(settings: BackupDestinationSettings): BackupDestinationResolution {
191
+ const url = usableWebDavUrl(settings.webdavUrl)
192
+ if (settings.webdavUrl.trim() === '') {
193
+ return { source: 'board', config: null, problem: 'The WebDAV destination has no address.' }
194
+ }
195
+ if (url === null) {
196
+ return {
197
+ source: 'board',
198
+ config: null,
199
+ problem:
200
+ 'The WebDAV address must be an http:// or https:// address of a folder, with no ' +
201
+ 'query string.',
202
+ }
203
+ }
204
+ const username = settings.webdavUsername.trim()
205
+ if ((username === '') !== (settings.webdavPassword === '')) {
206
+ return {
207
+ source: 'board',
208
+ config: null,
209
+ problem: 'The WebDAV username and password go together: fill in both, or neither.',
210
+ }
211
+ }
212
+ return {
213
+ source: 'board',
214
+ config: { kind: 'webdav', url, username, password: settings.webdavPassword },
215
+ problem: null,
216
+ }
217
+ }
218
+
219
+ export function backupDestinationFromSettings(
220
+ settings: BackupDestinationSettings,
221
+ ): BackupDestinationResolution {
222
+ if (settings.kind === 'none') return { source: 'none', config: null, problem: null }
223
+ if (settings.kind === 'webdav') return webDavFromSettings(settings)
224
+
225
+ const bucket = settings.bucket.trim()
226
+ if (bucket === '') {
227
+ return { source: 'board', config: null, problem: 'The S3 destination names no bucket.' }
228
+ }
229
+
230
+ const missing: string[] = []
231
+ if (settings.region.trim() === '') missing.push('a region')
232
+ if (settings.accessKeyId.trim() === '') missing.push('an access key id')
233
+ if (settings.secretAccessKey === '') missing.push('a secret access key')
234
+ if (missing.length > 0) {
235
+ return {
236
+ source: 'board',
237
+ config: null,
238
+ problem: `The destination names the ${bucket} bucket without ${missing.join(', ')}.`,
239
+ }
240
+ }
241
+
242
+ try {
243
+ return {
244
+ source: 'board',
245
+ config: {
246
+ kind: 's3',
247
+ bucket,
248
+ region: settings.region.trim(),
249
+ accessKeyId: settings.accessKeyId.trim(),
250
+ secretAccessKey: settings.secretAccessKey,
251
+ endpoint: settings.endpoint.trim() === '' ? undefined : settings.endpoint.trim(),
252
+ prefix: normalisePrefix(settings.prefix),
253
+ },
254
+ problem: null,
255
+ }
256
+ } catch (error) {
257
+ return {
258
+ source: 'board',
259
+ config: null,
260
+ problem: error instanceof Error ? error.message : String(error),
261
+ }
262
+ }
263
+ }
264
+
265
+ export function resolveBackupDestination(input: {
266
+ readonly environment: BackupDestinationEnvironment
267
+ readonly settings: BackupDestinationSettings | null
268
+ }): BackupDestinationResolution {
269
+ try {
270
+ const fromEnvironment = backupDestinationFromEnv(input.environment)
271
+ if (fromEnvironment !== undefined) {
272
+ return { source: 'environment', config: fromEnvironment, problem: null }
273
+ }
274
+ } catch (error) {
275
+ return {
276
+ source: 'environment',
277
+ config: null,
278
+ problem: error instanceof Error ? error.message : String(error),
279
+ }
280
+ }
281
+
282
+ if (input.settings === null) return { source: 'none', config: null, problem: null }
283
+ return backupDestinationFromSettings(input.settings)
284
+ }
285
+
286
+ export interface S3Like {
287
+ send(command: unknown): Promise<unknown>
288
+ }
289
+
290
+ function isNotFound(error: unknown): boolean {
291
+ const name = (error as { name?: string } | null)?.name
292
+ const status = (error as { $metadata?: { httpStatusCode?: number } } | null)?.$metadata
293
+ ?.httpStatusCode
294
+
295
+ return name === 'NoSuchKey' || name === 'NotFound' || status === 404
296
+ }
297
+
298
+ export class S3BackupDestination implements BackupDestination {
299
+ private readonly sender: S3Like
300
+
301
+ private readonly signingClient: S3Client
302
+
303
+ constructor(
304
+ private readonly config: S3DestinationConfig,
305
+ sender?: S3Like,
306
+ ) {
307
+ this.signingClient = new S3Client({
308
+ region: config.region,
309
+ credentials: {
310
+ accessKeyId: config.accessKeyId,
311
+ secretAccessKey: config.secretAccessKey,
312
+ },
313
+ ...(config.endpoint === undefined
314
+ ? {}
315
+ : ({
316
+ endpoint: config.endpoint,
317
+ forcePathStyle: true,
318
+ requestChecksumCalculation: 'WHEN_REQUIRED',
319
+ } as const)),
320
+ })
321
+ this.sender = sender ?? this.signingClient
322
+ }
323
+
324
+ get description(): string {
325
+ return this.config.prefix === undefined
326
+ ? `the ${this.config.bucket} bucket`
327
+ : `the ${this.config.bucket} bucket under ${this.config.prefix}/`
328
+ }
329
+
330
+ private key(name: string): string {
331
+ if (!isBundleName(name)) {
332
+ throw new ValidationError(`Not a backup bundle name: ${JSON.stringify(name)}`)
333
+ }
334
+ return this.config.prefix === undefined ? name : `${this.config.prefix}/${name}`
335
+ }
336
+
337
+ async putFile(name: string, filePath: string, size: number): Promise<void> {
338
+ await this.sender.send(
339
+ new PutObjectCommand({
340
+ Bucket: this.config.bucket,
341
+ Key: this.key(name),
342
+ Body: createReadStream(filePath),
343
+ ContentLength: size,
344
+ ContentType: 'application/gzip',
345
+ }),
346
+ )
347
+ }
348
+
349
+ async list(): Promise<readonly RemoteBundle[]> {
350
+ const prefix = this.config.prefix === undefined ? '' : `${this.config.prefix}/`
351
+ const bundles: RemoteBundle[] = []
352
+ let continuationToken: string | undefined
353
+
354
+ do {
355
+ const response = (await this.sender.send(
356
+ new ListObjectsV2Command({
357
+ Bucket: this.config.bucket,
358
+ ...(prefix === '' ? {} : { Prefix: prefix }),
359
+ ...(continuationToken === undefined ? {} : { ContinuationToken: continuationToken }),
360
+ }),
361
+ )) as {
362
+ Contents?: readonly { Key?: string; Size?: number }[]
363
+ IsTruncated?: boolean
364
+ NextContinuationToken?: string
365
+ }
366
+
367
+ for (const object of response.Contents ?? []) {
368
+ if (object.Key === undefined || !object.Key.startsWith(prefix)) continue
369
+ const name = object.Key.slice(prefix.length)
370
+ if (isBundleName(name)) bundles.push({ name, size: object.Size ?? 0 })
371
+ }
372
+
373
+ continuationToken = response.IsTruncated ? response.NextContinuationToken : undefined
374
+ } while (continuationToken !== undefined)
375
+
376
+ return bundles.sort((a, b) => a.name.localeCompare(b.name))
377
+ }
378
+
379
+ async getToFile(name: string, outPath: string): Promise<void> {
380
+ let response: { Body?: NodeJS.ReadableStream }
381
+ try {
382
+ response = (await this.sender.send(
383
+ new GetObjectCommand({ Bucket: this.config.bucket, Key: this.key(name) }),
384
+ )) as { Body?: NodeJS.ReadableStream }
385
+ } catch (error) {
386
+ if (isNotFound(error)) {
387
+ throw new ValidationError(
388
+ `${this.description} has no bundle named ${name}. meith backup:list names what it holds.`,
389
+ )
390
+ }
391
+ throw error
392
+ }
393
+ if (response.Body === undefined) {
394
+ throw new ConfigurationError(`${this.description} answered without a body for ${name}.`)
395
+ }
396
+ await pipeline(response.Body, createWriteStream(outPath, { mode: 0o600 }))
397
+ }
398
+
399
+ async open(name: string): Promise<RemoteBundleBody | null> {
400
+ let response: {
401
+ Body?: { transformToWebStream(): ReadableStream<Uint8Array> }
402
+ ContentLength?: number
403
+ }
404
+ try {
405
+ response = (await this.sender.send(
406
+ new GetObjectCommand({ Bucket: this.config.bucket, Key: this.key(name) }),
407
+ )) as typeof response
408
+ } catch (error) {
409
+ if (isNotFound(error)) return null
410
+ throw error
411
+ }
412
+ if (response.Body === undefined) return null
413
+ return { body: response.Body.transformToWebStream(), size: response.ContentLength ?? null }
414
+ }
415
+
416
+ async delete(name: string): Promise<void> {
417
+ await this.sender.send(
418
+ new DeleteObjectCommand({ Bucket: this.config.bucket, Key: this.key(name) }),
419
+ )
420
+ }
421
+
422
+ async prune(policy: RetentionPolicy, now: Date = new Date()): Promise<readonly string[]> {
423
+ const stale = retentionCandidates(
424
+ (await this.list()).map((bundle) => bundle.name),
425
+ policy,
426
+ now,
427
+ )
428
+ for (const name of stale) await this.delete(name)
429
+ return stale
430
+ }
431
+
432
+ async downloadUrl(name: string, expiresInSeconds: number): Promise<string> {
433
+ return getSignedUrl(
434
+ this.signingClient,
435
+ new GetObjectCommand({
436
+ Bucket: this.config.bucket,
437
+ Key: this.key(name),
438
+ ResponseContentDisposition: `attachment; filename="${name}"`,
439
+ }),
440
+ { expiresIn: expiresInSeconds },
441
+ )
442
+ }
443
+ }
444
+
445
+ export function openBackupDestination(config: BackupDestinationConfig): BackupDestination {
446
+ return config.kind === 'webdav'
447
+ ? new WebDavBackupDestination(config)
448
+ : new S3BackupDestination(config)
449
+ }
package/src/index.ts ADDED
@@ -0,0 +1,105 @@
1
+ export {
2
+ type ArchiveMember,
3
+ inspectArchive,
4
+ type RestoreLimits,
5
+ restoreLimits,
6
+ validateArchiveListing,
7
+ } from './archive'
8
+ export {
9
+ type BackupManifest,
10
+ BUNDLE_NAME_PATTERN,
11
+ bundleName,
12
+ bundleTakenAt,
13
+ contentTypeFor,
14
+ type FilestoreDriver,
15
+ formatBytes,
16
+ isBundleName,
17
+ parseManifest,
18
+ resolveUploadsMode,
19
+ skippedKeyLines,
20
+ type UploadsMode,
21
+ } from './bundle'
22
+ export { type BackupCapability, backupCapability } from './capability'
23
+ export {
24
+ type BackupLog,
25
+ type BackupOutcome,
26
+ BackupShippingError,
27
+ type BackupSource,
28
+ type BackupTarget,
29
+ type CreateBackupInput,
30
+ claimBackupDestination,
31
+ createBackup,
32
+ type LocalBundle,
33
+ localBundles,
34
+ reserveBackupDestination,
35
+ SILENT_LOG,
36
+ type WrittenBundle,
37
+ } from './create'
38
+ export {
39
+ BACKUP_DESTINATION_KEYS,
40
+ BACKUP_WEBDAV_KEYS,
41
+ type BackupDestination,
42
+ type BackupDestinationConfig,
43
+ type BackupDestinationEnvironment,
44
+ type BackupDestinationKind,
45
+ type BackupDestinationResolution,
46
+ type BackupDestinationSettings,
47
+ type BackupDestinationSource,
48
+ backupDestinationFromEnv,
49
+ backupDestinationFromSettings,
50
+ openBackupDestination,
51
+ type RemoteBundle,
52
+ type RemoteBundleBody,
53
+ resolveBackupDestination,
54
+ S3BackupDestination,
55
+ type S3DestinationConfig,
56
+ type S3Like,
57
+ usableWebDavUrl,
58
+ type WebDavDestinationConfig,
59
+ } from './destination'
60
+ export { postgresClientEnvironment } from './postgres-client'
61
+ export {
62
+ type RestoreInput,
63
+ type RestoreOutcome,
64
+ type RestoreTarget,
65
+ type RestoreTargetMode,
66
+ type RestoreUploadsPlan,
67
+ restoreBackup,
68
+ versionRefusal,
69
+ } from './restore'
70
+ export {
71
+ DEFAULT_KEEP,
72
+ pruneCandidates,
73
+ type RetentionPolicy,
74
+ resolveKeep,
75
+ retentionCandidates,
76
+ } from './retention'
77
+ export type {
78
+ BackupRunFinish,
79
+ BackupRunRecord,
80
+ BackupRunRepository,
81
+ BackupRunStatus,
82
+ BackupTrigger,
83
+ } from './runs'
84
+ export {
85
+ type BackupFrequency,
86
+ type BackupSchedule,
87
+ formatScheduleTime,
88
+ latestSlotAtOrBefore,
89
+ nextSlotAfter,
90
+ parseScheduleTime,
91
+ SCHEDULE_TIME_PATTERN,
92
+ scheduledBackupDue,
93
+ } from './schedule'
94
+ export {
95
+ type DrainedStore,
96
+ drainStoreToDirectory,
97
+ type ListableStore,
98
+ uploadDirectoryToStore,
99
+ } from './uploads'
100
+ export {
101
+ parsePropfind,
102
+ WebDavBackupDestination,
103
+ type WebDavRequester,
104
+ type WebDavResponse,
105
+ } from './webdav'
@@ -0,0 +1,141 @@
1
+ import { spawn } from 'node:child_process'
2
+
3
+ import { ConfigurationError, processEnvironment, ValidationError } from '@meith/core'
4
+
5
+ export function missingToolError(command: string): ConfigurationError {
6
+ return new ConfigurationError(
7
+ `${command} was not found on PATH. The shipped image carries the postgres client ` +
8
+ 'tools; elsewhere install them (postgresql18-client on Alpine, ' +
9
+ 'postgresql-client on Debian and Ubuntu).',
10
+ )
11
+ }
12
+
13
+ const POSTGRES_PARAMETERS: Readonly<Record<string, string>> = {
14
+ application_name: 'PGAPPNAME',
15
+ channel_binding: 'PGCHANNELBINDING',
16
+ connect_timeout: 'PGCONNECT_TIMEOUT',
17
+ gssencmode: 'PGGSSENCMODE',
18
+ options: 'PGOPTIONS',
19
+ requirepeer: 'PGREQUIREPEER',
20
+ sslcert: 'PGSSLCERT',
21
+ sslcompression: 'PGSSLCOMPRESSION',
22
+ sslcrl: 'PGSSLCRL',
23
+ sslcrldir: 'PGSSLCRLDIR',
24
+ sslkey: 'PGSSLKEY',
25
+ sslmode: 'PGSSLMODE',
26
+ sslpassword: 'PGSSLPASSWORD',
27
+ sslrootcert: 'PGSSLROOTCERT',
28
+ target_session_attrs: 'PGTARGETSESSIONATTRS',
29
+ }
30
+
31
+ const INHERITED_POSTGRES_VARIABLES = [
32
+ 'PGAPPNAME',
33
+ 'PGCHANNELBINDING',
34
+ 'PGCONNECT_TIMEOUT',
35
+ 'PGDATABASE',
36
+ 'PGGSSENCMODE',
37
+ 'PGHOST',
38
+ 'PGHOSTADDR',
39
+ 'PGOPTIONS',
40
+ 'PGPASSWORD',
41
+ 'PGPORT',
42
+ 'PGREQUIREPEER',
43
+ 'PGSERVICE',
44
+ 'PGSERVICEFILE',
45
+ 'PGSSLCERT',
46
+ 'PGSSLCOMPRESSION',
47
+ 'PGSSLCRL',
48
+ 'PGSSLCRLDIR',
49
+ 'PGSSLKEY',
50
+ 'PGSSLMODE',
51
+ 'PGSSLPASSWORD',
52
+ 'PGSSLROOTCERT',
53
+ 'PGTARGETSESSIONATTRS',
54
+ 'PGUSER',
55
+ ]
56
+
57
+ export function postgresClientEnvironment(
58
+ connectionString: string,
59
+ variable: string,
60
+ ): NodeJS.ProcessEnv {
61
+ let url: URL
62
+ try {
63
+ url = new URL(connectionString)
64
+ } catch {
65
+ throw new ValidationError(`${variable} must be a valid postgres:// connection string.`)
66
+ }
67
+
68
+ if (url.protocol !== 'postgres:' && url.protocol !== 'postgresql:') {
69
+ throw new ValidationError(`${variable} must be a postgres:// connection string.`)
70
+ }
71
+ let database: string
72
+ let username: string
73
+ let password: string
74
+ try {
75
+ database = decodeURIComponent(url.pathname.replace(/^\//, ''))
76
+ username = decodeURIComponent(url.username)
77
+ password = decodeURIComponent(url.password)
78
+ } catch {
79
+ throw new ValidationError(`${variable} contains invalid percent-encoding.`)
80
+ }
81
+ if (url.hostname === '' || username === '' || database === '') {
82
+ throw new ValidationError(`${variable} must include a host, user, and database name.`)
83
+ }
84
+
85
+ const childEnv: NodeJS.ProcessEnv = processEnvironment()
86
+ for (const environmentVariable of INHERITED_POSTGRES_VARIABLES) {
87
+ delete childEnv[environmentVariable]
88
+ }
89
+ Object.assign(childEnv, {
90
+ PGHOST: url.hostname.replace(/^\[|\]$/g, ''),
91
+ PGPORT: url.port || '5432',
92
+ PGUSER: username,
93
+ PGDATABASE: database,
94
+ })
95
+ if (password !== '') childEnv.PGPASSWORD = password
96
+
97
+ for (const [parameter, environmentVariable] of Object.entries(POSTGRES_PARAMETERS)) {
98
+ const value = url.searchParams.get(parameter)
99
+ if (value !== null) childEnv[environmentVariable] = value
100
+ }
101
+ return childEnv
102
+ }
103
+
104
+ export async function run(
105
+ command: string,
106
+ args: readonly string[],
107
+ childEnv: NodeJS.ProcessEnv = processEnvironment(),
108
+ input?: string,
109
+ ): Promise<string> {
110
+ return new Promise<string>((resolvePromise, reject) => {
111
+ const child = spawn(command, args, {
112
+ env: childEnv,
113
+ stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'],
114
+ })
115
+ let stdout = ''
116
+ let stderr = ''
117
+ child.stdout?.on('data', (chunk: Buffer) => {
118
+ stdout += String(chunk)
119
+ })
120
+ child.stderr?.on('data', (chunk: Buffer) => {
121
+ stderr += String(chunk)
122
+ })
123
+ child.on('error', (error) => {
124
+ reject((error as NodeJS.ErrnoException).code === 'ENOENT' ? missingToolError(command) : error)
125
+ })
126
+ child.on('close', (code) => {
127
+ if (code === 0) resolvePromise(stdout)
128
+ else {
129
+ reject(
130
+ new ConfigurationError(
131
+ `${command} exited with ${code === null ? 'a signal' : `code ${code}`}.` +
132
+ (stderr.trim() === '' ? '' : `\n${stderr.trim()}`),
133
+ ),
134
+ )
135
+ }
136
+ })
137
+ if (input !== undefined && child.stdin !== null) {
138
+ child.stdin.end(input)
139
+ }
140
+ })
141
+ }