feat(db): make the SQLite journal mode configurable (#1675)

WAL was hardcoded, and on network-backed storage — Azure App Service Linux,
SMB/NFS volumes on NAS and PaaS — SQLite documents it as unsafe, because the
coordination runs through the -shm file and mmap. Operators had no way out.

TREK_DB_JOURNAL_MODE and TREK_DB_SYNCHRONOUS now set both pragmas, defaulting
to today's behaviour. The mode lives in the file header, so all three
processes that open travel.db read the same configuration — the server,
reset-admin.js and migrate-encryption.ts, which used to hard-set WAL and would
have silently reverted a deliberate DELETE setup on the first key rotation.

An invalid value falls back and warns rather than refusing to start, and the
mode that actually took effect is read back and logged, so an operator can see
whether the setting landed.
This commit is contained in:
Maurice
2026-07-29 16:42:59 +02:00
committed by Maurice
parent e62dceb4bb
commit 98ad2450a2
10 changed files with 430 additions and 4 deletions
+8 -2
View File
@@ -63,7 +63,7 @@ PORT, HOST, TRUST_PROXY, SESSION_DURATION(_REMEMBER), MCP_SESSION_TTL,
MCP_MAX_SESSION_PER_USER, MCP_SSE_KEEPALIVE, TREK_PLUGIN_RPC_*/LOG_*/MAX_RSS_MB,
TREK_PLUGIN_REGISTRY_URL, TREK_WIKI_DIR*, TREK_PLACE_PHOTO_DIR, BACKUP_*,
TRANSIT_API_URL, LOG_LEVEL*, ALLOW_INTERNAL_NETWORK*, DEFAULT_LANGUAGE,
TREK_DB_FILE, ENCRYPTION_KEY.
TREK_DB_FILE, TREK_DB_JOURNAL_MODE, TREK_DB_SYNCHRONOUS, ENCRYPTION_KEY.
(* frozen today because the consuming module captures it at import; tests that
override these set them at file top, before the SUT import.)
@@ -85,7 +85,13 @@ ADMIN_PASSWORD, IDEMPOTENCY_TTL_SECONDS, MCP_RATE_LIMIT (request-path check).
`plugins/host/plugin-audit.ts`.
- `src/config.ts` ENCRYPTION_KEY/JWT_SECRET resolution — key material with file
persistence and runtime rotation, not env config.
- Standalone scripts (`reset-admin.js`, `scripts/*`) and `tests/**`.
- Standalone scripts (`reset-admin.js`, `scripts/*`) and `tests/**`. Note that
`reset-admin.js` and `scripts/migrate-encryption.ts` open the database file
read-write and therefore re-implement `TREK_DB_JOURNAL_MODE` /
`TREK_DB_SYNCHRONOUS` inline (the journal mode lives in the file header, so
they must agree with the server). Neither can import this layer — the image
ships `dist/`, not `src/` — so the defaults in `parsers.resolveDurability()`
and in those two files move together.
The exemption list is enforced by the `no-restricted-syntax` ban on
`process.env` in `eslint.config.mjs` — keep the two lists in sync.
+8
View File
@@ -23,6 +23,7 @@ import {
parseDurationMs,
positiveIntOr,
positiveNumberOr,
resolveDurability,
resolveKeepaliveMs,
resolveSessionTtlMs,
stripTrailingSlashes,
@@ -205,8 +206,15 @@ export function deriveBackup(raw: RawEnv) {
}
export function deriveDb(raw: RawEnv) {
const durability = resolveDurability(raw.TREK_DB_JOURNAL_MODE, raw.TREK_DB_SYNCHRONOUS);
return {
trekDbFile: raw.TREK_DB_FILE,
/** Resolved journal_mode — WAL unless the operator asked for something else (network storage needs DELETE/TRUNCATE). */
journalMode: durability.journalMode,
/** Resolved synchronous level — NORMAL under WAL (what SQLite already does), FULL under a rollback journal. */
synchronous: durability.synchronous,
/** Complaints about unusable values; derivation stays side-effect free, so db/durability.ts logs them when it opens the file. */
durabilityWarnings: durability.warnings,
};
}
+6
View File
@@ -116,6 +116,12 @@ export const envSchema = z.object({
// Data / paths
TREK_DB_FILE: anyString,
// The two SQLite pragmas are deliberately unvalidated: a typo here must fall
// back and warn rather than abort boot, because reset-admin.js — the way out
// of a locked-out instance — reads the same variables and is not covered by
// this schema at all. Resolution lives in parsers.resolveDurability().
TREK_DB_JOURNAL_MODE: anyString,
TREK_DB_SYNCHRONOUS: anyString,
TREK_WIKI_DIR: anyString,
TREK_PLACE_PHOTO_DIR: anyString,
BACKUP_UPLOAD_LIMIT_MB: positiveNumber,
+65
View File
@@ -106,3 +106,68 @@ export function resolveKeepaliveMs(raw: string | undefined): number {
const parsed = Number.parseInt(raw ?? '');
return Number.isFinite(parsed) && parsed >= 0 ? parsed * 1000 : 25_000;
}
/** SQLite's journal_mode vocabulary. WAL is the default and the right choice on local disk; DELETE and TRUNCATE are the ones that survive network storage (Azure App Service, SMB/NFS volumes), where WAL's shared-memory coordination is not reliable. */
const JOURNAL_MODES = ['DELETE', 'TRUNCATE', 'PERSIST', 'MEMORY', 'WAL', 'OFF'];
/** PRAGMA synchronous levels, in the order SQLite numbers them (0-3). */
const SYNCHRONOUS_LEVELS = ['OFF', 'NORMAL', 'FULL', 'EXTRA'];
export const DEFAULT_JOURNAL_MODE = 'WAL';
export interface Durability {
journalMode: string;
synchronous: string;
/** Rejected input, phrased for the log. Resolution never throws — a typo in a pragma must not lock an operator out of their instance — so the process that opens the file prints these instead. */
warnings: string[];
}
/**
* TREK_DB_JOURNAL_MODE / TREK_DB_SYNCHRONOUS → the pragmas to apply.
*
* journal_mode is persisted in the database file header, so it outlives the
* process that set it and every entry point opening the file read-write has to
* agree on it (see src/db/durability.ts). Defaults reproduce what an existing
* install runs today: WAL, and the synchronous=NORMAL that SQLite itself picks
* for a WAL database. Anything other than WAL keeps a rollback journal, where
* NORMAL means a power cut can lose the last transactions — those default to
* FULL, otherwise moving off WAL for safety would only trade one risk for
* another.
*/
export function resolveDurability(rawJournalMode: string | undefined, rawSynchronous: string | undefined): Durability {
const warnings: string[] = [];
let journalMode = DEFAULT_JOURNAL_MODE;
const wanted = rawJournalMode?.trim().toUpperCase();
if (wanted) {
if (JOURNAL_MODES.includes(wanted)) {
journalMode = wanted;
} else {
warnings.push(
`TREK_DB_JOURNAL_MODE="${rawJournalMode}" is not a SQLite journal mode ` +
`(${JOURNAL_MODES.join(', ')}) — using ${DEFAULT_JOURNAL_MODE}.`,
);
}
}
const defaultSynchronous = journalMode === DEFAULT_JOURNAL_MODE ? 'NORMAL' : 'FULL';
let synchronous = defaultSynchronous;
const wantedSync = rawSynchronous?.trim().toUpperCase();
if (wantedSync) {
if (SYNCHRONOUS_LEVELS.includes(wantedSync)) {
synchronous = wantedSync;
} else {
warnings.push(
`TREK_DB_SYNCHRONOUS="${rawSynchronous}" is not a SQLite synchronous level ` +
`(${SYNCHRONOUS_LEVELS.join(', ')}) — using ${defaultSynchronous}.`,
);
}
}
return { journalMode, synchronous, warnings };
}
/** `PRAGMA synchronous` reads back as a number — name it, so the boot log says FULL instead of 2. Unrecognized values pass through unchanged. */
export function synchronousName(level: unknown): string {
return SYNCHRONOUS_LEVELS[Number(level)] ?? String(level);
}
+9 -1
View File
@@ -2,6 +2,7 @@ import Database from 'better-sqlite3';
import path from 'path';
import fs from 'fs';
import { readEnv } from '../app-config';
import { applyDurabilityPragmas } from './durability';
import { createTables } from './schema';
import { runMigrations } from './migrations';
import { runSeeds } from './seeds';
@@ -39,9 +40,16 @@ function initDb(): void {
}
_db = new Database(dbPath);
_db.exec('PRAGMA journal_mode = WAL');
// Ahead of the journal switch now: changing journal_mode needs an exclusive
// lock, which a sibling process (reset-admin, the rotation script) may hold.
_db.exec('PRAGMA busy_timeout = 5000');
const durability = applyDurabilityPragmas(_db);
_db.exec('PRAGMA foreign_keys = ON');
// Reported so an operator can see whether their setting took — the test DB is
// :memory: and has no journal file, so there is nothing to report there.
if (dbPath !== ':memory:') {
console.log(`[DB] journal_mode=${durability.journalMode}, synchronous=${durability.synchronous}`);
}
createTables(_db);
runMigrations(_db);
+36
View File
@@ -0,0 +1,36 @@
import type Database from 'better-sqlite3';
import { readEnv } from '../app-config';
import { synchronousName } from '../app-config/parsers';
export interface ActiveDurability {
journalMode: string;
synchronous: string;
}
/**
* Applies the configured journal_mode / synchronous pragmas to a freshly opened
* connection and reports back what SQLite actually settled on.
*
* journal_mode is stored in the database file header, so it is not a per-process
* setting: whichever process opened the file last decides the mode for all of
* them. reset-admin.js and scripts/migrate-encryption.ts open the same file and
* therefore read the same two variables — they cannot import this module (they
* are standalone scripts and src/ is not in the image), so keep the three in
* sync when the defaults change.
*
* The returned values are read back from the engine rather than echoed from the
* config, so a mode SQLite refused shows up as what it really is — an in-memory
* database stays MEMORY however hard you ask for WAL.
*/
export function applyDurabilityPragmas(db: Database.Database): ActiveDurability {
const { journalMode, synchronous, durabilityWarnings } = readEnv().db;
durabilityWarnings.forEach(warning => console.warn(`[DB] ${warning}`));
db.exec(`PRAGMA journal_mode = ${journalMode}`);
db.exec(`PRAGMA synchronous = ${synchronous}`);
return {
journalMode: String(db.pragma('journal_mode', { simple: true })).toUpperCase(),
synchronous: synchronousName(db.pragma('synchronous', { simple: true })),
};
}