From e1360c19a2d98a170e2f495d3485134015963725 Mon Sep 17 00:00:00 2001 From: m Date: Wed, 9 Sep 2026 22:50:05 +0200 Subject: [PATCH] add sqlite --- mobile/AGENTS.md | 10 +- mobile/services/db/client.ts | 32 +++ mobile/services/db/index.ts | 4 + mobile/services/db/migrations.ts | 79 +++++++ mobile/services/db/repositories/files.ts | 60 +++++ mobile/services/db/repositories/folders.ts | 69 ++++++ mobile/services/db/repositories/index.ts | 9 + .../services/db/repositories/preferences.ts | 33 +++ mobile/services/db/schema.ts | 31 +++ .../{sqliteStorage.types.ts => db/types.ts} | 4 +- mobile/services/localStorage.ts | 4 +- mobile/services/sqliteStorage.ts | 209 ------------------ 12 files changed, 329 insertions(+), 215 deletions(-) create mode 100644 mobile/services/db/client.ts create mode 100644 mobile/services/db/index.ts create mode 100644 mobile/services/db/migrations.ts create mode 100644 mobile/services/db/repositories/files.ts create mode 100644 mobile/services/db/repositories/folders.ts create mode 100644 mobile/services/db/repositories/index.ts create mode 100644 mobile/services/db/repositories/preferences.ts create mode 100644 mobile/services/db/schema.ts rename mobile/services/{sqliteStorage.types.ts => db/types.ts} (85%) delete mode 100644 mobile/services/sqliteStorage.ts diff --git a/mobile/AGENTS.md b/mobile/AGENTS.md index 72dfb11..b9381b2 100644 --- a/mobile/AGENTS.md +++ b/mobile/AGENTS.md @@ -4,10 +4,14 @@ Read the exact versioned docs at https://docs.expo.dev/versions/v57.0.0/ before # Local storage -Persistence is SQLite-backed via `services/sqliteStorage.ts` (`expo-sqlite`, database `dot.db`). +Persistence is SQLite-backed via `services/db/` (`expo-sqlite`, database `dot.db`). -- Tables: `folders`, `files`, `user_preferences` (schema migrated via `PRAGMA user_version`). +- `services/db/client.ts` — connection lifecycle: `getDatabase()`, `closeDatabase()`, `withTransaction()`. +- `services/db/migrations.ts` — versioned, chained migrations via `PRAGMA user_version` (list of `{ version, up }`). +- `services/db/schema.ts` — `DATABASE_NAME`, `DATABASE_VERSION`, column-list constants. +- `services/db/repositories/` — one module per table (`folders`, `files`, `user_preferences`). +- `services/localStorage.ts` is a thin re-export (`services/db`) kept for legacy imports. +- Tables: `folders`, `files`, `user_preferences`; `files.folder_uri` has index `idx_files_folder_uri`. - Folder and file per-row sync status: `local` | `cloud` | `local-cloud`. -- `services/localStorage.ts` is a thin re-export of `sqliteStorage` for legacy imports. - Query usage: `getFiles(folderUri?)`, `getFolders()`, `getFolderFolders(parentUri)`, `saveFolder`, `saveFile`, `saveUserPreferences`, `getUserPreferences`, `removeFolder`, `removeFile`. - Heavy processing stays server-side; SQLite only persists local metadata/state. diff --git a/mobile/services/db/client.ts b/mobile/services/db/client.ts new file mode 100644 index 0000000..2c2a2e3 --- /dev/null +++ b/mobile/services/db/client.ts @@ -0,0 +1,32 @@ +import * as SQLite from 'expo-sqlite'; +import type { SQLiteDatabase } from 'expo-sqlite'; +import { migrateDatabase } from './migrations'; +import { DATABASE_NAME } from './schema'; + +let database: SQLiteDatabase | null = null; + +export async function getDatabase(): Promise { + if (database) return database; + + const db = await SQLite.openDatabaseAsync(DATABASE_NAME); + await migrateDatabase(db); + database = db; + return db; +} + +export async function closeDatabase(): Promise { + if (!database) return; + await database.closeAsync(); + database = null; +} + +export async function withTransaction( + work: (db: SQLiteDatabase) => Promise, +): Promise { + const db = await getDatabase(); + let result!: T; + await db.withTransactionAsync(async () => { + result = await work(db); + }); + return result; +} \ No newline at end of file diff --git a/mobile/services/db/index.ts b/mobile/services/db/index.ts new file mode 100644 index 0000000..b9c1121 --- /dev/null +++ b/mobile/services/db/index.ts @@ -0,0 +1,4 @@ +export { getDatabase, closeDatabase, withTransaction } from './client'; +export * from './repositories'; +export type { FolderContext, StoredFile, SyncStatus, UserPreferences } from './types'; +export { DATABASE_NAME, DATABASE_VERSION } from './schema'; \ No newline at end of file diff --git a/mobile/services/db/migrations.ts b/mobile/services/db/migrations.ts new file mode 100644 index 0000000..f9d0d60 --- /dev/null +++ b/mobile/services/db/migrations.ts @@ -0,0 +1,79 @@ +import type { SQLiteDatabase } from 'expo-sqlite'; +import { DATABASE_VERSION } from './schema'; + +type Migration = { + version: number; + up: (db: SQLiteDatabase) => Promise; +}; + +const MIGRATIONS: Migration[] = [ + { + version: 1, + up: async (db) => { + await db.execAsync(` +PRAGMA journal_mode = 'wal'; +PRAGMA foreign_keys = ON; +CREATE TABLE IF NOT EXISTS folders ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + uri TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + "exists" INTEGER, + sync_status TEXT NOT NULL DEFAULT 'local', + added_at INTEGER NOT NULL, + parent_uri TEXT +); +CREATE TABLE IF NOT EXISTS files ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + uri TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + folder_uri TEXT NOT NULL, + extension TEXT, + size INTEGER, + "type" TEXT, + "exists" INTEGER, + last_modified INTEGER, + sync_status TEXT NOT NULL DEFAULT 'local', + added_at INTEGER NOT NULL, + FOREIGN KEY (folder_uri) REFERENCES folders(uri) ON DELETE CASCADE +); +CREATE TABLE IF NOT EXISTS user_preferences ( + "key" TEXT PRIMARY KEY NOT NULL, + "value" TEXT NOT NULL, + updated_at INTEGER NOT NULL +); +`); + }, + }, + { + version: 2, + up: async (db) => { + await db.execAsync(` +CREATE INDEX IF NOT EXISTS idx_files_folder_uri ON files(folder_uri); +`); + }, + }, +]; + +async function readUserVersion(db: SQLiteDatabase): Promise { + const row = await db.getFirstAsync<{ user_version: number }>('PRAGMA user_version'); + return row?.user_version ?? 0; +} + +async function writeUserVersion(db: SQLiteDatabase, version: number): Promise { + await db.execAsync(`PRAGMA user_version = ${version}`); +} + +export async function migrateDatabase( + db: SQLiteDatabase, + targetVersion: number = DATABASE_VERSION, +): Promise { + let currentVersion = await readUserVersion(db); + + for (const migration of MIGRATIONS) { + if (migration.version > currentVersion && migration.version <= targetVersion) { + await migration.up(db); + await writeUserVersion(db, migration.version); + currentVersion = migration.version; + } + } +} \ No newline at end of file diff --git a/mobile/services/db/repositories/files.ts b/mobile/services/db/repositories/files.ts new file mode 100644 index 0000000..e83d6c9 --- /dev/null +++ b/mobile/services/db/repositories/files.ts @@ -0,0 +1,60 @@ +import { getDatabase } from '../client'; +import { FILE_COLUMNS_SQL } from '../schema'; +import type { FileEntry, FileRow, StoredFile } from '../types'; + +export async function saveFile(file: FileEntry, folderUri: string): Promise { + const db = await getDatabase(); + const exists = file.exists ? 1 : 0; + + await db.runAsync( + `INSERT INTO files (uri, name, folder_uri, extension, size, "type", "exists", last_modified, added_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(uri) DO UPDATE SET + name = excluded.name, folder_uri = excluded.folder_uri, extension = excluded.extension, + size = excluded.size, "type" = excluded."type", "exists" = excluded."exists", + last_modified = excluded.last_modified`, + file.uri, + file.name, + folderUri, + file.extension ?? null, + file.size, + file.type ?? null, + exists, + file.lastModified, + Date.now(), + ); +} + +export async function getFiles(folderUri?: string): Promise { + const db = await getDatabase(); + const rows = folderUri + ? await db.getAllAsync( + `SELECT ${FILE_COLUMNS_SQL} FROM files WHERE folder_uri = ? ORDER BY name ASC`, + folderUri, + ) + : await db.getAllAsync( + `SELECT ${FILE_COLUMNS_SQL} FROM files ORDER BY name ASC`, + ); + return rows.map(toStoredFile); +} + +export async function removeFile(uri: string): Promise { + const db = await getDatabase(); + await db.runAsync('DELETE FROM files WHERE uri = ?', uri); +} + +function toStoredFile(row: FileRow): StoredFile { + return { + uri: row.uri, + name: row.name, + isDirectory: false, + extension: row.extension ?? '', + exists: row.exists === 1, + size: row.size, + type: row.type ?? '', + lastModified: row.last_modified, + folderUri: row.folder_uri, + syncStatus: row.sync_status, + addedAt: row.added_at, + }; +} \ No newline at end of file diff --git a/mobile/services/db/repositories/folders.ts b/mobile/services/db/repositories/folders.ts new file mode 100644 index 0000000..94415c0 --- /dev/null +++ b/mobile/services/db/repositories/folders.ts @@ -0,0 +1,69 @@ +import { getDatabase } from '../client'; +import { FOLDER_COLUMNS_SQL } from '../schema'; +import type { Folder, FolderContext, FolderRow, SyncStatus } from '../types'; + +type SaveFolderOptions = { + parentUri?: string; + syncStatus?: SyncStatus; +}; + +export async function saveFolder( + folder: Folder, + options?: SaveFolderOptions, +): Promise { + const db = await getDatabase(); + const exists = folder.exists === undefined ? null : folder.exists ? 1 : 0; + + await db.runAsync( + `INSERT INTO folders (uri, name, "exists", sync_status, added_at, parent_uri) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(uri) DO UPDATE SET + name = excluded.name, "exists" = excluded."exists", sync_status = excluded.sync_status`, + folder.uri, + folder.name, + exists, + options?.syncStatus ?? 'local', + Date.now(), + options?.parentUri ?? null, + ); +} + +export async function saveDirectory(folder: Folder): Promise { + await saveFolder(folder); + return true; +} + +export async function getFolders(): Promise { + const db = await getDatabase(); + const rows = await db.getAllAsync( + `SELECT ${FOLDER_COLUMNS_SQL} FROM folders ORDER BY added_at ASC, name ASC`, + ); + return rows.map(toFolderContext); +} + +export async function getFolderFolders(parentUri: string): Promise { + const db = await getDatabase(); + const rows = await db.getAllAsync( + `SELECT ${FOLDER_COLUMNS_SQL} FROM folders WHERE parent_uri = ? ORDER BY name ASC`, + parentUri, + ); + return rows.map(toFolderContext); +} + +export async function removeFolder(uri: string): Promise { + const db = await getDatabase(); + await db.runAsync('DELETE FROM folders WHERE uri = ?', uri); +} + +function toFolderContext(row: FolderRow): FolderContext { + return { + syncStatus: row.sync_status, + addedAt: row.added_at, + folder: { + uri: row.uri, + name: row.name, + isDirectory: true, + exists: row.exists === null ? undefined : row.exists === 1, + }, + }; +} \ No newline at end of file diff --git a/mobile/services/db/repositories/index.ts b/mobile/services/db/repositories/index.ts new file mode 100644 index 0000000..b8ab289 --- /dev/null +++ b/mobile/services/db/repositories/index.ts @@ -0,0 +1,9 @@ +export { + saveFolder, + saveDirectory, + getFolders, + getFolderFolders, + removeFolder, +} from './folders'; +export { saveFile, getFiles, removeFile } from './files'; +export { saveUserPreferences, getUserPreferences } from './preferences'; \ No newline at end of file diff --git a/mobile/services/db/repositories/preferences.ts b/mobile/services/db/repositories/preferences.ts new file mode 100644 index 0000000..2b61e26 --- /dev/null +++ b/mobile/services/db/repositories/preferences.ts @@ -0,0 +1,33 @@ +import { getDatabase } from '../client'; +import { PREFERENCES_KEY } from '../schema'; +import type { UserPreferences } from '../types'; + +const DEFAULT_PREFERENCES: UserPreferences = { + syncMode: 'full', +}; + +export async function saveUserPreferences(preferences: UserPreferences): Promise { + const db = await getDatabase(); + await db.runAsync( + `INSERT INTO user_preferences ("key", "value", updated_at) VALUES (?, ?, ?) + ON CONFLICT("key") DO UPDATE SET "value" = excluded."value", updated_at = excluded.updated_at`, + PREFERENCES_KEY, + JSON.stringify(preferences), + Date.now(), + ); +} + +export async function getUserPreferences(): Promise { + const db = await getDatabase(); + const row = await db.getFirstAsync<{ value: string }>( + 'SELECT "value" FROM user_preferences WHERE "key" = ?', + PREFERENCES_KEY, + ); + if (!row) return DEFAULT_PREFERENCES; + + try { + return { ...DEFAULT_PREFERENCES, ...JSON.parse(row.value) } satisfies UserPreferences; + } catch { + return DEFAULT_PREFERENCES; + } +} \ No newline at end of file diff --git a/mobile/services/db/schema.ts b/mobile/services/db/schema.ts new file mode 100644 index 0000000..114d25f --- /dev/null +++ b/mobile/services/db/schema.ts @@ -0,0 +1,31 @@ +export const DATABASE_NAME = 'dot.db'; + +export const DATABASE_VERSION = 2; + +export const PREFERENCES_KEY = 'user_preferences'; + +export const FOLDER_COLUMNS = [ + 'uri', + 'name', + '"exists"', + 'sync_status', + 'added_at', + 'parent_uri', +] as const; + +export const FOLDER_COLUMNS_SQL = FOLDER_COLUMNS.join(', '); + +export const FILE_COLUMNS = [ + 'uri', + 'name', + 'folder_uri', + 'extension', + 'size', + '"type"', + '"exists"', + 'last_modified', + 'sync_status', + 'added_at', +] as const; + +export const FILE_COLUMNS_SQL = FILE_COLUMNS.join(', '); \ No newline at end of file diff --git a/mobile/services/sqliteStorage.types.ts b/mobile/services/db/types.ts similarity index 85% rename from mobile/services/sqliteStorage.types.ts rename to mobile/services/db/types.ts index 527cbd7..6398b00 100644 --- a/mobile/services/sqliteStorage.types.ts +++ b/mobile/services/db/types.ts @@ -1,4 +1,6 @@ -import type { FileEntry, Folder } from "./safDirectory.types"; +import type { FileEntry, Folder } from '../safDirectory.types'; + +export type { FileEntry, Folder } from '../safDirectory.types'; export type SyncStatus = 'local' | 'cloud' | 'local-cloud'; diff --git a/mobile/services/localStorage.ts b/mobile/services/localStorage.ts index 79cc347..395b239 100644 --- a/mobile/services/localStorage.ts +++ b/mobile/services/localStorage.ts @@ -9,6 +9,6 @@ export { saveFolder, saveDirectory, saveUserPreferences, -} from './sqliteStorage'; +} from './db'; -export type { FolderContext, StoredFile, SyncStatus, UserPreferences } from './sqliteStorage.types'; \ No newline at end of file +export type { FolderContext, StoredFile, SyncStatus, UserPreferences } from './db'; \ No newline at end of file diff --git a/mobile/services/sqliteStorage.ts b/mobile/services/sqliteStorage.ts deleted file mode 100644 index 8fed016..0000000 --- a/mobile/services/sqliteStorage.ts +++ /dev/null @@ -1,209 +0,0 @@ -import * as SQLite from 'expo-sqlite'; -import type { SQLiteDatabase } from 'expo-sqlite'; -import type { FileEntry, Folder } from './safDirectory.types'; -import type { UserPreferences,SyncStatus,FolderContext, FolderRow,StoredFile, FileRow } from './sqliteStorage.types'; - -const DATABASE_NAME = 'dot.db'; -const DATABASE_VERSION = 1; - -const PREFERENCES_KEY = 'user_preferences'; - -let database: SQLiteDatabase | null = null; - -const DEFAULT_PREFERENCES: UserPreferences = { - syncMode: 'full', -}; - -export async function getDatabase(): Promise { - if (database) return database; - - const db = await SQLite.openDatabaseAsync(DATABASE_NAME); - await migrateIfNeeded(db); - database = db; - return db; -} - -async function migrateIfNeeded(db: SQLiteDatabase): Promise { - const row = await db.getFirstAsync<{ user_version: number }>('PRAGMA user_version'); - const currentVersion = row?.user_version ?? 0; - if (currentVersion >= DATABASE_VERSION) return; - - if (currentVersion === 0) { - await db.execAsync(` -PRAGMA journal_mode = 'wal'; -PRAGMA foreign_keys = ON; -CREATE TABLE IF NOT EXISTS folders ( - id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - uri TEXT NOT NULL UNIQUE, - name TEXT NOT NULL, - "exists" INTEGER, - sync_status TEXT NOT NULL DEFAULT 'local', - added_at INTEGER NOT NULL, - parent_uri TEXT -); -CREATE TABLE IF NOT EXISTS files ( - id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - uri TEXT NOT NULL UNIQUE, - name TEXT NOT NULL, - folder_uri TEXT NOT NULL, - extension TEXT, - size INTEGER, - "type" TEXT, - "exists" INTEGER, - last_modified INTEGER, - sync_status TEXT NOT NULL DEFAULT 'local', - added_at INTEGER NOT NULL, - FOREIGN KEY (folder_uri) REFERENCES folders(uri) ON DELETE CASCADE -); -CREATE TABLE IF NOT EXISTS user_preferences ( - "key" TEXT PRIMARY KEY NOT NULL, - "value" TEXT NOT NULL, - updated_at INTEGER NOT NULL -); -`); - } - - await db.execAsync(`PRAGMA user_version = ${DATABASE_VERSION}`); -} - -export async function saveFolder( - folder: Folder, - options?: { parentUri?: string; syncStatus?: SyncStatus }, -): Promise { - const db = await getDatabase(); - const exists = folder.exists === undefined ? null : folder.exists ? 1 : 0; - - await db.runAsync( - `INSERT INTO folders (uri, name, "exists", sync_status, added_at, parent_uri) - VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT(uri) DO UPDATE SET - name = excluded.name, "exists" = excluded."exists", sync_status = excluded.sync_status`, - folder.uri, - folder.name, - exists, - options?.syncStatus ?? 'local', - Date.now(), - options?.parentUri ?? null, - ); -} - -export async function saveDirectory(folder: Folder): Promise { - await saveFolder(folder); - return true; -} - -export async function getFolders(): Promise { - const db = await getDatabase(); - const rows = await db.getAllAsync( - 'SELECT * FROM folders ORDER BY added_at ASC, name ASC', - ); - return rows.map(toFolderContext); -} - -export async function getFolderFolders(parentUri: string): Promise { - const db = await getDatabase(); - const rows = await db.getAllAsync( - 'SELECT * FROM folders WHERE parent_uri = ? ORDER BY name ASC', - parentUri, - ); - return rows.map(toFolderContext); -} - -export async function removeFolder(uri: string): Promise { - const db = await getDatabase(); - await db.runAsync('DELETE FROM folders WHERE uri = ?', uri); -} - -export async function saveFile(file: FileEntry, folderUri: string): Promise { - const db = await getDatabase(); - const exists = file.exists ? 1 : 0; - - await db.runAsync( - `INSERT INTO files (uri, name, folder_uri, extension, size, "type", "exists", last_modified, added_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(uri) DO UPDATE SET - name = excluded.name, folder_uri = excluded.folder_uri, extension = excluded.extension, - size = excluded.size, "type" = excluded."type", "exists" = excluded."exists", - last_modified = excluded.last_modified`, - file.uri, - file.name, - folderUri, - file.extension ?? null, - file.size, - file.type ?? null, - exists, - file.lastModified, - Date.now(), - ); -} - -export async function getFiles(folderUri?: string): Promise { - const db = await getDatabase(); - const rows = folderUri - ? await db.getAllAsync( - 'SELECT * FROM files WHERE folder_uri = ? ORDER BY name ASC', - folderUri, - ) - : await db.getAllAsync('SELECT * FROM files ORDER BY name ASC'); - return rows.map(toStoredFile); -} - -export async function removeFile(uri: string): Promise { - const db = await getDatabase(); - await db.runAsync('DELETE FROM files WHERE uri = ?', uri); -} - -export async function saveUserPreferences(preferences: UserPreferences): Promise { - const db = await getDatabase(); - await db.runAsync( - `INSERT INTO user_preferences ("key", "value", updated_at) VALUES (?, ?, ?) - ON CONFLICT("key") DO UPDATE SET "value" = excluded."value", updated_at = excluded.updated_at`, - PREFERENCES_KEY, - JSON.stringify(preferences), - Date.now(), - ); -} - -export async function getUserPreferences(): Promise { - const db = await getDatabase(); - const row = await db.getFirstAsync<{ value: string }>( - 'SELECT "value" FROM user_preferences WHERE "key" = ?', - PREFERENCES_KEY, - ); - if (!row) return DEFAULT_PREFERENCES; - - try { - return { ...DEFAULT_PREFERENCES, ...JSON.parse(row.value) } satisfies UserPreferences; - } catch { - return DEFAULT_PREFERENCES; - } -} - -function toFolderContext(row: FolderRow): FolderContext { - return { - syncStatus: row.sync_status, - addedAt: row.added_at, - folder: { - uri: row.uri, - name: row.name, - isDirectory: true, - exists: row.exists === null ? undefined : row.exists === 1, - }, - }; -} - -function toStoredFile(row: FileRow): StoredFile { - return { - uri: row.uri, - name: row.name, - isDirectory: false, - extension: row.extension ?? '', - exists: row.exists === 1, - size: row.size, - type: row.type ?? '', - lastModified: row.last_modified, - folderUri: row.folder_uri, - syncStatus: row.sync_status, - addedAt: row.added_at, - }; -} \ No newline at end of file