From 9f643eda76d689a2181dde6bfc7cf3969f655878 Mon Sep 17 00:00:00 2001 From: jzunigax2 <125698953+jzunigax2@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:05:49 -0600 Subject: [PATCH] feat(database): add support for configurable SQLite journal mode and enhance entrypoint logging --- docker/README.md | 2 + docker/entrypoint.sh | 2 + src/services/database/database.service.ts | 29 +++++++++ src/types/config.types.ts | 1 + .../database/database.service.test.ts | 61 +++++++++++++++++++ 5 files changed, 95 insertions(+) diff --git a/docker/README.md b/docker/README.md index c86e6b94..3c1f5a1e 100644 --- a/docker/README.md +++ b/docker/README.md @@ -27,6 +27,7 @@ services: WEBDAV_PASSWORD: "" # (Optional) Custom password for WebDAV authentication WEBDAV_DELETE_FILES_PERMANENTLY: "" # (Optional) Delete files permanently. Set to 'true' to enable WEBDAV_KEEPALIVE_ENABLED: "" # (Optional) Auto-renew the session/WebDAV server on failure. Defaults to 'true'. Set to 'false' to stop the container on failure instead + INXT_SQLITE_JOURNAL_MODE: "" # (Optional) Local cache journal mode. 'WAL' (default) or 'DELETE'. Use 'DELETE' if the CLI data dir is on a network share (NFS/SMB) ports: - "127.0.0.1:3005:3005" # Map container port to host. Change if WEBDAV_PORT is customized ``` @@ -54,6 +55,7 @@ docker run -d \ -e WEBDAV_PASSWORD="" \ -e WEBDAV_DELETE_FILES_PERMANENTLY="" \ -e WEBDAV_KEEPALIVE_ENABLED="" \ + -e INXT_SQLITE_JOURNAL_MODE="" \ -p 127.0.0.1:3005:3005 \ internxt/webdav:latest ``` diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index fd5508ac..31d0ede2 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -25,6 +25,8 @@ webdav_online() { } +echo "[entrypoint] $(internxt --version 2>/dev/null || echo 'internxt version unknown')" + if [ -z "$INXT_USER" ] || [ -z "$INXT_PASSWORD" ]; then echo "Error: INXT_USER and INXT_PASSWORD environment variables must be set." exit 1 diff --git a/src/services/database/database.service.ts b/src/services/database/database.service.ts index d2a1d174..23f5d694 100644 --- a/src/services/database/database.service.ts +++ b/src/services/database/database.service.ts @@ -2,6 +2,10 @@ import { DataSource } from 'typeorm'; import { DriveItemModel } from './drive-item/drive-item.model'; import { DRIVE_SQLITE_FILE } from '../../constants/configs'; import { ConfigService } from '../config.service'; +import { logger } from '../../utils/logger.utils'; + +type DatabasePragmas = { pragma: (source: string) => unknown }; +type JournalMode = 'WAL' | 'DELETE'; export class DatabaseService { public static readonly instance = new DatabaseService(); @@ -21,9 +25,34 @@ export class DatabaseService { logging: false, synchronize: true, entities: [DriveItemModel], + prepareDatabase: (db: DatabasePragmas) => DatabaseService.configureJournal(db), }, ); + public static readonly configureJournal = (db: DatabasePragmas) => { + const mode = DatabaseService.getJournalMode(); + try { + const result = db.pragma(`journal_mode = ${mode}`) as { journal_mode?: string }[] | undefined; + const activeMode = result?.[0]?.journal_mode; + if (activeMode?.toUpperCase() !== mode) { + logger.warn(`Could not set SQLite journal mode to ${mode}, using ${activeMode ?? 'unknown'} instead`); + return; + } + if (mode === 'WAL') db.pragma('journal_size_limit = 16777216'); + } catch (error) { + logger.warn(`Could not set SQLite journal mode to ${mode}: ${(error as Error).message}`); + } + }; + + private static readonly getJournalMode = (): JournalMode => { + const value = ConfigService.instance.get('INXT_SQLITE_JOURNAL_MODE', false).toUpperCase(); + if (value === 'DELETE') return 'DELETE'; + if (value && value !== 'WAL') { + logger.warn(`Unknown INXT_SQLITE_JOURNAL_MODE "${value}", expected WAL or DELETE. Using WAL`); + } + return 'WAL'; + }; + public initialize = () => { if (!this.dataSource.isInitialized) { return this.dataSource.initialize(); diff --git a/src/types/config.types.ts b/src/types/config.types.ts index ed1ecb39..e3b9ef67 100644 --- a/src/types/config.types.ts +++ b/src/types/config.types.ts @@ -7,4 +7,5 @@ export interface ConfigKeys { readonly APP_MAGIC_SALT: string; readonly NETWORK_URL: string; readonly DESKTOP_HEADER: string; + readonly INXT_SQLITE_JOURNAL_MODE: string; } diff --git a/test/services/database/database.service.test.ts b/test/services/database/database.service.test.ts index 8d0dfc36..62930dfe 100644 --- a/test/services/database/database.service.test.ts +++ b/test/services/database/database.service.test.ts @@ -3,6 +3,7 @@ import { DatabaseService } from '../../../src/services/database/database.service import { DriveItemModel } from '../../../src/services/database/drive-item/drive-item.model'; import { ConfigService } from '../../../src/services/config.service'; import { DRIVE_SQLITE_FILE } from '../../../src/constants/configs'; +import { logger } from '../../../src/utils/logger.utils'; describe('Database Service', () => { describe('dataSource configuration', () => { @@ -28,10 +29,70 @@ describe('Database Service', () => { expect(service.dataSource.options.database).toBe(DRIVE_SQLITE_FILE); expect(service.dataSource.options.synchronize).toBe(true); expect(service.dataSource.options.entities).toEqual([DriveItemModel]); + expect(service.dataSource.options).toHaveProperty('prepareDatabase'); expect(configServiceInstancespyOn).toHaveBeenCalledWith('NODE_ENV', false); }); }); + describe('configureJournal', () => { + const mockJournalMode = (value: string) => vi.spyOn(ConfigService.instance, 'get').mockReturnValueOnce(value); + + test('when no journal mode is configured, then WAL mode and the journal size limit are set', () => { + mockJournalMode(''); + const db = { pragma: vi.fn().mockReturnValueOnce([{ journal_mode: 'wal' }]) }; + + DatabaseService.configureJournal(db); + + expect(db.pragma).toHaveBeenCalledWith('journal_mode = WAL'); + expect(db.pragma).toHaveBeenCalledWith('journal_size_limit = 16777216'); + }); + + test('when the journal mode is set to delete, then the rollback journal is used without a size limit', () => { + mockJournalMode('delete'); + const db = { pragma: vi.fn().mockReturnValueOnce([{ journal_mode: 'delete' }]) }; + + DatabaseService.configureJournal(db); + + expect(db.pragma).toHaveBeenCalledWith('journal_mode = DELETE'); + expect(db.pragma).toHaveBeenCalledTimes(1); + }); + + test('when the journal mode is unknown, then a warning is logged and WAL mode is used', () => { + mockJournalMode('truncate'); + const warnSpy = vi.spyOn(logger, 'warn').mockReturnValue(logger); + const db = { pragma: vi.fn().mockReturnValueOnce([{ journal_mode: 'wal' }]) }; + + DatabaseService.configureJournal(db); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('TRUNCATE')); + expect(db.pragma).toHaveBeenCalledWith('journal_mode = WAL'); + }); + + test('when SQLite falls back to another journal mode, then a warning is logged and no size limit is set', () => { + mockJournalMode(''); + const warnSpy = vi.spyOn(logger, 'warn').mockReturnValue(logger); + const db = { pragma: vi.fn().mockReturnValueOnce([{ journal_mode: 'memory' }]) }; + + DatabaseService.configureJournal(db); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('using memory instead')); + expect(db.pragma).toHaveBeenCalledTimes(1); + }); + + test('when setting the journal mode fails, then the error is logged and not thrown', () => { + mockJournalMode(''); + const db = { + pragma: vi.fn().mockImplementation(() => { + throw new Error('database is locked'); + }), + }; + const warnSpy = vi.spyOn(logger, 'warn').mockReturnValue(logger); + + expect(() => DatabaseService.configureJournal(db)).not.toThrow(); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('database is locked')); + }); + }); + describe('Integration scenarios', () => { test('when the database is initialized, cleared, and destroyed, then the sequence completes successfully', async () => { const configServiceInstancespyOn = vi.spyOn(ConfigService.instance, 'get');