feat(backup): config-driven walker via backup_paths table
Stage B of the three-stage backup-hardening plan (Stage A:
inline-DB-dump + fail-loud guard already landed). The file-backup
walker used to hard-code its subdirectory list inside
`getFilesToBackupInternal`, which is the same footgun that hid the
`business-docs` gap for ~6 months — a new feature drops artefacts
under STORAGE_PATH and the maintainer has to remember to edit the
walker.
Now driven by a `backup_paths` table:
- Migration 108 creates the table and seeds the 7 canonical
defaults (events/active, events/archived, thumbnails, previews,
heroes, uploads, business-docs). Seed data lives on the
migration as `DEFAULT_PATHS` so the boot self-heal can re-use it.
- `_backupPathsBoot.js` mirrors `_emailTemplateBoot.js`: on every
boot it diffs the canonical list against the current rows and
`INSERT ... ON CONFLICT DO NOTHING`s the missing ones. Keeps
admin edits intact, picks up new defaults shipped after the
install (Knex won't re-run migration 108). Wired into server.js
just before `startBackupService()`.
- Walker now calls `resolveBackupPaths(config)` which:
* reads `backup_paths WHERE include_in_default=true ORDER BY
display_order`
* falls back to a hard-coded `LEGACY_BACKUP_PATHS` if the
table is missing OR empty (defense in depth — never silently
scans nothing)
* gates each row by its `feature_flag` column (matches how
`backup_include_archived` already worked; data-driven now)
- Backward compatible: `getFilesToBackup(true|false)` still works
for legacy callers and the existing businessDocs test. New
callers should pass the full config object so feature gates
other than `backup_include_archived` evaluate correctly.
Tests:
- new: `backupService.configurableWalker.test.js` — 7 cases
covering canonical seed, toggling include_in_default, runtime
INSERT picked up without restart, feature_flag gating both on
and off, empty-table → LEGACY fallback, boolean backward compat
- all 15 backup-walker integration tests pass
(configurableWalker 7 + inlineDbDump 5 + businessDocs 3)
- frontend build clean
- 4 pre-existing integration failures (webhookDelivery, storage
backend, adminPhotos.reference, imageProcessor.storage) confirmed
unrelated via `git stash` baseline run
Stage C (CRM feature coverage audit + diagnostic UI) follows
in a separate commit.
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Migration 108 — config-driven backup walker.
|
||||
*
|
||||
* Stage B of the three-stage backup-hardening plan. The file-backup
|
||||
* walker (`getFilesToBackupInternal` in backupService.js) historically
|
||||
* hard-coded its list of subdirectories: events/active, events/archived,
|
||||
* thumbnails, previews, heroes, uploads, business-docs.
|
||||
*
|
||||
* That list is a footgun every time a new feature lands that drops
|
||||
* artefacts under STORAGE_PATH/<something>/ — the maintainer has to
|
||||
* remember to edit the walker, and there's no schema-level record of
|
||||
* what *should* be backed up. The CRM rollout missed `business-docs`
|
||||
* for ~6 months (#XXX) for exactly this reason.
|
||||
*
|
||||
* This migration introduces a `backup_paths` table that the walker
|
||||
* reads at runtime. New features add a row; the walker picks them up
|
||||
* automatically. The `feature_flag` column gates scans behind an
|
||||
* existing app_settings boolean (e.g. `backup_include_archived`),
|
||||
* mirroring how the previous `includeArchived` parameter worked.
|
||||
*
|
||||
* Columns:
|
||||
* - path : relative to STORAGE_PATH, unique
|
||||
* - include_in_default : on/off without deleting the row (so
|
||||
* audit trail of "we used to back this up"
|
||||
* is preserved)
|
||||
* - feature_flag : nullable; when set, walker checks the
|
||||
* same-named app_settings boolean before
|
||||
* scanning. Matches the existing pattern
|
||||
* used by `backup_include_archived`.
|
||||
* - display_order : controls admin-UI listing order
|
||||
* - description : human-readable purpose, shown in admin UI
|
||||
*
|
||||
* Defense-in-depth: the walker also keeps a hard-coded LEGACY_DEFAULTS
|
||||
* fallback so that if this table is somehow empty (failed migration on
|
||||
* an existing install, manual truncation), backups still cover the
|
||||
* historical set instead of silently shipping nothing. The boot-time
|
||||
* self-heal in `_backupPathsBoot.js` re-seeds missing default rows on
|
||||
* every startup so newly-added defaults reach already-deployed
|
||||
* installs without a follow-up migration.
|
||||
*
|
||||
* Idempotent: skips the createTable if it already exists, and the
|
||||
* seed uses `onConflict('path').ignore()` so re-runs don't duplicate.
|
||||
*/
|
||||
|
||||
const DEFAULT_PATHS = [
|
||||
{
|
||||
path: 'events/active',
|
||||
include_in_default: true,
|
||||
feature_flag: null,
|
||||
display_order: 10,
|
||||
description: 'Active gallery photo originals',
|
||||
},
|
||||
{
|
||||
path: 'events/archived',
|
||||
include_in_default: true,
|
||||
feature_flag: 'backup_include_archived',
|
||||
display_order: 20,
|
||||
description: 'Archived gallery photo originals (gated by backup_include_archived)',
|
||||
},
|
||||
{
|
||||
path: 'thumbnails',
|
||||
include_in_default: true,
|
||||
feature_flag: null,
|
||||
display_order: 30,
|
||||
description: 'Generated gallery thumbnails',
|
||||
},
|
||||
{
|
||||
path: 'previews',
|
||||
include_in_default: true,
|
||||
feature_flag: null,
|
||||
display_order: 40,
|
||||
description: 'Lightbox preview tier (#492)',
|
||||
},
|
||||
{
|
||||
path: 'heroes',
|
||||
include_in_default: true,
|
||||
feature_flag: null,
|
||||
display_order: 50,
|
||||
description: 'Gallery hero header images',
|
||||
},
|
||||
{
|
||||
path: 'uploads',
|
||||
include_in_default: true,
|
||||
feature_flag: null,
|
||||
display_order: 60,
|
||||
description: 'Direct uploads root (wet-signature contracts, imported invoices, etc.)',
|
||||
},
|
||||
{
|
||||
path: 'business-docs',
|
||||
include_in_default: true,
|
||||
feature_flag: null,
|
||||
display_order: 70,
|
||||
description: 'CRM PDFs, signature artefacts, admin-imported historical invoices',
|
||||
},
|
||||
];
|
||||
|
||||
exports.up = async function(knex) {
|
||||
const exists = await knex.schema.hasTable('backup_paths');
|
||||
if (!exists) {
|
||||
await knex.schema.createTable('backup_paths', (t) => {
|
||||
t.increments('id').primary();
|
||||
t.string('path', 256).notNullable().unique();
|
||||
t.boolean('include_in_default').notNullable().defaultTo(true);
|
||||
t.string('feature_flag', 64).nullable();
|
||||
t.integer('display_order').notNullable().defaultTo(100);
|
||||
t.string('description', 256).nullable();
|
||||
t.timestamp('created_at').notNullable().defaultTo(knex.fn.now());
|
||||
t.timestamp('updated_at').notNullable().defaultTo(knex.fn.now());
|
||||
});
|
||||
}
|
||||
|
||||
// Seed defaults — `onConflict('path').ignore()` so already-seeded rows
|
||||
// (manual edits by admins, prior partial runs) survive untouched.
|
||||
await knex('backup_paths')
|
||||
.insert(DEFAULT_PATHS.map((row) => ({
|
||||
...row,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
})))
|
||||
.onConflict('path')
|
||||
.ignore();
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
await knex.schema.dropTableIfExists('backup_paths');
|
||||
};
|
||||
|
||||
// Exported so the self-heal boot helper can reuse the same authoritative
|
||||
// list without re-declaring it. Tests also import this to assert the
|
||||
// walker is reading from this source.
|
||||
exports.DEFAULT_PATHS = DEFAULT_PATHS;
|
||||
Reference in New Issue
Block a user