diff --git a/AGENTS.md b/AGENTS.md index 2419e4e..11e6582 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,12 +2,12 @@ ## Project Status -Project initialized — `webui/` (React Native) and `backend/` (Go) have scaffolding in place. +Project initialized — `mobile/` (React Native / Expo) and `backend/` (Go) have scaffolding in place. The SQLite layer (schema v4, migrations, repositories) is implemented and covered by tests. ## Architecture - **Backend**: Go, Gin HTTP framework, PostgreSQL, Tesseract OCR (system call) -- **Frontend**: React Native (Expo SDK 57), React Navigation, TanStack Query, react-native-image-picker +- **Frontend**: React Native (Expo SDK 57), expo-router, expo-sqlite, expo-file-system (SAF) ## Key Commands @@ -19,10 +19,13 @@ cd backend && go run cmd/server/main.go docker compose up postgres -d # Frontend -cd webui && npx expo start +cd mobile && npx expo start # Typecheck frontend -cd webui && npx tsc --noEmit +cd mobile && npx tsc --noEmit + +# SQLite layer tests (migrations + repositories) +cd mobile && npm run test:db ``` ## Backend Structure @@ -38,18 +41,32 @@ cd webui && npx tsc --noEmit ## Frontend Structure -- Entry point: `webui/App.tsx` (React Navigation + TanStack Query providers) -- Screens: `app/index.tsx` (home), `app/upload.tsx`, `app/scan.tsx`, `app/search.tsx` -- Components: `components/FileCard.tsx`, `TagChip.tsx`, `UploadProgress.tsx` -- Hooks: `hooks/useFiles.ts`, `hooks/useSearch.ts`, `hooks/useUpload.ts` -- API client: `api/client.ts`, types: `types/index.ts`, constants: `constants/api.ts` -- No business logic on the client — all heavy processing server-side -- API base URL configured via `EXPO_PUBLIC_API_BASE_URL` env var +- Entry point: `mobile/App.tsx` (expo-router layout + Auth context) +- Data layer — `mobile/services/`: + - `safDirectory.ts` + `safDirectory.types.ts`: physical access via `expo-file-system` (pick/list/create, Documents/SAF uris) + - `db/` — SQLite persistence, see `mobile/AGENTS.md` for the full contract (schema, migrations, repositories, tests) + - `localStorage.ts` — thin re-export of `services/db` (legacy alias) +- `features/syncDevice.ts` — device sync orchestration (two-pass SAF walk, single transaction per root, `exists = 0` reconciliation) +- `context/AuthContext.tsx` — session context +- No business logic on the client — heavy processing stays server-side +- API base URL configured via `EXPO_PUBLIC_API_BASE_URL` env var (client + hooks not yet built) + +## Data Conventions + +- Canonical identity for folders/files/shares/share_links is `resource_id`: opaque `lower(hex(randomblob(16)))`, generated locally, never reused. The physical `uri` is nullable (NULL = cloud-only) and is the reconciliation key for the SAF walk. +- `owner_id` is NOT NULL on every folder/file row, seeded from the device's `device_user_id`. +- Folder/file `sync_status` is a **placement** state: `local` | `cloud` | `local-cloud` (transitions via `transitionSyncStatus`). It is not a push progress marker. +- Shares/share_links carry no `sync_status`; their `pushStatus` (pending/synced/failed) is derived from the `pending_operations` outbox. +- Decisions are made **offline** from a cached `resource_permissions` snapshot pushed by the server; the server remains the source of truth. `canAccess` enforces ranking (viewer < commenter < editor < owner), `inherit`, `expires_at`, and a 24h stale-cache read-only downgrade. +- `password_hash` and download counters are **server-side only**; the client only stores the `has_password` boolean and a counter mirror. ## Non-Goals (V1) -Collaborative/multi-user, plugin system, complex offline sync, public sharing. +- Plugin system +- On-device OCR +- Full multi-tenant federation / public discovery +- Multi-writer sync conflicts (single-owner device identity; device-local `device_user_id`) ## References -- `README.md` — full spec, API endpoints, data flow, folder structure, iteration roadmap +- `README.md` — full spec, API endpoints, data flow, folder structure, iteration roadmap \ No newline at end of file diff --git a/mobile/AGENTS.md b/mobile/AGENTS.md index cbe555d..f920428 100644 --- a/mobile/AGENTS.md +++ b/mobile/AGENTS.md @@ -7,15 +7,17 @@ Read the exact versioned docs at https://docs.expo.dev/versions/v57.0.0/ before Persistence is SQLite-backed via `services/db/` (`expo-sqlite`, database `dot.db`, `user_version` = 4). - `services/db/client.ts` — connection lifecycle: `getDatabase()`, `closeDatabase()`, `withTransaction()`. -- `services/db/migrations.ts` — versioned, chained migrations via `PRAGMA user_version` (list of `{ version, up }`), single-flight `WeakMap` lock, `migrateDatabase(db, targetVersion?)`. v4 is a **transactional rebuild** (folders/files drop `uri` keys, gain `resource_id`). Migration tests: `npm run test:migrations` (better-sqlite3 harness in `tests/migrations.test.ts`, run with `tsx`). +- `services/db/migrations.ts` — versioned, chained migrations via `PRAGMA user_version` (list of `{ version, up }`), single-flight `WeakMap` lock, `migrateDatabase(db, targetVersion?)`. v4 is a **transactional rebuild** (folders/files drop `uri` keys, gain `resource_id` + partial unique index on non-null `uri`). Migration tests: `npm run test:migrations` (better-sqlite3 harness in `tests/migrations.test.ts`); repository tests: `npm run test:db` (both suites, better-sqlite3 via the `DbSession` seam). +- `services/db/session.ts` — `DbSession` (injectable `runAsync`/`getFirstAsync`/`getAllAsync`) ; repos get it through `getSession()`, tests override it with `__setDbForTests()`. `client.ts` loads `expo-sqlite` lazily so the test suites run under plain Node. - `services/db/schema.ts` — `DATABASE_NAME`, `DATABASE_VERSION`, column-list constants, `DEVICE_USER_ID_KEY`, `PERMISSION_TTL_MS` (24h offline stale-cache). - `services/db/id.ts` — `newResourceId()` opaque 32-hex `lower(hex(randomblob(16)))`, generated per row. - `services/db/transitions.ts` — `transitionSyncStatus(from, event)`: per-row sync status transitions. -- `services/db/repositories/` — one module per table: `folders`, `files`, `user_preferences` (+ `getDeviceUserId`), `resource_permissions` (`permissions.ts` with `canAccess`/`canWrite`/`isOwner`, hierarchical via `WITH RECURSIVE`), `shares`, `share_links`, `recipients`, `pending_operations` (`pendingOps.ts`, outbox). +- `services/db/repositories/` — one module per table: `folders`, `files`, `user_preferences` (+ `getDeviceUserId`), `resource_permissions` (`permissions.ts` with `canAccess`/`canWrite`/`isOwner`, hierarchical via `WITH RECURSIVE`, `inherit`/`expires_at` honored, 24h stale-cache read-only downgrade), `shares`, `share_links`, `recipients`, `pending_operations` (`pendingOps.ts`, outbox: FIFO on `(created_at, id)`, failure schedules a `pending` retry with backoff, dead-letter `failed` after `MAX_PENDING_ATTEMPTS`). - `services/localStorage.ts` is a thin re-export (`services/db`) kept for legacy imports. - Tables: `folders`, `files`, `user_preferences`, `resource_permissions`, `shares`, `share_links`, `recipients`, `pending_operations`. - Canonical identity: `resource_id` (opaque, unique) on folders/files/shares/share_links; `uri` (physical SAF path) is **nullable**, NULL = cloud-only; `owner_id` NOT NULL seeded from `device_user_id`. - Folder and file per-row sync status: `local` | `cloud` | `local-cloud` (placement state, transitions via `transitionSyncStatus`). - `shares`/`share_links` have NO `sync_status`: their `pushStatus` (`pending`/`synced`/`failed`) is **derived** from `pending_operations` (`ref_type` = `share`|`share_link`, `ref_id`). - Query usage: `getFiles(folderResourceId?)`, `getFolders()`, `getFolderFolders(parentResourceId)`, `getFolder`/`getFile(resourceId)`, `saveFolder`/`saveDirectory`, `saveFile(file, folderResourceId)`, `removeFolder`/`removeFile(resourceId)`, `saveUserPreferences`/`getUserPreferences`/`getDeviceUserId`, `saveResourcePermission`/`getResourcePermission`, `canAccess(resourceId, type, level)`, `saveShare`/`getShares`/`removeShare`, `createShareLink`/`getShareLinks`/`incrementLinkDownloads`/`revokeShareLink`, `saveRecipient`/`getRecipients`, `enqueuePendingOperation`/`getNextQueuedOperation`/`markPendingOperation`. +- SAF walk (`features/syncDevice.ts`, `syncDevice()`/`syncRoot()`): **two passes** (all folders sorted by uri depth, then all files) inside a **single transaction** (`withTransaction`), receives sync: an interruption rolls back entirely. Reconciles by physical `uri`; rows under the root with a `uri` no longer seen are **marked `exists = 0`** (never deleted). Root folders are the rows with `parent_resource_id IS NULL` + non-null `uri`. - Heavy processing stays server-side; SQLite only persists local metadata/state. diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index 8737343..c3ca94e 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'; import { Pressable, StyleSheet, Text, View } from 'react-native'; import { pickDirectory } from '../services/safDirectory'; import { saveDirectory, getFolders } from '../services/localStorage'; +import { syncRoot } from '../features/syncDevice'; export default function Index() { @@ -18,9 +19,14 @@ export default function Index() { const handlePickDirectory = async () => { const folder = await pickDirectory(); if ( !folder ) return; - await saveDirectory(folder); + const saved = await saveDirectory(folder); setFolders((prev) => [...prev, folder.name]); - console.log(folders) + try { + const result = await syncRoot(saved.resource_id); + console.info('walk', JSON.stringify(result)); + } catch (error) { + console.warn('walk failed', error); + } }; return ( diff --git a/mobile/features/syncDevice.ts b/mobile/features/syncDevice.ts index d441bf1..be147df 100644 --- a/mobile/features/syncDevice.ts +++ b/mobile/features/syncDevice.ts @@ -1,47 +1,129 @@ -import { getUserPreferences } from "../services/localStorage"; -import type { UserPreferences } from "../services/localStorage" +import { listDirectory, listFolders } from '../services/safDirectory'; +import type { FileEntry } from '../services/safDirectory.types'; +import { + getFiles, + getFolder, + getFolders, + saveFile, + saveFolder, + getUserPreferences, + withTransaction, + type StoredFile, + type StoredFolder, +} from '../services/db'; -let syncStartedAt: null | number = null; +export type SyncResult = { + rootUri: string; + folders: number; + files: number; + missing: number; +}; -export async function useSyncDevice() { +function uriDepth(uri: string): number { + return uri.split('/').length; +} - initialize() +function dirname(uri: string): string { + return uri.slice(0, uri.lastIndexOf('/')); +} - async function initialize() { - - if ( syncStartedAt ) return; +function storedToEntry(stored: StoredFile): FileEntry { + return { + uri: stored.uri ?? '', + name: stored.name, + isDirectory: false, + extension: stored.extension, + exists: false, + size: stored.size, + type: stored.type, + lastModified: stored.lastModified, + }; +} - syncStartedAt = Date.UTC(Date.now()); +function isChildOf(uri: string, rootUri: string): boolean { + return uri.startsWith(rootUri); +} - console.info("Sync started") +export async function syncRoot(rootResourceId: string): Promise { + const root = await getFolder(rootResourceId); + if (!root) throw new Error('unknown root folder'); + if (!root.uri) throw new Error(`root '${root.name}' has no physical uri`); - while (true) { + return withTransaction(async () => { + const seen = new Set(); - await sync() + const folders = listFolders(root.uri as string, { recursive: true, includeRoot: true }).sort( + (a, b) => uriDepth(a.uri) - uriDepth(b.uri), + ); - await new Promise((resolve)=>{ - setTimeout(() => { - resolve(true) - }, 1000); - }) - } - + const resourceIdByUri = new Map(); + const savedFolders: StoredFolder[] = []; + for (const folder of folders) { + seen.add(folder.uri); + const parentUri = dirname(folder.uri); + const parentResourceId = + folder.uri === root.uri ? null : (resourceIdByUri.get(parentUri) ?? root.resource_id); + const saved = await saveFolder( + { uri: folder.uri, name: folder.name, exists: folder.exists }, + { parentResourceId }, + ); + resourceIdByUri.set(folder.uri, saved.resource_id); + savedFolders.push(saved); } - async function sync() { - - const userPreferences = await getUserPreferences() - - console.log(userPreferences) - - } - - async function setSyncMode( mode: UserPreferences ) { - + let files = 0; + for (const folder of savedFolders) { + if (!folder.uri) continue; + for (const entry of listDirectory(folder.uri)) { + if (entry.isDirectory) continue; + seen.add(entry.uri); + await saveFile(entry, folder.resource_id); + files++; + } } - return { - + let missing = 0; + for (const folder of await getFolders()) { + if (folder.uri && isChildOf(folder.uri, root.uri as string) && folder.exists && !seen.has(folder.uri)) { + await saveFolder( + { uri: folder.uri, name: folder.name, exists: false, resource_id: folder.resource_id }, + { syncStatus: folder.syncStatus }, + ); + missing++; + } + } + for (const file of await getFiles()) { + if (file.uri && isChildOf(file.uri, root.uri as string) && file.exists && !seen.has(file.uri)) { + await saveFile(storedToEntry(file), file.folder_resource_id, { + resource_id: file.resource_id, + syncStatus: file.syncStatus, + }); + missing++; + } } + return { rootUri: root.uri as string, folders: folders.length, files, missing }; + }); +} + +export async function syncDevice(): Promise { + const roots = (await getFolders()).filter( + (folder) => folder.parent_resource_id === null && folder.uri !== null, + ); + const results: SyncResult[] = []; + for (const root of roots) { + results.push(await syncRoot(root.resource_id)); + } + return results; +} + +export async function useSyncDevice(intervalMs = 30_000): Promise { + const preferences = await getUserPreferences(); + if (preferences.syncMode === 'none') return; + + while (true) { + const results = await syncDevice(); + console.info('syncDevice', JSON.stringify(results)); + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } } \ No newline at end of file diff --git a/mobile/package.json b/mobile/package.json index 319865a..924e5e4 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -29,6 +29,7 @@ "android": "expo run:android", "ios": "expo run:ios", "web": "expo start --web", + "test:db": "tsx --test tests/migrations.test.ts tests/repositories.test.ts", "test:migrations": "tsx --test tests/migrations.test.ts" }, "private": true diff --git a/mobile/services/db/client.ts b/mobile/services/db/client.ts index 2c2a2e3..34d7b1e 100644 --- a/mobile/services/db/client.ts +++ b/mobile/services/db/client.ts @@ -1,13 +1,17 @@ -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; +async function loadSqlite(): Promise { + return import('expo-sqlite'); +} + export async function getDatabase(): Promise { if (database) return database; + const SQLite = await loadSqlite(); const db = await SQLite.openDatabaseAsync(DATABASE_NAME); await migrateDatabase(db); database = db; diff --git a/mobile/services/db/id.ts b/mobile/services/db/id.ts index 6e18d45..18d1dcf 100644 --- a/mobile/services/db/id.ts +++ b/mobile/services/db/id.ts @@ -1,7 +1,7 @@ -import { getDatabase } from './client'; +import { getSession } from './session'; export async function newResourceId(): Promise { - const db = await getDatabase(); + const db = await getSession(); const row = await db.getFirstAsync<{ id: string }>( 'SELECT lower(hex(randomblob(16))) AS id', ); diff --git a/mobile/services/db/migrations.ts b/mobile/services/db/migrations.ts index 7bc3945..4f44cac 100644 --- a/mobile/services/db/migrations.ts +++ b/mobile/services/db/migrations.ts @@ -210,6 +210,8 @@ CREATE INDEX IF NOT EXISTS idx_folders_resource_id ON folders(resource_id); CREATE INDEX IF NOT EXISTS idx_files_resource_id ON files(resource_id); CREATE INDEX IF NOT EXISTS idx_files_folder_resource ON files(folder_resource_id); CREATE INDEX IF NOT EXISTS idx_folders_parent ON folders(parent_resource_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_folders_uri ON folders(uri) WHERE uri IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS idx_files_uri ON files(uri) WHERE uri IS NOT NULL; `); await txn.execAsync(`PRAGMA user_version = 4;`); }); diff --git a/mobile/services/db/repositories/files.ts b/mobile/services/db/repositories/files.ts index 08ca378..786dd3c 100644 --- a/mobile/services/db/repositories/files.ts +++ b/mobile/services/db/repositories/files.ts @@ -1,4 +1,4 @@ -import { getDatabase } from '../client'; +import { getSession } from '../session'; import { newResourceId } from '../id'; import { FILE_COLUMNS_SQL } from '../schema'; import { getDeviceUserId } from './preferences'; @@ -6,6 +6,7 @@ import type { FileEntry, FileRow, StoredFile, SyncStatus } from '../types'; export type SaveFileOptions = { syncStatus?: SyncStatus; + resource_id?: string; }; export async function saveFile( @@ -13,15 +14,20 @@ export async function saveFile( folderResourceId: string, options: SaveFileOptions = {}, ): Promise { - const db = await getDatabase(); + const db = await getSession(); const now = Date.now(); const exists = file.exists ? 1 : 0; const ownerId = await getDeviceUserId(); - const existing = await db.getFirstAsync( - `SELECT ${FILE_COLUMNS_SQL} FROM files WHERE uri = ?`, - file.uri, - ); + const existing = options.resource_id + ? await db.getFirstAsync( + `SELECT ${FILE_COLUMNS_SQL} FROM files WHERE resource_id = ?`, + options.resource_id, + ) + : await db.getFirstAsync( + `SELECT ${FILE_COLUMNS_SQL} FROM files WHERE uri = ?`, + file.uri, + ); const resourceId = existing?.resource_id ?? (await newResourceId()); const baseSync = existing?.sync_status ?? options.syncStatus ?? 'local'; @@ -64,7 +70,7 @@ export async function saveFile( } export async function getFiles(folderResourceId?: string): Promise { - const db = await getDatabase(); + const db = await getSession(); const rows = folderResourceId === undefined ? await db.getAllAsync(`SELECT ${FILE_COLUMNS_SQL} FROM files ORDER BY name ASC`) @@ -76,7 +82,7 @@ export async function getFiles(folderResourceId?: string): Promise } export async function getFile(resourceId: string): Promise { - const db = await getDatabase(); + const db = await getSession(); const row = await db.getFirstAsync( `SELECT ${FILE_COLUMNS_SQL} FROM files WHERE resource_id = ?`, resourceId, @@ -85,7 +91,7 @@ export async function getFile(resourceId: string): Promise { } export async function removeFile(resourceId: string): Promise { - const db = await getDatabase(); + const db = await getSession(); await db.runAsync('DELETE FROM files WHERE resource_id = ?', resourceId); } diff --git a/mobile/services/db/repositories/folders.ts b/mobile/services/db/repositories/folders.ts index 9eea708..a1bb73a 100644 --- a/mobile/services/db/repositories/folders.ts +++ b/mobile/services/db/repositories/folders.ts @@ -1,4 +1,4 @@ -import { getDatabase } from '../client'; +import { getSession } from '../session'; import { newResourceId } from '../id'; import { FOLDER_COLUMNS_SQL } from '../schema'; import { getDeviceUserId } from './preferences'; @@ -20,21 +20,21 @@ export async function saveFolder( input: SaveFolderInput, options: SaveFolderOptions = {}, ): Promise { - const db = await getDatabase(); + const db = await getSession(); const now = Date.now(); const exists = input.exists === undefined || input.exists === null ? null : input.exists ? 1 : 0; const ownerId = await getDeviceUserId(); - const existing = input.uri + const existing = input.resource_id ? await db.getFirstAsync( - `SELECT ${FOLDER_COLUMNS_SQL} FROM folders WHERE uri = ?`, - input.uri, + `SELECT ${FOLDER_COLUMNS_SQL} FROM folders WHERE resource_id = ?`, + input.resource_id, ) - : input.resource_id + : input.uri ? await db.getFirstAsync( - `SELECT ${FOLDER_COLUMNS_SQL} FROM folders WHERE resource_id = ?`, - input.resource_id, + `SELECT ${FOLDER_COLUMNS_SQL} FROM folders WHERE uri = ?`, + input.uri, ) : null; @@ -82,7 +82,7 @@ export async function saveDirectory(folder: { } export async function getFolders(): Promise { - const db = await getDatabase(); + const db = await getSession(); const rows = await db.getAllAsync( `SELECT ${FOLDER_COLUMNS_SQL} FROM folders ORDER BY name ASC`, ); @@ -92,7 +92,7 @@ export async function getFolders(): Promise { export async function getFolderFolders( parentResourceId: string | null, ): Promise { - const db = await getDatabase(); + const db = await getSession(); const rows = await db.getAllAsync( `SELECT ${FOLDER_COLUMNS_SQL} FROM folders WHERE parent_resource_id IS ? ORDER BY name ASC`, parentResourceId, @@ -101,7 +101,7 @@ export async function getFolderFolders( } export async function getFolder(resourceId: string): Promise { - const db = await getDatabase(); + const db = await getSession(); const row = await db.getFirstAsync( `SELECT ${FOLDER_COLUMNS_SQL} FROM folders WHERE resource_id = ?`, resourceId, @@ -110,7 +110,7 @@ export async function getFolder(resourceId: string): Promise { - const db = await getDatabase(); + const db = await getSession(); await db.runAsync('DELETE FROM folders WHERE resource_id = ?', resourceId); } diff --git a/mobile/services/db/repositories/pendingOps.ts b/mobile/services/db/repositories/pendingOps.ts index dfddcb1..8851ea4 100644 --- a/mobile/services/db/repositories/pendingOps.ts +++ b/mobile/services/db/repositories/pendingOps.ts @@ -1,4 +1,4 @@ -import { getDatabase } from '../client'; +import { getSession } from '../session'; import type { NewPendingOperation, PendingOperation, @@ -13,10 +13,12 @@ const PENDING_OPERATION_COLUMNS = ` const MAX_BACKOFF_MS = 24 * 60 * 60 * 1000; const BASE_BACKOFF_MS = 30 * 1000; +export const MAX_PENDING_ATTEMPTS = 5; + export async function enqueuePendingOperation( operation: NewPendingOperation, ): Promise { - const db = await getDatabase(); + const db = await getSession(); const row = await db.getFirstAsync<{ id: number }>( `INSERT INTO pending_operations (resource_id, resource_type, ref_type, ref_id, operation, payload, status, attempts, created_at) @@ -36,7 +38,7 @@ export async function enqueuePendingOperation( export async function getPendingOperations( status?: PendingOperationStatus, ): Promise { - const db = await getDatabase(); + const db = await getSession(); const rows = status ? await db.getAllAsync( `SELECT ${PENDING_OPERATION_COLUMNS} FROM pending_operations @@ -51,7 +53,7 @@ export async function getPendingOperations( } export async function getNextQueuedOperation(): Promise { - const db = await getDatabase(); + const db = await getSession(); const row = await db.getFirstAsync( `SELECT ${PENDING_OPERATION_COLUMNS} FROM pending_operations WHERE status = 'pending' @@ -68,7 +70,7 @@ export async function markPendingOperation( status: PendingOperationStatus, error?: string | null, ): Promise { - const db = await getDatabase(); + const db = await getSession(); const now = Date.now(); if (status === 'failed') { @@ -78,12 +80,23 @@ export async function markPendingOperation( ); if (!current) return; const attempts = current.attempts + 1; + if (attempts >= MAX_PENDING_ATTEMPTS) { + await db.runAsync( + `UPDATE pending_operations SET + status = 'failed', attempts = ?, error = ?, next_retry_at = NULL, last_error_at = ? + WHERE id = ?`, + attempts, + error ?? null, + now, + id, + ); + return; + } const backoff = Math.min(BASE_BACKOFF_MS * 2 ** attempts, MAX_BACKOFF_MS); await db.runAsync( `UPDATE pending_operations SET - status = ?, attempts = ?, error = ?, next_retry_at = ?, last_error_at = ? + status = 'pending', attempts = ?, error = ?, next_retry_at = ?, last_error_at = ? WHERE id = ?`, - status, attempts, error ?? null, now + backoff, diff --git a/mobile/services/db/repositories/permissions.ts b/mobile/services/db/repositories/permissions.ts index d9b5579..cdbf4d5 100644 --- a/mobile/services/db/repositories/permissions.ts +++ b/mobile/services/db/repositories/permissions.ts @@ -1,5 +1,5 @@ -import type { SQLiteDatabase } from 'expo-sqlite'; -import { getDatabase } from '../client'; +import type { DbSession } from '../session'; +import { getSession } from '../session'; import { PERMISSION_TTL_MS } from '../schema'; import { getDeviceUserId } from './preferences'; import type { @@ -29,6 +29,7 @@ export type AccessCheck = { type LineageNode = { resource_id: string; + resource_type: ResourceType; parent_resource_id: string | null; owner_id: string; }; @@ -41,7 +42,7 @@ export async function getResourcePermission( resourceId: string, resourceType: ResourceType, ): Promise { - const db = await getDatabase(); + const db = await getSession(); const row = await db.getFirstAsync( `SELECT * FROM resource_permissions WHERE resource_id = ? AND resource_type = ?`, resourceId, @@ -53,7 +54,7 @@ export async function getResourcePermission( export async function saveResourcePermission( permission: NewResourcePermission, ): Promise { - const db = await getDatabase(); + const db = await getSession(); const now = Date.now(); await db.runAsync( `INSERT INTO resource_permissions @@ -83,21 +84,32 @@ async function resourceLineage( resourceId: string, resourceType: ResourceType, ): Promise { - const db = await getDatabase(); + const db = await getSession(); - const anchor = await db.getFirstAsync<{ folder_resource_id: string }>( - 'SELECT folder_resource_id FROM files WHERE resource_id = ?', - resourceId, - ); if (resourceType === 'file') { - if (!anchor) return []; - return folderLineage(db, anchor.folder_resource_id); + const file = await db.getFirstAsync<{ + folder_resource_id: string; + owner_id: string; + }>( + 'SELECT folder_resource_id, owner_id FROM files WHERE resource_id = ?', + resourceId, + ); + if (!file) return []; + return [ + { + resource_id: resourceId, + resource_type: 'file', + parent_resource_id: file.folder_resource_id, + owner_id: file.owner_id, + }, + ...(await folderLineage(db, file.folder_resource_id)), + ]; } return folderLineage(db, resourceId); } async function folderLineage( - db: SQLiteDatabase, + db: DbSession, startResourceId: string, ): Promise { const rows = await db.getAllAsync( @@ -108,34 +120,18 @@ async function folderLineage( FROM folders f JOIN lineage l ON f.resource_id = l.parent_resource_id ) - SELECT resource_id, parent_resource_id, owner_id FROM lineage`, + SELECT resource_id, 'folder' AS resource_type, parent_resource_id, owner_id FROM lineage`, startResourceId, ); return rows; } -function decideFromPermission( - permission: ResourcePermission, - required: AccessLevel, - now: number, -): AccessCheck { - if (permission.expiresAt != null && permission.expiresAt < now) { - return { - allowed: false, - access: permission.effectiveAccess, - source: 'cache', - stale: false, - expiresAt: permission.expiresAt, - }; - } - const granted = rank(permission.effectiveAccess) >= rank(required); - return { - allowed: granted, - access: permission.effectiveAccess, - source: 'cache', - stale: now - permission.cachedAt > PERMISSION_TTL_MS, - expiresAt: permission.expiresAt, - }; +function isStale(cachedAt: number, now: number): boolean { + return now - cachedAt > PERMISSION_TTL_MS; +} + +function readOnlyAccess(permission: ResourcePermission, now: number): AccessLevel { + return isStale(permission.cachedAt, now) ? 'viewer' : permission.effectiveAccess; } export async function canAccess( @@ -143,28 +139,75 @@ export async function canAccess( resourceType: ResourceType, required: AccessLevel, ): Promise { - const db = await getDatabase(); const now = Date.now(); const deviceUserId = await getDeviceUserId(); - const cached = await getResourcePermission(resourceId, resourceType); - if (cached) return decideFromPermission(cached, required, now); + const exactCache = await getResourcePermission(resourceId, resourceType); + if (exactCache) { + if (exactCache.expiresAt != null && exactCache.expiresAt < now) { + return { + allowed: false, + access: exactCache.effectiveAccess, + source: 'cache', + stale: false, + expiresAt: exactCache.expiresAt, + }; + } + const stale = isStale(exactCache.cachedAt, now); + const applyAccess = readOnlyAccess(exactCache, now); + return { + allowed: rank(applyAccess) >= rank(required), + access: applyAccess, + source: 'cache', + stale, + expiresAt: exactCache.expiresAt, + }; + } const lineage = await resourceLineage(resourceId, resourceType); - for (const node of lineage) { + let best: { + access: AccessLevel; + source: AccessSource; + stale: boolean; + expiresAt: number | null; + } | null = null; + + for (const [index, node] of lineage.entries()) { if (node.owner_id === deviceUserId) { return { allowed: true, access: 'owner', source: 'owner', stale: false, expiresAt: null }; } - const nodePermission = await getResourcePermission(node.resource_id, 'folder'); - if (nodePermission) { - const decision = decideFromPermission(nodePermission, required, now); - if (decision.source === 'cache') { - return { ...decision, source: 'inherited' }; - } + + const nodePermission = await getResourcePermission(node.resource_id, node.resource_type); + if (!nodePermission) continue; + if (nodePermission.expiresAt != null && nodePermission.expiresAt < now) continue; + if (index > 0 && nodePermission.inherit === false) continue; + + const stale = isStale(nodePermission.cachedAt, now); + const candidate = { + access: readOnlyAccess(nodePermission, now), + source: (index === 0 ? 'cache' : 'inherited') as AccessSource, + stale, + expiresAt: nodePermission.expiresAt, + }; + if ( + !best || + rank(candidate.access) > rank(best.access) || + (rank(candidate.access) === rank(best.access) && !candidate.stale && best.stale) + ) { + best = candidate; } } - return { allowed: false, access: null, source: 'none', stale: false, expiresAt: null }; + if (!best) { + return { allowed: false, access: null, source: 'none', stale: false, expiresAt: null }; + } + return { + allowed: rank(best.access) >= rank(required), + access: best.access, + source: best.source, + stale: best.stale, + expiresAt: best.expiresAt, + }; } export async function canWrite( diff --git a/mobile/services/db/repositories/preferences.ts b/mobile/services/db/repositories/preferences.ts index a94eb03..4a20b62 100644 --- a/mobile/services/db/repositories/preferences.ts +++ b/mobile/services/db/repositories/preferences.ts @@ -1,4 +1,4 @@ -import { getDatabase } from '../client'; +import { getSession } from '../session'; import { DEVICE_USER_ID_KEY, PREFERENCES_KEY } from '../schema'; import type { UserPreferences } from '../types'; @@ -7,7 +7,7 @@ const DEFAULT_PREFERENCES: UserPreferences = { }; export async function getDeviceUserId(): Promise { - const db = await getDatabase(); + const db = await getSession(); const row = await db.getFirstAsync<{ value: string }>( 'SELECT "value" FROM user_preferences WHERE "key" = ?', DEVICE_USER_ID_KEY, @@ -28,7 +28,7 @@ export async function getDeviceUserId(): Promise { } export async function saveUserPreferences(preferences: UserPreferences): Promise { - const db = await getDatabase(); + const db = await getSession(); 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`, @@ -39,7 +39,7 @@ export async function saveUserPreferences(preferences: UserPreferences): Promise } export async function getUserPreferences(): Promise { - const db = await getDatabase(); + const db = await getSession(); const row = await db.getFirstAsync<{ value: string }>( 'SELECT "value" FROM user_preferences WHERE "key" = ?', PREFERENCES_KEY, diff --git a/mobile/services/db/repositories/recipients.ts b/mobile/services/db/repositories/recipients.ts index a3971f4..94c676d 100644 --- a/mobile/services/db/repositories/recipients.ts +++ b/mobile/services/db/repositories/recipients.ts @@ -1,4 +1,4 @@ -import { getDatabase } from '../client'; +import { getSession } from '../session'; import type { Recipient, RecipientRow, RecipientType } from '../types'; export async function saveRecipient( @@ -6,7 +6,7 @@ export async function saveRecipient( recipientId: string, displayName: string, ): Promise { - const db = await getDatabase(); + const db = await getSession(); await db.runAsync( `INSERT INTO recipients (recipient_type, recipient_id, display_name, is_active, updated_at) VALUES (?, ?, ?, 1, ?) @@ -28,7 +28,7 @@ export async function saveRecipient( } export async function getRecipients(activeOnly = true): Promise { - const db = await getDatabase(); + const db = await getSession(); const rows = activeOnly ? await db.getAllAsync( 'SELECT * FROM recipients WHERE is_active = 1 ORDER BY display_name ASC', @@ -42,7 +42,7 @@ export async function setRecipientActive( recipientId: string, active: boolean, ): Promise { - const db = await getDatabase(); + const db = await getSession(); await db.runAsync( `UPDATE recipients SET is_active = ?, updated_at = ? WHERE recipient_type = ? AND recipient_id = ?`, diff --git a/mobile/services/db/repositories/shareLinks.ts b/mobile/services/db/repositories/shareLinks.ts index 940f81b..3ee3057 100644 --- a/mobile/services/db/repositories/shareLinks.ts +++ b/mobile/services/db/repositories/shareLinks.ts @@ -1,4 +1,4 @@ -import { getDatabase } from '../client'; +import { getSession } from '../session'; import type { NewShareLink, ResourceType, ShareLink, ShareLinkRow } from '../types'; const PUSH_STATUS_SQL = `( @@ -16,7 +16,7 @@ const PUSH_STATUS_SQL = `( ) AS push_status`; export async function createShareLink(input: NewShareLink): Promise { - const db = await getDatabase(); + const db = await getSession(); const now = Date.now(); let token = input.token; @@ -49,7 +49,7 @@ export async function createShareLink(input: NewShareLink): Promise { } export async function getShareLinkById(id: number): Promise { - const db = await getDatabase(); + const db = await getSession(); const row = await db.getFirstAsync( `SELECT sl.*, ${PUSH_STATUS_SQL} FROM share_links sl WHERE sl.id = ?`, id, @@ -58,7 +58,7 @@ export async function getShareLinkById(id: number): Promise { } export async function getShareLinkByToken(token: string): Promise { - const db = await getDatabase(); + const db = await getSession(); const row = await db.getFirstAsync( `SELECT sl.*, ${PUSH_STATUS_SQL} FROM share_links sl WHERE sl.token = ?`, token, @@ -70,7 +70,7 @@ export async function getShareLinks( resourceId?: string, resourceType?: ResourceType, ): Promise { - const db = await getDatabase(); + const db = await getSession(); let sql = `SELECT sl.*, ${PUSH_STATUS_SQL} FROM share_links sl`; const params: string[] = []; if (resourceId) { @@ -87,7 +87,7 @@ export async function getShareLinks( } export async function incrementLinkDownloads(id: number): Promise { - const db = await getDatabase(); + const db = await getSession(); await db.runAsync( `UPDATE share_links SET downloads_count = downloads_count + 1, updated_at = ? WHERE id = ?`, Date.now(), @@ -96,7 +96,7 @@ export async function incrementLinkDownloads(id: number): Promise { } export async function revokeShareLink(id: number): Promise { - const db = await getDatabase(); + const db = await getSession(); await db.runAsync( `UPDATE share_links SET is_revoked = 1, updated_at = ? WHERE id = ?`, Date.now(), @@ -108,7 +108,7 @@ export async function removeShareLinksForResource( resourceId: string, resourceType: ResourceType, ): Promise { - const db = await getDatabase(); + const db = await getSession(); await db.runAsync('DELETE FROM share_links WHERE resource_id = ? AND resource_type = ?', resourceId, resourceType); } diff --git a/mobile/services/db/repositories/shares.ts b/mobile/services/db/repositories/shares.ts index bb38493..af2ce2b 100644 --- a/mobile/services/db/repositories/shares.ts +++ b/mobile/services/db/repositories/shares.ts @@ -1,4 +1,4 @@ -import { getDatabase } from '../client'; +import { getSession } from '../session'; import type { NewShare, ResourceType, Share, ShareRow } from '../types'; const PUSH_STATUS_SQL = `( @@ -16,7 +16,7 @@ const PUSH_STATUS_SQL = `( ) AS push_status`; export async function saveShare(share: NewShare): Promise { - const db = await getDatabase(); + const db = await getSession(); const now = Date.now(); await db.runAsync( @@ -56,7 +56,7 @@ export async function getShare( recipientType: 'user' | 'group', recipientId: string, ): Promise { - const db = await getDatabase(); + const db = await getSession(); const row = await db.getFirstAsync( `SELECT s.*, ${PUSH_STATUS_SQL} FROM shares s WHERE s.resource_id = ? AND s.resource_type = ? AND s.recipient_type = ? AND s.recipient_id = ?`, @@ -72,7 +72,7 @@ export async function getShares( resourceId?: string, resourceType?: ResourceType, ): Promise { - const db = await getDatabase(); + const db = await getSession(); let sql = `SELECT s.*, ${PUSH_STATUS_SQL} FROM shares s`; const params: string[] = []; if (resourceId) { @@ -94,7 +94,7 @@ export async function removeShare( recipientType: 'user' | 'group', recipientId: string, ): Promise { - const db = await getDatabase(); + const db = await getSession(); await db.runAsync( `DELETE FROM shares WHERE resource_id = ? AND resource_type = ? AND recipient_type = ? AND recipient_id = ?`, @@ -109,7 +109,7 @@ export async function removeSharesForResource( resourceId: string, resourceType: ResourceType, ): Promise { - const db = await getDatabase(); + const db = await getSession(); await db.runAsync('DELETE FROM shares WHERE resource_id = ? AND resource_type = ?', resourceId, resourceType); } diff --git a/mobile/services/db/session.ts b/mobile/services/db/session.ts new file mode 100644 index 0000000..698659f --- /dev/null +++ b/mobile/services/db/session.ts @@ -0,0 +1,18 @@ +import { getDatabase } from './client'; +import type { SQLiteBindValue } from 'expo-sqlite'; + +export type DbSession = { + runAsync(sql: string, ...params: SQLiteBindValue[]): Promise; + getFirstAsync(sql: string, ...params: SQLiteBindValue[]): Promise; + getAllAsync(sql: string, ...params: SQLiteBindValue[]): Promise; +}; + +let override: DbSession | null = null; + +export function __setDbForTests(db: DbSession | null): void { + override = db; +} + +export async function getSession(): Promise { + return override ?? (await getDatabase()); +} \ No newline at end of file diff --git a/mobile/tests/repositories.test.ts b/mobile/tests/repositories.test.ts new file mode 100644 index 0000000..5dab8f9 --- /dev/null +++ b/mobile/tests/repositories.test.ts @@ -0,0 +1,442 @@ +import { test, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import Database from 'better-sqlite3'; +import { MIGRATIONS, type MigrationDb } from '../services/db/migrations'; +import type { DbSession } from '../services/db/session'; +import { __setDbForTests } from '../services/db/session'; +import { + saveFolder, + getFolders, + getFolder, + removeFolder, + saveFile, + getFiles, + canAccess, + saveResourcePermission, + saveShare, + getShares, + createShareLink, + getShareLinks, + incrementLinkDownloads, + enqueuePendingOperation, + getPendingOperations, + getNextQueuedOperation, + markPendingOperation, + getDeviceUserId, + DEVICE_USER_ID_KEY, + PERMISSION_TTL_MS, +} from '../services/db'; +import type { FileEntry } from '../services/safDirectory.types'; +import { MAX_PENDING_ATTEMPTS } from '../services/db/repositories/pendingOps'; + +type Harness = MigrationDb & DbSession; + +let h: Harness; + +function createHarness(): Harness { + const sqlite = new Database(':memory:'); + const harness: Harness = { + execAsync: async (sql: string) => { + sqlite.exec(sql); + }, + runAsync: async (sql: string, ...params: unknown[]) => { + sqlite.prepare(sql).run(...params); + }, + getFirstAsync: async (sql: string, ...params: unknown[]) => + (sqlite.prepare(sql).get(...params) ?? null) as never, + getAllAsync: async (sql: string, ...params: unknown[]) => + sqlite.prepare(sql).all(...params) as never, + withExclusiveTransactionAsync: async (task: (txn: Harness) => Promise) => { + sqlite.exec('BEGIN'); + try { + await task(harness); + sqlite.exec('COMMIT'); + } catch (error) { + sqlite.exec('ROLLBACK'); + throw error; + } + }, + }; + return harness; +} + +beforeEach(async () => { + h = createHarness(); + for (const migration of MIGRATIONS) { + await migration.up(h); + } + __setDbForTests(h); +}); + +afterEach(() => { + __setDbForTests(null); +}); + +function fileEntry(uri: string, overrides: Partial = {}): FileEntry { + return { + uri, + name: uri.split('/').pop() ?? uri, + isDirectory: false, + extension: uri.split('.').pop() ?? '', + exists: true, + size: 10, + type: 'application/octet-stream', + lastModified: 1, + ...overrides, + }; +} + +async function setOwner( + table: 'folders' | 'files', + resourceId: string, + ownerId: string, +): Promise { + await h.runAsync(`UPDATE ${table} SET owner_id = ? WHERE resource_id = ?`, ownerId, resourceId); +} + +test('folder upsert is idempotent by uri (same resource_id, single row)', async () => { + const a = await saveFolder({ uri: 'content://a', name: 'A' }); + const b = await saveFolder({ uri: 'content://a', name: 'A renamed' }); + + assert.equal(a.resource_id, b.resource_id); + assert.equal(b.name, 'A renamed'); + assert.equal(b.uri, 'content://a'); + assert.equal((await getFolders()).length, 1); +}); + +test('distinct uris produce distinct rows', async () => { + await saveFolder({ uri: 'content://a', name: 'A' }); + await saveFolder({ uri: 'content://b', name: 'B' }); + assert.equal((await getFolders()).length, 2); +}); + +test('cloud-only folder gains a uri without duplicating (reconciled by resource_id)', async () => { + const cloud = await saveFolder({ uri: null, name: 'Cloud', resource_id: 'abc123' }); + assert.equal(cloud.uri, null); + const wired = await saveFolder({ uri: 'content://cloud', name: 'Cloud', resource_id: 'abc123' }); + assert.equal(wired.resource_id, 'abc123'); + assert.equal(wired.uri, 'content://cloud'); + assert.equal((await getFolders()).length, 1); +}); + +test('inserting a folder with a nonexistent parent throws a FK error (no silent orphan)', async () => { + await assert.rejects( + () => saveFolder({ uri: 'content://child', name: 'C' }, { parentResourceId: 'does-not-exist' }), + /FOREIGN KEY/, + ); +}); + +test('inserting a file before its parent folder throws a FK error', async () => { + await assert.rejects( + () => saveFile(fileEntry('content://orphan/f.pdf'), 'missing-folder'), + /FOREIGN KEY/, + ); +}); + +test('re-saving a folder without a parent keeps the existing parent', async () => { + const root = await saveFolder({ uri: 'content://r', name: 'R' }); + const child = await saveFolder({ uri: 'content://r/c', name: 'C' }, { parentResourceId: root.resource_id }); + const reSaved = await saveFolder({ uri: 'content://r/c', name: 'C2' }); + assert.equal(reSaved.parent_resource_id, root.resource_id); +}); + +test('a full SAF walk repeated twice is a no-op (BFS order, uri reconciliation)', async () => { + const root = await saveFolder({ uri: 'content://root', name: 'Root' }); + const sub = await saveFolder({ uri: 'content://root/sub', name: 'Sub' }, { parentResourceId: root.resource_id }); + await saveFile(fileEntry('content://root/sub/f1.pdf'), sub.resource_id); + + const root2 = await saveFolder({ uri: 'content://root', name: 'Root' }); + const sub2 = await saveFolder({ uri: 'content://root/sub', name: 'Sub' }, { parentResourceId: root2.resource_id }); + await saveFile(fileEntry('content://root/sub/f1.pdf'), sub2.resource_id); + + assert.equal(root.resource_id, root2.resource_id); + assert.equal(sub.resource_id, sub2.resource_id); + assert.equal(sub2.parent_resource_id, root2.resource_id); + assert.equal((await getFolders()).length, 2); + assert.equal((await getFiles()).length, 1); + const stored = await getFiles(sub2.resource_id); + assert.equal(stored.length, 1); +}); + +test('removing a root folder cascades to its subtree and files', async () => { + const root = await saveFolder({ uri: 'content://root', name: 'Root' }); + const sub = await saveFolder({ uri: 'content://root/sub', name: 'Sub' }, { parentResourceId: root.resource_id }); + await saveFile(fileEntry('content://root/sub/f1.pdf'), sub.resource_id); + + await removeFolder(root.resource_id); + + assert.equal((await getFolders()).length, 0); + assert.equal((await getFiles()).length, 0); +}); + +test('canAccess: nothing granted => denied, owner fallback grants owner', async () => { + const root = await saveFolder({ uri: 'content://r', name: 'R' }); + const sub = await saveFolder({ uri: 'content://r/s', name: 'S' }, { parentResourceId: root.resource_id }); + const f = await saveFile(fileEntry('content://r/s/f.pdf'), sub.resource_id); + + const device = await getDeviceUserId(); + await setOwner('folders', root.resource_id, 'other'); + await setOwner('folders', sub.resource_id, 'other'); + await setOwner('files', f.resource_id, 'other'); + + assert.equal((await canAccess(f.resource_id, 'file', 'viewer')).allowed, false); + assert.equal((await canAccess(sub.resource_id, 'folder', 'viewer')).allowed, false); + + await setOwner('folders', sub.resource_id, device); + assert.equal((await canAccess(f.resource_id, 'file', 'owner')).allowed, true); + assert.equal((await canAccess(f.resource_id, 'file', 'viewer')).source, 'owner'); +}); + +test('canAccess: granted permission on an ancestor inherits through deep folders', async () => { + const ids: string[] = []; + let parentResourceId: string | null = null; + for (let depth = 0; depth < 5; depth++) { + const folder = await saveFolder( + { uri: `content://chain/${depth}`, name: `n${depth}` }, + { parentResourceId }, + ); + ids.push(folder.resource_id); + parentResourceId = folder.resource_id; + } + const f = await saveFile(fileEntry('content://chain/f.pdf'), parentResourceId!); + const device = await getDeviceUserId(); + for (const id of ids) await setOwner('folders', id, 'other'); + await setOwner('files', f.resource_id, 'other'); + + await saveResourcePermission({ + resource_id: ids[0], + resourceType: 'folder', + effectiveAccess: 'viewer', + inherit: true, + }); + assert.equal((await canAccess(f.resource_id, 'file', 'viewer')).allowed, true, 'viewer inherited from root'); + assert.equal((await canAccess(f.resource_id, 'file', 'editor')).allowed, false, 'viewer is not editor'); + assert.equal((await canAccess(f.resource_id, 'file', 'viewer')).source, 'inherited'); +}); + +test('canAccess: inherit=false blocks propagation but allows the node itself', async () => { + const root = await saveFolder({ uri: 'content://r', name: 'R' }); + const sub = await saveFolder({ uri: 'content://r/s', name: 'S' }, { parentResourceId: root.resource_id }); + const f = await saveFile(fileEntry('content://r/s/f.pdf'), sub.resource_id); + const device = await getDeviceUserId(); + await setOwner('folders', root.resource_id, 'other'); + await setOwner('folders', sub.resource_id, 'other'); + await setOwner('files', f.resource_id, 'other'); + + await saveResourcePermission({ + resource_id: root.resource_id, + resourceType: 'folder', + effectiveAccess: 'viewer', + inherit: false, + }); + assert.equal((await canAccess(root.resource_id, 'folder', 'viewer')).allowed, true, 'root itself keeps viewer'); + assert.equal((await canAccess(sub.resource_id, 'folder', 'viewer')).allowed, false, 'inheritance stopped'); + assert.equal((await canAccess(f.resource_id, 'file', 'viewer')).allowed, false); + + await saveResourcePermission({ + resource_id: sub.resource_id, + resourceType: 'folder', + effectiveAccess: 'viewer', + inherit: true, + }); + assert.equal((await canAccess(f.resource_id, 'file', 'viewer')).allowed, true, 'closer ancestor grants descend'); +}); + +test('canAccess: an expired permission is denied and does not propagate', async () => { + const root = await saveFolder({ uri: 'content://r', name: 'R' }); + const sub = await saveFolder({ uri: 'content://r/s', name: 'S' }, { parentResourceId: root.resource_id }); + const device = await getDeviceUserId(); + await setOwner('folders', root.resource_id, 'other'); + await setOwner('folders', sub.resource_id, 'other'); + + await saveResourcePermission({ + resource_id: root.resource_id, + resourceType: 'folder', + effectiveAccess: 'viewer', + inherit: true, + expiresAt: Date.now() - 1000, + }); + assert.equal((await canAccess(root.resource_id, 'folder', 'viewer')).allowed, false); + assert.equal((await canAccess(sub.resource_id, 'folder', 'viewer')).allowed, false); +}); + +test('canAccess: stale cache only allows read (viewer), fresh cache allows the granted level', async () => { + const root = await saveFolder({ uri: 'content://r', name: 'R' }); + const sub = await saveFolder({ uri: 'content://r/s', name: 'S' }, { parentResourceId: root.resource_id }); + const device = await getDeviceUserId(); + await setOwner('folders', root.resource_id, 'other'); + await setOwner('folders', sub.resource_id, 'other'); + + await saveResourcePermission({ + resource_id: root.resource_id, + resourceType: 'folder', + effectiveAccess: 'editor', + inherit: true, + }); + assert.equal((await canAccess(sub.resource_id, 'folder', 'editor')).allowed, true, 'fresh editor grant'); + + await h.runAsync( + 'UPDATE resource_permissions SET cached_at = ? WHERE resource_id = ?', + Date.now() - PERMISSION_TTL_MS - 60_000, + root.resource_id, + ); + assert.equal((await canAccess(sub.resource_id, 'folder', 'viewer')).allowed, true, 'stale still allows read'); + assert.equal((await canAccess(sub.resource_id, 'folder', 'editor')).allowed, false, 'stale blocks writes'); + assert.equal((await canAccess(sub.resource_id, 'folder', 'viewer')).stale, true); +}); + +test('canAccess: a cached decision on the exact node is authoritative even if weaker', async () => { + const root = await saveFolder({ uri: 'content://r', name: 'R' }); + const sub = await saveFolder({ uri: 'content://r/s', name: 'S' }, { parentResourceId: root.resource_id }); + const device = await getDeviceUserId(); + await setOwner('folders', root.resource_id, 'other'); + await setOwner('folders', sub.resource_id, 'other'); + + await saveResourcePermission({ + resource_id: root.resource_id, + resourceType: 'folder', + effectiveAccess: 'editor', + inherit: true, + }); + await saveResourcePermission({ + resource_id: sub.resource_id, + resourceType: 'folder', + effectiveAccess: 'viewer', + inherit: true, + }); + + assert.equal((await canAccess(sub.resource_id, 'folder', 'viewer')).allowed, true); + assert.equal((await canAccess(sub.resource_id, 'folder', 'editor')).allowed, false, 'exact viewer cache overrides inherited editor'); +}); + +test('outbox: ops on the same resource are drained in FIFO order (id tie-break)', async () => { + const id1 = await enqueuePendingOperation({ operation: 'create_resource', resourceId: 'a' }); + const id2 = await enqueuePendingOperation({ operation: 'update_metadata', resourceId: 'a' }); + const id3 = await enqueuePendingOperation({ operation: 'delete_resource', resourceId: 'a' }); + await h.runAsync('UPDATE pending_operations SET created_at = ?', 1000); + + const ops = await getPendingOperations(); + assert.deepEqual( + ops.map((o) => o.operation), + ['create_resource', 'update_metadata', 'delete_resource'], + ); + + const first = await getNextQueuedOperation(); + assert.equal(first!.id, id1); + await markPendingOperation(first!.id, 'completed'); + const second = await getNextQueuedOperation(); + assert.equal(second!.id, id2); + await markPendingOperation(second!.id, 'completed'); + assert.equal((await getNextQueuedOperation())!.id, id3); +}); + +test('outbox: a failure stays pending with a backoff, then dead-letters after max attempts', async () => { + const id = await enqueuePendingOperation({ operation: 'create_resource', resourceId: 'a' }); + + await markPendingOperation(id, 'failed', 'boom'); + let row = await h.getFirstAsync<{ + status: string; + attempts: number; + next_retry_at: number | null; + }>('SELECT status, attempts, next_retry_at FROM pending_operations WHERE id = ?', id); + assert.equal(row!.status, 'pending'); + assert.equal(row!.attempts, 1); + assert.ok((row!.next_retry_at ?? 0) > Date.now(), 'retry scheduled in the future'); + + assert.equal(await getNextQueuedOperation(), null, 'backoff respects next_retry_at'); + + await h.runAsync('UPDATE pending_operations SET next_retry_at = ? WHERE id = ?', 0, id); + const due = await getNextQueuedOperation(); + assert.ok(due); + assert.equal(due.attempts, 1); + + for (let i = 0; i < MAX_PENDING_ATTEMPTS - 1; i++) { + await markPendingOperation(id, 'failed', 'still failing'); + } + row = await h.getFirstAsync<{ + status: string; + attempts: number; + next_retry_at: number | null; + }>('SELECT status, attempts, next_retry_at FROM pending_operations WHERE id = ?', id); + assert.equal(row!.status, 'failed', 'dead-lettered'); + assert.equal(row!.attempts, MAX_PENDING_ATTEMPTS); + assert.equal(row!.next_retry_at, null); + assert.equal(await getNextQueuedOperation(), null, 'dead-lettered ops are never re-picked'); + + const share = await saveShare({ + resourceId: 'a', + resourceType: 'folder', + recipientType: 'user', + recipientId: 'u1', + relation: 'viewer', + }); + assert.equal(share.pushStatus, 'synced'); +}); + +test('shares and share links derive pushStatus from the outbox', async () => { + const share = await saveShare({ + resourceId: 'res-1', + resourceType: 'folder', + recipientType: 'user', + recipientId: 'u1', + relation: 'editor', + }); + assert.equal(share.pushStatus, 'synced'); + + const opId = await enqueuePendingOperation({ + refType: 'share', + refId: share.id, + operation: 'share', + resourceId: 'res-1', + resourceType: 'folder', + }); + assert.equal((await getShares('res-1', 'folder'))[0].pushStatus, 'pending'); + + await markPendingOperation(opId, 'failed', 'nope'); + assert.equal( + (await getShares('res-1', 'folder'))[0].pushStatus, + 'pending', + 'a single failure schedules a retry, share stays pending', + ); + + for (let attempt = 1; attempt < MAX_PENDING_ATTEMPTS; attempt++) { + await markPendingOperation(opId, 'failed', 'still failing'); + } + assert.equal((await getShares('res-1', 'folder'))[0].pushStatus, 'failed', 'dead-lettered'); + + await markPendingOperation(opId, 'completed'); + const shares = await getShares('res-1', 'folder'); + assert.equal(shares.length, 1); + assert.equal(shares[0].pushStatus, 'synced', 'no pending/failed left'); + + const link = await createShareLink({ resourceId: 'res-1', resourceType: 'folder' }); + assert.match(link.token, /^[0-9a-f]{32}$/); + assert.equal(link.pushStatus, 'synced'); + + const linkOpId = await enqueuePendingOperation({ + refType: 'share_link', + refId: link.id, + operation: 'create_link', + }); + assert.equal((await getShareLinks('res-1', 'folder'))[0].pushStatus, 'pending'); + await markPendingOperation(linkOpId, 'completed'); + assert.equal((await getShareLinks('res-1', 'folder'))[0].pushStatus, 'synced'); + + await incrementLinkDownloads(link.id); + assert.equal((await getShareLinks('res-1', 'folder'))[0].downloadsCount, 1); +}); + +test('device user id is a stable lowercase 32-hex string shared by all rows', async () => { + const device = await getDeviceUserId(); + assert.match(device, /^[0-9a-f]{32}$/); + + const root = await saveFolder({ uri: 'content://r', name: 'R' }); + const rootRow = await getFolder(root.resource_id); + assert.equal(rootRow!.owner_id, device); + + const seeded = await h.getFirstAsync<{ value: string }>( + 'SELECT "value" FROM user_preferences WHERE "key" = ?', + DEVICE_USER_ID_KEY, + ); + assert.equal(seeded!.value, device); +}); \ No newline at end of file