This commit is contained in:
m
2026-09-10 06:39:25 +02:00
parent 7dd29239b7
commit 85b1871c18
18 changed files with 783 additions and 147 deletions
+30 -13
View File
@@ -2,12 +2,12 @@
## Project Status ## 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 ## Architecture
- **Backend**: Go, Gin HTTP framework, PostgreSQL, Tesseract OCR (system call) - **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 ## Key Commands
@@ -19,10 +19,13 @@ cd backend && go run cmd/server/main.go
docker compose up postgres -d docker compose up postgres -d
# Frontend # Frontend
cd webui && npx expo start cd mobile && npx expo start
# Typecheck frontend # 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 ## Backend Structure
@@ -38,18 +41,32 @@ cd webui && npx tsc --noEmit
## Frontend Structure ## Frontend Structure
- Entry point: `webui/App.tsx` (React Navigation + TanStack Query providers) - Entry point: `mobile/App.tsx` (expo-router layout + Auth context)
- Screens: `app/index.tsx` (home), `app/upload.tsx`, `app/scan.tsx`, `app/search.tsx` - Data layer — `mobile/services/`:
- Components: `components/FileCard.tsx`, `TagChip.tsx`, `UploadProgress.tsx` - `safDirectory.ts` + `safDirectory.types.ts`: physical access via `expo-file-system` (pick/list/create, Documents/SAF uris)
- Hooks: `hooks/useFiles.ts`, `hooks/useSearch.ts`, `hooks/useUpload.ts` - `db/` — SQLite persistence, see `mobile/AGENTS.md` for the full contract (schema, migrations, repositories, tests)
- API client: `api/client.ts`, types: `types/index.ts`, constants: `constants/api.ts` - `localStorage.ts` — thin re-export of `services/db` (legacy alias)
- No business logic on the client — all heavy processing server-side - `features/syncDevice.ts` — device sync orchestration (two-pass SAF walk, single transaction per root, `exists = 0` reconciliation)
- API base URL configured via `EXPO_PUBLIC_API_BASE_URL` env var - `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) ## 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 ## 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
+4 -2
View File
@@ -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). 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/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/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/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/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. - `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`. - 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`. - 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`). - 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`). - `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`. - 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. - Heavy processing stays server-side; SQLite only persists local metadata/state.
+8 -2
View File
@@ -3,6 +3,7 @@ import { useEffect, useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native'; import { Pressable, StyleSheet, Text, View } from 'react-native';
import { pickDirectory } from '../services/safDirectory'; import { pickDirectory } from '../services/safDirectory';
import { saveDirectory, getFolders } from '../services/localStorage'; import { saveDirectory, getFolders } from '../services/localStorage';
import { syncRoot } from '../features/syncDevice';
export default function Index() { export default function Index() {
@@ -18,9 +19,14 @@ export default function Index() {
const handlePickDirectory = async () => { const handlePickDirectory = async () => {
const folder = await pickDirectory(); const folder = await pickDirectory();
if ( !folder ) return; if ( !folder ) return;
await saveDirectory(folder); const saved = await saveDirectory(folder);
setFolders((prev) => [...prev, folder.name]); 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 ( return (
+113 -31
View File
@@ -1,47 +1,129 @@
import { getUserPreferences } from "../services/localStorage"; import { listDirectory, listFolders } from '../services/safDirectory';
import type { UserPreferences } from "../services/localStorage" 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() { function storedToEntry(stored: StoredFile): FileEntry {
return {
if ( syncStartedAt ) 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<SyncResult> {
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<string>();
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)=>{ const resourceIdByUri = new Map<string, string>();
setTimeout(() => { const savedFolders: StoredFolder[] = [];
resolve(true) for (const folder of folders) {
}, 1000); 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() { let files = 0;
for (const folder of savedFolders) {
const userPreferences = await getUserPreferences() if (!folder.uri) continue;
for (const entry of listDirectory(folder.uri)) {
console.log(userPreferences) if (entry.isDirectory) continue;
seen.add(entry.uri);
} await saveFile(entry, folder.resource_id);
files++;
async function setSyncMode( mode: UserPreferences ) { }
} }
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<SyncResult[]> {
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<void> {
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));
}
} }
+1
View File
@@ -29,6 +29,7 @@
"android": "expo run:android", "android": "expo run:android",
"ios": "expo run:ios", "ios": "expo run:ios",
"web": "expo start --web", "web": "expo start --web",
"test:db": "tsx --test tests/migrations.test.ts tests/repositories.test.ts",
"test:migrations": "tsx --test tests/migrations.test.ts" "test:migrations": "tsx --test tests/migrations.test.ts"
}, },
"private": true "private": true
+5 -1
View File
@@ -1,13 +1,17 @@
import * as SQLite from 'expo-sqlite';
import type { SQLiteDatabase } from 'expo-sqlite'; import type { SQLiteDatabase } from 'expo-sqlite';
import { migrateDatabase } from './migrations'; import { migrateDatabase } from './migrations';
import { DATABASE_NAME } from './schema'; import { DATABASE_NAME } from './schema';
let database: SQLiteDatabase | null = null; let database: SQLiteDatabase | null = null;
async function loadSqlite(): Promise<typeof import('expo-sqlite')> {
return import('expo-sqlite');
}
export async function getDatabase(): Promise<SQLiteDatabase> { export async function getDatabase(): Promise<SQLiteDatabase> {
if (database) return database; if (database) return database;
const SQLite = await loadSqlite();
const db = await SQLite.openDatabaseAsync(DATABASE_NAME); const db = await SQLite.openDatabaseAsync(DATABASE_NAME);
await migrateDatabase(db); await migrateDatabase(db);
database = db; database = db;
+2 -2
View File
@@ -1,7 +1,7 @@
import { getDatabase } from './client'; import { getSession } from './session';
export async function newResourceId(): Promise<string> { export async function newResourceId(): Promise<string> {
const db = await getDatabase(); const db = await getSession();
const row = await db.getFirstAsync<{ id: string }>( const row = await db.getFirstAsync<{ id: string }>(
'SELECT lower(hex(randomblob(16))) AS id', 'SELECT lower(hex(randomblob(16))) AS id',
); );
+2
View File
@@ -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_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_files_folder_resource ON files(folder_resource_id);
CREATE INDEX IF NOT EXISTS idx_folders_parent ON folders(parent_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;`); await txn.execAsync(`PRAGMA user_version = 4;`);
}); });
+15 -9
View File
@@ -1,4 +1,4 @@
import { getDatabase } from '../client'; import { getSession } from '../session';
import { newResourceId } from '../id'; import { newResourceId } from '../id';
import { FILE_COLUMNS_SQL } from '../schema'; import { FILE_COLUMNS_SQL } from '../schema';
import { getDeviceUserId } from './preferences'; import { getDeviceUserId } from './preferences';
@@ -6,6 +6,7 @@ import type { FileEntry, FileRow, StoredFile, SyncStatus } from '../types';
export type SaveFileOptions = { export type SaveFileOptions = {
syncStatus?: SyncStatus; syncStatus?: SyncStatus;
resource_id?: string;
}; };
export async function saveFile( export async function saveFile(
@@ -13,15 +14,20 @@ export async function saveFile(
folderResourceId: string, folderResourceId: string,
options: SaveFileOptions = {}, options: SaveFileOptions = {},
): Promise<StoredFile> { ): Promise<StoredFile> {
const db = await getDatabase(); const db = await getSession();
const now = Date.now(); const now = Date.now();
const exists = file.exists ? 1 : 0; const exists = file.exists ? 1 : 0;
const ownerId = await getDeviceUserId(); const ownerId = await getDeviceUserId();
const existing = await db.getFirstAsync<FileRow>( const existing = options.resource_id
`SELECT ${FILE_COLUMNS_SQL} FROM files WHERE uri = ?`, ? await db.getFirstAsync<FileRow>(
file.uri, `SELECT ${FILE_COLUMNS_SQL} FROM files WHERE resource_id = ?`,
); options.resource_id,
)
: await db.getFirstAsync<FileRow>(
`SELECT ${FILE_COLUMNS_SQL} FROM files WHERE uri = ?`,
file.uri,
);
const resourceId = existing?.resource_id ?? (await newResourceId()); const resourceId = existing?.resource_id ?? (await newResourceId());
const baseSync = existing?.sync_status ?? options.syncStatus ?? 'local'; const baseSync = existing?.sync_status ?? options.syncStatus ?? 'local';
@@ -64,7 +70,7 @@ export async function saveFile(
} }
export async function getFiles(folderResourceId?: string): Promise<StoredFile[]> { export async function getFiles(folderResourceId?: string): Promise<StoredFile[]> {
const db = await getDatabase(); const db = await getSession();
const rows = const rows =
folderResourceId === undefined folderResourceId === undefined
? await db.getAllAsync<FileRow>(`SELECT ${FILE_COLUMNS_SQL} FROM files ORDER BY name ASC`) ? await db.getAllAsync<FileRow>(`SELECT ${FILE_COLUMNS_SQL} FROM files ORDER BY name ASC`)
@@ -76,7 +82,7 @@ export async function getFiles(folderResourceId?: string): Promise<StoredFile[]>
} }
export async function getFile(resourceId: string): Promise<StoredFile | null> { export async function getFile(resourceId: string): Promise<StoredFile | null> {
const db = await getDatabase(); const db = await getSession();
const row = await db.getFirstAsync<FileRow>( const row = await db.getFirstAsync<FileRow>(
`SELECT ${FILE_COLUMNS_SQL} FROM files WHERE resource_id = ?`, `SELECT ${FILE_COLUMNS_SQL} FROM files WHERE resource_id = ?`,
resourceId, resourceId,
@@ -85,7 +91,7 @@ export async function getFile(resourceId: string): Promise<StoredFile | null> {
} }
export async function removeFile(resourceId: string): Promise<void> { export async function removeFile(resourceId: string): Promise<void> {
const db = await getDatabase(); const db = await getSession();
await db.runAsync('DELETE FROM files WHERE resource_id = ?', resourceId); await db.runAsync('DELETE FROM files WHERE resource_id = ?', resourceId);
} }
+12 -12
View File
@@ -1,4 +1,4 @@
import { getDatabase } from '../client'; import { getSession } from '../session';
import { newResourceId } from '../id'; import { newResourceId } from '../id';
import { FOLDER_COLUMNS_SQL } from '../schema'; import { FOLDER_COLUMNS_SQL } from '../schema';
import { getDeviceUserId } from './preferences'; import { getDeviceUserId } from './preferences';
@@ -20,21 +20,21 @@ export async function saveFolder(
input: SaveFolderInput, input: SaveFolderInput,
options: SaveFolderOptions = {}, options: SaveFolderOptions = {},
): Promise<StoredFolder> { ): Promise<StoredFolder> {
const db = await getDatabase(); const db = await getSession();
const now = Date.now(); const now = Date.now();
const exists = const exists =
input.exists === undefined || input.exists === null ? null : input.exists ? 1 : 0; input.exists === undefined || input.exists === null ? null : input.exists ? 1 : 0;
const ownerId = await getDeviceUserId(); const ownerId = await getDeviceUserId();
const existing = input.uri const existing = input.resource_id
? await db.getFirstAsync<FolderRow>( ? await db.getFirstAsync<FolderRow>(
`SELECT ${FOLDER_COLUMNS_SQL} FROM folders WHERE uri = ?`, `SELECT ${FOLDER_COLUMNS_SQL} FROM folders WHERE resource_id = ?`,
input.uri, input.resource_id,
) )
: input.resource_id : input.uri
? await db.getFirstAsync<FolderRow>( ? await db.getFirstAsync<FolderRow>(
`SELECT ${FOLDER_COLUMNS_SQL} FROM folders WHERE resource_id = ?`, `SELECT ${FOLDER_COLUMNS_SQL} FROM folders WHERE uri = ?`,
input.resource_id, input.uri,
) )
: null; : null;
@@ -82,7 +82,7 @@ export async function saveDirectory(folder: {
} }
export async function getFolders(): Promise<StoredFolder[]> { export async function getFolders(): Promise<StoredFolder[]> {
const db = await getDatabase(); const db = await getSession();
const rows = await db.getAllAsync<FolderRow>( const rows = await db.getAllAsync<FolderRow>(
`SELECT ${FOLDER_COLUMNS_SQL} FROM folders ORDER BY name ASC`, `SELECT ${FOLDER_COLUMNS_SQL} FROM folders ORDER BY name ASC`,
); );
@@ -92,7 +92,7 @@ export async function getFolders(): Promise<StoredFolder[]> {
export async function getFolderFolders( export async function getFolderFolders(
parentResourceId: string | null, parentResourceId: string | null,
): Promise<StoredFolder[]> { ): Promise<StoredFolder[]> {
const db = await getDatabase(); const db = await getSession();
const rows = await db.getAllAsync<FolderRow>( const rows = await db.getAllAsync<FolderRow>(
`SELECT ${FOLDER_COLUMNS_SQL} FROM folders WHERE parent_resource_id IS ? ORDER BY name ASC`, `SELECT ${FOLDER_COLUMNS_SQL} FROM folders WHERE parent_resource_id IS ? ORDER BY name ASC`,
parentResourceId, parentResourceId,
@@ -101,7 +101,7 @@ export async function getFolderFolders(
} }
export async function getFolder(resourceId: string): Promise<StoredFolder | null> { export async function getFolder(resourceId: string): Promise<StoredFolder | null> {
const db = await getDatabase(); const db = await getSession();
const row = await db.getFirstAsync<FolderRow>( const row = await db.getFirstAsync<FolderRow>(
`SELECT ${FOLDER_COLUMNS_SQL} FROM folders WHERE resource_id = ?`, `SELECT ${FOLDER_COLUMNS_SQL} FROM folders WHERE resource_id = ?`,
resourceId, resourceId,
@@ -110,7 +110,7 @@ export async function getFolder(resourceId: string): Promise<StoredFolder | null
} }
export async function removeFolder(resourceId: string): Promise<void> { export async function removeFolder(resourceId: string): Promise<void> {
const db = await getDatabase(); const db = await getSession();
await db.runAsync('DELETE FROM folders WHERE resource_id = ?', resourceId); await db.runAsync('DELETE FROM folders WHERE resource_id = ?', resourceId);
} }
+20 -7
View File
@@ -1,4 +1,4 @@
import { getDatabase } from '../client'; import { getSession } from '../session';
import type { import type {
NewPendingOperation, NewPendingOperation,
PendingOperation, PendingOperation,
@@ -13,10 +13,12 @@ const PENDING_OPERATION_COLUMNS = `
const MAX_BACKOFF_MS = 24 * 60 * 60 * 1000; const MAX_BACKOFF_MS = 24 * 60 * 60 * 1000;
const BASE_BACKOFF_MS = 30 * 1000; const BASE_BACKOFF_MS = 30 * 1000;
export const MAX_PENDING_ATTEMPTS = 5;
export async function enqueuePendingOperation( export async function enqueuePendingOperation(
operation: NewPendingOperation, operation: NewPendingOperation,
): Promise<number> { ): Promise<number> {
const db = await getDatabase(); const db = await getSession();
const row = await db.getFirstAsync<{ id: number }>( const row = await db.getFirstAsync<{ id: number }>(
`INSERT INTO pending_operations `INSERT INTO pending_operations
(resource_id, resource_type, ref_type, ref_id, operation, payload, status, attempts, created_at) (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( export async function getPendingOperations(
status?: PendingOperationStatus, status?: PendingOperationStatus,
): Promise<PendingOperation[]> { ): Promise<PendingOperation[]> {
const db = await getDatabase(); const db = await getSession();
const rows = status const rows = status
? await db.getAllAsync<PendingOperationRow>( ? await db.getAllAsync<PendingOperationRow>(
`SELECT ${PENDING_OPERATION_COLUMNS} FROM pending_operations `SELECT ${PENDING_OPERATION_COLUMNS} FROM pending_operations
@@ -51,7 +53,7 @@ export async function getPendingOperations(
} }
export async function getNextQueuedOperation(): Promise<PendingOperation | null> { export async function getNextQueuedOperation(): Promise<PendingOperation | null> {
const db = await getDatabase(); const db = await getSession();
const row = await db.getFirstAsync<PendingOperationRow>( const row = await db.getFirstAsync<PendingOperationRow>(
`SELECT ${PENDING_OPERATION_COLUMNS} FROM pending_operations `SELECT ${PENDING_OPERATION_COLUMNS} FROM pending_operations
WHERE status = 'pending' WHERE status = 'pending'
@@ -68,7 +70,7 @@ export async function markPendingOperation(
status: PendingOperationStatus, status: PendingOperationStatus,
error?: string | null, error?: string | null,
): Promise<void> { ): Promise<void> {
const db = await getDatabase(); const db = await getSession();
const now = Date.now(); const now = Date.now();
if (status === 'failed') { if (status === 'failed') {
@@ -78,12 +80,23 @@ export async function markPendingOperation(
); );
if (!current) return; if (!current) return;
const attempts = current.attempts + 1; 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); const backoff = Math.min(BASE_BACKOFF_MS * 2 ** attempts, MAX_BACKOFF_MS);
await db.runAsync( await db.runAsync(
`UPDATE pending_operations SET `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 = ?`, WHERE id = ?`,
status,
attempts, attempts,
error ?? null, error ?? null,
now + backoff, now + backoff,
+89 -46
View File
@@ -1,5 +1,5 @@
import type { SQLiteDatabase } from 'expo-sqlite'; import type { DbSession } from '../session';
import { getDatabase } from '../client'; import { getSession } from '../session';
import { PERMISSION_TTL_MS } from '../schema'; import { PERMISSION_TTL_MS } from '../schema';
import { getDeviceUserId } from './preferences'; import { getDeviceUserId } from './preferences';
import type { import type {
@@ -29,6 +29,7 @@ export type AccessCheck = {
type LineageNode = { type LineageNode = {
resource_id: string; resource_id: string;
resource_type: ResourceType;
parent_resource_id: string | null; parent_resource_id: string | null;
owner_id: string; owner_id: string;
}; };
@@ -41,7 +42,7 @@ export async function getResourcePermission(
resourceId: string, resourceId: string,
resourceType: ResourceType, resourceType: ResourceType,
): Promise<ResourcePermission | null> { ): Promise<ResourcePermission | null> {
const db = await getDatabase(); const db = await getSession();
const row = await db.getFirstAsync<ResourcePermissionRow>( const row = await db.getFirstAsync<ResourcePermissionRow>(
`SELECT * FROM resource_permissions WHERE resource_id = ? AND resource_type = ?`, `SELECT * FROM resource_permissions WHERE resource_id = ? AND resource_type = ?`,
resourceId, resourceId,
@@ -53,7 +54,7 @@ export async function getResourcePermission(
export async function saveResourcePermission( export async function saveResourcePermission(
permission: NewResourcePermission, permission: NewResourcePermission,
): Promise<void> { ): Promise<void> {
const db = await getDatabase(); const db = await getSession();
const now = Date.now(); const now = Date.now();
await db.runAsync( await db.runAsync(
`INSERT INTO resource_permissions `INSERT INTO resource_permissions
@@ -83,21 +84,32 @@ async function resourceLineage(
resourceId: string, resourceId: string,
resourceType: ResourceType, resourceType: ResourceType,
): Promise<LineageNode[]> { ): Promise<LineageNode[]> {
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 (resourceType === 'file') {
if (!anchor) return []; const file = await db.getFirstAsync<{
return folderLineage(db, anchor.folder_resource_id); 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); return folderLineage(db, resourceId);
} }
async function folderLineage( async function folderLineage(
db: SQLiteDatabase, db: DbSession,
startResourceId: string, startResourceId: string,
): Promise<LineageNode[]> { ): Promise<LineageNode[]> {
const rows = await db.getAllAsync<LineageNode>( const rows = await db.getAllAsync<LineageNode>(
@@ -108,34 +120,18 @@ async function folderLineage(
FROM folders f FROM folders f
JOIN lineage l ON f.resource_id = l.parent_resource_id 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, startResourceId,
); );
return rows; return rows;
} }
function decideFromPermission( function isStale(cachedAt: number, now: number): boolean {
permission: ResourcePermission, return now - cachedAt > PERMISSION_TTL_MS;
required: AccessLevel, }
now: number,
): AccessCheck { function readOnlyAccess(permission: ResourcePermission, now: number): AccessLevel {
if (permission.expiresAt != null && permission.expiresAt < now) { return isStale(permission.cachedAt, now) ? 'viewer' : permission.effectiveAccess;
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,
};
} }
export async function canAccess( export async function canAccess(
@@ -143,28 +139,75 @@ export async function canAccess(
resourceType: ResourceType, resourceType: ResourceType,
required: AccessLevel, required: AccessLevel,
): Promise<AccessCheck> { ): Promise<AccessCheck> {
const db = await getDatabase();
const now = Date.now(); const now = Date.now();
const deviceUserId = await getDeviceUserId(); const deviceUserId = await getDeviceUserId();
const cached = await getResourcePermission(resourceId, resourceType); const exactCache = await getResourcePermission(resourceId, resourceType);
if (cached) return decideFromPermission(cached, required, now); 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); 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) { if (node.owner_id === deviceUserId) {
return { allowed: true, access: 'owner', source: 'owner', stale: false, expiresAt: null }; return { allowed: true, access: 'owner', source: 'owner', stale: false, expiresAt: null };
} }
const nodePermission = await getResourcePermission(node.resource_id, 'folder');
if (nodePermission) { const nodePermission = await getResourcePermission(node.resource_id, node.resource_type);
const decision = decideFromPermission(nodePermission, required, now); if (!nodePermission) continue;
if (decision.source === 'cache') { if (nodePermission.expiresAt != null && nodePermission.expiresAt < now) continue;
return { ...decision, source: 'inherited' }; 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( export async function canWrite(
@@ -1,4 +1,4 @@
import { getDatabase } from '../client'; import { getSession } from '../session';
import { DEVICE_USER_ID_KEY, PREFERENCES_KEY } from '../schema'; import { DEVICE_USER_ID_KEY, PREFERENCES_KEY } from '../schema';
import type { UserPreferences } from '../types'; import type { UserPreferences } from '../types';
@@ -7,7 +7,7 @@ const DEFAULT_PREFERENCES: UserPreferences = {
}; };
export async function getDeviceUserId(): Promise<string> { export async function getDeviceUserId(): Promise<string> {
const db = await getDatabase(); const db = await getSession();
const row = await db.getFirstAsync<{ value: string }>( const row = await db.getFirstAsync<{ value: string }>(
'SELECT "value" FROM user_preferences WHERE "key" = ?', 'SELECT "value" FROM user_preferences WHERE "key" = ?',
DEVICE_USER_ID_KEY, DEVICE_USER_ID_KEY,
@@ -28,7 +28,7 @@ export async function getDeviceUserId(): Promise<string> {
} }
export async function saveUserPreferences(preferences: UserPreferences): Promise<void> { export async function saveUserPreferences(preferences: UserPreferences): Promise<void> {
const db = await getDatabase(); const db = await getSession();
await db.runAsync( await db.runAsync(
`INSERT INTO user_preferences ("key", "value", updated_at) VALUES (?, ?, ?) `INSERT INTO user_preferences ("key", "value", updated_at) VALUES (?, ?, ?)
ON CONFLICT("key") DO UPDATE SET "value" = excluded."value", updated_at = excluded.updated_at`, 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<UserPreferences> { export async function getUserPreferences(): Promise<UserPreferences> {
const db = await getDatabase(); const db = await getSession();
const row = await db.getFirstAsync<{ value: string }>( const row = await db.getFirstAsync<{ value: string }>(
'SELECT "value" FROM user_preferences WHERE "key" = ?', 'SELECT "value" FROM user_preferences WHERE "key" = ?',
PREFERENCES_KEY, PREFERENCES_KEY,
@@ -1,4 +1,4 @@
import { getDatabase } from '../client'; import { getSession } from '../session';
import type { Recipient, RecipientRow, RecipientType } from '../types'; import type { Recipient, RecipientRow, RecipientType } from '../types';
export async function saveRecipient( export async function saveRecipient(
@@ -6,7 +6,7 @@ export async function saveRecipient(
recipientId: string, recipientId: string,
displayName: string, displayName: string,
): Promise<Recipient> { ): Promise<Recipient> {
const db = await getDatabase(); const db = await getSession();
await db.runAsync( await db.runAsync(
`INSERT INTO recipients (recipient_type, recipient_id, display_name, is_active, updated_at) `INSERT INTO recipients (recipient_type, recipient_id, display_name, is_active, updated_at)
VALUES (?, ?, ?, 1, ?) VALUES (?, ?, ?, 1, ?)
@@ -28,7 +28,7 @@ export async function saveRecipient(
} }
export async function getRecipients(activeOnly = true): Promise<Recipient[]> { export async function getRecipients(activeOnly = true): Promise<Recipient[]> {
const db = await getDatabase(); const db = await getSession();
const rows = activeOnly const rows = activeOnly
? await db.getAllAsync<RecipientRow>( ? await db.getAllAsync<RecipientRow>(
'SELECT * FROM recipients WHERE is_active = 1 ORDER BY display_name ASC', 'SELECT * FROM recipients WHERE is_active = 1 ORDER BY display_name ASC',
@@ -42,7 +42,7 @@ export async function setRecipientActive(
recipientId: string, recipientId: string,
active: boolean, active: boolean,
): Promise<void> { ): Promise<void> {
const db = await getDatabase(); const db = await getSession();
await db.runAsync( await db.runAsync(
`UPDATE recipients SET is_active = ?, updated_at = ? `UPDATE recipients SET is_active = ?, updated_at = ?
WHERE recipient_type = ? AND recipient_id = ?`, WHERE recipient_type = ? AND recipient_id = ?`,
@@ -1,4 +1,4 @@
import { getDatabase } from '../client'; import { getSession } from '../session';
import type { NewShareLink, ResourceType, ShareLink, ShareLinkRow } from '../types'; import type { NewShareLink, ResourceType, ShareLink, ShareLinkRow } from '../types';
const PUSH_STATUS_SQL = `( const PUSH_STATUS_SQL = `(
@@ -16,7 +16,7 @@ const PUSH_STATUS_SQL = `(
) AS push_status`; ) AS push_status`;
export async function createShareLink(input: NewShareLink): Promise<ShareLink> { export async function createShareLink(input: NewShareLink): Promise<ShareLink> {
const db = await getDatabase(); const db = await getSession();
const now = Date.now(); const now = Date.now();
let token = input.token; let token = input.token;
@@ -49,7 +49,7 @@ export async function createShareLink(input: NewShareLink): Promise<ShareLink> {
} }
export async function getShareLinkById(id: number): Promise<ShareLink | null> { export async function getShareLinkById(id: number): Promise<ShareLink | null> {
const db = await getDatabase(); const db = await getSession();
const row = await db.getFirstAsync<ShareLinkRow & { push_status: ShareLink['pushStatus'] }>( const row = await db.getFirstAsync<ShareLinkRow & { push_status: ShareLink['pushStatus'] }>(
`SELECT sl.*, ${PUSH_STATUS_SQL} FROM share_links sl WHERE sl.id = ?`, `SELECT sl.*, ${PUSH_STATUS_SQL} FROM share_links sl WHERE sl.id = ?`,
id, id,
@@ -58,7 +58,7 @@ export async function getShareLinkById(id: number): Promise<ShareLink | null> {
} }
export async function getShareLinkByToken(token: string): Promise<ShareLink | null> { export async function getShareLinkByToken(token: string): Promise<ShareLink | null> {
const db = await getDatabase(); const db = await getSession();
const row = await db.getFirstAsync<ShareLinkRow & { push_status: ShareLink['pushStatus'] }>( const row = await db.getFirstAsync<ShareLinkRow & { push_status: ShareLink['pushStatus'] }>(
`SELECT sl.*, ${PUSH_STATUS_SQL} FROM share_links sl WHERE sl.token = ?`, `SELECT sl.*, ${PUSH_STATUS_SQL} FROM share_links sl WHERE sl.token = ?`,
token, token,
@@ -70,7 +70,7 @@ export async function getShareLinks(
resourceId?: string, resourceId?: string,
resourceType?: ResourceType, resourceType?: ResourceType,
): Promise<ShareLink[]> { ): Promise<ShareLink[]> {
const db = await getDatabase(); const db = await getSession();
let sql = `SELECT sl.*, ${PUSH_STATUS_SQL} FROM share_links sl`; let sql = `SELECT sl.*, ${PUSH_STATUS_SQL} FROM share_links sl`;
const params: string[] = []; const params: string[] = [];
if (resourceId) { if (resourceId) {
@@ -87,7 +87,7 @@ export async function getShareLinks(
} }
export async function incrementLinkDownloads(id: number): Promise<void> { export async function incrementLinkDownloads(id: number): Promise<void> {
const db = await getDatabase(); const db = await getSession();
await db.runAsync( await db.runAsync(
`UPDATE share_links SET downloads_count = downloads_count + 1, updated_at = ? WHERE id = ?`, `UPDATE share_links SET downloads_count = downloads_count + 1, updated_at = ? WHERE id = ?`,
Date.now(), Date.now(),
@@ -96,7 +96,7 @@ export async function incrementLinkDownloads(id: number): Promise<void> {
} }
export async function revokeShareLink(id: number): Promise<void> { export async function revokeShareLink(id: number): Promise<void> {
const db = await getDatabase(); const db = await getSession();
await db.runAsync( await db.runAsync(
`UPDATE share_links SET is_revoked = 1, updated_at = ? WHERE id = ?`, `UPDATE share_links SET is_revoked = 1, updated_at = ? WHERE id = ?`,
Date.now(), Date.now(),
@@ -108,7 +108,7 @@ export async function removeShareLinksForResource(
resourceId: string, resourceId: string,
resourceType: ResourceType, resourceType: ResourceType,
): Promise<void> { ): Promise<void> {
const db = await getDatabase(); const db = await getSession();
await db.runAsync('DELETE FROM share_links WHERE resource_id = ? AND resource_type = ?', resourceId, resourceType); await db.runAsync('DELETE FROM share_links WHERE resource_id = ? AND resource_type = ?', resourceId, resourceType);
} }
+6 -6
View File
@@ -1,4 +1,4 @@
import { getDatabase } from '../client'; import { getSession } from '../session';
import type { NewShare, ResourceType, Share, ShareRow } from '../types'; import type { NewShare, ResourceType, Share, ShareRow } from '../types';
const PUSH_STATUS_SQL = `( const PUSH_STATUS_SQL = `(
@@ -16,7 +16,7 @@ const PUSH_STATUS_SQL = `(
) AS push_status`; ) AS push_status`;
export async function saveShare(share: NewShare): Promise<Share> { export async function saveShare(share: NewShare): Promise<Share> {
const db = await getDatabase(); const db = await getSession();
const now = Date.now(); const now = Date.now();
await db.runAsync( await db.runAsync(
@@ -56,7 +56,7 @@ export async function getShare(
recipientType: 'user' | 'group', recipientType: 'user' | 'group',
recipientId: string, recipientId: string,
): Promise<Share | null> { ): Promise<Share | null> {
const db = await getDatabase(); const db = await getSession();
const row = await db.getFirstAsync<ShareRow & { push_status: Share['pushStatus'] }>( const row = await db.getFirstAsync<ShareRow & { push_status: Share['pushStatus'] }>(
`SELECT s.*, ${PUSH_STATUS_SQL} FROM shares s `SELECT s.*, ${PUSH_STATUS_SQL} FROM shares s
WHERE s.resource_id = ? AND s.resource_type = ? AND s.recipient_type = ? AND s.recipient_id = ?`, 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, resourceId?: string,
resourceType?: ResourceType, resourceType?: ResourceType,
): Promise<Share[]> { ): Promise<Share[]> {
const db = await getDatabase(); const db = await getSession();
let sql = `SELECT s.*, ${PUSH_STATUS_SQL} FROM shares s`; let sql = `SELECT s.*, ${PUSH_STATUS_SQL} FROM shares s`;
const params: string[] = []; const params: string[] = [];
if (resourceId) { if (resourceId) {
@@ -94,7 +94,7 @@ export async function removeShare(
recipientType: 'user' | 'group', recipientType: 'user' | 'group',
recipientId: string, recipientId: string,
): Promise<void> { ): Promise<void> {
const db = await getDatabase(); const db = await getSession();
await db.runAsync( await db.runAsync(
`DELETE FROM shares `DELETE FROM shares
WHERE resource_id = ? AND resource_type = ? AND recipient_type = ? AND recipient_id = ?`, WHERE resource_id = ? AND resource_type = ? AND recipient_type = ? AND recipient_id = ?`,
@@ -109,7 +109,7 @@ export async function removeSharesForResource(
resourceId: string, resourceId: string,
resourceType: ResourceType, resourceType: ResourceType,
): Promise<void> { ): Promise<void> {
const db = await getDatabase(); const db = await getSession();
await db.runAsync('DELETE FROM shares WHERE resource_id = ? AND resource_type = ?', resourceId, resourceType); await db.runAsync('DELETE FROM shares WHERE resource_id = ? AND resource_type = ?', resourceId, resourceType);
} }
+18
View File
@@ -0,0 +1,18 @@
import { getDatabase } from './client';
import type { SQLiteBindValue } from 'expo-sqlite';
export type DbSession = {
runAsync(sql: string, ...params: SQLiteBindValue[]): Promise<unknown>;
getFirstAsync<T>(sql: string, ...params: SQLiteBindValue[]): Promise<T | null>;
getAllAsync<T>(sql: string, ...params: SQLiteBindValue[]): Promise<T[]>;
};
let override: DbSession | null = null;
export function __setDbForTests(db: DbSession | null): void {
override = db;
}
export async function getSession(): Promise<DbSession> {
return override ?? (await getDatabase());
}
+442
View File
@@ -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<void>) => {
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> = {}): 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<void> {
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);
});