the contact inbox is a mailbox, and it is copied the moment it changes
Messages are the one thing that happens between publishes. A backup triggered by publishing would never contain them, and a RESTORE would destroy every message received since the dump being restored — turning a routine recovery into losing the only copy of something a stranger sent, with nothing about the operation looking like it had done that. So they live in their own SQLite file, in the shape Officer's email plugin already uses. Not a schema that fits a contact form: this IS mail — a from, a subject, a date and a body wearing a form — and storing it as a mailbox means the mail client can open it, and anybody's agent already knows how to read it without being told. The form's own fields become labelled lines in text_body, where a reader and a grep both find them; inventing columns would be the moment it stopped being a mailbox. This side watches the file and copies it within seconds of a write, to the local backup directory and to the machine off this one. The alternative is a window: a message at ten past nine and a disk that fails at twenty past is a message nobody sees, while the sender believes they contacted you. Two details are the whole reason this is not a `cp`. The WAL is checkpointed first — the site writes in WAL mode, so a new message lives in messages.db-wal and not yet in messages.db, and copying the main file alone yields a database that is intact, readable, and missing exactly the message that triggered the copy. And the DIRECTORY is watched rather than the file, because SQLite replaces files during a checkpoint and a watch on a replaced inode goes quiet for good — a watcher that silently stops is worse than none, since the panel would go on implying the inbox is protected. The copy is current, not history; the ordinary backup runs take a timestamped one alongside the dumps, so a restored site can have the inbox that matches it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+25
-1
@@ -1,4 +1,4 @@
|
||||
import { mkdir, readdir, stat, unlink } from 'node:fs/promises';
|
||||
import { copyFile, mkdir, readdir, stat, unlink } from 'node:fs/promises';
|
||||
import { isAbsolute, join } from 'node:path';
|
||||
import { CronJob, CronTime, validateCronExpression } from 'cron';
|
||||
import { db } from 'officerdb/db';
|
||||
@@ -6,6 +6,7 @@ import { eq } from 'drizzle-orm';
|
||||
import { OFFICER_ROOT } from '@@/data-path';
|
||||
import { fofosAdminSettings } from '../db/schema';
|
||||
import { readDatabases } from './databases';
|
||||
import { messagesFile } from './messages';
|
||||
|
||||
/**
|
||||
* Backups of the site's databases: when, where, where else, and how many to keep.
|
||||
@@ -304,6 +305,29 @@ export async function runBackup(): Promise<{ ok: boolean; files: string[]; outpu
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
The mailbox, timestamped, alongside the dumps.
|
||||
|
||||
The watcher keeps a CURRENT copy at each destination and that is what protects a message minutes
|
||||
after it arrives. This is the history: without it, restoring last week's site would come with this
|
||||
morning's inbox and no way back to the one that matches.
|
||||
*/
|
||||
if (row?.rootPath) {
|
||||
const mailbox = messagesFile(row.rootPath);
|
||||
if (await stat(mailbox).catch(() => null)) {
|
||||
const copy = join(dir, `messages-${stamp}.db`);
|
||||
await copyFile(mailbox, copy).catch(() => {});
|
||||
lines.push(`messages-${stamp}.db`);
|
||||
if (row.backupSshHost && row.backupSshPath) {
|
||||
const sent = await run(['scp', ...SSH_OPTIONS, copy, `${row.backupSshHost}:${row.backupSshPath}/`]);
|
||||
if (!sent.ok) {
|
||||
ok = false;
|
||||
lines.push(` -> COPY FAILED: ${sent.output}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (row?.backupKeep) lines.push(...(await prune(row.backupKeep, dir, row.backupSshHost, row.backupSshPath)));
|
||||
|
||||
/*
|
||||
|
||||
Reference in New Issue
Block a user