@jwilleke/ngdpbase-types 5.18.0 → 5.20.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.
@@ -25,15 +25,49 @@
25
25
  * Only a declared database is opened (#1860): the add-on names it in its own
26
26
  * config/default-config.json (`ngdpbase.addons.<slug>.database`), and one
27
27
  * declared `encrypt: "required"` is never opened without the key.
28
+ *
29
+ * Backup and restore (#1723): `backup()` and `restore()` are the ones
30
+ * BackupManager calls on every manager, so add-ons write no backup code. A
31
+ * backup copies the application database and every declared add-on database,
32
+ * on or off, at one point in time, on a worker thread, each into its own file
33
+ * beside BackupManager's document, encrypted under `NGDPBASE_BACKUP_KEY`. A
34
+ * restore stages `<file>.staged` beside each database, all or nothing, and the
35
+ * next start puts them in place before any database opens.
28
36
  */
29
37
  import path from 'node:path';
30
38
  import BaseManager from './BaseManager.js';
31
39
  import SqliteDatabaseProvider, { DEFAULT_BUSY_TIMEOUT_MS } from '../providers/SqliteDatabaseProvider.js';
32
40
  import DATABASE_MIGRATIONS from '../providers/databaseMigrations.js';
41
+ import { createHmac } from 'node:crypto';
42
+ import { existsSync, readFileSync, rmSync } from 'node:fs';
43
+ import { actorOf } from '../context/ActorContext.js';
44
+ import { recordAuditEvent } from '../utils/auditEvents.js';
45
+ import { AUDIT_EVENT } from '../utils/auditEventNames.js';
33
46
  import logger from '../utils/logger.js';
34
47
  import { ADDON_SLUG, ledgerPrefix } from '../utils/addonDatabaseRules.js';
48
+ import { applyStagedRestore, manifestPath, runOffThread, stagedPath, writeManifest } from '../providers/sqliteBackup.js';
35
49
  /** The environment variable holding the SQLCipher key. Never a config key: config is readable from the admin screens. */
36
50
  export const DATABASE_KEY_ENV = 'NGDPBASE_DATABASE_KEY';
51
+ /**
52
+ * The environment variable holding the key every database backup is encrypted
53
+ * under (#1723). Its own secret, not the database key: the copy that travels
54
+ * and the copy that stays should not fall to the same compromise. Never a
55
+ * config key. Unset, no database is backed up, and admins are told.
56
+ */
57
+ export const BACKUP_KEY_ENV = 'NGDPBASE_BACKUP_KEY';
58
+ /** How long a database backup or restore staging may run on its worker before it is stopped, when not configured. */
59
+ export const DEFAULT_BACKUP_WORKER_TIMEOUT_MS = 3_600_000;
60
+ /**
61
+ * A short fingerprint of a backup key (#1723, D3): the first 16 hex digits of
62
+ * an HMAC of a fixed label under the key. Recorded in each backup so a restore
63
+ * under another key says so plainly; it never reveals the key.
64
+ */
65
+ export function backupKeyFingerprint(key) {
66
+ return createHmac('sha256', key).update('ngdpbase backup key fingerprint v1').digest('hex').slice(0, 16);
67
+ }
68
+ /** The backup's name for the application database; an add-on's is `addon-<owner>`. */
69
+ const APPLICATION = 'application';
70
+ const addonName = (owner) => `addon-${owner}`;
37
71
  /**
38
72
  * The request rules (addonDatabaseRules): the owner is a canonical slug, and
39
73
  * the ledger table starts with its prefix. `openAddonDatabase` applies them,
@@ -73,6 +107,10 @@ class DatabaseManager extends BaseManager {
73
107
  databaseFile = null;
74
108
  busyTimeoutMs = DEFAULT_BUSY_TIMEOUT_MS;
75
109
  addonDatabases = new Map();
110
+ /** What the start did with a staged restore (#1723), reported once the notifications exist. */
111
+ restoreAtStart = null;
112
+ /** Why this manager is degraded, one entry per cause, so one cause clearing leaves the others. */
113
+ faults = new Map();
76
114
  constructor(engine) {
77
115
  super(engine);
78
116
  }
@@ -95,6 +133,8 @@ class DatabaseManager extends BaseManager {
95
133
  this.busyTimeoutMs = busy;
96
134
  const file = configManager.getResolvedDataPath('ngdpbase.database.file', './data/ngdpbase.db');
97
135
  const key = process.env[DATABASE_KEY_ENV] ?? '';
136
+ // #1723: a staged restore goes in now, before any database opens.
137
+ this.applyRestoreAtStart(file);
98
138
  const provider = new SqliteDatabaseProvider(file, key, DATABASE_MIGRATIONS, 'schema_migrations', { busyTimeoutMs: this.busyTimeoutMs });
99
139
  if (!provider.integrityOk()) {
100
140
  provider.close();
@@ -107,6 +147,11 @@ class DatabaseManager extends BaseManager {
107
147
  if (!key) {
108
148
  logger.warn(`[DatabaseManager] ${DATABASE_KEY_ENV} is not set, so the application database is stored unencrypted (#1536)`);
109
149
  }
150
+ const keyProblem = this.backupKeyProblem();
151
+ if (keyProblem) {
152
+ logger.error(`[DatabaseManager] ${keyProblem}`);
153
+ this.fault('backup-key', keyProblem);
154
+ }
110
155
  }
111
156
  /** Whether an application database is open. */
112
157
  isEnabled() {
@@ -210,7 +255,316 @@ class DatabaseManager extends BaseManager {
210
255
  static keyRefusal(declaration, key) {
211
256
  if (declaration.encrypt !== 'required' || key)
212
257
  return null;
213
- return `Add-on '${declaration.declaredBy}' declares database '${declaration.owner}' with encrypt "required", but ${DATABASE_KEY_ENV} is not set — it is never opened unencrypted (#1860)`;
258
+ return `Add-on '${declaration.declaredBy}' declares database '${declaration.owner}' with encrypt "required", but ${DATABASE_KEY_ENV} is not set — it is never opened unencrypted. Set ${DATABASE_KEY_ENV} in the environment or the instance .env (<FAST_STORAGE>/.env) and restart (#1860, #1883)`;
259
+ }
260
+ // ---- Backup and restore (#1723) ------------------------------------------
261
+ /**
262
+ * Copy every database into the backup BackupManager is writing: the
263
+ * application database and every declared add-on database, on or off, at
264
+ * one point in time, on a worker thread. Each goes into its own file,
265
+ * `<stem>.<name>.db`, beside the document, encrypted under
266
+ * `NGDPBASE_BACKUP_KEY`; the document references them. A database that
267
+ * fails is named in `warnings`, and the others are still written.
268
+ *
269
+ * Throws when there is no backup key: nothing is ever written unencrypted.
270
+ */
271
+ async backup(options) {
272
+ const base = { managerName: 'DatabaseManager', timestamp: new Date().toISOString() };
273
+ if (!this.provider || !this.databaseFile) {
274
+ return { ...base, data: null, note: 'No application database (ngdpbase.database.provider is none)' };
275
+ }
276
+ if (!options?.directory || !options.stem) {
277
+ throw new Error('DatabaseManager writes its databases beside the backup document, so it is backed up through BackupManager (#1723)');
278
+ }
279
+ const backupKey = this.backupKey();
280
+ if (!backupKey) {
281
+ const reason = `${BACKUP_KEY_ENV} is not set, so no database was backed up. Database backups are always encrypted and are never written without it (#1723)`;
282
+ this.fault('backup-key', reason);
283
+ throw new Error(reason);
284
+ }
285
+ const key = process.env[DATABASE_KEY_ENV] ?? '';
286
+ const keyProblem = this.backupKeyProblem();
287
+ if (keyProblem) {
288
+ // D3: still backed up, but as loudly as a missing key.
289
+ logger.error(`[DatabaseManager] ${keyProblem}`);
290
+ this.fault('backup-key', keyProblem);
291
+ await this.notify('error', 'The backup key is the database key', `${keyProblem}. This backup was still taken.`);
292
+ }
293
+ else {
294
+ this.clearFault('backup-key');
295
+ }
296
+ const sources = this.backupSources(this.databaseFile);
297
+ const absent = sources.filter((s) => !existsSync(s.file)).map((s) => s.name);
298
+ const exports = sources
299
+ .filter((s) => existsSync(s.file))
300
+ .map((s) => ({ name: s.name, source: s.file, sourceKey: key, target: path.join(options.directory, `${options.stem}.${s.name}.db`) }));
301
+ let outcome;
302
+ try {
303
+ outcome = await runOffThread({ kind: 'export', exports, backupKey }, this.workerTimeoutMs());
304
+ }
305
+ catch (err) {
306
+ for (const e of exports)
307
+ rmSync(e.target, { force: true });
308
+ throw err;
309
+ }
310
+ const byName = new Map(sources.map((s) => [s.name, s]));
311
+ const data = {
312
+ format: 1,
313
+ keyFingerprint: backupKeyFingerprint(backupKey),
314
+ databases: outcome.written.map((w) => {
315
+ const s = byName.get(w.name);
316
+ return { name: w.name, kind: s.kind, ...(s.owner ? { owner: s.owner } : {}), file: path.basename(w.target), sizeBytes: w.sizeBytes, tables: w.tables };
317
+ }),
318
+ absent,
319
+ failed: outcome.failed
320
+ };
321
+ logger.info(`[DatabaseManager] Backed up ${data.databases.length} database(s): ${data.databases.map((d) => d.name).join(', ')}${absent.length ? `; not created yet: ${absent.join(', ')}` : ''} (#1723)`);
322
+ const warnings = outcome.failed.map((f) => `database ${f.name} was not backed up: ${f.error}`);
323
+ for (const w of warnings)
324
+ logger.error(`[DatabaseManager] ${w}`);
325
+ return { ...base, data, ...(warnings.length > 0 ? { warnings } : {}) };
326
+ }
327
+ /**
328
+ * Stage a restore of the databases in a backup, for the next start. Each
329
+ * backed-up database is opened under the backup key, integrity-checked, and
330
+ * copied into `<live>.staged` under this site's database key. All or
331
+ * nothing: if any fails, none is staged. A database the backup does not
332
+ * contain is left as it is. Live files are never touched here.
333
+ *
334
+ * BackupManager takes a fresh backup of the live data before calling this.
335
+ * A backed-up add-on database that no add-on on this site declares is not
336
+ * staged, and admins are told.
337
+ */
338
+ async restore(backupData, options) {
339
+ await super.restore(backupData);
340
+ const data = backupData.data;
341
+ if (!data || !Array.isArray(data.databases) || data.databases.length === 0) {
342
+ logger.info('[DatabaseManager] The backup holds no database; every database is left as it is (#1723)');
343
+ return;
344
+ }
345
+ if (!this.provider || !this.databaseFile) {
346
+ throw new Error("The backup holds databases, but this site has none open — set ngdpbase.database.provider to 'sqlite' first (#1723)");
347
+ }
348
+ if (!options?.directory) {
349
+ throw new Error('DatabaseManager reads its databases from beside the backup document, so it is restored through BackupManager (#1723)');
350
+ }
351
+ const backupKey = this.backupKey();
352
+ if (!backupKey) {
353
+ throw new Error(`${BACKUP_KEY_ENV} is not set, so the databases in this backup cannot be read; nothing was staged (#1723)`);
354
+ }
355
+ if (data.keyFingerprint && data.keyFingerprint !== backupKeyFingerprint(backupKey)) {
356
+ throw new Error(`This backup was made with a different backup key (fingerprint ${data.keyFingerprint}; ${BACKUP_KEY_ENV} now has ${backupKeyFingerprint(backupKey)}). Set ${BACKUP_KEY_ENV} to the key it was made with; nothing was staged (#1723)`);
357
+ }
358
+ const staged = this.getStagedRestore();
359
+ if (staged && !options.replaceStaged) {
360
+ throw new Error(`A restore from ${staged.source || staged.backup} is already staged (by ${staged.stagedBy || 'unknown'}, ${staged.stagedAt}). Cancel it, or replace it; nothing was changed (#1723)`);
361
+ }
362
+ const targetKey = process.env[DATABASE_KEY_ENV] ?? '';
363
+ const dir = path.dirname(this.databaseFile);
364
+ const declarations = this.declarations();
365
+ const stages = [];
366
+ const skipped = [];
367
+ for (const entry of data.databases) {
368
+ if (typeof entry?.file !== 'string' || path.basename(entry.file) !== entry.file || entry.file.startsWith('.')) {
369
+ throw new Error(`The backup names a database file '${String(entry?.file)}' outside its directory; nothing was staged (#1723)`);
370
+ }
371
+ let live;
372
+ if (entry.name === APPLICATION) {
373
+ live = this.databaseFile;
374
+ }
375
+ else {
376
+ const owner = entry.name.startsWith('addon-') ? entry.name.slice('addon-'.length) : '';
377
+ if (!ADDON_SLUG.test(owner)) {
378
+ throw new Error(`The backup holds a database '${entry.name}' this build does not know; nothing was staged (#1723)`);
379
+ }
380
+ const declared = declarations.filter((d) => d.owner === owner);
381
+ if (declared.length === 0) {
382
+ skipped.push(entry.name);
383
+ continue;
384
+ }
385
+ if (!targetKey && declared.some((d) => d.encrypt === 'required')) {
386
+ throw new Error(`Database '${owner}' is declared encrypt "required", but ${DATABASE_KEY_ENV} is not set — it is never restored unencrypted; nothing was staged (#1723)`);
387
+ }
388
+ live = path.join(dir, `${owner}.db`);
389
+ }
390
+ const backupFile = path.join(options.directory, entry.file);
391
+ if (!existsSync(backupFile)) {
392
+ throw new Error(`The backup's database file ${entry.file} is missing from ${options.directory}; nothing was staged (#1723)`);
393
+ }
394
+ stages.push({ name: entry.name, backupFile, live, targetKey });
395
+ }
396
+ if (stages.length === 0) {
397
+ throw new Error(`None of the databases in this backup belongs to this site (${skipped.join(', ')}); nothing was staged (#1723)`);
398
+ }
399
+ // A restore staged earlier and not yet applied is replaced by this one.
400
+ const manifestFile = manifestPath(this.databaseFile);
401
+ for (const previous of this.getStagedRestore()?.databases ?? [])
402
+ rmSync(stagedPath(previous.live), { force: true });
403
+ rmSync(manifestFile, { force: true });
404
+ try {
405
+ await runOffThread({ kind: 'stage', stages, backupKey }, this.workerTimeoutMs());
406
+ }
407
+ catch (err) {
408
+ for (const s of stages)
409
+ rmSync(stagedPath(s.live), { force: true });
410
+ throw new Error(`The databases were not staged, and nothing was changed: ${err.message}`, { cause: err });
411
+ }
412
+ const manifest = {
413
+ version: 1,
414
+ stagedAt: new Date().toISOString(),
415
+ backup: String(backupData.timestamp ?? ''),
416
+ source: options.source ?? '',
417
+ stagedBy: actorOf(options.ctx).user,
418
+ ...(options.safetyBackup ? { safetyBackup: options.safetyBackup } : {}),
419
+ databases: stages.map((s) => ({ name: s.name, live: s.live, staged: stagedPath(s.live) }))
420
+ };
421
+ writeManifest(manifestFile, manifest);
422
+ const names = stages.map((s) => s.name).join(', ');
423
+ logger.warn(`[DatabaseManager] Restore staged for ${names} — restart to apply it (#1723)`);
424
+ await this.notify('warning', 'A database restore is staged', `The databases ${names} from the backup taken ${manifest.backup} are staged. Restart the site to put them in place; the current files are kept as *.pre-restore.${manifest.safetyBackup ? ` The current data was backed up first, as ${manifest.safetyBackup}.` : ''}`);
425
+ if (skipped.length > 0) {
426
+ const message = `The backup also holds ${skipped.join(', ')}, which no add-on on this site declares, so they were not restored. Install the add-on and restore again to bring them back.`;
427
+ logger.error(`[DatabaseManager] ${message}`);
428
+ await this.notify('error', 'Some databases in the backup were not restored', message);
429
+ }
430
+ }
431
+ /** The restore staged and waiting for the next start, or null. */
432
+ getStagedRestore() {
433
+ if (!this.databaseFile)
434
+ return null;
435
+ const file = manifestPath(this.databaseFile);
436
+ if (!existsSync(file))
437
+ return null;
438
+ try {
439
+ return JSON.parse(readFileSync(file, 'utf8'));
440
+ }
441
+ catch {
442
+ return null;
443
+ }
444
+ }
445
+ /**
446
+ * Throw away a staged restore before the restart applies it (#1723, D5):
447
+ * the staged files and the manifest are removed, the live databases are
448
+ * untouched. Recorded first (`backup-restore-cancel`, refuse): a cancel that
449
+ * cannot be recorded does not happen.
450
+ */
451
+ async cancelStagedRestore(ctx) {
452
+ const staged = this.getStagedRestore();
453
+ if (!staged || !this.databaseFile)
454
+ throw new Error('No database restore is staged');
455
+ const who = actorOf(ctx);
456
+ await recordAuditEvent(this.engine.getManager('AuditManager'), {
457
+ eventType: AUDIT_EVENT.BACKUP_RESTORE_CANCEL,
458
+ ...who,
459
+ action: 'backup-restore-cancel',
460
+ result: 'success',
461
+ severity: 'high',
462
+ resource: staged.source || staged.backup,
463
+ resourceType: 'backup',
464
+ metadata: { ...who.metadata, stagedAt: staged.stagedAt, stagedBy: staged.stagedBy, databases: staged.databases.map((d) => d.name) }
465
+ });
466
+ for (const d of staged.databases)
467
+ rmSync(stagedPath(d.live), { force: true });
468
+ rmSync(manifestPath(this.databaseFile), { force: true });
469
+ logger.warn(`[DatabaseManager] Staged restore from ${staged.source || staged.backup} cancelled by ${who.user} (#1723)`);
470
+ return staged;
471
+ }
472
+ /**
473
+ * Tell admins what the start found (#1723): what it did with a staged
474
+ * restore, and a backup key that is missing or the database key. Called by
475
+ * the engine once every manager exists: DatabaseManager starts before the
476
+ * notifications do.
477
+ */
478
+ async reportAtBoot() {
479
+ const keyProblem = this.provider ? this.backupKeyProblem() : null;
480
+ if (keyProblem)
481
+ await this.notify('error', 'Databases are not safely backed up', keyProblem);
482
+ const outcome = this.restoreAtStart;
483
+ this.restoreAtStart = null;
484
+ if (!outcome)
485
+ return;
486
+ if (outcome.refused) {
487
+ await this.notify('error', 'A database restore was not applied', `${outcome.refused}. The site started on its current databases.`);
488
+ return;
489
+ }
490
+ await this.notify('success', 'A database restore was applied', `Restored at start: ${outcome.applied.map((a) => a.name).join(', ')}. The previous files are kept as *.pre-restore.`);
491
+ }
492
+ /** The application database and every declared add-on database (one per owner), on or off. */
493
+ backupSources(applicationFile) {
494
+ const sources = [{ name: APPLICATION, kind: 'application', file: applicationFile }];
495
+ for (const owner of new Set(this.declarations().map((d) => d.owner))) {
496
+ sources.push({ name: addonName(owner), kind: 'addon', owner, file: path.join(path.dirname(applicationFile), `${owner}.db`) });
497
+ }
498
+ return sources;
499
+ }
500
+ /** Put a staged restore in place, before any database opens; loud when it cannot. */
501
+ applyRestoreAtStart(file) {
502
+ const dir = path.resolve(path.dirname(file));
503
+ const allowed = (live) => {
504
+ const resolved = path.resolve(live);
505
+ if (resolved === path.resolve(file))
506
+ return true;
507
+ const name = path.basename(resolved);
508
+ return path.dirname(resolved) === dir && name.endsWith('.db') && ADDON_SLUG.test(name.slice(0, -3));
509
+ };
510
+ const outcome = applyStagedRestore(file, allowed);
511
+ if (!outcome)
512
+ return;
513
+ this.restoreAtStart = outcome;
514
+ if (outcome.refused) {
515
+ const reason = `A staged database restore was NOT applied: ${outcome.refused}. The site started on its current databases (#1723)`;
516
+ logger.error(`[DatabaseManager] ${reason}`);
517
+ this.fault('restore', reason);
518
+ return;
519
+ }
520
+ for (const a of outcome.applied) {
521
+ logger.warn(`[DatabaseManager] Restore applied: ${a.name} -> ${a.live} (previous kept as ${path.basename(a.kept)}) (#1723)`);
522
+ }
523
+ }
524
+ backupKey() {
525
+ return (process.env[BACKUP_KEY_ENV] ?? '').trim();
526
+ }
527
+ /** What is wrong with the backup key: missing, or the database key itself (D3). Null when sound. */
528
+ backupKeyProblem() {
529
+ const backupKey = this.backupKey();
530
+ if (!backupKey)
531
+ return `${BACKUP_KEY_ENV} is not set, so no database is backed up — set it in the environment or the instance .env (#1723)`;
532
+ const key = process.env[DATABASE_KEY_ENV] ?? '';
533
+ if (key && key.trim() === backupKey) {
534
+ return `${BACKUP_KEY_ENV} is the same as ${DATABASE_KEY_ENV}: a stolen backup and a stolen data disk then fall to one secret — give the backups a key of their own (#1723)`;
535
+ }
536
+ return null;
537
+ }
538
+ /** `ngdpbase.database.backup.worker-timeout-ms`, read at use. */
539
+ workerTimeoutMs() {
540
+ const configured = this.engine.getManager('ConfigurationManager')
541
+ ?.getProperty('ngdpbase.database.backup.worker-timeout-ms', DEFAULT_BACKUP_WORKER_TIMEOUT_MS) ?? DEFAULT_BACKUP_WORKER_TIMEOUT_MS;
542
+ if (typeof configured !== 'number' || !Number.isInteger(configured) || configured <= 0) {
543
+ throw new Error(`ngdpbase.database.backup.worker-timeout-ms must be a whole number of milliseconds, more than 0 — got ${JSON.stringify(configured)} (#1723)`);
544
+ }
545
+ return configured;
546
+ }
547
+ /** Degrade for one cause; the dashboard shows every cause still standing. */
548
+ fault(cause, reason) {
549
+ this.faults.set(cause, reason);
550
+ this.markDegraded([...this.faults.values()].join('; '));
551
+ }
552
+ clearFault(cause) {
553
+ if (!this.faults.delete(cause))
554
+ return;
555
+ if (this.faults.size === 0)
556
+ this.markReady();
557
+ else
558
+ this.markDegraded([...this.faults.values()].join('; '));
559
+ }
560
+ async notify(level, title, message) {
561
+ try {
562
+ await this.engine.getManager('NotificationManager')
563
+ ?.addNotification({ type: 'system', level, title, message });
564
+ }
565
+ catch (err) {
566
+ logger.error(`[DatabaseManager] The notification '${title}' could not be sent (#1723):`, err);
567
+ }
214
568
  }
215
569
  /** Whether every open database passes SQLite's quick check; true when none is configured. */
216
570
  integrityOk() {
@@ -0,0 +1,128 @@
1
+ import type DatabaseConstructor from 'better-sqlite3-multiple-ciphers';
2
+ /**
3
+ * The SQLite driver, handed in rather than imported: the worker loads it from
4
+ * the file the site's own thread resolved (`job.driver`), so both threads use
5
+ * the same native module, and a worker run from source (tsx) is not sent to
6
+ * the type declarations tsconfig's `paths` maps the package name to.
7
+ */
8
+ export type Driver = typeof DatabaseConstructor;
9
+ /** One database to copy into a backup: where it is, the key it opens under ('' = plaintext), and the backup file to write. */
10
+ export interface DatabaseExport {
11
+ name: string;
12
+ source: string;
13
+ sourceKey: string;
14
+ target: string;
15
+ }
16
+ export interface ExportOutcome {
17
+ written: Array<{
18
+ name: string;
19
+ target: string;
20
+ sizeBytes: number;
21
+ tables: number;
22
+ }>;
23
+ /** Databases that could not be copied; the others still are. Each is named, never dropped silently. */
24
+ failed: Array<{
25
+ name: string;
26
+ error: string;
27
+ }>;
28
+ }
29
+ /** One database to stage from a backup: the backup's file, and the live file it will replace at the next start. */
30
+ export interface DatabaseStage {
31
+ name: string;
32
+ backupFile: string;
33
+ live: string;
34
+ targetKey: string;
35
+ }
36
+ export interface StageOutcome {
37
+ staged: Array<{
38
+ name: string;
39
+ staged: string;
40
+ live: string;
41
+ tables: number;
42
+ }>;
43
+ }
44
+ export type SqliteBackupJob = {
45
+ kind: 'export';
46
+ exports: DatabaseExport[];
47
+ backupKey: string;
48
+ } | {
49
+ kind: 'stage';
50
+ stages: DatabaseStage[];
51
+ backupKey: string;
52
+ };
53
+ /** A job as the worker receives it: with the driver's resolved file. */
54
+ export type WorkerJob = SqliteBackupJob & {
55
+ driver: string;
56
+ };
57
+ /** The staged file for a live database: beside it, so staging and applying name the same path. */
58
+ export declare const stagedPath: (live: string) => string;
59
+ /**
60
+ * Write an encrypted copy of each database under `backupKey`. A database that
61
+ * cannot be opened or copied is named in `failed`, its partial file removed;
62
+ * the others are still written. A target that already exists is never
63
+ * overwritten.
64
+ */
65
+ export declare function exportDatabasesSync(Database: Driver, exports: DatabaseExport[], backupKey: string): ExportOutcome;
66
+ /**
67
+ * Stage a restore: each backup file is opened under the backup key,
68
+ * integrity-checked, and copied into `<live>.staged` under the target
69
+ * database's key, which is then opened and checked too. All or nothing: if
70
+ * any database fails, every staged file is removed and the error names it.
71
+ * Live files are never touched.
72
+ */
73
+ export declare function stageDatabasesSync(Database: Driver, stages: DatabaseStage[], backupKey: string): StageOutcome;
74
+ /** Run a job of this module on a worker thread, so a large database never stalls requests or the liveness probe. */
75
+ export declare function runOffThread(job: {
76
+ kind: 'export';
77
+ exports: DatabaseExport[];
78
+ backupKey: string;
79
+ }, timeoutMs: number): Promise<ExportOutcome>;
80
+ export declare function runOffThread(job: {
81
+ kind: 'stage';
82
+ stages: DatabaseStage[];
83
+ backupKey: string;
84
+ }, timeoutMs: number): Promise<StageOutcome>;
85
+ /** What a staged restore is: written last, once every database is staged; its presence is what the next start applies. */
86
+ export interface StagedRestoreManifest {
87
+ version: 1;
88
+ stagedAt: string;
89
+ /** When the backup it came from was taken. */
90
+ backup: string;
91
+ /** The backup document's filename. */
92
+ source: string;
93
+ /** Who staged it: the account named by the request's context. */
94
+ stagedBy: string;
95
+ /** The backup of the live data taken just before staging (D6): what to restore to undo this one. */
96
+ safetyBackup?: string;
97
+ databases: Array<{
98
+ name: string;
99
+ live: string;
100
+ staged: string;
101
+ }>;
102
+ }
103
+ /** Where the manifest of a staged restore lives: beside the application database. */
104
+ export declare const manifestPath: (applicationFile: string) => string;
105
+ export declare function writeManifest(file: string, manifest: StagedRestoreManifest): void;
106
+ export interface ApplyOutcome {
107
+ /** Databases now on the restored copy, each with where its previous file was kept. */
108
+ applied: Array<{
109
+ name: string;
110
+ live: string;
111
+ kept: string;
112
+ }>;
113
+ /** Why nothing was applied; the staged files were set aside as `*.incomplete`. */
114
+ refused?: string;
115
+ }
116
+ /**
117
+ * Apply a staged restore at start, before any database opens. Each live file,
118
+ * with its `-wal` and `-shm`, steps aside as `*.pre-restore` (`-wal`/`-shm`
119
+ * beside it, so the kept copy still opens as it was), and the staged file
120
+ * takes its name. A rename, never a write into a live database.
121
+ *
122
+ * All or nothing: if any database the manifest names is not staged, nothing
123
+ * is applied; what is staged is renamed `*.incomplete`, so it is neither
124
+ * applied nor retried. A staged file with no manifest (staging was
125
+ * interrupted) is set aside the same way. `allowed` says which live paths may
126
+ * be replaced, so a tampered manifest cannot rename an arbitrary file.
127
+ */
128
+ export declare function applyStagedRestore(applicationFile: string, allowed: (live: string) => boolean): ApplyOutcome | null;