diff --git a/src/apps/main/database/initialize-virtual-drive-sqlite.test.ts b/src/apps/main/database/initialize-virtual-drive-sqlite.test.ts new file mode 100644 index 000000000..fac21dcc8 --- /dev/null +++ b/src/apps/main/database/initialize-virtual-drive-sqlite.test.ts @@ -0,0 +1,69 @@ +import { calls } from 'tests/vitest/utils.helper'; +import { initializeVirtualDriveSqlite } from './initialize-virtual-drive-sqlite'; + +describe('initialize-virtual-drive-sqlite', () => { + const queryMock = vi.fn<(statement: string) => Promise>(); + let props: NonNullable[0]>; + + beforeEach(() => { + queryMock.mockResolvedValue(undefined); + props = { + dataSource: { + isInitialized: true, + query: queryMock, + }, + }; + }); + + it('should skip the bootstrap when the data source is not initialized', async () => { + props.dataSource!.isInitialized = false; + + await initializeVirtualDriveSqlite(props); + + calls(queryMock).toHaveLength(0); + }); + + it('should configure sqlite and create the directory state schema', async () => { + await initializeVirtualDriveSqlite(props); + + calls(queryMock).toHaveLength(11); + expect(queryMock.mock.invocationCallOrder).toStrictEqual( + [...queryMock.mock.invocationCallOrder].sort((a, b) => a - b), + ); + expect(queryMock).toHaveBeenCalledWith('PRAGMA journal_mode=WAL;'); + expect(queryMock).not.toHaveBeenCalledWith('PRAGMA synchronous=NORMAL;'); + expect(queryMock).toHaveBeenCalledWith(expect.stringContaining('CREATE TABLE IF NOT EXISTS drive_directory_state')); + expect(queryMock.mock.calls[0]).toStrictEqual(['PRAGMA busy_timeout=5000;']); + expect(queryMock.mock.calls[1]).toStrictEqual(['PRAGMA journal_mode=WAL;']); + }); + + it('should propagate WAL errors', async () => { + const error = new Error('database is unavailable'); + queryMock.mockImplementation((statement) => { + if (statement === 'PRAGMA journal_mode=WAL;') { + return Promise.reject(error); + } + + return Promise.resolve(undefined); + }); + + await expect(initializeVirtualDriveSqlite(props)).rejects.toBe(error); + + calls(queryMock).toHaveLength(2); + }); + + it('should propagate directory state schema errors', async () => { + const error = new Error('schema creation failed'); + queryMock.mockImplementation((statement) => { + if (statement.includes('CREATE TABLE IF NOT EXISTS drive_directory_state')) { + return Promise.reject(error); + } + + return Promise.resolve(undefined); + }); + + await expect(initializeVirtualDriveSqlite(props)).rejects.toBe(error); + + expect(queryMock).toHaveBeenCalledWith(expect.stringContaining('CREATE TABLE IF NOT EXISTS drive_directory_state')); + }); +}); diff --git a/src/apps/main/database/initialize-virtual-drive-sqlite.ts b/src/apps/main/database/initialize-virtual-drive-sqlite.ts new file mode 100644 index 000000000..6c233821c --- /dev/null +++ b/src/apps/main/database/initialize-virtual-drive-sqlite.ts @@ -0,0 +1,41 @@ +import { AppDataSource } from './data-source'; + +type SqliteQueryExecutor = { + isInitialized: boolean; + query: (statement: string) => Promise; +}; + +type Props = { + dataSource?: SqliteQueryExecutor; +}; + +const SQLITE_BOOTSTRAP_STATEMENTS = [ + 'PRAGMA busy_timeout=5000;', + 'PRAGMA journal_mode=WAL;', + 'PRAGMA temp_store=MEMORY;', + 'PRAGMA foreign_keys=ON;', + 'PRAGMA wal_autocheckpoint=1000;', + 'PRAGMA mmap_size=268435456;', + `CREATE TABLE IF NOT EXISTS drive_directory_state ( + folder_id INTEGER NOT NULL, + status_scope TEXT NOT NULL, + children_loaded_at TEXT NULL, + children_last_error_at TEXT NULL, + fetch_state TEXT NOT NULL DEFAULT 'idle', + PRIMARY KEY (folder_id, status_scope) + );`, + 'CREATE INDEX IF NOT EXISTS idx_drive_file_folder_status_updated ON drive_file(folderId, status, updatedAt);', + 'CREATE INDEX IF NOT EXISTS idx_drive_file_status_updated ON drive_file(status, updatedAt);', + 'CREATE INDEX IF NOT EXISTS idx_drive_folder_parent_status_updated ON drive_folder(parentId, status, updatedAt);', + 'CREATE INDEX IF NOT EXISTS idx_drive_folder_status_updated ON drive_folder(status, updatedAt);', +]; + +export async function initializeVirtualDriveSqlite({ dataSource = AppDataSource }: Props = {}) { + if (!dataSource.isInitialized) { + return; + } + + for (const statement of SQLITE_BOOTSTRAP_STATEMENTS) { + await dataSource.query(statement); + } +} diff --git a/src/backend/features/auth/get-user-or-throw.ts b/src/backend/features/auth/get-user-or-throw.ts index 46c65e249..d223102e0 100644 --- a/src/backend/features/auth/get-user-or-throw.ts +++ b/src/backend/features/auth/get-user-or-throw.ts @@ -1,9 +1,8 @@ import { getUser } from './get-user'; - export function getUserOrThrow() { - const { data: user, error } = getUser(); - if (error) throw error; + const { data: user, error } = getUser(); + if (error) throw error; - return user; -} \ No newline at end of file + return user; +} diff --git a/src/backend/features/virtual-drive/services/lazy/constants.ts b/src/backend/features/virtual-drive/services/lazy/constants.ts new file mode 100644 index 000000000..9b42de721 --- /dev/null +++ b/src/backend/features/virtual-drive/services/lazy/constants.ts @@ -0,0 +1 @@ +export const DIRECTORY_STATE_TTL_MS = 30_000; diff --git a/src/backend/features/virtual-drive/services/lazy/directory-state-sqlite-repository.test.ts b/src/backend/features/virtual-drive/services/lazy/directory-state-sqlite-repository.test.ts new file mode 100644 index 000000000..4f1c6bd1e --- /dev/null +++ b/src/backend/features/virtual-drive/services/lazy/directory-state-sqlite-repository.test.ts @@ -0,0 +1,64 @@ +import { call } from 'tests/vitest/utils.helper'; +import { DirectoryStateRepository } from './directory-state-sqlite-repository'; + +describe('directory-state-sqlite-repository', () => { + const queryMock = + vi.fn<(statement: string, parameters?: Array) => Promise>>(); + const stateProps = { folderId: 42, statusScope: 'EXISTS' as const }; + const dataSource = { query: queryMock }; + + beforeEach(() => { + queryMock.mockResolvedValue([]); + }); + + it('should report a directory as stale when it has not been loaded', async () => { + const isFresh = await DirectoryStateRepository.isFresh({ dataSource, ...stateProps }); + + expect(isFresh).toBe(false); + call(queryMock).toStrictEqual([ + 'SELECT children_loaded_at FROM drive_directory_state WHERE folder_id = ? AND status_scope = ? LIMIT 1', + [42, 'EXISTS'], + ]); + }); + + it('should report a directory as fresh when its state is within the ttl', async () => { + queryMock.mockResolvedValue([{ children_loaded_at: new Date().toISOString() }]); + + const isFresh = await DirectoryStateRepository.isFresh({ dataSource, ...stateProps, ttlMs: 30_000 }); + + expect(isFresh).toBe(true); + }); + + it('should report a directory as stale when its state exceeds the ttl', async () => { + queryMock.mockResolvedValue([{ children_loaded_at: new Date(Date.now() - 30_001).toISOString() }]); + + const isFresh = await DirectoryStateRepository.isFresh({ dataSource, ...stateProps, ttlMs: 30_000 }); + + expect(isFresh).toBe(false); + }); + + it('should record a successful directory load', async () => { + await DirectoryStateRepository.markLoaded({ dataSource, ...stateProps }); + + expect(queryMock).toHaveBeenCalledWith( + expect.stringContaining('DO UPDATE SET children_loaded_at = excluded.children_loaded_at'), + [42, 'EXISTS', expect.any(String)], + ); + }); + + it('should record a directory load error', async () => { + await DirectoryStateRepository.markError({ dataSource, ...stateProps }); + + expect(queryMock).toHaveBeenCalledWith(expect.stringContaining('VALUES (?, ?, NULL, ?,'), [ + 42, + 'EXISTS', + expect.any(String), + ]); + }); + + it('should clear all directory states', async () => { + await DirectoryStateRepository.clear({ dataSource }); + + call(queryMock).toStrictEqual('DELETE FROM drive_directory_state'); + }); +}); diff --git a/src/backend/features/virtual-drive/services/lazy/directory-state-sqlite-repository.ts b/src/backend/features/virtual-drive/services/lazy/directory-state-sqlite-repository.ts new file mode 100644 index 000000000..68383f062 --- /dev/null +++ b/src/backend/features/virtual-drive/services/lazy/directory-state-sqlite-repository.ts @@ -0,0 +1,57 @@ +import { AppDataSource } from '../../../../../apps/main/database/data-source'; +import { DIRECTORY_STATE_TTL_MS } from './constants'; +import type { + DirectoryStateFreshnessProps, + DirectoryStateOperationProps, + DirectoryStateRepositoryProps, +} from './types'; + +async function isFresh({ + dataSource = AppDataSource, + folderId, + statusScope, + ttlMs = DIRECTORY_STATE_TTL_MS, +}: DirectoryStateFreshnessProps) { + const rows = await dataSource.query( + 'SELECT children_loaded_at FROM drive_directory_state WHERE folder_id = ? AND status_scope = ? LIMIT 1', + [folderId, statusScope], + ); + const loadedAt = rows[0]?.children_loaded_at; + + if (!loadedAt) { + return false; + } + + return Date.now() - new Date(loadedAt).getTime() < ttlMs; +} + +async function markLoaded({ dataSource = AppDataSource, folderId, statusScope }: DirectoryStateOperationProps) { + await dataSource.query( + `INSERT INTO drive_directory_state (folder_id, status_scope, children_loaded_at, children_last_error_at, fetch_state) + VALUES (?, ?, ?, NULL, 'idle') + ON CONFLICT(folder_id, status_scope) + DO UPDATE SET children_loaded_at = excluded.children_loaded_at, children_last_error_at = NULL, fetch_state = 'idle'`, + [folderId, statusScope, new Date().toISOString()], + ); +} + +async function markError({ dataSource = AppDataSource, folderId, statusScope }: DirectoryStateOperationProps) { + await dataSource.query( + `INSERT INTO drive_directory_state (folder_id, status_scope, children_loaded_at, children_last_error_at, fetch_state) + VALUES (?, ?, NULL, ?, 'error') + ON CONFLICT(folder_id, status_scope) + DO UPDATE SET children_last_error_at = excluded.children_last_error_at, fetch_state = 'error'`, + [folderId, statusScope, new Date().toISOString()], + ); +} + +async function clear({ dataSource = AppDataSource }: DirectoryStateRepositoryProps = {}) { + await dataSource.query('DELETE FROM drive_directory_state'); +} + +export const DirectoryStateRepository = { + isFresh, + markLoaded, + markError, + clear, +}; diff --git a/src/backend/features/virtual-drive/services/lazy/types.ts b/src/backend/features/virtual-drive/services/lazy/types.ts new file mode 100644 index 000000000..eac6337bd --- /dev/null +++ b/src/backend/features/virtual-drive/services/lazy/types.ts @@ -0,0 +1,24 @@ +export type DirectoryStatusScope = 'EXISTS'; + +export type DirectoryStateRow = { + children_loaded_at: string | null; +}; + +export type SqliteQueryExecutor = { + query: (statement: string, parameters?: Array) => Promise>; +}; + +export type DirectoryStateProps = { + folderId: number; + statusScope: DirectoryStatusScope; +}; + +export type DirectoryStateRepositoryProps = { + dataSource?: SqliteQueryExecutor; +}; + +export type DirectoryStateOperationProps = DirectoryStateProps & DirectoryStateRepositoryProps; + +export type DirectoryStateFreshnessProps = DirectoryStateOperationProps & { + ttlMs?: number; +}; diff --git a/src/core/bootstrap/register-session-event-handlers.test.ts b/src/core/bootstrap/register-session-event-handlers.test.ts index aa6cfdfca..0ec88edf2 100644 --- a/src/core/bootstrap/register-session-event-handlers.test.ts +++ b/src/core/bootstrap/register-session-event-handlers.test.ts @@ -1,5 +1,6 @@ import * as eventBusModule from '../../apps/main/event-bus'; import * as appDataSourceModule from '../../apps/main/database/data-source'; +import * as virtualDriveSqliteModule from '../../apps/main/database/initialize-virtual-drive-sqlite'; import * as widgetModule from '../../apps/main/windows/widget'; import * as authWindowModule from '../../apps/main/windows/auth'; import * as configStoreModule from '../../apps/main/config'; @@ -28,6 +29,10 @@ vi.mock('../../apps/main/database/data-source', () => ({ }, })); +vi.mock('../../apps/main/database/initialize-virtual-drive-sqlite', () => ({ + initializeVirtualDriveSqlite: vi.fn(), +})); + vi.mock('../../apps/main/windows/widget', () => ({ getOrCreateWidged: vi.fn(), setBoundsOfWidgetByPath: vi.fn(), @@ -84,7 +89,10 @@ import { registerSessionEventHandlers } from './register-session-event-handlers' describe('register-session-event-handlers', () => { const eventBusOnSpy = partialSpyOn(eventBusModule.default, 'on'); + const eventBusEmitSpy = partialSpyOn(eventBusModule.default, 'emit'); const appDataSourceInitializeSpy = partialSpyOn(appDataSourceModule.AppDataSource, 'initialize'); + const appDataSourceIsInitializedSpy = vi.spyOn(appDataSourceModule.AppDataSource, 'isInitialized', 'get'); + const initializeVirtualDriveSqliteSpy = partialSpyOn(virtualDriveSqliteModule, 'initializeVirtualDriveSqlite'); const getOrCreateWidgedSpy = partialSpyOn(widgetModule, 'getOrCreateWidged'); const getAuthWindowSpy = partialSpyOn(authWindowModule, 'getAuthWindow'); const configStoreGetSpy = partialSpyOn(configStoreModule.default, 'get'); @@ -101,7 +109,9 @@ describe('register-session-event-handlers', () => { beforeEach(() => { eventBusOnSpy.mockImplementation(() => ({}) as never); + appDataSourceIsInitializedSpy.mockReturnValue(false); appDataSourceInitializeSpy.mockResolvedValue({} as never); + initializeVirtualDriveSqliteSpy.mockResolvedValue(undefined); getOrCreateWidgedSpy.mockResolvedValue({ show: vi.fn() } as never); getAuthWindowSpy.mockReturnValue({ hide: vi.fn(), destroy: vi.fn(), isDestroyed: () => false } as never); configStoreGetSpy.mockReturnValue(undefined); @@ -132,6 +142,10 @@ describe('register-session-event-handlers', () => { await (loginHandler as (...args: unknown[]) => Promise | void)(); expect(appDataSourceInitializeSpy).toHaveBeenCalledTimes(1); + expect(initializeVirtualDriveSqliteSpy).toHaveBeenCalledTimes(1); + expect(appDataSourceInitializeSpy.mock.invocationCallOrder[0]).toBeLessThan( + initializeVirtualDriveSqliteSpy.mock.invocationCallOrder[0], + ); expect(getUserAvailableProductsAndStoreSpy).toHaveBeenCalledTimes(1); expect(getThemeSpy).toHaveBeenCalledTimes(1); expect(resetTrayStatusSpy).toHaveBeenCalledWith('IDLE'); @@ -139,4 +153,20 @@ describe('register-session-event-handlers', () => { expect(trySetupAntivirusSpy).toHaveBeenCalledTimes(1); expect(showMarketingNotificationsSpy).toHaveBeenCalledTimes(1); }); + + it('should retry sqlite bootstrap after it fails on a previous login attempt', async () => { + const error = new Error('sqlite bootstrap failed'); + initializeVirtualDriveSqliteSpy.mockRejectedValueOnce(error).mockResolvedValueOnce(undefined); + appDataSourceIsInitializedSpy.mockReturnValueOnce(false).mockReturnValue(true); + registerSessionEventHandlers(); + + const [, loginHandler] = eventBusOnSpy.mock.calls[1]; + await (loginHandler as (...args: unknown[]) => Promise | void)(); + await (loginHandler as (...args: unknown[]) => Promise | void)(); + + expect(appDataSourceInitializeSpy).toHaveBeenCalledTimes(1); + expect(initializeVirtualDriveSqliteSpy).toHaveBeenCalledTimes(2); + expect(eventBusEmitSpy).toHaveBeenCalledWith('APP_DATA_SOURCE_INITIALIZED'); + expect(eventBusEmitSpy).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/core/bootstrap/register-session-event-handlers.ts b/src/core/bootstrap/register-session-event-handlers.ts index d97e7a4bf..623a98067 100644 --- a/src/core/bootstrap/register-session-event-handlers.ts +++ b/src/core/bootstrap/register-session-event-handlers.ts @@ -1,6 +1,7 @@ import { logger } from '@internxt/drive-desktop-core/build/backend'; import eventBus from '../../apps/main/event-bus'; import { AppDataSource } from '../../apps/main/database/data-source'; +import { initializeVirtualDriveSqlite } from '../../apps/main/database/initialize-virtual-drive-sqlite'; import { getOrCreateWidged, setBoundsOfWidgetByPath } from '../../apps/main/windows/widget'; import { getAuthWindow } from '../../apps/main/windows/auth'; import configStore from '../../apps/main/config'; @@ -14,17 +15,31 @@ import { startBackupsIfAvailable } from '../../backend/features/backup/start-bac import { resolveUserFileSizeLimit } from '../../backend/features/user/file-size-limit/resolve-user-file-size-limit'; import { showMarketingNotifications } from '../../backend/features/marketing'; +let isVirtualDriveSqliteInitialized = false; + function onWidgetIsReady() { registerBackupHandlers(); startBackupsIfAvailable(); } +async function initializeAppDataSource() { + if (!AppDataSource.isInitialized) { + isVirtualDriveSqliteInitialized = false; + await AppDataSource.initialize(); + } + + if (isVirtualDriveSqliteInitialized) { + return; + } + + await initializeVirtualDriveSqlite(); + isVirtualDriveSqliteInitialized = true; + eventBus.emit('APP_DATA_SOURCE_INITIALIZED'); +} + async function onUserLoggedIn() { try { - if (!AppDataSource.isInitialized) { - await AppDataSource.initialize(); - eventBus.emit('APP_DATA_SOURCE_INITIALIZED'); - } + await initializeAppDataSource(); // Keep product flags in sync even if later UI steps fail. await getUserAvailableProductsAndStore();