diff --git a/README.md b/README.md index 8b66434..4e0bac6 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,12 @@ npm run dev - Last browser session tabs are restored on startup (up to 20 tabs). - Renderer supports a coder-focused orange light/dark theme toggle (persisted per user). +## Storage Foundation + +- Main-process SQLite storage foundation is initialized from `src/main/storage`. +- Versioned migrations create core tables for bookmarks, history, and session tabs. +- Repository interfaces are ready for feature-layer integration. + ## Commit Format Checker This project enforces Conventional Commits through Husky + commitlint. @@ -85,7 +91,7 @@ Before shipping a production app, make sure you have: Use this gate before cutting a release branch or tag. 1. CI policy: - - PRs to `develop` and `main` must pass quality, smoke, security audit, and Windows package smoke jobs. + - PRs to `develop` and `main` must pass quality, smoke, security audit, and Linux package smoke jobs. 2. Local verification: - `npm run quality` - `npm run test:smoke` diff --git a/src/main/index.ts b/src/main/index.ts index 1630e56..353edd2 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -12,6 +12,8 @@ import { import { IPC_CHANNELS } from '../shared/ipc-contract'; import type { BrowserBounds, TabSnapshot, TabsStateSnapshot } from '../shared/ipc'; import { isHttpNavigationUrl } from '../shared/url'; +import { initializeStorageLayer } from './storage'; +import type { StorageLayer } from './storage'; const VITE_DEV_SERVER_URL = process.env.ELECTRON_RENDERER_URL; const RENDERER_DIST = path.join(__dirname, '../renderer'); @@ -41,6 +43,7 @@ let attachedView: BrowserView | null = null; let nextTabId = 1; let activeTabId: number | null = null; let tabs: ManagedTab[] = []; +let storageLayer: StorageLayer | null = null; let persistedStateStore: Store | null = null; let browserBounds: BrowserBounds = { x: 0, @@ -685,6 +688,15 @@ process.on('unhandledRejection', (reason) => { app.whenReady().then(() => { configureSessionSecurity(); configureWebContentsSecurity(); + + try { + storageLayer = initializeStorageLayer({ + userDataPath: app.getPath('userData'), + }); + } catch (error) { + console.error('[main] failed to initialize SQLite storage layer', error); + } + restoreTabsSession(); createMainWindow(); @@ -705,6 +717,13 @@ app.on('window-all-closed', () => { if (process.platform !== 'darwin') { destroyAllTabs(); closeFloatWindow(); + storageLayer?.close(); + storageLayer = null; app.quit(); } }); + +app.on('before-quit', () => { + storageLayer?.close(); + storageLayer = null; +}); diff --git a/src/main/storage/index.ts b/src/main/storage/index.ts new file mode 100644 index 0000000..2f7762f --- /dev/null +++ b/src/main/storage/index.ts @@ -0,0 +1,12 @@ +export { initializeStorageLayer, getStorageFilePath } from './sqlite-storage'; +export type { + BookmarkRecord, + BookmarksRepository, + HistoryRecord, + HistoryRepository, + SessionRepository, + SessionSnapshot, + SessionTabRecord, + StorageLayer, + StorageOptions, +} from './types'; \ No newline at end of file diff --git a/src/main/storage/migrations.ts b/src/main/storage/migrations.ts new file mode 100644 index 0000000..e042a51 --- /dev/null +++ b/src/main/storage/migrations.ts @@ -0,0 +1,34 @@ +import type { StorageMigration } from './types'; + +export const STORAGE_MIGRATIONS: StorageMigration[] = [ + { + version: 1, + name: 'create_core_storage_tables', + statements: [ + `CREATE TABLE IF NOT EXISTS bookmarks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + url TEXT NOT NULL UNIQUE, + title TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + )`, + `CREATE TABLE IF NOT EXISTS history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + url TEXT NOT NULL UNIQUE, + title TEXT NOT NULL, + visit_count INTEGER NOT NULL DEFAULT 1, + last_visited_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + )`, + `CREATE TABLE IF NOT EXISTS session_tabs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tab_order INTEGER NOT NULL, + url TEXT, + is_active INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + )`, + 'CREATE INDEX IF NOT EXISTS idx_bookmarks_updated_at ON bookmarks(updated_at)', + 'CREATE INDEX IF NOT EXISTS idx_history_last_visited_at ON history(last_visited_at)', + 'CREATE INDEX IF NOT EXISTS idx_session_tabs_order ON session_tabs(tab_order)', + ], + }, +]; \ No newline at end of file diff --git a/src/main/storage/sqlite-storage.ts b/src/main/storage/sqlite-storage.ts new file mode 100644 index 0000000..fd275c2 --- /dev/null +++ b/src/main/storage/sqlite-storage.ts @@ -0,0 +1,377 @@ +import { mkdirSync } from 'node:fs'; +import path from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; + +import { STORAGE_MIGRATIONS } from './migrations'; +import type { + BookmarkRecord, + BookmarksRepository, + HistoryRecord, + HistoryRepository, + SessionRepository, + SessionTabRecord, + StorageLayer, + StorageMigration, + StorageOptions, +} from './types'; + +const DEFAULT_STORAGE_FILE_NAME = 'orb-storage.sqlite'; + +interface SqliteStatement { + run(...params: unknown[]): unknown; + get(...params: unknown[]): unknown; + all(...params: unknown[]): unknown; +} + +interface SqliteDatabase { + exec(sql: string): void; + prepare(sql: string): SqliteStatement; + close(): void; +} + +interface SqliteModule { + DatabaseSync: new (filename: string) => SqliteDatabase; +} + +function loadSqliteModule(): SqliteModule { + if (!DatabaseSync) { + throw new Error('SQLite module is unavailable: DatabaseSync constructor was not found'); + } + + return { DatabaseSync }; +} + +function asObjectRows(value: unknown): Array> { + if (!Array.isArray(value)) { + return []; + } + + return value.filter((entry): entry is Record => { + return entry !== null && typeof entry === 'object'; + }); +} + +function asObjectRow(value: unknown): Record | null { + if (!value || typeof value !== 'object') { + return null; + } + + return value as Record; +} + +function asNumber(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + + if (typeof value === 'bigint') { + const parsedValue = Number(value); + return Number.isFinite(parsedValue) ? parsedValue : null; + } + + return null; +} + +function asString(value: unknown): string | null { + return typeof value === 'string' ? value : null; +} + +function mapBookmarkRow(row: Record): BookmarkRecord | null { + const id = asNumber(row.id); + const url = asString(row.url); + const title = asString(row.title); + const createdAt = asString(row.created_at); + const updatedAt = asString(row.updated_at); + + if (id === null || !url || !title || !createdAt || !updatedAt) { + return null; + } + + return { + id, + url, + title, + createdAt, + updatedAt, + }; +} + +function mapHistoryRow(row: Record): HistoryRecord | null { + const id = asNumber(row.id); + const url = asString(row.url); + const title = asString(row.title); + const visitCount = asNumber(row.visit_count); + const lastVisitedAt = asString(row.last_visited_at); + + if (id === null || !url || !title || visitCount === null || !lastVisitedAt) { + return null; + } + + return { + id, + url, + title, + visitCount, + lastVisitedAt, + }; +} + +function mapSessionTabRow(row: Record): SessionTabRecord | null { + const tabOrder = asNumber(row.tab_order); + const isActive = asNumber(row.is_active); + const rawUrl = row.url; + + if (tabOrder === null || isActive === null) { + return null; + } + + if (rawUrl !== null && rawUrl !== undefined && typeof rawUrl !== 'string') { + return null; + } + + return { + tabOrder, + url: rawUrl ?? null, + isActive: isActive > 0, + }; +} + +function runInTransaction(db: SqliteDatabase, callback: () => void): void { + db.exec('BEGIN'); + + try { + callback(); + db.exec('COMMIT'); + } catch (error) { + db.exec('ROLLBACK'); + throw error; + } +} + +function ensureMigrationTable(db: SqliteDatabase): void { + db.exec(`CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + name TEXT NOT NULL, + applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + )`); +} + +function getAppliedMigrationVersions(db: SqliteDatabase): Set { + const statement = db.prepare('SELECT version FROM schema_migrations ORDER BY version ASC'); + const rows = asObjectRows(statement.all()); + const appliedVersions = new Set(); + + rows.forEach(row => { + const version = asNumber(row.version); + if (version !== null) { + appliedVersions.add(version); + } + }); + + return appliedVersions; +} + +function applyMigration(db: SqliteDatabase, migration: StorageMigration): void { + runInTransaction(db, () => { + migration.statements.forEach(statement => { + db.exec(statement); + }); + + db.prepare('INSERT INTO schema_migrations(version, name) VALUES (?, ?)').run( + migration.version, + migration.name, + ); + }); +} + +function applyMigrations(db: SqliteDatabase): void { + ensureMigrationTable(db); + const appliedVersions = getAppliedMigrationVersions(db); + + STORAGE_MIGRATIONS.forEach(migration => { + if (!appliedVersions.has(migration.version)) { + applyMigration(db, migration); + } + }); +} + +function createBookmarksRepository(db: SqliteDatabase): BookmarksRepository { + return { + list: () => { + const statement = db.prepare( + 'SELECT id, url, title, created_at, updated_at FROM bookmarks ORDER BY updated_at DESC', + ); + + return asObjectRows(statement.all()) + .map(mapBookmarkRow) + .filter((entry): entry is BookmarkRecord => entry !== null); + }, + + upsert: (url, title) => { + db.prepare( + `INSERT INTO bookmarks(url, title, created_at, updated_at) + VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + ON CONFLICT(url) + DO UPDATE SET + title = excluded.title, + updated_at = CURRENT_TIMESTAMP`, + ).run(url, title); + + const row = asObjectRow( + db.prepare( + 'SELECT id, url, title, created_at, updated_at FROM bookmarks WHERE url = ?', + ).get(url), + ); + + if (!row) { + throw new Error('Failed to read bookmark after upsert'); + } + + const mappedRow = mapBookmarkRow(row); + if (!mappedRow) { + throw new Error('Bookmark row shape is invalid'); + } + + return mappedRow; + }, + + remove: (id) => { + db.prepare('DELETE FROM bookmarks WHERE id = ?').run(id); + }, + }; +} + +function createHistoryRepository(db: SqliteDatabase): HistoryRepository { + return { + listRecent: (limit) => { + const safeLimit = Math.max(1, Math.min(500, Math.trunc(limit))); + const statement = db.prepare( + `SELECT id, url, title, visit_count, last_visited_at + FROM history + ORDER BY last_visited_at DESC + LIMIT ?`, + ); + + return asObjectRows(statement.all(safeLimit)) + .map(mapHistoryRow) + .filter((entry): entry is HistoryRecord => entry !== null); + }, + + recordVisit: (url, title) => { + db.prepare( + `INSERT INTO history(url, title, visit_count, last_visited_at) + VALUES (?, ?, 1, CURRENT_TIMESTAMP) + ON CONFLICT(url) + DO UPDATE SET + title = excluded.title, + visit_count = history.visit_count + 1, + last_visited_at = CURRENT_TIMESTAMP`, + ).run(url, title); + + const row = asObjectRow( + db.prepare( + `SELECT id, url, title, visit_count, last_visited_at + FROM history + WHERE url = ?`, + ).get(url), + ); + + if (!row) { + throw new Error('Failed to read history row after recordVisit'); + } + + const mappedRow = mapHistoryRow(row); + if (!mappedRow) { + throw new Error('History row shape is invalid'); + } + + return mappedRow; + }, + + clear: () => { + db.prepare('DELETE FROM history').run(); + }, + }; +} + +function createSessionRepository(db: SqliteDatabase): SessionRepository { + return { + load: () => { + const statement = db.prepare( + `SELECT tab_order, url, is_active + FROM session_tabs + ORDER BY tab_order ASC`, + ); + + const tabs = asObjectRows(statement.all()) + .map(mapSessionTabRow) + .filter((entry): entry is SessionTabRecord => entry !== null); + + if (tabs.length === 0) { + return null; + } + + let activeTabIndex = tabs.findIndex(tab => tab.isActive); + if (activeTabIndex < 0) { + activeTabIndex = 0; + } + + return { + tabs, + activeTabIndex, + }; + }, + + save: (snapshot) => { + runInTransaction(db, () => { + db.prepare('DELETE FROM session_tabs').run(); + + snapshot.tabs.forEach((tab, index) => { + const isActive = index === snapshot.activeTabIndex || tab.isActive ? 1 : 0; + db.prepare( + 'INSERT INTO session_tabs(tab_order, url, is_active) VALUES (?, ?, ?)', + ).run(tab.tabOrder, tab.url, isActive); + }); + }); + }, + + clear: () => { + db.prepare('DELETE FROM session_tabs').run(); + }, + }; +} + +class SqliteStorageLayer implements StorageLayer { + public readonly bookmarks: BookmarksRepository; + public readonly history: HistoryRepository; + public readonly session: SessionRepository; + + constructor(private readonly db: SqliteDatabase) { + this.bookmarks = createBookmarksRepository(db); + this.history = createHistoryRepository(db); + this.session = createSessionRepository(db); + } + + close(): void { + this.db.close(); + } +} + +export function getStorageFilePath(options: StorageOptions): string { + return path.join(options.userDataPath, options.fileName ?? DEFAULT_STORAGE_FILE_NAME); +} + +export function initializeStorageLayer(options: StorageOptions): StorageLayer { + mkdirSync(options.userDataPath, { recursive: true }); + const sqliteModule = loadSqliteModule(); + const databasePath = getStorageFilePath(options); + const db = new sqliteModule.DatabaseSync(databasePath); + + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA foreign_keys = ON'); + db.exec('PRAGMA busy_timeout = 5000'); + + applyMigrations(db); + + return new SqliteStorageLayer(db); +} \ No newline at end of file diff --git a/src/main/storage/types.ts b/src/main/storage/types.ts new file mode 100644 index 0000000..81aa3bb --- /dev/null +++ b/src/main/storage/types.ts @@ -0,0 +1,62 @@ +export interface BookmarkRecord { + id: number; + url: string; + title: string; + createdAt: string; + updatedAt: string; +} + +export interface HistoryRecord { + id: number; + url: string; + title: string; + visitCount: number; + lastVisitedAt: string; +} + +export interface SessionTabRecord { + tabOrder: number; + url: string | null; + isActive: boolean; +} + +export interface SessionSnapshot { + tabs: SessionTabRecord[]; + activeTabIndex: number; +} + +export interface BookmarksRepository { + list(): BookmarkRecord[]; + upsert(url: string, title: string): BookmarkRecord; + remove(id: number): void; +} + +export interface HistoryRepository { + listRecent(limit: number): HistoryRecord[]; + recordVisit(url: string, title: string): HistoryRecord; + clear(): void; +} + +export interface SessionRepository { + load(): SessionSnapshot | null; + save(snapshot: SessionSnapshot): void; + clear(): void; +} + +export interface StorageLayer { + bookmarks: BookmarksRepository; + history: HistoryRepository; + session: SessionRepository; + close(): void; +} + +export interface StorageOptions { + userDataPath: string; + fileName?: string; +} + +export interface StorageMigration { + version: number; + name: string; + statements: string[]; +} \ No newline at end of file diff --git a/src/types/node-sqlite.d.ts b/src/types/node-sqlite.d.ts new file mode 100644 index 0000000..9846677 --- /dev/null +++ b/src/types/node-sqlite.d.ts @@ -0,0 +1,14 @@ +declare module 'node:sqlite' { + export class StatementSync { + run(...params: unknown[]): unknown; + get(...params: unknown[]): unknown; + all(...params: unknown[]): unknown; + } + + export class DatabaseSync { + constructor(filename: string); + exec(sql: string): void; + prepare(sql: string): StatementSync; + close(): void; + } +} \ No newline at end of file diff --git a/tests/storage-foundation.test.ts b/tests/storage-foundation.test.ts new file mode 100644 index 0000000..31d0be6 --- /dev/null +++ b/tests/storage-foundation.test.ts @@ -0,0 +1,88 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { initializeStorageLayer } from '../src/main/storage'; + +const temporaryDirectories: string[] = []; + +function createStorage() { + const temporaryDirectory = mkdtempSync(path.join(os.tmpdir(), 'orb-storage-')); + temporaryDirectories.push(temporaryDirectory); + + return initializeStorageLayer({ + userDataPath: temporaryDirectory, + }); +} + +afterEach(() => { + while (temporaryDirectories.length > 0) { + const directory = temporaryDirectories.pop(); + if (directory) { + rmSync(directory, { recursive: true, force: true }); + } + } +}); + +describe('sqlite storage foundation', () => { + it('supports bookmark, history, and session repositories', () => { + const storage = createStorage(); + + const bookmark = storage.bookmarks.upsert('https://example.com/', 'Example'); + expect(bookmark.url).toBe('https://example.com/'); + expect(storage.bookmarks.list()).toHaveLength(1); + + const updatedBookmark = storage.bookmarks.upsert('https://example.com/', 'Example Home'); + expect(updatedBookmark.title).toBe('Example Home'); + + const historyRow = storage.history.recordVisit('https://example.com/', 'Example Home'); + expect(historyRow.visitCount).toBe(1); + const revisitedRow = storage.history.recordVisit('https://example.com/', 'Example Home'); + expect(revisitedRow.visitCount).toBe(2); + expect(storage.history.listRecent(10)).toHaveLength(1); + + storage.session.save({ + tabs: [ + { + tabOrder: 0, + url: 'https://example.com/', + isActive: true, + }, + { + tabOrder: 1, + url: null, + isActive: false, + }, + ], + activeTabIndex: 0, + }); + + const session = storage.session.load(); + expect(session).not.toBeNull(); + expect(session?.tabs).toHaveLength(2); + expect(session?.activeTabIndex).toBe(0); + + storage.close(); + }); + + it('keeps data across reinitialization with migrations applied once', () => { + const temporaryDirectory = mkdtempSync(path.join(os.tmpdir(), 'orb-storage-reopen-')); + temporaryDirectories.push(temporaryDirectory); + + const firstStorage = initializeStorageLayer({ + userDataPath: temporaryDirectory, + }); + firstStorage.bookmarks.upsert('https://orb.dev/', 'Orb'); + firstStorage.close(); + + const secondStorage = initializeStorageLayer({ + userDataPath: temporaryDirectory, + }); + const bookmarks = secondStorage.bookmarks.list(); + expect(bookmarks).toHaveLength(1); + expect(bookmarks[0]?.title).toBe('Orb'); + secondStorage.close(); + }); +}); \ No newline at end of file