Merge branch 'beta' of https://github.com/the-luap/picpeak into feat/crm-improvements

This commit is contained in:
Luca
2026-06-02 14:17:46 +02:00
45 changed files with 6096 additions and 310 deletions
+68 -3
View File
@@ -24,8 +24,73 @@ try {
try { logger.warn('SQLite directory ensure failed', { error: e.message }); } catch (_) {}
}
// Create database connection with built-in retry logic
const db = knex(knexConfig);
// Create database connection with built-in retry logic.
//
// The underlying knex instance is held in `_db` and reachable through a
// Proxy `db` that forwards every call to the current instance. This
// indirection exists so `reinitPool()` below can swap the live pool
// without breaking the thousands of existing `const { db } = require(...)`
// imports — they capture the Proxy once, and every subsequent
// `db('table')` / `db.schema.hasTable(...)` lookup goes through the
// Proxy to whatever `_db` currently points at.
//
// Used by the restore service after DROP/CREATE DATABASE: the old pool
// is destroyed during the drop (to release PG connections so the DROP
// can succeed), then `reinitPool()` opens a fresh pool against the
// recreated DB. Without this, every query in the process after a
// restore failed with "Unable to acquire a connection" until the
// container was manually restarted — exactly the footgun Ralf hit
// repeatedly on 2026-05-30.
let _db = knex(knexConfig);
const db = new Proxy(function knexCall() {}, {
// db('tableName') — knex's query builder entry point
apply(_target, _thisArg, args) {
return _db(...args);
},
// db.schema, db.raw, db.migrate, etc.
get(_target, prop) {
const v = _db[prop];
return typeof v === 'function' ? v.bind(_db) : v;
},
// Defensive: future code that does `if ('schema' in db)` works.
has(_target, prop) { return prop in _db; },
});
/**
* Tear down the current knex pool and open a fresh one.
*
* Idempotent — calling it twice in a row just destroys + recreates
* twice, no error. Throws if the new pool can't establish a
* connection (which surfaces a clear error rather than letting the
* caller proceed with a half-broken pool).
*
* Used by restoreService after the DROP/CREATE DATABASE pair.
*/
async function reinitPool() {
const prev = _db;
try {
await prev.destroy();
} catch (err) {
logger.warn(`Old pool destroy failed (continuing with reinit): ${err.message}`);
}
_db = knex(knexConfig);
// Probe the new pool with a no-op query so we fail loudly here if
// the new pool can't connect — better than silently handing the
// caller a broken pool and surfacing the error on the next admin
// request.
try {
if (knexConfig.client === 'pg') {
await _db.raw('SELECT 1');
} else {
await _db.raw('SELECT 1');
}
} catch (probeErr) {
logger.error('New pool failed health-check after reinit', { error: probeErr.message });
throw probeErr;
}
logger.info('knex pool re-initialized successfully');
}
// Connection retry configuration
const MAX_RETRIES = 3;
@@ -607,4 +672,4 @@ async function logActivity(activityType, metadata = {}, eventId = null, actor =
}
}
module.exports = { db, initializeDatabase, logActivity, withRetry };
module.exports = { db, initializeDatabase, logActivity, withRetry, reinitPool };
+13 -2
View File
@@ -13,7 +13,18 @@ let cachedTimeout = null;
let cacheExpiry = 0;
const CACHE_DURATION = 30 * 60 * 1000; // 30 minutes - reduced DB queries
// Clean up expired sessions every 5 minutes
// Clean up expired sessions every 5 minutes.
//
// `.unref()` so this timer doesn't keep the event loop alive on its
// own — without it, every jest worker that requires this module
// (directly or transitively via server.js / a middleware-importing
// route file) gets stuck and either prints the "worker failed to
// exit gracefully" warning or, under high CI load, force-kills mid-
// test and takes an unrelated suite down with it (we hit this with
// integration/storageBackend.test.js on PR #555). Production
// behaviour is unchanged: the timer fires every 5 min as long as
// the server has anything else keeping the loop alive (HTTP server,
// other intervals), which is always.
setInterval(() => {
const now = Date.now();
for (const [token, lastActivity] of sessions.entries()) {
@@ -21,7 +32,7 @@ setInterval(() => {
sessions.delete(token);
}
}
}, 5 * 60 * 1000);
}, 5 * 60 * 1000).unref();
async function getSessionTimeout() {
const now = Date.now();
@@ -28,6 +28,13 @@ jest.mock('../../middleware/auth', () => ({
adminAuth: (_req, _res, next) => next(),
}));
// requirePermission is its own module — without this mock the real
// implementation runs, queries role_permissions on the mocked db, and
// 403s before we ever reach the handler.
jest.mock('../../middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next(),
}));
const { db } = require('../../database/db');
const notificationsRouter = require('../adminNotifications');
+14 -54
View File
@@ -98,62 +98,22 @@ router.put('/read-all', adminAuth, requirePermission('settings.edit'), async (re
}
});
// Delete old notifications (older than 30 days and read)
router.delete('/clear-old', adminAuth, requirePermission('settings.edit'), async (req, res) => {
// Clear all notifications (#597).
//
// The frontend AdminHeader "Clear All" button hits this — its service
// at `notifications.service.ts` does DELETE /admin/notifications/clear-all.
// The previous /clear-old route was named for an "older than 30 days
// and read" semantic but had a fallback that deleted EVERYTHING when
// nothing matched the date filter, so it was effectively a confusingly
// named Clear All anyway. Drop the rename and the branching, return
// the simple deletedCount the existing test (and frontend toast) expect.
router.delete('/clear-all', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try {
// Use database-agnostic date calculation
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
let deletedCount = 0;
const client = db?.client?.config?.client;
if (client === 'pg') {
const primaryResult = await db.raw(
`
WITH deleted AS (
DELETE FROM activity_logs
WHERE read_at IS NOT NULL OR created_at < ?
RETURNING id
)
SELECT COUNT(*)::int AS count FROM deleted
`,
[thirtyDaysAgo.toISOString()]
);
deletedCount = primaryResult.rows?.[0]?.count || 0;
if (deletedCount === 0) {
const fallbackResult = await db.raw(
`
WITH deleted AS (
DELETE FROM activity_logs
RETURNING id
)
SELECT COUNT(*)::int AS count FROM deleted
`
);
deletedCount = fallbackResult.rows?.[0]?.count || 0;
}
} else {
deletedCount = await db('activity_logs')
.where(function () {
this.whereNotNull('read_at')
.orWhere('created_at', '<', thirtyDaysAgo);
})
.delete();
if (deletedCount === 0) {
deletedCount = await db('activity_logs').delete();
}
}
res.json({
message: deletedCount > 0 ? 'Old notifications cleared' : 'No notifications to clear',
deletedCount
});
const deletedCount = await db('activity_logs').delete();
res.json({ message: 'All notifications cleared', deletedCount });
} catch (error) {
console.error('Clear old notifications error:', error);
res.status(500).json({ error: 'Failed to clear old notifications' });
console.error('Clear notifications error:', error);
res.status(500).json({ error: 'Failed to clear notifications' });
}
});
+269 -101
View File
@@ -348,51 +348,7 @@ router.get('/run/:id/report', requirePermission('backup.view'), async (req, res)
*/
router.get('/available-backups', requirePermission('backup.view'), async (req, res) => {
try {
const backups = [];
// Get local file backups
const backupConfig = await getBackupConfig();
if (backupConfig.backup_destination_type === 'local' && backupConfig.backup_destination_path) {
try {
const files = await fs.readdir(backupConfig.backup_destination_path);
for (const file of files) {
if (file.endsWith('.json') || file.endsWith('.yaml')) {
const filePath = path.join(backupConfig.backup_destination_path, file);
const stats = await fs.stat(filePath);
backups.push({
type: 'local',
name: file,
path: filePath,
size: stats.size,
modified: stats.mtime
});
}
}
} catch (error) {
logger.warn('Failed to list local backups:', error);
}
}
// Get database backups from backup_runs table
const backupRuns = await db('backup_runs')
.where('status', 'completed')
.whereNotNull('manifest_path')
.orderBy('completed_at', 'desc')
.limit(20);
for (const run of backupRuns) {
backups.push({
type: run.manifest_path.startsWith('s3://') ? 's3' : 'local',
name: `Backup ${run.completed_at}`,
path: run.manifest_path,
manifestId: run.manifest_id,
size: run.total_size_bytes,
filesCount: run.files_backed_up,
duration: run.duration_seconds,
completed: run.completed_at
});
}
const backups = await discoverAvailableBackups();
res.json({
success: true,
data: backups
@@ -406,6 +362,213 @@ router.get('/available-backups', requirePermission('backup.view'), async (req, r
}
});
/**
* Discover restorable backups by walking the configured destination
* directory + harvesting the backup_runs table.
*
* **Why we recurse the disk first, DB second**
*
* The disk is the source of truth for restore. After a disaster
* (`docker compose down -v`, drive corruption, fresh install) the
* `backup_runs` table is empty — but the manifest JSONs are exactly
* what's left on disk for an admin to recover from. A wizard that
* only reads the DB shows "No backups found" precisely when the
* admin needs it most. So we walk first, dedupe-by-manifestId
* against any surviving DB rows, and present a unified list.
*
* Discovery rules:
* - Walks `backup_destination_path` AND `backup_manifest_path` if
* they're distinct (manifests can live in a sibling directory).
* - Recurses up to 3 levels deep — enough to find
* `<root>/manifests/backup-manifest-<id>.json` (the default
* layout) without scanning the entire photo tree.
* - Matches manifest files by glob: `backup-manifest-*.json`,
* `backup-manifest-*.yaml`, and the legacy bare `manifest.json`.
* - Parses each manifest to extract real metadata (timestamp,
* size, file count, source type) instead of showing the admin
* a list of opaque filenames.
*
* Returns: array of `{ type, name, path, manifestId, size,
* filesCount, completed, source: 'disk' | 'db' }`.
*/
async function discoverAvailableBackups() {
const backupConfig = await getBackupConfig();
const backups = [];
const seenManifestIds = new Set();
if (backupConfig.backup_destination_type === 'local') {
const roots = new Set();
if (backupConfig.backup_destination_path) roots.add(backupConfig.backup_destination_path);
if (backupConfig.backup_manifest_path) roots.add(backupConfig.backup_manifest_path);
for (const root of roots) {
try {
const manifestPaths = await walkForManifests(root, 3);
for (const filePath of manifestPaths) {
try {
const parsed = await parseManifestMetadata(filePath);
if (parsed.manifestId) seenManifestIds.add(parsed.manifestId);
backups.push(parsed);
} catch (err) {
// Don't fail discovery because ONE manifest is corrupt —
// surface the file with a note so the admin sees something
// is wrong and can investigate.
logger.warn(`Manifest unreadable at ${filePath}: ${err.message}`);
const stats = await fs.stat(filePath).catch(() => null);
backups.push({
type: 'local',
name: path.basename(filePath),
path: filePath,
manifestId: null,
size: stats?.size || 0,
filesCount: null,
completed: stats?.mtime || null,
source: 'disk',
corrupt: true,
error: err.message,
});
}
}
} catch (err) {
logger.warn(`Could not scan backup root ${root}: ${err.message}`);
}
}
}
// Layer in surviving DB rows, deduping by manifest_id so we don't
// show the same backup twice with different shapes.
const backupRuns = await db('backup_runs')
.where('status', 'completed')
.whereNotNull('manifest_path')
.orderBy('completed_at', 'desc')
.limit(50);
for (const run of backupRuns) {
if (run.manifest_id && seenManifestIds.has(run.manifest_id)) continue;
backups.push({
type: run.manifest_path.startsWith('s3://') ? 's3' : 'local',
name: `Backup ${run.completed_at}`,
path: run.manifest_path,
manifestId: run.manifest_id,
size: run.total_size_bytes,
filesCount: run.files_backed_up,
duration: run.duration_seconds,
completed: run.completed_at,
source: 'db',
});
}
// Most recent first.
backups.sort((a, b) => {
const aTime = a.completed ? new Date(a.completed).getTime() : 0;
const bTime = b.completed ? new Date(b.completed).getTime() : 0;
return bTime - aTime;
});
return backups;
}
/**
* Recursive manifest finder. Depth-limited so we don't enumerate
* thousands of photo files. Yields absolute paths.
*/
async function walkForManifests(dir, maxDepth, depth = 0) {
if (depth > maxDepth) return [];
let entries;
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch (err) {
if (err.code === 'ENOENT') return [];
throw err;
}
const out = [];
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
// Skip obvious noise: photo trees, node_modules, hidden dirs.
if (entry.name === 'events' || entry.name === 'business-docs'
|| entry.name === 'thumbnails' || entry.name === 'previews'
|| entry.name === 'heroes' || entry.name === 'uploads'
|| entry.name.startsWith('.')
|| entry.name === 'node_modules') continue;
out.push(...await walkForManifests(full, maxDepth, depth + 1));
} else if (entry.isFile() && isManifestFilename(entry.name)) {
out.push(full);
}
}
return out;
}
function isManifestFilename(name) {
// Canonical: backup-manifest-<id>.json / .yaml
// Legacy: manifest.json (inside backup-<id>/manifest.json layout)
// Be liberal in what we accept — admin may have renamed.
if (/^backup-manifest-.+\.(json|ya?ml)$/i.test(name)) return true;
if (/^manifest\.(json|ya?ml)$/i.test(name)) return true;
return false;
}
/**
* Parse a manifest file and pull out the fields the wizard wants.
* Tolerates schema drift across manifest versions (v1, v2) by
* checking multiple shapes.
*/
async function parseManifestMetadata(filePath) {
const raw = await fs.readFile(filePath, 'utf8');
let parsed;
if (filePath.toLowerCase().endsWith('.json')) {
parsed = JSON.parse(raw);
} else {
// Minimal YAML support — most admins use JSON; only do require()
// if a .yaml manifest is actually present.
const yaml = require('js-yaml');
parsed = yaml.load(raw);
}
const stats = await fs.stat(filePath);
// v2 shape: { manifest: { id, timestamp }, backup: { ... }, files: [...], database: { ... } }
// v1 shape: { backup_id, started_at, files: [...] } (older)
const manifestId =
parsed?.manifest?.id
|| parsed?.backup?.id
|| parsed?.backup_id
|| null;
const completed =
parsed?.backup?.completed_at
|| parsed?.manifest?.timestamp
|| parsed?.completed_at
|| stats.mtime;
const filesCount =
(Array.isArray(parsed?.files) ? parsed.files.length : null)
?? parsed?.backup?.total_files
?? null;
const totalSizeBytes =
parsed?.backup?.total_size_bytes
?? parsed?.total_size_bytes
?? null;
return {
type: 'local',
name: path.basename(filePath),
path: filePath,
manifestId,
size: totalSizeBytes ?? stats.size,
filesCount,
completed,
source: 'disk',
databaseIncluded: Boolean(parsed?.database?.backup_file),
// Helpful for the UI: lets it show "This backup has no DB" warning
// — exactly the surface that would have caught Ralf's original
// four files-only manifests if it had existed.
schemaVersion: parsed?.manifest?.version || parsed?.version || '1.0',
};
}
/**
* List backups for restore (POST version for frontend compatibility)
* Accepts source type in request body
@@ -413,59 +576,58 @@ router.get('/available-backups', requirePermission('backup.view'), async (req, r
router.post('/list-backups', requirePermission('backup.view'), async (req, res) => {
try {
const { source } = req.body; // 'local', 's3', or undefined for all
const backups = [];
// Get backup configuration
const backupConfig = await getBackupConfig();
// Use the same disk-first discovery the GET endpoint uses so that
// a fresh post-`docker compose down -v` install (empty backup_runs
// table) can still see what's on disk. The whole point of restore
// is "the DB is broken, rebuild it from disk" — a wizard that
// only queries the DB shows "No backups found" exactly when it's
// needed most. See discoverAvailableBackups for the full rationale.
const discovered = await discoverAvailableBackups();
// Get database backups from backup_runs table
const backupRuns = await db('backup_runs')
.where('status', 'completed')
.whereNotNull('manifest_path')
.orderBy('completed_at', 'desc')
.limit(20);
const filtered = source
? discovered.filter((b) => b.type === source)
: discovered;
for (const run of backupRuns) {
const isS3 = run.manifest_path.startsWith('s3://');
const backupType = isS3 ? 's3' : 'local';
// Filter by source if specified
if (source && source !== backupType) {
continue;
}
backups.push({
id: run.id,
type: backupType,
name: `Backup from ${new Date(run.completed_at).toLocaleString()}`,
path: run.manifest_path,
manifest_path: run.manifest_path,
manifestId: run.manifest_id,
manifestPath: run.manifest_path,
size: parseInt(run.total_size_bytes) || 0,
total_size: parseInt(run.total_size_bytes) || 0,
total_size_bytes: parseInt(run.total_size_bytes) || 0,
filesCount: run.files_backed_up || 0,
files_backed_up: run.files_backed_up || 0,
duration: run.duration_seconds,
duration_seconds: run.duration_seconds,
// Frontend expects snake_case date fields
created_at: run.completed_at,
completed_at: run.completed_at,
started_at: run.started_at,
// camelCase aliases
completedAt: run.completed_at,
startedAt: run.started_at,
// Backup metadata
status: run.status,
backup_type: run.backup_type,
backupType: run.backup_type,
backup_mode: run.backup_mode,
backupMode: run.backup_mode,
app_version: run.app_version,
appVersion: run.app_version
});
}
// Shape for frontend compatibility — preserves every alias the
// frontend was already reading (snake_case + camelCase), so the
// UI rendering doesn't have to change.
const backups = filtered.map((b) => ({
id: b.manifestId || null,
type: b.type,
name: b.completed
? `Backup from ${new Date(b.completed).toLocaleString()}`
: b.name,
path: b.path,
manifest_path: b.path,
manifestId: b.manifestId,
manifestPath: b.path,
size: parseInt(b.size) || 0,
total_size: parseInt(b.size) || 0,
total_size_bytes: parseInt(b.size) || 0,
filesCount: b.filesCount || 0,
files_backed_up: b.filesCount || 0,
duration: b.duration || null,
duration_seconds: b.duration || null,
created_at: b.completed,
completed_at: b.completed,
started_at: b.completed,
completedAt: b.completed,
startedAt: b.completed,
status: 'completed',
// Stage A-aware: when the source is a disk-scanned manifest we
// can tell the wizard whether the DB dump is present, so the
// UI can warn before the admin picks a files-only backup.
database_included: b.databaseIncluded,
databaseIncluded: b.databaseIncluded,
corrupt: b.corrupt || false,
// Provenance: 'disk' (manifest read from filesystem) vs 'db'
// (backup_runs row that the disk didn't surface) — useful for
// debugging which side is missing.
source: b.source,
schema_version: b.schemaVersion,
schemaVersion: b.schemaVersion,
}));
res.json({
success: true,
@@ -556,17 +718,23 @@ async function getRestoreSettings() {
const settings = await db('app_settings')
.where('setting_type', 'restore')
.select('setting_key', 'setting_value');
const result = {};
settings.forEach(setting => {
// Convert boolean strings to actual booleans
if (setting.setting_value === '1' || setting.setting_value === '0') {
result[setting.setting_key] = setting.setting_value === '1';
// Boolean-string normalization. Historically only handled '1'/'0',
// but other code paths (boot self-heal, admin UI, direct SQL) write
// 'true' / 'false' or JSON-encoded "true" / "false". Accept all four
// shapes so `!settings.<key>` evaluates correctly downstream.
const raw = setting.setting_value;
if (raw === '1' || raw === 'true' || raw === '"true"') {
result[setting.setting_key] = true;
} else if (raw === '0' || raw === 'false' || raw === '"false"') {
result[setting.setting_key] = false;
} else {
result[setting.setting_key] = setting.setting_value;
result[setting.setting_key] = raw;
}
});
return result;
}
+87
View File
@@ -0,0 +1,87 @@
/**
* Admin → System Health
*
* Endpoint mounted at /api/admin/system-health. The "Backup
* integrity" sub-endpoint is the on-demand verifier for CRM
* document artefacts — confirms every `*_path` column on quotes /
* contracts / invoices points at a file that actually exists on
* disk and (where a `*_sha256` column is set) the file's bytes
* still hash to the expected value.
*
* Per the design decisions locked with the maintainer:
* - On-demand only; no scheduler (D1)
* - Not auto-triggered after restore (D2)
* - Wet-upload contracts are hash-verified same as system-rendered (D3)
*
* Read-only. Returns a JSON report — never mutates DB or fs.
*/
const express = require('express');
const { query } = require('express-validator');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
const { verifyDocumentArtefacts } = require('../services/backupIntegrityService');
const { getCoverageReport } = require('../services/backupCoverageService');
const router = express.Router();
router.use(adminAuth);
const VALID_SCOPES = ['quote', 'contract', 'contract-signature', 'invoice'];
router.get(
'/backup-integrity',
requirePermission('settings.view'),
[
// CSV string like `?scope=contract,invoice`. Each member must be
// one of the four known scopes. Empty / omitted means full scan.
query('scope').optional({ values: 'falsy' }).isString().isLength({ max: 128 }),
],
handleAsync(async (req, res) => {
validateRequest(req);
let scope;
if (req.query.scope) {
scope = String(req.query.scope)
.split(',')
.map((s) => s.trim())
.filter(Boolean);
// Defense-in-depth: reject unknown scope tokens so a typo doesn't
// silently scan everything when the caller wanted just one slice.
const unknown = scope.filter((s) => !VALID_SCOPES.includes(s));
if (unknown.length > 0) {
return res.status(400).json({
error: `Unknown scope(s): ${unknown.join(', ')}`,
code: 'BACKUP_INTEGRITY_UNKNOWN_SCOPE',
validScopes: VALID_SCOPES,
});
}
}
const report = await verifyDocumentArtefacts({ scope });
return successResponse(res, { report });
}),
);
/**
* GET /api/admin/system-health/backup-coverage
*
* Stage C of the backup-hardening plan. Returns the data-driven
* coverage report — what the next "Run Backup Now" will include /
* skip / silently miss, plus the database-dump status block.
*
* Read-only, on-demand. No scope parameter — the report is cheap
* (only top-level directory listing under STORAGE_PATH, no recursion).
*
* See backupCoverageService.js for the full rationale and the
* coverage-classification rules.
*/
router.get(
'/backup-coverage',
requirePermission('settings.view'),
handleAsync(async (req, res) => {
const report = await getCoverageReport();
return successResponse(res, { report });
}),
);
module.exports = router;
@@ -201,14 +201,22 @@ describe('DatabaseBackupService', () => {
delete: jest.fn().mockResolvedValue(1)
});
// Mock fs.unlink
fs.unlink = jest.fn().mockResolvedValue(undefined);
// Stub fs.promises.unlink via jest.spyOn so the original is
// restored when the test finishes. The previous form
// (`fs.unlink = jest.fn()`) leaked into every test that ran
// after this one in the same jest worker — most visibly
// integration/storageBackend.test.js, whose LocalFsStorage
// delete() became a silent no-op and the subsequent
// exists() assertion flipped from false to true. spyOn +
// mockRestore in afterEach keeps the stub scoped to this test.
const unlinkSpy = jest.spyOn(fs, 'unlink').mockResolvedValue(undefined);
await service.cleanupOldBackups(30);
expect(fs.unlink).toHaveBeenCalledTimes(2);
expect(fs.unlink).toHaveBeenCalledWith('/backup/old1.sql.gz');
expect(fs.unlink).toHaveBeenCalledWith('/backup/old2.sql.gz');
expect(unlinkSpy).toHaveBeenCalledTimes(2);
expect(unlinkSpy).toHaveBeenCalledWith('/backup/old1.sql.gz');
expect(unlinkSpy).toHaveBeenCalledWith('/backup/old2.sql.gz');
unlinkSpy.mockRestore();
});
});
+90
View File
@@ -0,0 +1,90 @@
/**
* Boot-time self-heal for the `backup_paths` table.
*
* **Why this exists**
*
* Knex won't re-run an applied migration, so once migration
* 109_add_backup_paths.js has run, any later default we want to add
* (a new subdirectory shipped by a future feature) would never reach
* already-deployed installs. The historical fix for this kind of
* "schema is fine, seed drifted" problem is the boot-time self-heal
* pattern documented in [[feedback_self_heal_pattern]] — we just
* re-apply the canonical seed on every boot with `onConflict.ignore()`
* so admin edits stay intact and new rows trickle in.
*
* **Authoritative list**
*
* The list of defaults lives on migration 109 itself
* (`DEFAULT_PATHS` export) — one source of truth that both the
* migration and this seeder read. Tests assert these two stay in
* lockstep.
*
* **Failure semantics**
*
* If the table doesn't exist yet (migrations haven't run, fresh
* install before migration 109 lands, etc.) we no-op and log. The
* walker has a hard-coded `LEGACY_DEFAULTS` fallback for the same
* reason — defense in depth so "Run Backup Now" can never silently
* ship a files-only manifest because of a seed issue. See
* `backupService.js` getFilesToBackupInternal.
*/
const { DEFAULT_PATHS } = require('../../migrations/core/109_add_backup_paths');
let booted = false;
/**
* Idempotently re-seed `backup_paths` with the canonical defaults.
*
* @param {object} db knex instance
* @param {object} logger app logger (must expose .info / .warn)
* @returns {Promise<{ seeded: string[] }>} paths newly inserted on this boot.
*/
async function seedBackupPathsAtBoot(db, logger) {
const log = logger || { info: () => {}, warn: () => {} };
if (booted) return { seeded: [] };
if (!(await db.schema.hasTable('backup_paths'))) {
log.warn('backup_paths table missing at boot — self-heal skipped (migration 109 may not have run yet)');
return { seeded: [] };
}
// Diff: which canonical paths are missing from the table right now?
// We can't easily get "what got inserted by onConflict.ignore" out of
// knex on both backends, so we just compute the diff ourselves and log
// it — admins benefit from seeing exactly what got auto-added when a
// new feature ships.
const existing = await db('backup_paths').select('path');
const existingSet = new Set(existing.map((r) => r.path));
const missing = DEFAULT_PATHS.filter((p) => !existingSet.has(p.path));
if (missing.length === 0) {
booted = true;
return { seeded: [] };
}
try {
await db('backup_paths')
.insert(missing.map((row) => ({
...row,
created_at: new Date(),
updated_at: new Date(),
})))
.onConflict('path')
.ignore();
log.info(`backup_paths self-heal added ${missing.length} row(s): ${missing.map((m) => m.path).join(', ')}`);
} catch (err) {
log.warn(`backup_paths self-heal failed: ${err.message}`);
}
booted = true;
return { seeded: missing.map((m) => m.path) };
}
// Test-only: reset the module-level boot flag so jest can re-exercise
// the seeder against a fresh test DB inside a single worker.
function _resetBootForTests() {
booted = false;
}
module.exports = { seedBackupPathsAtBoot, _resetBootForTests };
@@ -0,0 +1,242 @@
/**
* Install-from-backup boot hook.
*
* **The problem this closes**
*
* Without this hook, recovering a picpeak install from a backup is a
* six-step process:
* 1. Stand up the compose stack with empty volumes
* 2. Wait for boot → land on the onboarding wizard
* 3. Create a throwaway fresh-install admin
* 4. Navigate to Backup → Restore
* 5. Walk through the wizard with Force Restore ticked
* 6. Log out, log back in with original (pre-disaster) credentials
*
* With this hook, admins skip steps 2-6. They place their backup
* artefacts in the existing bind-mounted /backup directory, drop a
* trigger file alongside, and the next container start runs the
* restore BEFORE creating the throwaway onboarding admin. Server
* comes up populated, original login works first try.
*
* **The trigger file convention (chosen for zero compose-file changes)**
*
* Drop a file named `RESTORE_ON_INSTALL` (no extension, or .txt) into
* the root of the `/backup` mount. Two payload variants:
*
* 1. EMPTY file (or pure whitespace) — auto-pick the newest
* `backup-manifest-*.json` from `/backup/manifests/`. Useful when
* the admin doesn't know or care which one is most recent.
*
* 2. NON-EMPTY file containing a relative or absolute path to a
* specific manifest. Trimmed; first line wins. Useful when the
* admin wants a specific older backup.
*
* After a successful restore the trigger file is DELETED so the next
* boot doesn't re-trigger. On failure the file is preserved + the
* error is logged, so the admin can fix the input and retry by just
* restarting the container.
*
* **Safety**
*
* Three layers gate this against accidental data loss:
* 1. Trigger file must exist (intentional admin action, not auto-magic)
* 2. DB must be empty — admin_users.count = 0 AND events.count = 0.
* If either is non-zero, the hook refuses to run.
* 3. The Stage A restore path (with all of tonight's fixes) handles
* the actual swap atomically. If anything fails, the rollback
* runs and the install stays in fresh-install state.
*
* Override: `INSTALL_FROM_BACKUP_FORCE=true` skips the empty-DB check
* for the "I know what I'm doing" edge case (e.g. dev environment
* rebuilds where there's leftover data that's safe to clobber).
*/
const fs = require('fs');
const path = require('path');
const TRIGGER_FILENAMES = ['RESTORE_ON_INSTALL', 'RESTORE_ON_INSTALL.txt'];
/**
* Resolve which file should be treated as the trigger. Returns
* `{ triggerPath, manifestPath }` if found, or null if no trigger
* file is present (the common case — most boots).
*/
async function findTrigger(backupRoot, logger) {
for (const name of TRIGGER_FILENAMES) {
const triggerPath = path.join(backupRoot, name);
if (fs.existsSync(triggerPath)) {
let payload;
try {
payload = fs.readFileSync(triggerPath, 'utf8').trim();
} catch (err) {
logger.warn(`Install-from-backup: trigger file ${triggerPath} is unreadable: ${err.message}`);
return null;
}
if (!payload) {
// Auto-pick the newest manifest
const manifestsDir = path.join(backupRoot, 'manifests');
if (!fs.existsSync(manifestsDir)) {
logger.warn(`Install-from-backup: trigger file found but ${manifestsDir} doesn't exist`);
return null;
}
const entries = fs.readdirSync(manifestsDir)
.filter((f) => /^backup-manifest-.+\.(json|ya?ml)$/i.test(f))
.map((f) => {
const full = path.join(manifestsDir, f);
return { full, mtime: fs.statSync(full).mtimeMs };
})
.sort((a, b) => b.mtime - a.mtime);
if (entries.length === 0) {
logger.warn(`Install-from-backup: no manifests found in ${manifestsDir}`);
return null;
}
return { triggerPath, manifestPath: entries[0].full };
}
// Take the first non-empty line as the manifest path
const firstLine = payload.split(/\r?\n/).find((l) => l.trim()) || '';
const manifestPath = path.isAbsolute(firstLine)
? firstLine
: path.join(backupRoot, firstLine);
if (!fs.existsSync(manifestPath)) {
logger.warn(`Install-from-backup: trigger file points at ${manifestPath} which doesn't exist`);
return null;
}
return { triggerPath, manifestPath };
}
}
return null;
}
/**
* Check the DB is empty enough that restoring on top is safe.
* Returns `true` if safe, `false` if there's existing data.
*/
async function isDatabaseFresh(db, logger) {
try {
if (!(await db.schema.hasTable('admin_users'))) {
// No admin_users table yet — schema is mid-migration or wholly
// empty. Definitely safe to restore on top.
return true;
}
const adminCount = await db('admin_users').count('* as c').first();
const adminN = Number(adminCount?.c || 0);
let eventN = 0;
if (await db.schema.hasTable('events')) {
const eventCount = await db('events').count('* as c').first();
eventN = Number(eventCount?.c || 0);
}
if (adminN > 1 || eventN > 0) {
logger.warn(
`Install-from-backup: refusing — install has ${adminN} admin(s) and ${eventN} event(s). `
+ 'This guard prevents accidental clobbering of production data. '
+ 'Override with INSTALL_FROM_BACKUP_FORCE=true if you really want to restore on top.'
);
return false;
}
// adminN === 1 is the "fresh install ran migration 001 and auto-created
// the default admin" case. That admin is throwaway — the restore will
// replace it with the backup's admin row. So we treat 1 admin + 0
// events as fresh.
return true;
} catch (err) {
logger.warn(`Install-from-backup: fresh-install check threw: ${err.message}. Assuming NOT fresh.`);
return false;
}
}
/**
* Public entry point — called from server.js after migrations and
* before startServer.
*
* @returns {Promise<{ ran: boolean, manifestPath?: string, error?: string }>}
*/
async function tryInstallFromBackup(db, logger) {
const log = logger || { info: () => {}, warn: () => {}, error: () => {} };
// The container's winston logger writes to `/app/logs/combined.log`
// by default and may not always tee to stdout, so admins running
// `docker logs picpeak-beta-backend` after a `compose up` would
// see no signal that a restore happened — flagged on PR #596
// review. We mirror the key trigger / start / end lines to
// console.log as well so the docker-logs surface tells the story
// without needing to exec into the container.
const announce = (msg) => {
try { console.log(`[install-from-backup] ${msg}`); } catch (_) { /* defensive */ }
};
const backupRoot = process.env.BACKUP_ROOT || '/backup';
if (!fs.existsSync(backupRoot)) {
return { ran: false };
}
const trigger = await findTrigger(backupRoot, log);
if (!trigger) {
return { ran: false };
}
log.info(`Install-from-backup: trigger file found at ${trigger.triggerPath}, target manifest ${trigger.manifestPath}`);
announce(`trigger file detected → ${trigger.manifestPath}`);
const forceOverride = process.env.INSTALL_FROM_BACKUP_FORCE === 'true';
const isFresh = await isDatabaseFresh(db, log);
if (!isFresh && !forceOverride) {
log.warn('Install-from-backup: skipping. Trigger file left in place so you can correct + retry.');
announce('skipping — install has existing data and INSTALL_FROM_BACKUP_FORCE is not set');
return { ran: false, error: 'Database not empty' };
}
log.info(`Install-from-backup: restoring from ${trigger.manifestPath}...`);
announce(`starting restore from ${trigger.manifestPath}`);
try {
const { restoreService } = require('./restoreService');
const result = await restoreService.restore({
source: 'local',
manifestPath: trigger.manifestPath,
restoreType: 'full',
// Force=true because the fresh-install admin auto-created by
// migration 001 trips the "1 active admin" warning — we WANT to
// override that warning, since replacing the throwaway admin
// with the backup's admin is exactly the goal.
force: true,
// SkipPreBackup=true because backing up an empty install is
// pointless. Saves a few seconds and reduces disk noise.
skipPreBackup: true,
operator: {
type: 'install-from-backup',
userId: null,
ip: null,
},
});
if (result?.success === false) {
throw new Error(result?.error || 'Restore service reported failure');
}
log.info(`Install-from-backup: restore completed successfully from ${trigger.manifestPath}`);
announce('restore completed successfully');
// Remove the trigger so the next boot doesn't redo it.
try {
fs.unlinkSync(trigger.triggerPath);
log.info(`Install-from-backup: removed trigger file ${trigger.triggerPath}`);
} catch (unlinkErr) {
log.warn(`Install-from-backup: could not remove trigger file (manual cleanup needed): ${unlinkErr.message}`);
}
return { ran: true, manifestPath: trigger.manifestPath };
} catch (err) {
log.error(`Install-from-backup: FAILED — ${err.message}`);
log.warn('Trigger file left in place so you can fix the input and retry by restarting the container.');
announce(`FAILED — ${err.message}. Trigger file left in place for retry.`);
return { ran: false, error: err.message };
}
}
module.exports = { tryInstallFromBackup };
@@ -0,0 +1,173 @@
/**
* Boot-time self-heal for restore-meta settings.
*
* **Why this exists**
*
* `restore_allow_force` gates whether the Restore wizard accepts a
* `force: true` payload. The flag exists to add admin friction
* before letting a restore override safety warnings (e.g. "1 active
* admin user — restoring would clobber the current install").
*
* In practice the friction lands at the worst possible moment: a
* fresh install (no app_settings row yet OR `restore_allow_force =
* false` by default) hits the wall on its very FIRST restore. The
* admin is mid disaster-recovery, panicked, and gets:
*
* "Force restore is not allowed by system settings"
*
* They then have to hand-craft SQL like
*
* INSERT INTO app_settings (setting_key, setting_value, ...)
* VALUES ('restore_allow_force', 'true', 'restore', NOW())
* ON CONFLICT ... SET setting_value = 'true';
*
* before they can recover their data. This isn't security — the
* admin who could run that SQL could also flip the setting via the
* UI. It's just a sharp edge that bites every new install once.
*
* Cure: seed the default ON at boot via `INSERT ... ON CONFLICT
* DO NOTHING`. New installs get force-allowed out of the box.
* Existing installs that have explicitly set the row (true OR
* false) are NOT overwritten — admin policy wins. Same pattern
* `_backupPathsBoot.js` uses for the canonical backup_paths rows.
*
* **Default-ON rationale (matches Stage A's principle)**
*
* Stage A defaulted inline DB dumps to ON because the cost of
* forgetting was data loss. By the same logic, `restore_allow_force`
* defaults ON because the cost of forgetting is being unable to
* recover from a disaster. Audit logging captures every forced
* restore so the accountability story stays intact.
*
* If/when the broader "exclude restore-meta settings from being
* overwritten by restore" follow-up lands (the second half of this
* chicken-and-egg), this self-heal becomes the safety net for
* fresh installs only — existing installs by that point have the
* row preserved across restores.
*/
const SEEDS = [
{
setting_key: 'restore_allow_force',
setting_value: 'true',
setting_type: 'restore',
rationale: 'Default ON so fresh installs can recover from disaster '
+ 'without a SQL incantation. Admins who want to require manual '
+ 'intervention can disable via the admin UI.',
},
];
/**
* Installs that ran migration 032 BEFORE the 2026-05-30 in-place edit
* have a `restore_allow_force` row with the deprecated `false` default
* (literal string `'false'` from `JSON.stringify(false)`). Per
* [[feedback_self_heal_pattern]] knex won't re-run the corrected
* migration on those installs, so we have to bump the row to `true`
* here ONCE at boot.
*
* The bump is guarded by a tracking row `restore_allow_force_auto_upgraded`
* so we don't fight an admin who explicitly disables force later:
* - Tracking row absent → bump if the value is the deprecated `'false'`
* - Tracking row present → never touch `restore_allow_force` again
*
* The bump applies ONLY when the existing value EXACTLY equals the old
* migration default. Any other value (`'true'`, admin-set anything,
* empty, null) is left alone — those reflect either the fixed
* migration's output or a deliberate admin choice.
*/
const DEPRECATED_DEFAULT_VALUE = 'false';
const AUTO_UPGRADE_FLAG_KEY = 'restore_allow_force_auto_upgraded';
let booted = false;
/**
* Seed the canonical restore-meta settings on fresh installs.
*
* @param {object} db knex instance
* @param {object} logger app logger (must expose .info / .warn)
* @returns {Promise<{ seeded: string[], upgraded: string[] }>}
*/
async function seedRestoreSettingsAtBoot(db, logger) {
const log = logger || { info: () => {}, warn: () => {} };
if (booted) return { seeded: [], upgraded: [] };
if (!(await db.schema.hasTable('app_settings'))) {
log.warn('app_settings table missing at boot — restore-settings self-heal skipped');
return { seeded: [], upgraded: [] };
}
const seeded = [];
const upgraded = [];
// Step 1: fresh-install seeding. Insert rows that don't exist at all.
for (const seed of SEEDS) {
try {
const existing = await db('app_settings')
.where('setting_key', seed.setting_key)
.first();
if (existing) continue;
await db('app_settings').insert({
setting_key: seed.setting_key,
setting_value: seed.setting_value,
setting_type: seed.setting_type,
updated_at: new Date(),
});
seeded.push(seed.setting_key);
log.info(`Seeded restore-meta setting ${seed.setting_key}=${seed.setting_value}`);
} catch (err) {
log.warn(`Failed to seed restore-meta setting ${seed.setting_key}: ${err.message}`);
}
}
// Step 2: one-time auto-upgrade for installs that ran the OLD
// migration 032 (which seeded restore_allow_force='false'). Bump
// to 'true' iff the value is still the deprecated default AND the
// auto-upgrade tracking flag hasn't already been set.
try {
const guard = await db('app_settings')
.where('setting_key', AUTO_UPGRADE_FLAG_KEY)
.first();
if (!guard) {
const row = await db('app_settings')
.where('setting_key', 'restore_allow_force')
.first();
if (row && row.setting_value === DEPRECATED_DEFAULT_VALUE) {
await db('app_settings')
.where('setting_key', 'restore_allow_force')
.update({
setting_value: 'true',
updated_at: new Date(),
});
upgraded.push('restore_allow_force');
log.info('Auto-upgraded restore_allow_force from deprecated migration-032 default \'false\' to \'true\' '
+ '(fresh-install disaster recovery now works without SQL incantation)');
}
// Always set the guard, even if no upgrade happened — prevents
// the bump from firing later if an admin sets the value to
// false on purpose.
await db('app_settings').insert({
setting_key: AUTO_UPGRADE_FLAG_KEY,
setting_value: 'true',
setting_type: 'restore',
updated_at: new Date(),
});
}
} catch (err) {
log.warn(`restore_allow_force auto-upgrade failed: ${err.message}`);
}
booted = true;
return { seeded, upgraded };
}
// Test-only: reset the module-level boot flag so jest can re-exercise
// the seeder against a fresh test DB inside a single worker.
function _resetBootForTests() {
booted = false;
}
module.exports = { seedRestoreSettingsAtBoot, _resetBootForTests, SEEDS };
@@ -0,0 +1,351 @@
/**
* Backup-coverage diagnostic — Stage C of the backup-hardening plan.
*
* **Why this is a separate service**
*
* Stage A (inline DB dump + fail-loud) and Stage B (config-driven
* walker via `backup_paths`) close the data-loss footgun, but they
* don't tell an admin *what* the next backup will actually cover.
* That's a separate question — and a particularly important one,
* because the whole reason Stage B exists is that the walker's
* subdirectory list used to silently fall behind reality every time
* a new feature dropped artefacts under STORAGE_PATH.
*
* This service answers two questions:
*
* 1. For every row in `backup_paths`, what will the next backup
* do with it? (scan / skip-via-feature-flag / skip-via-toggle /
* missing-on-disk)
* 2. What subdirectories EXIST under STORAGE_PATH but have NO row
* in `backup_paths` — i.e. drift the admin should know about
* before they lose data on a restore?
*
* Plus a top-level database-dump status block: are we configured
* for inline dump (default), or relying on the scheduled dump?
* When was the last successful dump? Is it stale?
*
* **What this service does NOT do**
*
* - Does not run the backup
* - Does not write anything (no DB mutations, no fs touches)
* - Does not auto-recover drift (it's a diagnostic — admins decide
* whether to add a `backup_paths` row, delete the orphan dir, etc.)
* - Does not walk file contents — only top-level directory entries
* under STORAGE_PATH are inspected (cheap; no recursion through
* potentially-millions of photos)
*
* Read-only. Returns a JSON report — same shape as
* backupIntegrityService.verifyDocumentArtefacts.
*/
const fs = require('fs').promises;
const path = require('path');
const { db } = require('../database/db');
const logger = require('../utils/logger');
const backupService = require('./backupService');
const STORAGE_ROOT = () => process.env.STORAGE_PATH || path.join(process.cwd(), 'storage');
/**
* Top-level subdirectories we expect to find under STORAGE_PATH but
* which are intentionally NOT in `backup_paths` — they're generated
* caches / runtime artefacts that the backup is supposed to skip.
* Listing them here keeps the drift detector from flagging them.
*
* `backups` — the destination directory the backup writer itself
* creates, plus the `database_backup_runs` dump files.
* Including it in the walker would create a recursive
* "backup of backups" feedback loop.
*
* `tmp` — short-lived scratch space (e.g. PDF render staging,
* S3 multipart uploads). Re-created on demand, never
* holds the only copy of anything.
*/
const EXPECTED_NON_BACKUP_DIRS = new Set([
'backups',
'tmp',
]);
/**
* How stale a database dump can be before we flag it. 26 hours so a
* daily scheduled dump is still considered "fresh" if it ran a few
* hours late.
*/
const DB_DUMP_STALE_AFTER_MS = 26 * 60 * 60 * 1000;
function parseSettingValue(raw) {
if (raw === null || raw === undefined) return null;
if (typeof raw !== 'string') return raw;
try { return JSON.parse(raw); } catch (_) {
if (raw === 'true') return true;
if (raw === 'false') return false;
const n = Number(raw);
return Number.isFinite(n) ? n : raw;
}
}
async function readBackupConfig() {
try {
const rows = await db('app_settings')
.where('setting_type', 'backup')
.select('setting_key', 'setting_value');
const cfg = {};
for (const row of rows) {
cfg[row.setting_key] = parseSettingValue(row.setting_value);
}
return cfg;
} catch (err) {
logger.warn(`backup-coverage: could not read backup config — ${err.message}`);
return {};
}
}
async function listConfiguredPaths() {
try {
if (!(await db.schema.hasTable('backup_paths'))) return null;
return await db('backup_paths')
.orderBy('display_order', 'asc')
.select('path', 'include_in_default', 'feature_flag', 'display_order', 'description');
} catch (err) {
logger.warn(`backup-coverage: could not read backup_paths — ${err.message}`);
return null;
}
}
async function listTopLevelStorageDirs() {
const root = STORAGE_ROOT();
try {
const entries = await fs.readdir(root, { withFileTypes: true });
return entries.filter((e) => e.isDirectory()).map((e) => e.name);
} catch (err) {
if (err.code === 'ENOENT') return [];
logger.warn(`backup-coverage: could not read STORAGE_PATH (${root}) — ${err.message}`);
return [];
}
}
async function statPath(absPath) {
try {
const st = await fs.stat(absPath);
return { exists: true, isDir: st.isDirectory() };
} catch (err) {
if (err.code === 'ENOENT') return { exists: false, isDir: false };
throw err;
}
}
/**
* Build the database-dump status block. Tells the admin whether
* "Run Backup Now" will inline-dump (default) or rely on the
* scheduled-dump path, plus how fresh the most recent dump is.
*/
async function buildDatabaseStatus(config) {
// normalizeBoolean(undefined) === false, so we have to gate on
// explicit-false the same way ensureDatabaseDumpForBackup does.
const inlineExplicitlyOff = config.backup_database_inline_dump !== undefined
&& config.backup_database_inline_dump !== null
&& config.backup_database_inline_dump === false;
const mode = inlineExplicitlyOff ? 'scheduled-only' : 'inline';
let recent = null;
try {
if (await db.schema.hasTable('database_backup_runs')) {
recent = await db('database_backup_runs')
.where('status', 'completed')
.orderBy('completed_at', 'desc')
.first();
}
} catch (err) {
logger.warn(`backup-coverage: could not read database_backup_runs — ${err.message}`);
}
const status = {
mode,
inlineDumpExplicitlyDisabled: inlineExplicitlyOff,
lastDumpAt: recent ? recent.completed_at : null,
lastDumpType: recent ? recent.backup_type : null,
lastDumpSizeBytes: recent ? Number(recent.file_size_bytes || 0) : 0,
lastDumpFilePath: recent ? recent.file_path : null,
lastDumpAgeMs: null,
lastDumpStale: null,
ok: null,
};
if (recent && recent.completed_at) {
const completedAt = recent.completed_at instanceof Date
? recent.completed_at
: new Date(recent.completed_at);
status.lastDumpAgeMs = Date.now() - completedAt.getTime();
status.lastDumpStale = status.lastDumpAgeMs > DB_DUMP_STALE_AFTER_MS;
}
// ok semantics:
// - inline mode: always ok=true (next backup will produce a fresh
// dump on demand, staleness is irrelevant)
// - scheduled-only: ok=true iff a recent non-stale dump exists,
// because the file-backup guard will fail-loud otherwise
if (mode === 'inline') {
status.ok = true;
} else {
status.ok = Boolean(recent && recent.file_path && status.lastDumpStale === false);
}
return status;
}
/**
* Per-path coverage:
* - configured + include_in_default + (no feature_flag OR flag truthy) → 'will-scan'
* - configured + include_in_default + flag falsey → 'skipped-by-feature-flag'
* - configured + include_in_default = false → 'skipped-by-toggle'
* - configured but missing on disk → 'missing-on-disk'
*
* Returns one entry per `backup_paths` row.
*/
async function buildConfiguredPathReport(configuredRows, config) {
const root = STORAGE_ROOT();
const result = [];
for (const row of configuredRows) {
const absPath = path.join(root, row.path);
const stat = await statPath(absPath);
const includedInDefault = Boolean(row.include_in_default);
let featureFlagValue = null;
if (row.feature_flag) {
const v = config[row.feature_flag];
featureFlagValue = v === undefined ? null : Boolean(v);
}
let coverage;
if (!includedInDefault) {
coverage = 'skipped-by-toggle';
} else if (row.feature_flag && featureFlagValue !== true) {
// null (unset) and explicit false both gate the path off — matches
// the walker's normalizeBoolean semantics
coverage = 'skipped-by-feature-flag';
} else if (!stat.exists) {
coverage = 'missing-on-disk';
} else {
coverage = 'will-scan';
}
result.push({
path: row.path,
includeInDefault: includedInDefault,
featureFlag: row.feature_flag || null,
featureFlagValue,
displayOrder: row.display_order,
description: row.description || null,
existsOnDisk: stat.exists,
coverage,
});
}
return result;
}
/**
* Drift detection: top-level subdirs under STORAGE_PATH that are not
* in `backup_paths` AND not in the `EXPECTED_NON_BACKUP_DIRS` allow-list.
*
* These are the directories that will be missed by "Run Backup Now"
* — either intentionally (a new feature drops cache files there and
* the admin doesn't want them backed up — they should add them to the
* allow-list) or accidentally (a feature shipped without a matching
* `backup_paths` row — the data-loss footgun this whole effort is
* designed to catch).
*/
function detectDrift(diskDirs, configuredPaths) {
// configured paths can be nested ('events/active'); we only diff the
// top-level segment ('events') because that's the granularity admins
// see in the storage tree. A path like 'events/active' implies the
// 'events' top-level is "known to the backup config".
const configuredTopLevels = new Set(
configuredPaths.map((p) => p.path.split('/')[0]),
);
return diskDirs
.filter((d) => !configuredTopLevels.has(d))
.filter((d) => !EXPECTED_NON_BACKUP_DIRS.has(d))
.sort();
}
/**
* Public entry point.
*
* @returns {Promise<{
* database: object,
* paths: Array<object>,
* drift: { unconfiguredOnDisk: string[], expectedNonBackupDirs: string[] },
* summary: object,
* generatedAt: string,
* }>}
*/
async function getCoverageReport() {
const config = await readBackupConfig();
const configuredRows = await listConfiguredPaths();
const diskDirs = await listTopLevelStorageDirs();
// Fallback when the table doesn't exist yet (migration 108 hasn't
// run for some reason). Mirrors the walker's LEGACY_BACKUP_PATHS
// contract — every other layer of this system uses the same
// belt-and-suspenders fallback.
const fallback = configuredRows === null;
const effectiveRows = configuredRows || [
{ path: 'events/active', include_in_default: true, feature_flag: null, display_order: 10, description: 'Legacy fallback (backup_paths missing)' },
{ path: 'events/archived', include_in_default: true, feature_flag: 'backup_include_archived', display_order: 20, description: 'Legacy fallback (backup_paths missing)' },
{ path: 'thumbnails', include_in_default: true, feature_flag: null, display_order: 30, description: 'Legacy fallback (backup_paths missing)' },
{ path: 'previews', include_in_default: true, feature_flag: null, display_order: 40, description: 'Legacy fallback (backup_paths missing)' },
{ path: 'heroes', include_in_default: true, feature_flag: null, display_order: 50, description: 'Legacy fallback (backup_paths missing)' },
{ path: 'uploads', include_in_default: true, feature_flag: null, display_order: 60, description: 'Legacy fallback (backup_paths missing)' },
{ path: 'business-docs', include_in_default: true, feature_flag: null, display_order: 70, description: 'Legacy fallback (backup_paths missing)' },
];
const [database, paths] = await Promise.all([
buildDatabaseStatus(config),
buildConfiguredPathReport(effectiveRows, config),
]);
const unconfiguredOnDisk = detectDrift(diskDirs, effectiveRows);
const summary = {
configuredCount: effectiveRows.length,
willScanCount: paths.filter((p) => p.coverage === 'will-scan').length,
skippedByToggleCount: paths.filter((p) => p.coverage === 'skipped-by-toggle').length,
skippedByFeatureFlagCount: paths.filter((p) => p.coverage === 'skipped-by-feature-flag').length,
missingOnDiskCount: paths.filter((p) => p.coverage === 'missing-on-disk').length,
driftCount: unconfiguredOnDisk.length,
tableMissingFallbackInUse: fallback,
databaseOk: database.ok,
// overall: green only when DB is ok AND there's at least one path
// that will actually be scanned AND no drift was found
overallOk: Boolean(
database.ok
&& paths.some((p) => p.coverage === 'will-scan')
&& unconfiguredOnDisk.length === 0,
),
};
return {
database,
paths,
drift: {
unconfiguredOnDisk,
expectedNonBackupDirs: Array.from(EXPECTED_NON_BACKUP_DIRS).sort(),
},
summary,
generatedAt: new Date().toISOString(),
};
}
module.exports = {
getCoverageReport,
// Exported for test introspection — the route doesn't use these.
EXPECTED_NON_BACKUP_DIRS,
DB_DUMP_STALE_AFTER_MS,
};
// Silence unused import lint warning — backupService is required so
// the module-graph cache primes (some tests jest.mock it before
// requiring this service).
void backupService;
@@ -0,0 +1,228 @@
/**
* Backup-integrity verifier — walks every CRM document-artefact path
* column and confirms (a) the file exists on disk, (b) when a SHA-256
* is stored, the file's actual bytes hash to the stored value.
*
* **Why this is a separate service**
*
* The audit trail captured at issue / sign time (signed_customer_ip,
* signed_by_customer_at, signed_pdf_sha256, signed_*_signature_path,
* issue_date, etc.) is worth exactly nothing on its own — what makes
* it legally meaningful is being able to produce the document the
* audit trail refers to. A backup that captures the DB but skips
* `storage/business-docs/` (the bug fixed in this same PR) leaves
* every `*_path` column a broken FK and every `*_sha256` column with
* nothing to verify against. This service is the diagnostic for
* exactly that drift — runs on demand, surfaces missing files +
* hash mismatches without making any changes.
*
* **Verification modes**
*
* - existence — file at `*_path` must exist on disk
* - sha256 — file at `*_path` must exist AND its sha256 must
* equal `*_sha256` column (when that column is set)
*
* Per-table coverage (lines reference migrations/core/107_crm_consolidated.js):
*
* quotes.pdf_path line 844 (existence)
* contracts.pdf_path line 1245 (existence + sha256 via contracts.pdf_sha256)
* contracts.signed_pdf_path line 1246 (existence + sha256 via contracts.signed_pdf_sha256)
* contracts.signed_customer_signature_path line 1269 (existence — drawn signatures, no hash column)
* contracts.signed_admin_signature_path line 1273 (existence — admin counter-signature drawing)
* invoices.pdf_path line 1026 (existence)
* invoices.imported_pdf_path line 1020 (existence — admin-uploaded scans)
*
* Wet uploads (`contracts.signed_pdf_is_wet_upload = true`) DO have a
* `signed_pdf_sha256` computed at upload time (contractService.js
* upload route), so they're hash-verified the same as system-rendered
* contracts — no special case here.
*
* **What this service does NOT do**
*
* - Does not write anything (no DB mutations, no fs touches)
* - Does not fail the request when a mismatch is found — the
* report shape carries the data, the caller decides what to do
* - Does not auto-trigger after restore (D2 decision: surface a
* CTA on the restore-completed screen instead)
* - Does not run on a schedule (D1 decision: on-demand v1; revisit
* once we have runtime data on large installs)
*/
const fs = require('fs');
const crypto = require('crypto');
const path = require('path');
const { db } = require('../database/db');
const logger = require('../utils/logger');
const STORAGE_ROOT = () => process.env.STORAGE_PATH || path.join(process.cwd(), 'storage');
/**
* Every column the verifier walks, declared once so the test suite
* and the service share a single source of truth. Order is the order
* the report lists rows in — table-major, then column-by-column.
*/
const CHECKS = [
{ table: 'quotes', pathColumn: 'pdf_path', shaColumn: null, scope: 'quote' },
{ table: 'contracts', pathColumn: 'pdf_path', shaColumn: 'pdf_sha256', scope: 'contract' },
{ table: 'contracts', pathColumn: 'signed_pdf_path', shaColumn: 'signed_pdf_sha256', scope: 'contract' },
{ table: 'contracts', pathColumn: 'signed_customer_signature_path', shaColumn: null, scope: 'contract-signature' },
{ table: 'contracts', pathColumn: 'signed_admin_signature_path', shaColumn: null, scope: 'contract-signature' },
{ table: 'invoices', pathColumn: 'pdf_path', shaColumn: null, scope: 'invoice' },
{ table: 'invoices', pathColumn: 'imported_pdf_path', shaColumn: null, scope: 'invoice' },
];
/** Stream-hash a file to sha256 hex without buffering the whole thing. */
function hashFile(absPath) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('sha256');
const stream = fs.createReadStream(absPath);
stream.on('error', reject);
stream.on('data', (chunk) => hash.update(chunk));
stream.on('end', () => resolve(hash.digest('hex')));
});
}
/**
* @param {object} [options]
* @param {string[]} [options.scope] Filter checks by scope tag:
* 'quote' | 'contract' | 'contract-signature' | 'invoice'.
* Defaults to all four (full scan).
* @returns {Promise<{
* scannedAt: string,
* scopes: string[],
* summary: {
* totalRows: number,
* verifiedOk: number,
* missingFiles: number,
* hashMismatches: number,
* existsButNoHash: number,
* },
* missing: Array<{ table, rowId, column, expectedPath }>,
* hashMismatches: Array<{ table, rowId, column, expectedPath, expectedSha, actualSha }>,
* existsButNoHash: Array<{ table, rowId, column, path }>,
* }>}
*
* `existsButNoHash` is the existence-only-verified bucket — the file
* was found but no `*_sha256` column exists for it (quote/invoice PDFs,
* signature PNGs). Surfaced separately so admins can distinguish
* "verified by hash" from "verified by existence only" — the latter
* is weaker evidence in a legal dispute.
*/
async function verifyDocumentArtefacts(options = {}) {
const scopes = Array.isArray(options.scope) && options.scope.length > 0
? options.scope.slice()
: Array.from(new Set(CHECKS.map((c) => c.scope)));
const checksToRun = CHECKS.filter((c) => scopes.includes(c.scope));
const storageRoot = STORAGE_ROOT();
const missing = [];
const hashMismatches = [];
const existsButNoHash = [];
let totalRows = 0;
let verifiedOk = 0;
for (const check of checksToRun) {
// Skip the check cleanly when the column or table doesn't exist
// on this install — keeps the verifier safe to run on partial
// migrations or installs that have features disabled.
if (!(await db.schema.hasTable(check.table))) continue;
if (!(await db.schema.hasColumn(check.table, check.pathColumn))) continue;
const select = ['id', check.pathColumn];
const hasHashColumn = check.shaColumn
&& (await db.schema.hasColumn(check.table, check.shaColumn));
if (hasHashColumn) select.push(check.shaColumn);
const rows = await db(check.table)
.whereNotNull(check.pathColumn)
.select(...select);
for (const row of rows) {
totalRows += 1;
const storedPath = row[check.pathColumn];
// Stored paths can be absolute (older rows) or relative-to-
// storage (newer rows). Normalize: resolve relative paths
// against STORAGE_PATH; absolute paths are used verbatim.
const absPath = path.isAbsolute(storedPath)
? storedPath
: path.join(storageRoot, storedPath);
let exists = false;
try {
exists = fs.existsSync(absPath);
} catch (_) { exists = false; }
if (!exists) {
missing.push({
table: check.table,
rowId: row.id,
column: check.pathColumn,
expectedPath: storedPath,
});
continue;
}
const expectedSha = hasHashColumn ? row[check.shaColumn] : null;
if (!expectedSha) {
// File exists but we have no hash to verify it against.
existsButNoHash.push({
table: check.table,
rowId: row.id,
column: check.pathColumn,
path: storedPath,
});
continue;
}
let actualSha;
try {
actualSha = await hashFile(absPath);
} catch (err) {
logger.warn(`backupIntegrity: failed to hash ${absPath}: ${err.message}`);
missing.push({
table: check.table,
rowId: row.id,
column: check.pathColumn,
expectedPath: storedPath,
});
continue;
}
if (actualSha !== expectedSha) {
hashMismatches.push({
table: check.table,
rowId: row.id,
column: check.pathColumn,
expectedPath: storedPath,
expectedSha,
actualSha,
});
continue;
}
verifiedOk += 1;
}
}
return {
scannedAt: new Date().toISOString(),
scopes,
summary: {
totalRows,
verifiedOk,
missingFiles: missing.length,
hashMismatches: hashMismatches.length,
existsButNoHash: existsButNoHash.length,
},
missing,
hashMismatches,
existsButNoHash,
};
}
module.exports = {
verifyDocumentArtefacts,
// Exported for tests; not part of the route API.
_internal: { CHECKS, hashFile },
};
+292 -20
View File
@@ -259,6 +259,73 @@ async function hasDatabaseChanged(sinceTime) {
}
}
/**
* Run an inline database dump (default ON) and then verify a usable dump
* is actually on disk before letting the file-backup proceed. Returns the
* verified `databaseInfo` so the caller can pass it straight into the
* manifest builder without re-querying.
*
* Why this lives here and not inline in `runBackupInternal`:
* - Encapsulates the "Run Backup Now must include DB" guarantee
* introduced when the silent files-only bug was discovered
* (2026-05-29 — admin lost CRM after `docker compose down -v`)
* - Lets the manifest path share the same `databaseInfo` object
* instead of doing a second `getDatabaseBackupInfo()` round-trip
* - Thrown errors bubble up to `runBackupInternal`'s catch, which
* marks the `backup_runs` row failed and queues the admin email
*
* Default-ON semantics: `backup_database_inline_dump` is only treated
* as disabled when explicitly set to false. `undefined` (the case on
* every existing install that predates the setting) falls through to
* the safe-default ON branch. `normalizeBoolean(undefined)` returns
* false, so a naive `!== false` check would silently disable the
* inline dump for every upgrading install.
*/
async function ensureDatabaseDumpForBackup(config) {
const inlineDumpExplicitlyOff = config.backup_database_inline_dump !== undefined
&& config.backup_database_inline_dump !== null
&& normalizeBoolean(config.backup_database_inline_dump) === false;
if (!inlineDumpExplicitlyOff) {
logger.info('Running inline database dump before file backup...');
const { databaseBackupService } = require('./databaseBackup');
const dumpResult = await databaseBackupService.backup({});
logger.info(`Inline database dump completed: ${dumpResult.path} ` +
`(${(dumpResult.size / 1024 / 1024).toFixed(2)} MB)`);
}
const databaseInfo = await service.getDatabaseBackupInfo();
if (!databaseInfo.backupFile) {
throw new Error(
'No database backup available to include in this file backup. ' +
'Either keep backup_database_inline_dump enabled (default) or configure ' +
'backup_database_schedule and let it run at least once first.'
);
}
let dumpStat;
try {
dumpStat = await fs.stat(databaseInfo.backupFile);
} catch (statErr) {
if (statErr.code === 'ENOENT') {
throw new Error(
`Database backup file at ${databaseInfo.backupFile} is missing from disk. ` +
'Refusing to proceed with file backup; configure backup_database_schedule or ' +
'keep backup_database_inline_dump enabled.'
);
}
throw statErr;
}
if (!dumpStat.size) {
throw new Error(
`Database backup file at ${databaseInfo.backupFile} is empty (0 bytes). ` +
'Refusing to proceed with file backup to avoid shipping a manifest with no DB content.'
);
}
return databaseInfo;
}
async function getDatabaseBackupInfoInternal() {
try {
const recent = await db('database_backup_runs')
@@ -350,28 +417,179 @@ async function scanDirectory(dirPath, fileList, basePath, excludePatterns = [])
}
}
async function getFilesToBackupInternal(includeArchived = true) {
/**
* Hard-coded fallback when `backup_paths` is missing/empty. Mirrors
* the canonical seed in migration 109 — kept here as defense in depth
* so the walker can never silently degrade to "no directories scanned"
* because of a seed problem.
*
* Order matches the legacy behavior of the inlined sequence this
* function used to contain.
*/
const LEGACY_BACKUP_PATHS = [
{ path: 'events/active', feature_flag: null },
{ path: 'events/archived', feature_flag: 'backup_include_archived' },
{ path: 'thumbnails', feature_flag: null },
{ path: 'previews', feature_flag: null },
{ path: 'heroes', feature_flag: null },
{ path: 'uploads', feature_flag: null },
{ path: 'business-docs', feature_flag: null },
];
/**
* Resolve the walker's target subdirectories from `backup_paths`.
*
* Layered fallback (defense in depth — no scenario where the walker
* silently scans nothing):
* 1. Read `backup_paths` rows where include_in_default = true,
* ordered by display_order.
* 2. If the table is missing OR returns zero rows, fall back to
* LEGACY_BACKUP_PATHS. Logged loudly so the admin sees it.
*
* Per-row gating: when `feature_flag` is set, the corresponding
* config key in `app_settings` must resolve truthy for that path to
* be included. Mirrors the historical `includeArchived` parameter,
* but now driven by data instead of a hard-coded boolean.
*
* @param {object} config resolved backup config (parseSettingValue'd).
* Used to evaluate feature_flag gates.
* @returns {Promise<Array<{ path: string, feature_flag: string|null }>>}
*/
async function resolveBackupPaths(config) {
let rows;
try {
if (!(await db.schema.hasTable('backup_paths'))) {
logger.warn('backup_paths table missing — falling back to LEGACY_BACKUP_PATHS');
rows = LEGACY_BACKUP_PATHS;
} else {
rows = await db('backup_paths')
.where('include_in_default', formatBoolean(true))
.orderBy('display_order', 'asc')
.select('path', 'feature_flag');
if (!rows.length) {
logger.warn('backup_paths has no rows with include_in_default=true — falling back to LEGACY_BACKUP_PATHS');
rows = LEGACY_BACKUP_PATHS;
}
}
} catch (err) {
logger.warn(`Failed to query backup_paths (${err.message}) — falling back to LEGACY_BACKUP_PATHS`);
rows = LEGACY_BACKUP_PATHS;
}
// Apply feature_flag gating. A row with feature_flag='backup_include_archived'
// requires config.backup_include_archived to be truthy (same semantics as
// the historical `includeArchived` parameter).
return rows.filter((row) => {
if (!row.feature_flag) return true;
const flagValue = config ? config[row.feature_flag] : undefined;
return normalizeBoolean(flagValue);
});
}
/**
* Bucket actually-backed-up files into their owning `backup_paths` row.
*
* Uses longest-prefix match — e.g. `events/active/E1/photo.jpg` matches
* `events/active` (length 13) rather than `events` (length 6, if that
* row existed). This handles the case where a future feature ships a
* nested backup_paths row that overlaps an existing one.
*
* @param {string[]} backedUpRelativePaths — paths that actually got
* copied / uploaded (post-incremental-filter). The exact list
* the destination implementation reports back.
* @param {Array<{relativePath, size}>} allFiles — the full file
* catalogue from the walker, used as a size lookup table.
* @returns {Promise<Record<string, { count: number, size: number }>>}
* keyed by `backup_paths.path` (e.g. 'events/active'). Paths
* with zero matches are omitted to keep the manifest compact.
*/
async function computePerPathStats(backedUpRelativePaths, allFiles) {
if (!backedUpRelativePaths || backedUpRelativePaths.length === 0) {
return {};
}
// Reuse the same source of truth the walker uses, so a row toggled
// off by include_in_default doesn't appear in the breakdown either.
let configuredPaths;
try {
if (await db.schema.hasTable('backup_paths')) {
configuredPaths = await db('backup_paths')
.where('include_in_default', formatBoolean(true))
.orderBy('display_order', 'asc')
.select('path');
}
} catch (err) {
logger.warn(`Could not load backup_paths for per-path stats — falling back to legacy: ${err.message}`);
}
if (!configuredPaths || configuredPaths.length === 0) {
configuredPaths = LEGACY_BACKUP_PATHS.map((p) => ({ path: p.path }));
}
// Longest-prefix-first so nested paths win over their parents.
const sortedPaths = configuredPaths
.map((row) => row.path)
.sort((a, b) => b.length - a.length);
// Size lookup. relativePath uses OS path separators in `allFiles`
// (whatever scanDirectory built); the backup_paths rows always use
// forward slashes. Normalize the lookup key once.
const sizeByPath = new Map();
for (const f of allFiles || []) {
sizeByPath.set(f.relativePath.split(path.sep).join('/'), f.size || 0);
}
const stats = {};
for (const relativePath of backedUpRelativePaths) {
const norm = relativePath.split(path.sep).join('/');
// Find the longest configured path that this file's relativePath starts with.
const match = sortedPaths.find(
(p) => norm === p || norm.startsWith(`${p}/`)
);
if (!match) continue; // file outside any configured path (shouldn't happen)
if (!stats[match]) stats[match] = { count: 0, size: 0 };
stats[match].count += 1;
stats[match].size += sizeByPath.get(norm) || 0;
}
return stats;
}
async function getFilesToBackupInternal(configOrIncludeArchived = true) {
const files = [];
const storagePath = getStoragePath();
await scanDirectory(path.join(storagePath, 'events/active'), files, storagePath);
if (normalizeBoolean(includeArchived)) {
await scanDirectory(path.join(storagePath, 'events/archived'), files, storagePath);
// Backward-compatible call signature:
// - Boolean `true|false` → legacy `includeArchived` argument. We
// forge a config-shaped object so the feature-flag gating
// resolves the same way the old code path did.
// - Object → full resolved backup config (preferred).
// - Anything else → treated as "include archived" (truthy).
let config;
if (typeof configOrIncludeArchived === 'object' && configOrIncludeArchived !== null) {
config = configOrIncludeArchived;
} else {
config = { backup_include_archived: normalizeBoolean(configOrIncludeArchived) };
}
await scanDirectory(path.join(storagePath, 'thumbnails'), files, storagePath);
// Lightbox preview tier (#492). Cheap to back up — typically a few
// hundred KB per photo — and saves admins the regenerate cycle on
// a restore. Tolerated when missing (admins who never enabled the
// feature won't have the folder; scanDirectory short-circuits on
// ENOENT cleanly).
await scanDirectory(path.join(storagePath, 'previews'), files, storagePath);
// Heroes too — same logic; admins who picked a hero photo for the
// gallery header had its 1920x1080 file generated and was missed
// by the original backup walk before this addition.
await scanDirectory(path.join(storagePath, 'heroes'), files, storagePath);
await scanDirectory(path.join(storagePath, 'uploads'), files, storagePath);
const targets = await resolveBackupPaths(config);
for (const target of targets) {
// CRM document estate is special-cased in the comment block below
// because it's the most expensive omission to recover from:
// - business-docs/quote/<year>/*.pdf
// - business-docs/contract/<year>/*.pdf (system-rendered + wet uploads)
// - business-docs/contract/signatures/<contract_id>/*.{png,jpg}
// (drawn signatures, forensic-preserved per Date.now() filename)
// - business-docs/invoice/<year>/*.pdf (issued invoices + Storno)
// - business-docs/invoice-imports/<year>/*.pdf (admin-imported
// historical invoices — irrecoverable if not backed up)
// Without this scan, the audit trail (signed_pdf_sha256, signed_*
// _ip, accepted_at, etc.) survives the restore but the documents
// those values refer to do not, leaving every CRM *_path column a
// broken FK. scanDirectory short-circuits on ENOENT so installs
// that never used CRM features won't error.
await scanDirectory(path.join(storagePath, target.path), files, storagePath);
}
return files;
}
@@ -799,7 +1017,17 @@ async function runBackupInternal(isManual = false) {
}).returning('id');
runId = insertResult[0]?.id || insertResult[0];
const files = await service.getFilesToBackup(config.backup_include_archived);
// Inline DB dump + fail-loud verification. The returned `databaseInfo`
// is reused at manifest-build time below so we don't pay a second
// `getDatabaseBackupInfo()` round-trip — see `ensureDatabaseDumpForBackup`
// for the full rationale.
const verifiedDatabaseInfo = await ensureDatabaseDumpForBackup(config);
// Pass the full config so the walker can evaluate any feature_flag
// gates declared in the backup_paths table (e.g. `events/archived`
// gated by `backup_include_archived`). Boolean signature is still
// supported for legacy callers and tests — see getFilesToBackupInternal.
const files = await service.getFilesToBackup(config);
logger.info(`Found ${files.length} files to check for backup`);
let result;
@@ -826,7 +1054,13 @@ async function runBackupInternal(isManual = false) {
const previousBackup = await getPreviousSuccessfulBackup(runId);
const manifestFiles = buildManifestFiles(result.backedUpFiles, files);
const databaseInfo = result.databaseInfo || await service.getDatabaseBackupInfo();
// `verifiedDatabaseInfo` came from ensureDatabaseDumpForBackup at the
// top of this run — reuse it so manifest building doesn't pay a
// second `getDatabaseBackupInfo()` round-trip. The
// `result.databaseInfo` branch is kept for destination implementations
// (S3, future destinations) that override the local info on the result
// object; falls back to the verified copy otherwise.
const databaseInfo = result.databaseInfo || verifiedDatabaseInfo;
const manifestOptions = {
backupType: previousBackup ? 'incremental' : 'full',
@@ -869,6 +1103,18 @@ async function runBackupInternal(isManual = false) {
logger.error('Failed to generate backup manifest:', error);
}
// Per-Stage-B-path stats — bucket the actually-backed-up files
// into their owning backup_paths row by longest-prefix match. Lets
// the Backup History detail pane render a true breakdown
// events/active: 142 files (3.2 GB)
// business-docs: 17 files (4.5 MB)
// thumbnails: 142 files (12.4 MB)
// instead of the legacy "Photos + Archives + Other" categorization
// that didn't reflect Stage B's data-driven walker. Falls back to
// an empty map if backup_paths is missing (defense in depth — the
// walker has the same fallback).
const perPath = await computePerPathStats(result.backedUpFiles, files);
await db('backup_runs')
.where('id', runId)
.update({
@@ -887,11 +1133,14 @@ async function runBackupInternal(isManual = false) {
total_files_checked: files.length,
average_file_size: result.backedUpCount ? Math.round(result.backedUpSize / result.backedUpCount) : 0,
destination: destinationType,
// Per-Stage-B-path breakdown — { [pathKey]: { count, size } }
per_path: perPath,
// Keep camelCase for backward compatibility
totalFilesChecked: files.length,
filesBackedUp: result.backedUpCount,
totalSize: result.backedUpSize,
averageFileSize: result.backedUpCount ? Math.round(result.backedUpSize / result.backedUpCount) : 0
averageFileSize: result.backedUpCount ? Math.round(result.backedUpSize / result.backedUpCount) : 0,
perPath
})
});
@@ -1071,11 +1320,34 @@ async function getBackupStatus(limit = 10) {
const lastRunWithManifest = lastRun ? { ...lastRun, manifestValid } : null;
// Separate "most recent attempt" from "most recent SUCCESS" so the
// dashboard widget can distinguish:
// - last attempt failed → red, "Last attempt failed at X"
// - last attempt running → blue spinner, "In progress since X"
// - never succeeded → critical, "No successful backup yet"
// - last attempt succeeded → green tick, "Last backup X ago"
// Previously the widget showed the most-recent row with a generic
// green tick regardless of status, so a crashed run from 5 minutes
// ago looked identical to a successful one. Same "silent failure
// not surfaced" class Stage A was designed to fight.
const lastSuccessful = runs.find(r => r.status === 'completed') || null;
// Detect zombie running rows (started >30min ago, never updated)
// — these are processes that died without writing a completed_at.
// Surface them so the admin can tell at a glance vs a live run.
const ZOMBIE_THRESHOLD_MS = 30 * 60 * 1000;
const zombieRuns = runs.filter(r =>
r.status === 'running'
&& r.started_at
&& (Date.now() - new Date(r.started_at).getTime()) > ZOMBIE_THRESHOLD_MS
);
return {
isRunning,
isHealthy: Boolean(lastRun && lastRun.status === 'completed'),
lastRun: lastRunWithManifest,
lastBackup: lastRunWithManifest, // Alias for frontend compatibility
lastSuccessfulBackup: lastSuccessful, // NEW — see comment above
zombieRuns, // NEW — running >30min, likely crashed
recentRuns: runs,
recentBackups: runs, // Alias for frontend compatibility
totalBackups: runs.filter(r => r.status === 'completed').length,
+34 -10
View File
@@ -206,12 +206,20 @@ class DatabaseBackupService {
'--format=plain',
'--encoding=UTF8'
];
// Add transaction support for consistency
if (!options.noTransaction) {
pgDumpOptions.push('--single-transaction');
}
// NOTE: do NOT add `--single-transaction` here. It looks like
// the right flag for "consistent snapshot" but it isn't a pg_dump
// option — it belongs to pg_restore / psql and pg_dump rejects it
// with `unrecognized option: single-transaction` (exit code 1).
// pg_dump already wraps the entire export in a single REPEATABLE
// READ snapshot automatically (since Postgres 9.x), so consistency
// is built in. If we ever need stricter cross-pg-cluster snapshot
// sharing, use `--snapshot=<id>` — but the typical inline-dump
// path doesn't need it. Bug went undetected until Stage A wired
// this code into the user-facing "Run Backup Now" path; prior
// callers (scheduled cron, dedicated admin DB-backup page) hit
// the same failure but on installs that had never exercised them.
// Add compression if not doing it separately
if (options.compress && !options.separateCompression) {
pgDumpOptions.push('--compress=6');
@@ -309,8 +317,23 @@ class DatabaseBackupService {
// Get current schema version
const schemaVersion = await this.getCurrentSchemaVersion();
// Create backup run record with version info
const [runId] = await db('database_backup_runs').insert({
// Create backup run record with version info.
//
// Insert shape divergence between SQLite + Postgres made the old
// `const [runId] = await db(...).insert({...})` form throw
// "(intermediate value) is not iterable" on Postgres installs:
//
// - SQLite-via-knex: insert() returns `[lastInsertId]` (array)
// - Postgres-via-knex: insert() without .returning() returns an
// empty object / row count — not iterable
//
// Bug went undetected until Stage A wired this method into the
// "Run Backup Now" inline-dump path — before that, only the
// scheduled-cron + dedicated-admin-page callers exercised it,
// and Ralf's install had never triggered either. Cure: same
// explicit `.returning('id')` + dual-shape coalesce pattern that
// `backupService.js:949` uses for its own `backup_runs` insert.
const insertResult = await db('database_backup_runs').insert({
started_at: startTime,
status: 'running',
backup_type: this.dbType,
@@ -324,8 +347,9 @@ class DatabaseBackupService {
node_env: process.env.NODE_ENV || 'production',
db_type: this.dbType
})
});
}).returning('id');
const runId = insertResult[0]?.id || insertResult[0];
backupRun = { id: runId };
// Get initial checksums
+477 -32
View File
@@ -39,6 +39,12 @@ class RestoreService {
this.currentProgress = null;
this.restoreLog = [];
this.preRestoreBackupPath = null;
// Snapshot of operator-meta settings (e.g. `restore_allow_force`)
// captured by performDatabaseRestore BEFORE the DROP DATABASE.
// Drained by restore() AFTER post-restore verification passes so
// the replay doesn't inflate the row-count check. Reset per run
// via beforeRestore() to keep state from leaking across calls.
this.preservedMetaSnapshot = [];
this.dbType = knexConfig.client === 'pg' ? 'postgresql' : 'sqlite';
this.tempDir = path.join(os.tmpdir(), 'picpeak-restore');
}
@@ -64,6 +70,7 @@ class RestoreService {
this.isRunning = true;
this.restoreLog = [];
this.preservedMetaSnapshot = []; // reset per run
const startTime = new Date();
let restoreRun = null;
@@ -154,9 +161,37 @@ class RestoreService {
this.log('warn', 'Pre-restore backup skipped at user request');
}
// Step 5: Download backup if from S3
// Step 5: Download backup if from S3, or resolve the local root.
//
// The wizard passes `options.source = 'local'` (the SOURCE TYPE
// string) — not a path. The old code assigned that string to
// `localBackupPath` verbatim and every downstream `path.join(...)`
// ended up with junk like `local/database/<file>.sql.gz`. Caused
// the disaster-recovery restore flow to fail with
// `Database backup file not found: local/database/...` even when
// the manifest recorded the correct absolute path AND the file
// existed at exactly that path on disk.
//
// Resolve `'local'` to the configured backup destination root by
// reading `backup_destination_path` from app_settings. That's the
// same root the file-backup walker writes to, so every relative
// `file.path` in the manifest resolves correctly via
// `path.join(localBackupPath, file.path)` further down.
let localBackupPath = options.source;
if (options.source.startsWith('s3://')) {
if (options.source === 'local') {
try {
const row = await db('app_settings')
.where('setting_key', 'backup_destination_path')
.first();
if (row?.setting_value) {
let parsed;
try { parsed = JSON.parse(row.setting_value); } catch (_) { parsed = row.setting_value; }
if (parsed) localBackupPath = parsed;
}
} catch (err) {
this.log('warn', `Could not resolve backup_destination_path: ${err.message}`);
}
} else if (options.source.startsWith('s3://')) {
this.updateProgress('Downloading backup from S3...');
localBackupPath = await this.downloadFromS3(options.source, manifest, options);
}
@@ -193,6 +228,89 @@ class RestoreService {
throw new Error(`Post-restore verification failed: ${verification.errors.join(', ')}`);
}
// Step 7b: Replay operator-meta settings AFTER verification.
//
// `performDatabaseRestore` stashed the pre-DROP snapshot of
// operator-meta keys on `this.preservedMetaSnapshot`. We drain
// it here, AFTER verification has already confirmed the
// restored DB matches the backup's row counts. Running this
// upsert sequence here instead of inside performDatabaseRestore
// (where it used to live) prevents the replay from inflating
// the post-restore row count and tripping the verification
// check — see the round-3 PR #596 notes for the full story.
//
// UPSERT by setting_key: if the backup had the same key with a
// different value, we overwrite; if the row doesn't exist in
// the backup, we insert. Either way the operator's pre-restore
// policy survives. SQLite branch leaves the snapshot empty so
// this block is a no-op there.
if (this.preservedMetaSnapshot && this.preservedMetaSnapshot.length > 0) {
try {
for (const row of this.preservedMetaSnapshot) {
await db('app_settings')
.insert({
setting_key: row.setting_key,
setting_value: row.setting_value,
setting_type: row.setting_type || 'restore',
updated_at: new Date(),
})
.onConflict('setting_key')
.merge({
setting_value: row.setting_value,
updated_at: new Date(),
});
}
this.log('info', `Replayed ${this.preservedMetaSnapshot.length} restore-meta setting(s) post-verification`);
} catch (err) {
this.log('warn', `Could not replay restore-meta settings (admin may need to re-set them): ${err.message}`);
}
}
// Step 7c: Apply any post-backup migrations to the restored DB.
//
// The backup carries the schema state of whatever migrations had
// been applied at backup time. If the running image is NEWER —
// because the admin upgraded picpeak between when the backup
// was taken and when they restored — the restored DB ends up
// mismatched against the running code: queries fail, new
// columns are missing, new tables don't exist.
//
// Previously the comment said "deferred to next container
// restart" — but that left the running process serving a
// mismatched schema until the operator manually restarted.
// Not acceptable per the "backup must restore completely even
// when new features have been added in the meantime" contract.
//
// Implementation: shell out to `npm run migrate:safe`, which is
// the EXACT script wait-for-db.sh runs on boot. Running it as a
// subprocess means no risk to our reinit'd pool (subprocess gets
// its own knex instance, destroys it on exit; our parent pool
// is untouched). Idempotent — migrations already applied are
// tracked in the restored `migrations` table and get skipped.
//
// Failure is non-fatal: the restore data itself is in place,
// and the next container restart's wait-for-db.sh will retry.
// Surfacing the error gives the operator a chance to investigate
// proactively rather than discovering it on the next 500 from
// a missing column.
try {
this.log('info', 'Applying post-restore migrations to restored database...');
this.updateProgress('Applying any post-backup migrations...');
const backendRoot = path.join(__dirname, '..', '..');
const { stderr } = await spawnAsync('npm', ['run', 'migrate:safe'], {
cwd: backendRoot,
env: { ...process.env },
});
if (stderr && stderr.trim()) {
this.log('info', `Post-restore migrate:safe stderr: ${stderr.slice(0, 500)}`);
}
this.log('info', 'Post-restore migrations applied');
} catch (migErr) {
this.log('warn',
`Post-restore migrate:safe failed — restore data is in place but the schema may lag the running image. ` +
`A container restart will retry via wait-for-db.sh. Error: ${migErr.message}`);
}
// Step 8: Clean up temporary files
if (localBackupPath !== options.source) {
await fs.unlink(localBackupPath).catch(err =>
@@ -208,6 +326,15 @@ class RestoreService {
await db('restore_runs').where('id', runId).update({
completed_at: endTime,
status: 'completed',
// Default for the column is `false`. Without this line, every
// SUCCESSFUL restore ends up with `status='completed',
// was_successful=false` — which the BackupDashboard "last
// successful restore" widget then filters out, and any future
// audit query that gates on was_successful misses the row
// entirely. Cosmetic but enough to mislead an operator
// scanning restore history. Catches Ralf 2026-06-01 + maintainer
// PR #596 review note about the cosmetic.
was_successful: true,
duration_seconds: durationSeconds,
pre_restore_backup_path: this.preRestoreBackupPath,
statistics: JSON.stringify({
@@ -242,12 +369,53 @@ class RestoreService {
} catch (error) {
this.log('error', 'Restore failed', { error: error.message, stack: error.stack });
// Update restore run record
// Always attempt rollback when a pre-restore backup exists.
// Historically rollback was only triggered when post-restore
// verification failed (inside the try block) — anything that
// threw earlier (path-resolution bugs, pg_restore failure, file
// copy errors) left the destination half-clobbered and forced
// the admin to do another reset-from-volume cycle before the
// next attempt could be honest. Fixing the rollback here closes
// the "every failed restore makes the next one worse" footgun.
let rollbackAttempted = false;
let rollbackSucceeded = false;
let rollbackError = null;
if (this.preRestoreBackupPath) {
rollbackAttempted = true;
try {
this.log('info', 'Attempting rollback from pre-restore safety backup', {
path: this.preRestoreBackupPath,
});
await this.attemptRollback(this.preRestoreBackupPath);
rollbackSucceeded = true;
this.log('info', 'Rollback completed');
} catch (rbErr) {
rollbackError = rbErr.message;
this.log('error', 'Rollback FAILED — install may be in a partial state',
{ error: rbErr.message, stack: rbErr.stack });
}
} else {
this.log('warn', 'No pre-restore backup available — cannot auto-rollback. ' +
'Destination may be in a partial state. Verify business-docs/ and the DB before retrying.');
}
// Update restore run record. We persist BOTH the original
// restore failure AND the rollback status so the admin can tell
// from a single SQL query which scenario they're in:
// - rollback succeeded → destination is back to pre-restore state, safe to retry
// - rollback failed → partial state, admin must inspect before next attempt
// - rollback skipped → user opted out via skipPreBackup; same as above
if (restoreRun) {
const failureMessage = rollbackAttempted
? (rollbackSucceeded
? `${error.message} (rolled back successfully to pre-restore state)`
: `${error.message} | ROLLBACK ALSO FAILED: ${rollbackError} — destination is in a partial state, inspect before retrying`)
: `${error.message} (no pre-restore backup available — destination may be partial)`;
await db('restore_runs').where('id', restoreRun.id).update({
completed_at: new Date(),
status: 'failed',
error_message: error.message,
error_message: failureMessage,
was_rollback_attempted: rollbackAttempted,
restore_log: JSON.stringify(this.restoreLog)
});
}
@@ -255,7 +423,9 @@ class RestoreService {
// Send failure notification
await this.sendRestoreNotification('failure', {
error: error.message,
restoreType: options.restoreType
restoreType: options.restoreType,
rollbackAttempted,
rollbackSucceeded,
});
throw error;
@@ -371,21 +541,32 @@ class RestoreService {
}
}
// Check if restoring would overwrite existing data
// Check if restoring would overwrite existing data.
//
// NOTE: pg-driver returns `count('* as count')` as a STRING (it
// serialises `bigint` to string to avoid JS precision loss for
// huge counts) — see PR #596 review for the `bigint`-as-string
// discussion. Both blocks below coerce to `Number` before
// comparing AND before interpolating into the warning text, so
// the count renders as `5` not `"5"` regardless of DB driver.
// Don't drop the `Number()` calls without also re-auditing the
// strict-equality call sites flagged in the same review.
if (options.restoreType === 'full' || options.restoreType === 'database') {
const eventCount = await db('events').count('* as count').first();
if (eventCount && eventCount.count > 0) {
validation.warnings.push(`Database contains ${eventCount.count} existing events that will be overwritten`);
const eventCountN = Number(eventCount?.count || 0);
if (eventCountN > 0) {
validation.warnings.push(`Database contains ${eventCountN} existing events that will be overwritten`);
}
}
// Check for active users
// Check for active users (same coercion contract as above).
const activeUsers = await db('admin_users')
.where('is_active', formatBoolean(true))
.count('* as count')
.first();
if (activeUsers && activeUsers.count > 0) {
validation.warnings.push(`There are ${activeUsers.count} active admin users`);
const activeUsersN = Number(activeUsers?.count || 0);
if (activeUsersN > 0) {
validation.warnings.push(`There are ${activeUsersN} active admin users`);
}
} catch (error) {
@@ -661,13 +842,55 @@ class RestoreService {
throw new Error('No database backup file found in manifest');
}
const dbBackupPath = path.join(backupPath, 'database', path.basename(dbBackupFile));
// Check if backup file exists
try {
await fs.access(dbBackupPath);
} catch (error) {
throw new Error(`Database backup file not found: ${dbBackupPath}`);
// Layered resolution for the database dump path:
//
// 1. Manifest stores the absolute path the dumper wrote to
// (e.g. `/backup/database/picpeak-db-postgresql-<ts>.sql.gz`).
// That's the truth — try it first.
// 2. Some older manifests store a path RELATIVE to the file-backup
// destination root (`database/<file>.sql.gz`). Reconstruct that
// way as a fallback.
// 3. Final fallback: `<backupPath>/database/<basename>`, the
// historical reconstruction used before this fix. Preserved so
// no existing valid path breaks.
//
// The original code used (3) exclusively, which meant the restore
// service ignored the absolute path the manifest recorded and
// looked under a synthetic `<backupPath>/database/<file>` root —
// which on Ralf's install became `local/database/<file>` because
// `backupPath` was the source type string, not a directory. Caused
// the canonical disaster-recovery flow to fail with
// `Database backup file not found: local/database/...sql.gz`
// even though the file existed at exactly the path the manifest
// recorded.
const candidates = [
// (1) Honour absolute paths recorded by the dumper.
path.isAbsolute(dbBackupFile) ? dbBackupFile : null,
// (2) Relative-to-backupPath as-stored (no basename munging).
path.join(backupPath, dbBackupFile),
// (3) Legacy reconstruct.
path.join(backupPath, 'database', path.basename(dbBackupFile)),
].filter(Boolean);
let dbBackupPath = null;
for (const candidate of candidates) {
try {
await fs.access(candidate);
dbBackupPath = candidate;
break;
} catch (_) {
// try next candidate
}
}
if (!dbBackupPath) {
throw new Error(
`Database backup file not found. Tried: ${candidates.join(', ')}. ` +
`Manifest recorded path: ${dbBackupFile}. ` +
`Hint: this usually means the manifest's database.backup_file path no longer ` +
`exists on disk (deleted? moved? volume not mounted?). Check ` +
`~/<your-compose-dir>/backup/database/ on the host.`
);
}
// Decompress if needed
@@ -679,6 +902,31 @@ class RestoreService {
restoreFile = decompressedPath;
}
// Snapshot of operator-meta keys captured BEFORE the DROP.
// Stashed onto `this.preservedMetaSnapshot` so the parent
// `restore()` method can drain + apply it AFTER post-restore
// verification passes. Order matters here:
//
// - PR #596 round 1: lifted the declaration above the
// SQLite/PG split to fix a ReferenceError when the replay
// was inline at the bottom of this method.
// - PR #596 round 3: moved the REPLAY itself out of here and
// into restore(), because the round-1 in-method replay ran
// BEFORE post-restore verification — which then counted the
// replayed row and flagged
// Table app_settings row count mismatch: expected 190, got 191
// as a verification failure even though both Stage A and
// the replay had succeeded. Verification now sees the
// as-restored DB (matches the backup exactly), replay layers
// on top after verification has signed off.
//
// SQLite branch leaves `preservedMetaSnapshot` empty — verification
// and replay both no-op for it, unchanged behaviour.
const PRESERVED_META_KEYS = [
'restore_allow_force',
'restore_allow_force_auto_upgraded',
];
try {
if (this.dbType === 'sqlite') {
// SQLite restore
@@ -715,24 +963,210 @@ class RestoreService {
// PostgreSQL restore
const { host, port, user, password, database } = knexConfig.connection;
const env = { ...process.env, PGPASSWORD: password };
// Snapshot operator-meta settings BEFORE the DROP so we can
// restore them after the psql load. These keys are about how
// the operator wants the install to behave (force-restore
// permission, auto-upgrade tracking), not user-facing state —
// they should NOT be overwritten by whatever values the backup
// happens to contain.
//
// Chicken-and-egg this closes: `restore_allow_force` defaults
// to true (post tonight's migration 032 edit), but every
// restore would overwrite it with whatever the backup carried.
// Admin sets it to true → restores → wakes up with the row
// back to whatever was in the backup. Two consecutive restores
// needed the SQL workaround again. With this snapshot/replay,
// the operator's policy persists across restores.
//
// PRESERVED_META_KEYS is declared above the SQLite/PG split
// (~L820). The snapshot READ happens here in the PG branch
// (must run before DROP), but is stashed on
// `this.preservedMetaSnapshot` for the parent `restore()`
// method to consume AFTER verification — see the round-3
// notes there.
try {
this.preservedMetaSnapshot = await db('app_settings')
.whereIn('setting_key', PRESERVED_META_KEYS)
.select('setting_key', 'setting_value', 'setting_type');
this.log('info', `Snapshotted ${this.preservedMetaSnapshot.length} restore-meta setting(s) for post-restore replay`, {
keys: this.preservedMetaSnapshot.map(r => r.setting_key),
});
} catch (err) {
this.log('warn', `Could not snapshot restore-meta settings (continuing): ${err.message}`);
}
// `psql` with no `-d` defaults to a database whose name matches
// the connecting user, NOT a maintenance DB. So on installs
// where the user's home DB doesn't exist (e.g. user=`picpeak`,
// target DB=`picpeak_prod`, no `picpeak` DB), the next two
// statements failed with:
// FATAL: database "picpeak" does not exist
// even though the actual target DB was alive and connectable.
//
// Fix: explicitly connect to `postgres` (the maintenance DB
// every PG cluster ships with) for the DROP/CREATE. We can't
// connect to the target DB itself anyway — DROP DATABASE
// refuses to run while a connection is open to it.
//
// Use `DB_CHECK_DB` env var as an override hook (matches the
// pattern wait-for-db.sh already exposes) for installs where
// the `postgres` DB is restricted to superusers.
const maintenanceDb = process.env.DB_CHECK_DB || 'postgres';
// The backend's own knex pool holds N active connections to
// the target database (default 5-25 per knexfile.js). PostgreSQL
// refuses DROP DATABASE while any session is connected:
// ERROR: database "X" is being accessed by other users
// DETAIL: There are N other sessions using the database.
// We have to evict those sessions ourselves before issuing the
// DROP. Two-step approach:
// 1. Close knex's own pool so we don't fight ourselves.
// 2. pg_terminate_backend() the rest (other server replicas,
// pg_stat_activity stragglers, leftover idle txns).
//
// After CREATE DATABASE, knex will lazily re-open the pool on
// the next query — handled by db.js's connection retry logic.
this.log('warn', 'Closing knex pool before dropping target database...');
try { await db.destroy(); } catch (poolErr) {
this.log('warn', `Pool destroy threw (continuing): ${poolErr.message}`);
}
this.log('warn', 'Terminating any remaining sessions on target database...', {
target: database,
});
// pg_terminate_backend takes a pid. Kill every session against
// the target DB except our own connection (which is to the
// maintenance DB anyway). Wrapped in `SELECT ... FROM ... WHERE`
// so we get one psql round-trip instead of N.
await spawnAsync('psql', [
'-h', host, '-p', String(port), '-U', user, '-d', maintenanceDb,
'-c',
`SELECT pg_terminate_backend(pid) FROM pg_stat_activity ` +
`WHERE datname = '${database.replace(/'/g, "''")}' AND pid <> pg_backend_pid()`,
], { env });
// Drop and recreate database (extremely dangerous!)
this.log('warn', 'Dropping and recreating PostgreSQL database...');
await spawnAsync('psql', ['-h', host, '-p', String(port), '-U', user, '-c', `DROP DATABASE IF EXISTS ${database}`], { env });
this.log('warn', 'Dropping and recreating PostgreSQL database...', {
target: database, via: maintenanceDb,
});
await spawnAsync('psql', ['-h', host, '-p', String(port), '-U', user, '-c', `CREATE DATABASE ${database}`], { env });
// WITH (FORCE) on Postgres 13+ kills any remaining connections
// atomically with the DROP. On older Postgres the FORCE option
// doesn't exist, so we fall back to plain DROP IF EXISTS — by
// which point pg_terminate_backend should have cleared the
// table. Try FORCE first, fall back to plain on syntax error.
try {
await spawnAsync('psql', ['-h', host, '-p', String(port), '-U', user, '-d', maintenanceDb,
'-c', `DROP DATABASE IF EXISTS "${database}" WITH (FORCE)`], { env });
} catch (forceErr) {
// PG < 13: WITH (FORCE) is a syntax error. Plain DROP after
// our pg_terminate_backend pass should now succeed.
this.log('info', 'DROP DATABASE WITH (FORCE) not supported — falling back to plain DROP', {
error: forceErr.message,
});
await spawnAsync('psql', ['-h', host, '-p', String(port), '-U', user, '-d', maintenanceDb,
'-c', `DROP DATABASE IF EXISTS "${database}"`], { env });
}
// Restore from backup
await spawnAsync('psql', ['-h', host, '-p', String(port), '-U', user, '-d', maintenanceDb, '-c', `CREATE DATABASE "${database}"`], { env });
// Restore from backup — this one DOES connect to the target DB.
await spawnFromFile('psql', ['-h', host, '-p', String(port), '-U', user, '-d', database], restoreFile, { env });
// Re-sync every SERIAL / IDENTITY sequence in the public schema
// to MAX(id)+1 of its owning table. pg_dump emits setval()
// statements, but they don't always land cleanly when:
// - the dump has `--clean` (the setval may execute before
// the rebuilt rows, depending on dump ordering)
// - the in-process knex pool had a cached sequence value
// before db.destroy() (already mitigated, but defensive)
// - rows were inserted mid-restore (the pre-restore safety
// backup creates a database_backup_runs row before DROP)
// Result if skipped: every subsequent INSERT into a serial-id
// table fails with `duplicate key value violates unique
// constraint "<table>_pkey"`. Surfaced on Ralf's install as
// "A record with this value already exists" on every CRUD
// action AND `database_backup_runs_pkey` violation on the
// next Run Backup Now. Fix is a single DO block that walks
// pg_class + pg_attribute and setval()s each sequence to
// GREATEST(MAX(<col>), 1). Cheap (a few ms even on large
// schemas), safe (doesn't touch row data), idempotent.
this.log('info', 'Re-syncing PostgreSQL sequences to MAX(id) of each table...');
await spawnAsync('psql', [
'-h', host, '-p', String(port), '-U', user, '-d', database,
'-c',
`DO $$
DECLARE
r RECORD;
max_id BIGINT;
BEGIN
FOR r IN
SELECT n.nspname AS schema_name, t.relname AS table_name, a.attname AS column_name,
pg_get_serial_sequence(quote_ident(n.nspname) || '.' || quote_ident(t.relname), a.attname) AS seq_name
FROM pg_class t
JOIN pg_namespace n ON n.oid = t.relnamespace
JOIN pg_attribute a ON a.attrelid = t.oid
WHERE n.nspname = 'public'
AND t.relkind = 'r'
AND a.attnum > 0
AND NOT a.attisdropped
AND pg_get_serial_sequence(quote_ident(n.nspname) || '.' || quote_ident(t.relname), a.attname) IS NOT NULL
LOOP
EXECUTE format('SELECT COALESCE(MAX(%I), 0) FROM %I.%I', r.column_name, r.schema_name, r.table_name) INTO max_id;
EXECUTE format('SELECT setval(%L, %s, true)', r.seq_name, GREATEST(max_id, 1));
END LOOP;
END $$;`
], { env });
this.log('info', 'Sequence resync completed');
}
// Re-initialize database connection
const { db: newDb } = require('../database/db');
// Run migrations to ensure schema is up to date
this.log('info', 'Running database migrations...');
await newDb.migrate.latest();
// Re-initialize the in-process knex pool. The DROP/CREATE
// DATABASE pair above destroyed our connections and the recreated
// database has a different pg_database OID — any pooled
// connection from before would either be dead or pointed at a
// ghost. Without explicit reinit, every query in the process
// after restore returns `Error: Unable to acquire a connection`
// until the container is manually restarted (and admin sees
// "An error occurred" on the login screen even after the restore
// technically succeeded). reinitPool destroys + rebuilds the
// pool and probes the new one with `SELECT 1` so failures here
// surface immediately instead of polluting the next request.
const { reinitPool } = require('../database/db');
this.log('info', 'Re-initializing knex pool against the restored database...');
await reinitPool();
this.log('info', 'Knex pool re-initialized');
// NOTE: we deliberately do NOT call `db.migrate.latest()` here.
//
// The picpeak migrations directory contains `helpers.js` (a
// shared helper module, not a migration), plus `core/` and
// `legacy/` subdirectories. Knex's built-in migrator scans the
// top-level directory and rejects any file without `up`/`down`
// exports — so `db.migrate.latest()` throws
// Invalid migration: helpers.js must have both an up and down function
// every time it runs in this codebase. The production code path
// uses `npm run migrate:safe` (run-migrations-safe.js) which
// knows to skip helpers.js + walks core/ explicitly.
//
// The safe runner gets invoked AFTER verification in restore()
// (see step 7c) to apply any post-backup migrations to the
// restored DB. This closes the contract "backup must restore
// completely even when new features have been added in the
// meantime" — without this step, restoring an old backup on a
// newer image would leave the running process serving a
// mismatched schema until the next container restart.
this.log('info', 'Schema migrations deferred to restore() step 7c (npm run migrate:safe subprocess)');
// NOTE: operator-meta REPLAY does NOT happen here any more.
// PR #596 round 3: if the replay runs inside performDatabaseRestore,
// it lands BEFORE post-restore verification — and verification
// then counts the replayed row as a mismatch (e.g. "expected 190,
// got 191" because the fresh-install seeded
// `restore_allow_force_auto_upgraded` that wasn't in the backup).
// Replay is now drained by the parent `restore()` method AFTER
// verification passes. Snapshot lives on
// `this.preservedMetaSnapshot` for that drain.
return { success: true };
@@ -905,9 +1339,20 @@ class RestoreService {
for (const [table, expected] of Object.entries(manifest.database.row_counts)) {
try {
const result = await db(table).count('* as count').first();
if (result.count !== expected.rowCount) {
// pg-driver serialises `bigint` as string to preserve
// precision for huge counts, so `result.count` on PG is
// e.g. `"16"` while the manifest's `expected.rowCount`
// is the JS number `16`. Strict `!==` flagged every
// match as a mismatch on PG. Caught on PR #596 review:
// `Table activity_logs row count mismatch:
// expected 16, got 16`
// every table, all "matching". Coerce both sides to
// Number to compare reliably across SQLite (number) and
// PG (string).
const actual = Number(result.count);
if (actual !== expected.rowCount) {
verification.errors.push(
`Table ${table} row count mismatch: expected ${expected.rowCount}, got ${result.count}`
`Table ${table} row count mismatch: expected ${expected.rowCount}, got ${actual}`
);
}
} catch (error) {
+89 -21
View File
@@ -41,60 +41,128 @@ function spawnAsync(cmd, args = [], options = {}) {
/**
* Run a command and redirect stdout to a file (replaces shell `> file`).
*
* Historically this passed `fs.createWriteStream(outputPath)` directly as
* `stdio[1]` to `child_process.spawn`. That relied on Node auto-extracting
* the WriteStream's `.fd` — but the stream opens async, so on a fast call
* `fd` is still `null` when `spawn()` reads it. Older Node releases would
* tolerate this; Node 22 throws synchronously with
* `The argument 'stdio' is invalid. Received WriteStream { fd: null, ... }`.
*
* Cure: use `stdio: ['ignore', 'pipe', 'pipe']` and wire the WriteStream
* up via the streams API (`stdout.pipe(outStream)`). Works on every Node
* version; also gives us a clean error bridge from both the WriteStream
* AND the child process to the promise, instead of the previous code's
* blind `outStream.destroy()` / `outStream.end()` calls that left stream
* errors uncaught (Node 22 process-fatal — separate footgun this fixes
* by the same change).
*
* Used by:
* - databaseBackup.createPostgreSQLBackup (inline-dump path, the
* thing that just bit Ralf's install)
* - restoreService pre-restore safety snapshot (would have hit the
* same on next restore attempt)
*/
function spawnToFile(cmd, args, outputPath, options = {}) {
const fs = require('fs');
return new Promise((resolve, reject) => {
const outStream = fs.createWriteStream(outputPath);
let settled = false;
const settleReject = (err) => {
if (settled) return;
settled = true;
try { outStream.destroy(); } catch (_) { /* best effort */ }
reject(err);
};
const settleResolve = (value) => {
if (settled) return;
settled = true;
resolve(value);
};
// Bridge WriteStream errors (EACCES, ENOSPC, etc.) to the promise.
// Without this, an unhandled 'error' event on the stream is process-
// fatal on Node 22 and bypasses the caller's try/catch entirely —
// which is exactly the failure mode that crashed the picpeak
// backend container on its first inline-dump attempt.
outStream.on('error', settleReject);
const child = spawn(cmd, args, {
shell: false,
...options,
stdio: ['ignore', outStream, 'pipe']
stdio: ['ignore', 'pipe', 'pipe']
});
// Pipe stdout → file. The pipe call attaches its own 'error'
// handlers on both ends so a child-stdout failure also reaches us.
child.stdout.pipe(outStream);
const stderrChunks = [];
child.stderr.on('data', chunk => stderrChunks.push(chunk));
child.stderr.on('error', settleReject);
child.on('error', (err) => {
outStream.destroy();
reject(err);
});
child.on('error', settleReject);
child.on('close', (code) => {
outStream.end();
const stderr = Buffer.concat(stderrChunks).toString();
if (code !== 0) {
const err = new Error(`${cmd} exited with code ${code}: ${stderr}`);
err.code = code;
err.stderr = stderr;
return reject(err);
}
resolve({ stderr });
// Wait for the file write to flush before resolving — otherwise
// a fast 'close' could resolve while the WriteStream still has
// buffered bytes, producing a truncated dump.
outStream.end(() => {
const stderr = Buffer.concat(stderrChunks).toString();
if (code !== 0) {
const err = new Error(`${cmd} exited with code ${code}: ${stderr}`);
err.code = code;
err.stderr = stderr;
return settleReject(err);
}
settleResolve({ stderr });
});
});
});
}
/**
* Run a command and pipe a file into stdin (replaces shell `< file`).
*
* Same Node 22 stdio strictness applies as for `spawnToFile` above — the
* ReadStream `fd` is null at spawn time. Use `stdio[0] = 'pipe'` and pipe
* the file stream into `child.stdin` via the streams API instead.
*/
function spawnFromFile(cmd, args, inputPath, options = {}) {
const fs = require('fs');
return new Promise((resolve, reject) => {
const inStream = fs.createReadStream(inputPath);
let settled = false;
const settleReject = (err) => {
if (settled) return;
settled = true;
try { inStream.destroy(); } catch (_) { /* best effort */ }
reject(err);
};
const settleResolve = (value) => {
if (settled) return;
settled = true;
resolve(value);
};
inStream.on('error', settleReject);
const child = spawn(cmd, args, {
shell: false,
...options,
stdio: [inStream, 'pipe', 'pipe']
stdio: ['pipe', 'pipe', 'pipe']
});
inStream.pipe(child.stdin);
const stdoutChunks = [];
const stderrChunks = [];
child.stdout.on('data', chunk => stdoutChunks.push(chunk));
child.stdout.on('error', settleReject);
child.stderr.on('data', chunk => stderrChunks.push(chunk));
child.stderr.on('error', settleReject);
child.stdin.on('error', settleReject);
child.on('error', (err) => {
inStream.destroy();
reject(err);
});
child.on('error', settleReject);
child.on('close', (code) => {
const stdout = Buffer.concat(stdoutChunks).toString();
const stderr = Buffer.concat(stderrChunks).toString();
@@ -103,9 +171,9 @@ function spawnFromFile(cmd, args, inputPath, options = {}) {
err.code = code;
err.stdout = stdout;
err.stderr = stderr;
return reject(err);
return settleReject(err);
}
resolve({ stdout, stderr });
settleResolve({ stdout, stderr });
});
});
}