From 98ad2450a285b372cab4899e9db77bb1e578a4e7 Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 29 Jul 2026 16:42:59 +0200 Subject: [PATCH] feat(db): make the SQLite journal mode configurable (#1675) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- server/.env.example | 3 + server/reset-admin.js | 31 ++- server/src/app-config/README.md | 10 +- server/src/app-config/derive.ts | 8 + server/src/app-config/env.schema.ts | 6 + server/src/app-config/parsers.ts | 65 +++++++ server/src/db/database.ts | 10 +- server/src/db/durability.ts | 36 ++++ server/tests/unit/db/durability.test.ts | 241 ++++++++++++++++++++++++ wiki/Environment-Variables.md | 24 +++ 10 files changed, 430 insertions(+), 4 deletions(-) create mode 100644 server/src/db/durability.ts create mode 100644 server/tests/unit/db/durability.test.ts diff --git a/server/.env.example b/server/.env.example index eba493ac4..ef5d3d65c 100644 --- a/server/.env.example +++ b/server/.env.example @@ -43,6 +43,9 @@ DEMO_MODE=false # Demo mode - resets data hourly # BACKUP_UPLOAD_LIMIT_MB=500 # Max size (MB) of a backup archive you can upload when restoring. Raise it if your backup exceeds 500 MB. If you sit behind a reverse proxy, raise its upload limit too (e.g. nginx client_max_body_size). +# TREK_DB_JOURNAL_MODE=WAL # SQLite journal mode (DELETE, TRUNCATE, PERSIST, MEMORY, WAL, OFF). WAL is fastest and the right choice on local disk. Set DELETE if the data directory lives on network storage (Azure App Service, an SMB/NFS volume on a NAS) — WAL coordinates writers through a shared-memory file that those filesystems do not implement reliably. The mode is stored inside the database file, so it survives a restart; changing this applies on the next boot. +# TREK_DB_SYNCHRONOUS=NORMAL # How hard SQLite flushes to disk (OFF, NORMAL, FULL, EXTRA). Defaults to NORMAL under WAL and FULL under any other journal mode — leave it alone unless you know why you want otherwise. Both variables fall back to their default (with a warning in the log) if the value is not a mode SQLite knows. + # MCP_RATE_LIMIT=300 # Max MCP API requests per user per minute (default: 300) # MCP_MAX_SESSION_PER_USER=20 # Max concurrent MCP sessions per user (default: 20) diff --git a/server/reset-admin.js b/server/reset-admin.js index 84df51e68..da41ecca6 100644 --- a/server/reset-admin.js +++ b/server/reset-admin.js @@ -7,7 +7,8 @@ * * Defaults to admin@trek.local with a generated password (printed below). The * account is flagged must_change_password, so you are prompted to set a new one - * on first login. Honours TREK_DB_FILE the same way the server does. + * on first login. Honours TREK_DB_FILE, TREK_DB_JOURNAL_MODE and + * TREK_DB_SYNCHRONOUS the same way the server does. */ const path = require('path'); const crypto = require('crypto'); @@ -21,8 +22,36 @@ const email = process.env.RESET_ADMIN_EMAIL || 'admin@trek.local'; const password = process.env.RESET_ADMIN_PASSWORD || crypto.randomBytes(12).toString('base64url'); const generated = !process.env.RESET_ADMIN_PASSWORD; +// journal_mode is stored in the database file header, not per connection, so +// opening the file here decides the mode for the server too. Plain JS outside +// the Nest context can't reach src/app-config, hence the direct read — the +// defaults must match parsers.resolveDurability() (WAL + NORMAL, FULL once a +// rollback journal is in play). Run this via `docker exec` and the container's +// environment applies, same as for the server. +const journalModes = ['DELETE', 'TRUNCATE', 'PERSIST', 'MEMORY', 'WAL', 'OFF']; +const syncLevels = ['OFF', 'NORMAL', 'FULL', 'EXTRA']; + +const wantedJournal = (process.env.TREK_DB_JOURNAL_MODE || '').trim().toUpperCase(); +const journalMode = journalModes.includes(wantedJournal) ? wantedJournal : 'WAL'; +if (wantedJournal && journalMode !== wantedJournal) { + console.warn(`TREK_DB_JOURNAL_MODE="${process.env.TREK_DB_JOURNAL_MODE}" is not a SQLite journal mode — using ${journalMode}.`); +} + +const wantedSync = (process.env.TREK_DB_SYNCHRONOUS || '').trim().toUpperCase(); +const defaultSync = journalMode === 'WAL' ? 'NORMAL' : 'FULL'; +const synchronous = syncLevels.includes(wantedSync) ? wantedSync : defaultSync; +if (wantedSync && synchronous !== wantedSync) { + console.warn(`TREK_DB_SYNCHRONOUS="${process.env.TREK_DB_SYNCHRONOUS}" is not a SQLite synchronous level — using ${synchronous}.`); +} + const dbPath = process.env.TREK_DB_FILE || path.join(__dirname, 'data/travel.db'); const db = new Database(dbPath); +// Spelled out rather than left to better-sqlite3's default, so switching the +// journal mode below waits for a busy server instead of failing on the spot. +// Five seconds, same as src/db/database.ts. +db.exec('PRAGMA busy_timeout = 5000'); +db.exec(`PRAGMA journal_mode = ${journalMode}`); +db.exec(`PRAGMA synchronous = ${synchronous}`); const hash = bcrypt.hashSync(password, BCRYPT_COST); const existing = db.prepare('SELECT id FROM users WHERE email = ?').get(email); diff --git a/server/src/app-config/README.md b/server/src/app-config/README.md index 718db3038..594bb2b36 100644 --- a/server/src/app-config/README.md +++ b/server/src/app-config/README.md @@ -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. diff --git a/server/src/app-config/derive.ts b/server/src/app-config/derive.ts index 753dfc562..d2c8ec2e8 100644 --- a/server/src/app-config/derive.ts +++ b/server/src/app-config/derive.ts @@ -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, }; } diff --git a/server/src/app-config/env.schema.ts b/server/src/app-config/env.schema.ts index 51c8569b6..2d8d489ac 100644 --- a/server/src/app-config/env.schema.ts +++ b/server/src/app-config/env.schema.ts @@ -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, diff --git a/server/src/app-config/parsers.ts b/server/src/app-config/parsers.ts index aa8250cf3..d4709c244 100644 --- a/server/src/app-config/parsers.ts +++ b/server/src/app-config/parsers.ts @@ -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); +} diff --git a/server/src/db/database.ts b/server/src/db/database.ts index a7e5b82ef..f8642446e 100644 --- a/server/src/db/database.ts +++ b/server/src/db/database.ts @@ -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); diff --git a/server/src/db/durability.ts b/server/src/db/durability.ts new file mode 100644 index 000000000..3e3457508 --- /dev/null +++ b/server/src/db/durability.ts @@ -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 })), + }; +} diff --git a/server/tests/unit/db/durability.test.ts b/server/tests/unit/db/durability.test.ts new file mode 100644 index 000000000..23ae39020 --- /dev/null +++ b/server/tests/unit/db/durability.test.ts @@ -0,0 +1,241 @@ +import { execFileSync } from 'child_process'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import Database from 'better-sqlite3'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { resolveDurability } from '../../../src/app-config/parsers'; +import { applyDurabilityPragmas } from '../../../src/db/durability'; + +// journal_mode is written into the database file header, so it outlives the +// connection that set it. Anything asserting a mode here therefore reopens the +// file: that is the property operators on SMB/NFS volumes actually depend on. + +const SERVER_ROOT = path.resolve(__dirname, '../../..'); + +let tmpDir: string; +let dbPath: string; + +function readModes(file: string): { journalMode: string; synchronous: number } { + const db = new Database(file); + try { + return { + journalMode: String(db.pragma('journal_mode', { simple: true })).toUpperCase(), + synchronous: Number(db.pragma('synchronous', { simple: true })), + }; + } finally { + db.close(); + } +} + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'trek-durability-')); + dbPath = path.join(tmpDir, 'travel.db'); + delete process.env.TREK_DB_JOURNAL_MODE; + delete process.env.TREK_DB_SYNCHRONOUS; +}); + +afterEach(() => { + delete process.env.TREK_DB_JOURNAL_MODE; + delete process.env.TREK_DB_SYNCHRONOUS; + vi.restoreAllMocks(); + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe('resolveDurability', () => { + it('defaults to WAL with the synchronous level a WAL database already runs at', () => { + expect(resolveDurability(undefined, undefined)).toEqual({ + journalMode: 'WAL', + synchronous: 'NORMAL', + warnings: [], + }); + expect(resolveDurability('', '')).toEqual({ journalMode: 'WAL', synchronous: 'NORMAL', warnings: [] }); + }); + + it('accepts every SQLite journal mode, case- and padding-insensitively', () => { + expect(resolveDurability('delete', undefined).journalMode).toBe('DELETE'); + expect(resolveDurability(' Truncate ', undefined).journalMode).toBe('TRUNCATE'); + expect(resolveDurability('PERSIST', undefined).journalMode).toBe('PERSIST'); + expect(resolveDurability('memory', undefined).journalMode).toBe('MEMORY'); + expect(resolveDurability('off', undefined).journalMode).toBe('OFF'); + }); + + it('raises the default to FULL once a rollback journal is in play', () => { + // NORMAL is only safe under WAL; on DELETE/TRUNCATE it lets a power cut eat + // committed transactions, which would defeat the point of leaving WAL. + expect(resolveDurability('DELETE', undefined).synchronous).toBe('FULL'); + expect(resolveDurability('TRUNCATE', undefined).synchronous).toBe('FULL'); + expect(resolveDurability('WAL', undefined).synchronous).toBe('NORMAL'); + }); + + it('lets synchronous be set independently of the journal mode', () => { + expect(resolveDurability('WAL', 'full').synchronous).toBe('FULL'); + expect(resolveDurability('DELETE', 'normal').synchronous).toBe('NORMAL'); + expect(resolveDurability(undefined, 'EXTRA').synchronous).toBe('EXTRA'); + expect(resolveDurability(undefined, 'off').synchronous).toBe('OFF'); + }); + + it('falls back and reports instead of rejecting an unusable value', () => { + const result = resolveDurability('WALL', 'sort-of'); + expect(result.journalMode).toBe('WAL'); + expect(result.synchronous).toBe('NORMAL'); + expect(result.warnings).toHaveLength(2); + expect(result.warnings[0]).toContain('TREK_DB_JOURNAL_MODE="WALL"'); + expect(result.warnings[1]).toContain('TREK_DB_SYNCHRONOUS="sort-of"'); + }); + + it('keeps the per-mode synchronous default when only that value is bogus', () => { + const result = resolveDurability('DELETE', 'maybe'); + expect(result.journalMode).toBe('DELETE'); + expect(result.synchronous).toBe('FULL'); + expect(result.warnings).toHaveLength(1); + }); +}); + +describe('applyDurabilityPragmas', () => { + it('leaves an unconfigured install on WAL', () => { + const db = new Database(dbPath); + const active = applyDurabilityPragmas(db); + db.exec('CREATE TABLE t (a)'); + db.close(); + + expect(active).toEqual({ journalMode: 'WAL', synchronous: 'NORMAL' }); + expect(readModes(dbPath).journalMode).toBe('WAL'); + }); + + it('switches the file to the configured mode and persists it', () => { + process.env.TREK_DB_JOURNAL_MODE = 'DELETE'; + + const wal = new Database(dbPath); + wal.exec('PRAGMA journal_mode = WAL'); + wal.exec('CREATE TABLE t (a)'); + wal.close(); + expect(readModes(dbPath).journalMode).toBe('WAL'); + + const db = new Database(dbPath); + const active = applyDurabilityPragmas(db); + db.close(); + + expect(active.journalMode).toBe('DELETE'); + // FULL (2) — a rollback journal without it is no safer than the WAL setup + // the operator moved away from. + expect(active.synchronous).toBe('FULL'); + expect(readModes(dbPath)).toEqual({ journalMode: 'DELETE', synchronous: 2 }); + }); + + it('applies an explicit synchronous level on top of the journal mode', () => { + process.env.TREK_DB_SYNCHRONOUS = 'FULL'; + + const db = new Database(dbPath); + const active = applyDurabilityPragmas(db); + expect(active).toEqual({ journalMode: 'WAL', synchronous: 'FULL' }); + expect(Number(db.pragma('synchronous', { simple: true }))).toBe(2); + db.close(); + }); + + it('warns and stays on the default when the mode is misspelled', () => { + process.env.TREK_DB_JOURNAL_MODE = 'DELET'; + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const db = new Database(dbPath); + const active = applyDurabilityPragmas(db); + db.close(); + + expect(active.journalMode).toBe('WAL'); + expect(warn).toHaveBeenCalledTimes(1); + expect(String(warn.mock.calls[0][0])).toContain('TREK_DB_JOURNAL_MODE="DELET"'); + }); + + it('warns and keeps the mode default when synchronous is misspelled', () => { + process.env.TREK_DB_SYNCHRONOUS = 'VERY'; + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const db = new Database(dbPath); + const active = applyDurabilityPragmas(db); + db.close(); + + expect(active.synchronous).toBe('NORMAL'); + expect(warn).toHaveBeenCalledTimes(1); + expect(String(warn.mock.calls[0][0])).toContain('TREK_DB_SYNCHRONOUS="VERY"'); + }); + + it('reports what the engine settled on, not what was asked for', () => { + process.env.TREK_DB_JOURNAL_MODE = 'WAL'; + const db = new Database(':memory:'); + expect(applyDurabilityPragmas(db).journalMode).toBe('MEMORY'); + db.close(); + }); +}); + +// The other two processes that open travel.db read-write. They cannot import +// the module above (standalone scripts, and src/ is not in the container +// image), so they are exercised as processes — a copy of the resolution that +// drifts flips the header back and silently un-does the operator's choice. +describe('standalone scripts honour the same configuration', () => { + function seedWalDb(): void { + const db = new Database(dbPath); + db.exec('PRAGMA journal_mode = WAL'); + db.exec(` + CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE, + email TEXT UNIQUE, + password_hash TEXT, + role TEXT, + must_change_password INTEGER DEFAULT 0, + mfa_secret TEXT, + maps_api_key TEXT, + openweather_api_key TEXT, + immich_api_key TEXT, + synology_password TEXT, + synology_sid TEXT, + synology_did TEXT + ); + CREATE TABLE app_settings (key TEXT PRIMARY KEY, value TEXT); + CREATE TABLE settings (user_id INTEGER, key TEXT, value TEXT); + CREATE TABLE trip_album_links (id INTEGER PRIMARY KEY, passphrase TEXT); + CREATE TABLE trek_photos (id INTEGER PRIMARY KEY, passphrase TEXT); + `); + db.close(); + expect(readModes(dbPath).journalMode).toBe('WAL'); + } + + it('reset-admin.js applies TREK_DB_JOURNAL_MODE instead of leaving WAL behind', () => { + seedWalDb(); + + execFileSync(process.execPath, ['reset-admin.js'], { + cwd: SERVER_ROOT, + env: { + ...process.env, + TREK_DB_FILE: dbPath, + TREK_DB_JOURNAL_MODE: 'DELETE', + RESET_ADMIN_EMAIL: 'locked-out@example.com', + RESET_ADMIN_PASSWORD: 'Recovery12345!', + }, + stdio: 'pipe', + }); + + expect(readModes(dbPath)).toEqual({ journalMode: 'DELETE', synchronous: 2 }); + + const db = new Database(dbPath); + const admin = db.prepare('SELECT role FROM users WHERE email = ?').get('locked-out@example.com') as + | { role: string } + | undefined; + db.close(); + expect(admin?.role).toBe('admin'); + }, 60000); + + it('migrate-encryption.ts no longer forces the database back to WAL', () => { + seedWalDb(); + + execFileSync(process.execPath, ['--import', 'tsx', 'scripts/migrate-encryption.ts'], { + cwd: SERVER_ROOT, + env: { ...process.env, DB_PATH: dbPath, TREK_DB_JOURNAL_MODE: 'DELETE' }, + input: 'old-key\nnew-key\nyes\n', + stdio: 'pipe', + }); + + expect(readModes(dbPath)).toEqual({ journalMode: 'DELETE', synchronous: 2 }); + }, 120000); +}); diff --git a/wiki/Environment-Variables.md b/wiki/Environment-Variables.md index 12a343690..9ff47f98b 100644 --- a/wiki/Environment-Variables.md +++ b/wiki/Environment-Variables.md @@ -256,6 +256,30 @@ To get a key: create a free account at [unsplash.com/developers](https://unsplas |--------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------| | `TREK_PLACE_PHOTO_DIR` | Directory where cached Google place photos are stored. Created recursively on boot. Set this to point photo storage at a dedicated mounted volume. | `uploads/photos/google` | | `BACKUP_UPLOAD_LIMIT_MB` | Maximum **compressed** size (in MB) of a restore-backup archive that may be uploaded. Raise it if your backups (which include the `uploads/` directory) exceed the default. Non-positive or invalid values log a warning and fall back to the default. | `500` | +| `TREK_DB_JOURNAL_MODE` | SQLite [journal mode](https://sqlite.org/pragma.html#pragma_journal_mode): `DELETE`, `TRUNCATE`, `PERSIST`, `MEMORY`, `WAL` or `OFF`. Set `DELETE` when the data directory lives on network storage — see below. Values SQLite doesn't know log a warning and fall back. | `WAL` | +| `TREK_DB_SYNCHRONOUS` | SQLite [synchronous](https://sqlite.org/pragma.html#pragma_synchronous) level: `OFF`, `NORMAL`, `FULL` or `EXTRA`. The default follows the journal mode — `NORMAL` under WAL (what SQLite itself uses there), `FULL` otherwise, because a rollback journal at `NORMAL` can lose committed transactions on a power cut. | `NORMAL` / `FULL` | + +### Running the database on network storage + +WAL is the right mode on a local disk and stays the default. It coordinates readers and writers through a shared-memory +file (`travel.db-shm`) and memory mapping, and [SQLite's own documentation](https://sqlite.org/wal.html#noshm) says that +combination is not safe on filesystems that don't implement those primitives properly. In practice that means Azure App +Service (Linux), and SMB/NFS volumes mounted from a NAS or a PaaS host. If your `data/` directory is one of those, set: + +``` +TREK_DB_JOURNAL_MODE=DELETE +``` + +The journal mode is written into the database file header, not held per connection, so it survives restarts and applies +from the next boot onward. The startup log prints what is actually in effect: + +``` +[DB] journal_mode=DELETE, synchronous=FULL +``` + +The maintenance tools that open the same file — `reset-admin.js` and `scripts/migrate-encryption.ts` — read the same two +variables, so run them with the same environment (`docker exec` into the container does this for you). Otherwise the +next key rotation or admin reset would quietly switch the file back to WAL. ---