part ok
This commit is contained in:
@@ -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,17 +41,31 @@ 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
|
||||
|
||||
|
||||
+4
-2
@@ -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.
|
||||
|
||||
@@ -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 (
|
||||
|
||||
+126
-44
@@ -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 async function useSyncDevice() {
|
||||
|
||||
initialize()
|
||||
|
||||
async function initialize() {
|
||||
|
||||
if ( syncStartedAt ) return;
|
||||
|
||||
syncStartedAt = Date.UTC(Date.now());
|
||||
|
||||
console.info("Sync started")
|
||||
|
||||
while (true) {
|
||||
|
||||
await sync()
|
||||
|
||||
await new Promise((resolve)=>{
|
||||
setTimeout(() => {
|
||||
resolve(true)
|
||||
}, 1000);
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async function sync() {
|
||||
|
||||
const userPreferences = await getUserPreferences()
|
||||
|
||||
console.log(userPreferences)
|
||||
|
||||
}
|
||||
|
||||
async function setSyncMode( mode: UserPreferences ) {
|
||||
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
}
|
||||
export type SyncResult = {
|
||||
rootUri: string;
|
||||
folders: number;
|
||||
files: number;
|
||||
missing: number;
|
||||
};
|
||||
|
||||
function uriDepth(uri: string): number {
|
||||
return uri.split('/').length;
|
||||
}
|
||||
|
||||
function dirname(uri: string): string {
|
||||
return uri.slice(0, uri.lastIndexOf('/'));
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
function isChildOf(uri: string, rootUri: string): boolean {
|
||||
return uri.startsWith(rootUri);
|
||||
}
|
||||
|
||||
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`);
|
||||
|
||||
return withTransaction(async () => {
|
||||
const seen = new Set<string>();
|
||||
|
||||
const folders = listFolders(root.uri as string, { recursive: true, includeRoot: true }).sort(
|
||||
(a, b) => uriDepth(a.uri) - uriDepth(b.uri),
|
||||
);
|
||||
|
||||
const resourceIdByUri = new Map<string, string>();
|
||||
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);
|
||||
}
|
||||
|
||||
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++;
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<typeof import('expo-sqlite')> {
|
||||
return import('expo-sqlite');
|
||||
}
|
||||
|
||||
export async function getDatabase(): Promise<SQLiteDatabase> {
|
||||
if (database) return database;
|
||||
|
||||
const SQLite = await loadSqlite();
|
||||
const db = await SQLite.openDatabaseAsync(DATABASE_NAME);
|
||||
await migrateDatabase(db);
|
||||
database = db;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getDatabase } from './client';
|
||||
import { getSession } from './session';
|
||||
|
||||
export async function newResourceId(): Promise<string> {
|
||||
const db = await getDatabase();
|
||||
const db = await getSession();
|
||||
const row = await db.getFirstAsync<{ id: string }>(
|
||||
'SELECT lower(hex(randomblob(16))) AS id',
|
||||
);
|
||||
|
||||
@@ -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;`);
|
||||
});
|
||||
|
||||
@@ -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<StoredFile> {
|
||||
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<FileRow>(
|
||||
`SELECT ${FILE_COLUMNS_SQL} FROM files WHERE uri = ?`,
|
||||
file.uri,
|
||||
);
|
||||
const existing = options.resource_id
|
||||
? await db.getFirstAsync<FileRow>(
|
||||
`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 baseSync = existing?.sync_status ?? options.syncStatus ?? 'local';
|
||||
@@ -64,7 +70,7 @@ export async function saveFile(
|
||||
}
|
||||
|
||||
export async function getFiles(folderResourceId?: string): Promise<StoredFile[]> {
|
||||
const db = await getDatabase();
|
||||
const db = await getSession();
|
||||
const rows =
|
||||
folderResourceId === undefined
|
||||
? 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> {
|
||||
const db = await getDatabase();
|
||||
const db = await getSession();
|
||||
const row = await db.getFirstAsync<FileRow>(
|
||||
`SELECT ${FILE_COLUMNS_SQL} FROM files WHERE resource_id = ?`,
|
||||
resourceId,
|
||||
@@ -85,7 +91,7 @@ export async function getFile(resourceId: string): Promise<StoredFile | null> {
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<StoredFolder> {
|
||||
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<FolderRow>(
|
||||
`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<FolderRow>(
|
||||
`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<StoredFolder[]> {
|
||||
const db = await getDatabase();
|
||||
const db = await getSession();
|
||||
const rows = await db.getAllAsync<FolderRow>(
|
||||
`SELECT ${FOLDER_COLUMNS_SQL} FROM folders ORDER BY name ASC`,
|
||||
);
|
||||
@@ -92,7 +92,7 @@ export async function getFolders(): Promise<StoredFolder[]> {
|
||||
export async function getFolderFolders(
|
||||
parentResourceId: string | null,
|
||||
): Promise<StoredFolder[]> {
|
||||
const db = await getDatabase();
|
||||
const db = await getSession();
|
||||
const rows = await db.getAllAsync<FolderRow>(
|
||||
`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<StoredFolder | null> {
|
||||
const db = await getDatabase();
|
||||
const db = await getSession();
|
||||
const row = await db.getFirstAsync<FolderRow>(
|
||||
`SELECT ${FOLDER_COLUMNS_SQL} FROM folders WHERE resource_id = ?`,
|
||||
resourceId,
|
||||
@@ -110,7 +110,7 @@ export async function getFolder(resourceId: string): Promise<StoredFolder | null
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<number> {
|
||||
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<PendingOperation[]> {
|
||||
const db = await getDatabase();
|
||||
const db = await getSession();
|
||||
const rows = status
|
||||
? await db.getAllAsync<PendingOperationRow>(
|
||||
`SELECT ${PENDING_OPERATION_COLUMNS} FROM pending_operations
|
||||
@@ -51,7 +53,7 @@ export async function getPendingOperations(
|
||||
}
|
||||
|
||||
export async function getNextQueuedOperation(): Promise<PendingOperation | null> {
|
||||
const db = await getDatabase();
|
||||
const db = await getSession();
|
||||
const row = await db.getFirstAsync<PendingOperationRow>(
|
||||
`SELECT ${PENDING_OPERATION_COLUMNS} FROM pending_operations
|
||||
WHERE status = 'pending'
|
||||
@@ -68,7 +70,7 @@ export async function markPendingOperation(
|
||||
status: PendingOperationStatus,
|
||||
error?: string | null,
|
||||
): Promise<void> {
|
||||
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,
|
||||
|
||||
@@ -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<ResourcePermission | null> {
|
||||
const db = await getDatabase();
|
||||
const db = await getSession();
|
||||
const row = await db.getFirstAsync<ResourcePermissionRow>(
|
||||
`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<void> {
|
||||
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<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 (!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<LineageNode[]> {
|
||||
const rows = await db.getAllAsync<LineageNode>(
|
||||
@@ -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<AccessCheck> {
|
||||
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(
|
||||
|
||||
@@ -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<string> {
|
||||
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<string> {
|
||||
}
|
||||
|
||||
export async function saveUserPreferences(preferences: UserPreferences): Promise<void> {
|
||||
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<UserPreferences> {
|
||||
const db = await getDatabase();
|
||||
const db = await getSession();
|
||||
const row = await db.getFirstAsync<{ value: string }>(
|
||||
'SELECT "value" FROM user_preferences WHERE "key" = ?',
|
||||
PREFERENCES_KEY,
|
||||
|
||||
@@ -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<Recipient> {
|
||||
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<Recipient[]> {
|
||||
const db = await getDatabase();
|
||||
const db = await getSession();
|
||||
const rows = activeOnly
|
||||
? await db.getAllAsync<RecipientRow>(
|
||||
'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<void> {
|
||||
const db = await getDatabase();
|
||||
const db = await getSession();
|
||||
await db.runAsync(
|
||||
`UPDATE recipients SET is_active = ?, updated_at = ?
|
||||
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';
|
||||
|
||||
const PUSH_STATUS_SQL = `(
|
||||
@@ -16,7 +16,7 @@ const PUSH_STATUS_SQL = `(
|
||||
) AS push_status`;
|
||||
|
||||
export async function createShareLink(input: NewShareLink): Promise<ShareLink> {
|
||||
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<ShareLink> {
|
||||
}
|
||||
|
||||
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'] }>(
|
||||
`SELECT sl.*, ${PUSH_STATUS_SQL} FROM share_links sl WHERE sl.id = ?`,
|
||||
id,
|
||||
@@ -58,7 +58,7 @@ export async function getShareLinkById(id: number): 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'] }>(
|
||||
`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<ShareLink[]> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
}
|
||||
|
||||
export async function revokeShareLink(id: number): Promise<void> {
|
||||
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<void> {
|
||||
const db = await getDatabase();
|
||||
const db = await getSession();
|
||||
await db.runAsync('DELETE FROM share_links WHERE resource_id = ? AND resource_type = ?', resourceId, resourceType);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Share> {
|
||||
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<Share | null> {
|
||||
const db = await getDatabase();
|
||||
const db = await getSession();
|
||||
const row = await db.getFirstAsync<ShareRow & { push_status: Share['pushStatus'] }>(
|
||||
`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<Share[]> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
const db = await getDatabase();
|
||||
const db = await getSession();
|
||||
await db.runAsync('DELETE FROM shares WHERE resource_id = ? AND resource_type = ?', resourceId, resourceType);
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user