From 7dd29239b735d0824c7f1db59f31806073429362 Mon Sep 17 00:00:00 2001 From: m Date: Wed, 9 Sep 2026 23:12:15 +0200 Subject: [PATCH] sqlite local --- mobile/AGENTS.md | 18 +- mobile/app/index.tsx | 2 +- mobile/package-lock.json | 554 ++++++++++++++++++ mobile/package.json | 6 +- mobile/services/db/id.ts | 9 + mobile/services/db/index.ts | 44 +- mobile/services/db/migrations.ts | 195 +++++- mobile/services/db/repositories/files.ts | 96 ++- mobile/services/db/repositories/folders.ts | 130 ++-- mobile/services/db/repositories/index.ts | 33 +- mobile/services/db/repositories/pendingOps.ts | 128 ++++ .../services/db/repositories/permissions.ts | 197 +++++++ .../services/db/repositories/preferences.ts | 23 +- mobile/services/db/repositories/recipients.ts | 64 ++ mobile/services/db/repositories/shareLinks.ts | 131 +++++ mobile/services/db/repositories/shares.ts | 130 ++++ mobile/services/db/schema.ts | 18 +- mobile/services/db/transitions.ts | 48 ++ mobile/services/db/types.ts | 251 +++++++- mobile/services/localStorage.ts | 49 +- mobile/tests/migrations.test.ts | 259 ++++++++ 21 files changed, 2284 insertions(+), 101 deletions(-) create mode 100644 mobile/services/db/id.ts create mode 100644 mobile/services/db/repositories/pendingOps.ts create mode 100644 mobile/services/db/repositories/permissions.ts create mode 100644 mobile/services/db/repositories/recipients.ts create mode 100644 mobile/services/db/repositories/shareLinks.ts create mode 100644 mobile/services/db/repositories/shares.ts create mode 100644 mobile/services/db/transitions.ts create mode 100644 mobile/tests/migrations.test.ts diff --git a/mobile/AGENTS.md b/mobile/AGENTS.md index b9381b2..cbe555d 100644 --- a/mobile/AGENTS.md +++ b/mobile/AGENTS.md @@ -4,14 +4,18 @@ Read the exact versioned docs at https://docs.expo.dev/versions/v57.0.0/ before # Local storage -Persistence is SQLite-backed via `services/db/` (`expo-sqlite`, database `dot.db`). +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 }`). -- `services/db/schema.ts` — `DATABASE_NAME`, `DATABASE_VERSION`, column-list constants. -- `services/db/repositories/` — one module per table (`folders`, `files`, `user_preferences`). +- `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/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/localStorage.ts` is a thin re-export (`services/db`) kept for legacy imports. -- Tables: `folders`, `files`, `user_preferences`; `files.folder_uri` has index `idx_files_folder_uri`. -- Folder and file per-row sync status: `local` | `cloud` | `local-cloud`. -- Query usage: `getFiles(folderUri?)`, `getFolders()`, `getFolderFolders(parentUri)`, `saveFolder`, `saveFile`, `saveUserPreferences`, `getUserPreferences`, `removeFolder`, `removeFile`. +- 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`. - Heavy processing stays server-side; SQLite only persists local metadata/state. diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index e64ffe1..8737343 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -11,7 +11,7 @@ export default function Index() { useEffect(() => { (async () => { const saved = await getFolders(); - setFolders(saved.map((ctx) => ctx.folder.name)); + setFolders(saved.map((folder) => folder.name)); })(); }, []); diff --git a/mobile/package-lock.json b/mobile/package-lock.json index 8d95735..22d94b2 100644 --- a/mobile/package-lock.json +++ b/mobile/package-lock.json @@ -22,8 +22,11 @@ "react-native-screens": "~4.26.0" }, "devDependencies": { + "@types/better-sqlite3": "^9.6.0", "@types/react": "~19.2.2", "babel-preset-expo": "^57.0.4", + "better-sqlite3": "^13.0.3", + "tsx": "^4.23.13", "typescript": "~6.0.3" } }, @@ -1123,6 +1126,448 @@ "node": ">=6.9.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@expo-google-fonts/material-symbols": { "version": "0.4.46", "resolved": "https://registry.npmjs.org/@expo-google-fonts/material-symbols/-/material-symbols-0.4.46.tgz", @@ -2386,6 +2831,16 @@ "integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==", "license": "MIT" }, + "node_modules/@types/better-sqlite3": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-9.6.0.tgz", + "integrity": "sha512-ZEEwBSgMu7GYJOynoagg5X9JbxfL6dTJDsgViJIqh67jV44kyOr9RXfmFjLK5rzC4MWssP06t9hu/JwGDnUbCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -2814,6 +3269,19 @@ "node": ">=6.0.0" } }, + "node_modules/better-sqlite3": { + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz", + "integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.0.0" + }, + "engines": { + "node": ">=22" + } + }, "node_modules/big-integer": { "version": "1.6.52", "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", @@ -3384,6 +3852,48 @@ "node": ">= 0.4" } }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -4293,6 +4803,21 @@ "node": ">= 0.6" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -5593,6 +6118,16 @@ "url": "https://opencollective.com/express" } }, + "node_modules/node-addon-api": { + "version": "8.9.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz", + "integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, "node_modules/node-forge": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", @@ -7114,6 +7649,25 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.23.13", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz", + "integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/type-fest": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.7.1.tgz", diff --git a/mobile/package.json b/mobile/package.json index d0c44bb..319865a 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -17,15 +17,19 @@ "react-native-screens": "~4.26.0" }, "devDependencies": { + "@types/better-sqlite3": "^9.6.0", "@types/react": "~19.2.2", "babel-preset-expo": "^57.0.4", + "better-sqlite3": "^13.0.3", + "tsx": "^4.23.13", "typescript": "~6.0.3" }, "scripts": { "start": "expo start", "android": "expo run:android", "ios": "expo run:ios", - "web": "expo start --web" + "web": "expo start --web", + "test:migrations": "tsx --test tests/migrations.test.ts" }, "private": true } diff --git a/mobile/services/db/id.ts b/mobile/services/db/id.ts new file mode 100644 index 0000000..6e18d45 --- /dev/null +++ b/mobile/services/db/id.ts @@ -0,0 +1,9 @@ +import { getDatabase } from './client'; + +export async function newResourceId(): Promise { + const db = await getDatabase(); + const row = await db.getFirstAsync<{ id: string }>( + 'SELECT lower(hex(randomblob(16))) AS id', + ); + return row!.id; +} \ No newline at end of file diff --git a/mobile/services/db/index.ts b/mobile/services/db/index.ts index b9c1121..6ee55cc 100644 --- a/mobile/services/db/index.ts +++ b/mobile/services/db/index.ts @@ -1,4 +1,44 @@ export { getDatabase, closeDatabase, withTransaction } from './client'; +export { migrateDatabase, type Migration, type MigrationDb } from './migrations'; export * from './repositories'; -export type { FolderContext, StoredFile, SyncStatus, UserPreferences } from './types'; -export { DATABASE_NAME, DATABASE_VERSION } from './schema'; \ No newline at end of file +export type { AccessCheck, AccessSource } from './repositories/permissions'; +export { + transitionSyncStatus, + type SyncEvent, + type SyncTransitionResult, +} from './transitions'; +export type { + AccessLevel, + NewPendingOperation, + NewResourcePermission, + NewShare, + NewShareLink, + PendingOperation, + PendingOperationRefType, + PendingOperationRow, + PendingOperationStatus, + PendingOperationType, + PushStatus, + Recipient, + RecipientRow, + RecipientType, + ResourcePermission, + ResourceType, + Share, + ShareLink, + ShareLinkRow, + ShareRow, + StoredFile, + StoredFolder, + SyncStatus, + UserPreferences, + FolderRow, + FileRow, +} from './types'; +export { + DATABASE_NAME, + DATABASE_VERSION, + DEVICE_USER_ID_KEY, + PREFERENCES_KEY, + PERMISSION_TTL_MS, +} from './schema'; \ No newline at end of file diff --git a/mobile/services/db/migrations.ts b/mobile/services/db/migrations.ts index f9d0d60..7bc3945 100644 --- a/mobile/services/db/migrations.ts +++ b/mobile/services/db/migrations.ts @@ -1,12 +1,17 @@ -import type { SQLiteDatabase } from 'expo-sqlite'; -import { DATABASE_VERSION } from './schema'; +import { DATABASE_VERSION, DEVICE_USER_ID_KEY } from './schema'; -type Migration = { - version: number; - up: (db: SQLiteDatabase) => Promise; +export type MigrationDb = { + execAsync(source: string): Promise; + getFirstAsync(source: string, ...params: unknown[]): Promise; + withExclusiveTransactionAsync(task: (txn: MigrationDb) => Promise): Promise; }; -const MIGRATIONS: Migration[] = [ +export type Migration = { + version: number; + up: (db: MigrationDb) => Promise; +}; + +export const MIGRATIONS: Migration[] = [ { version: 1, up: async (db) => { @@ -52,21 +57,193 @@ CREATE INDEX IF NOT EXISTS idx_files_folder_uri ON files(folder_uri); `); }, }, + { + version: 3, + up: async (db) => { + await db.execAsync(` +CREATE TABLE IF NOT EXISTS resource_permissions ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + resource_id TEXT NOT NULL, + resource_type TEXT NOT NULL CHECK (resource_type IN ('folder', 'file')), + effective_access TEXT NOT NULL CHECK (effective_access IN ('owner', 'editor', 'commenter', 'viewer')), + inherit INTEGER NOT NULL DEFAULT 1, + owner_id TEXT, + shared_by_id TEXT, + expires_at INTEGER, + cached_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + UNIQUE (resource_id, resource_type) +); +CREATE TABLE IF NOT EXISTS shares ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + resource_id TEXT NOT NULL, + resource_type TEXT NOT NULL CHECK (resource_type IN ('folder', 'file')), + recipient_type TEXT NOT NULL CHECK (recipient_type IN ('user', 'group')), + recipient_id TEXT NOT NULL, + relation TEXT NOT NULL CHECK (relation IN ('owner', 'editor', 'commenter', 'viewer')), + inherit INTEGER NOT NULL DEFAULT 1, + expires_at INTEGER, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + UNIQUE (resource_id, resource_type, recipient_type, recipient_id) +); +CREATE TABLE IF NOT EXISTS recipients ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + recipient_type TEXT NOT NULL CHECK (recipient_type IN ('user', 'group')), + recipient_id TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + is_active INTEGER NOT NULL DEFAULT 1, + updated_at INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS share_links ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + token TEXT NOT NULL UNIQUE, + resource_id TEXT NOT NULL, + resource_type TEXT NOT NULL CHECK (resource_type IN ('folder', 'file')), + has_password INTEGER NOT NULL DEFAULT 0, + allow_download INTEGER NOT NULL DEFAULT 1, + expires_at INTEGER, + max_downloads INTEGER, + downloads_count INTEGER NOT NULL DEFAULT 0, + is_revoked INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + CHECK (max_downloads IS NULL OR max_downloads >= 0), + CHECK (downloads_count >= 0) +); +CREATE TABLE IF NOT EXISTS pending_operations ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + resource_id TEXT, + resource_type TEXT CHECK (resource_type IN ('folder', 'file')), + ref_type TEXT CHECK (ref_type IN ('resource', 'share', 'share_link')), + ref_id INTEGER, + operation TEXT NOT NULL CHECK (operation IN ( + 'create_resource', 'update_metadata', 'delete_resource', 'move_resource', + 'share', 'revoke_share', 'update_share', + 'create_link', 'revoke_link' + )), + payload TEXT NOT NULL DEFAULT '{}', + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'in_progress', 'completed', 'failed', 'cancelled')), + attempts INTEGER NOT NULL DEFAULT 0, + error TEXT, + created_at INTEGER NOT NULL, + next_retry_at INTEGER, + last_error_at INTEGER +); +CREATE INDEX IF NOT EXISTS idx_pending_queue ON pending_operations(created_at) WHERE status = 'pending'; +CREATE INDEX IF NOT EXISTS idx_pending_resource ON pending_operations(resource_id); +`); + await db.execAsync( + `INSERT OR IGNORE INTO user_preferences ("key", "value", updated_at) + VALUES ('${DEVICE_USER_ID_KEY}', lower(hex(randomblob(16))), ${Date.now()});`, + ); + }, + }, + { + version: 4, + up: async (db) => { + await db.execAsync('PRAGMA foreign_keys = OFF;'); + await db.withExclusiveTransactionAsync(async (txn) => { + await txn.execAsync(` +CREATE TABLE folders_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + resource_id TEXT NOT NULL UNIQUE, + uri TEXT, + name TEXT NOT NULL, + "exists" INTEGER, + parent_resource_id TEXT REFERENCES folders_new(resource_id) ON DELETE CASCADE, + owner_id TEXT NOT NULL, + sync_status TEXT NOT NULL DEFAULT 'local', + added_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); +`); + await txn.execAsync(` +INSERT INTO folders_new (id, resource_id, uri, name, "exists", parent_resource_id, owner_id, sync_status, added_at, updated_at) +SELECT id, lower(hex(randomblob(16))), uri, name, "exists", NULL, + (SELECT "value" FROM user_preferences WHERE "key" = '${DEVICE_USER_ID_KEY}'), + sync_status, added_at, added_at FROM folders; +`); + await txn.execAsync(` +UPDATE folders_new SET parent_resource_id = ( + SELECT parent_new.resource_id + FROM folders old_child + JOIN folders old_parent ON old_parent.uri = old_child.parent_uri + JOIN folders_new parent_new ON parent_new.uri = old_parent.uri + WHERE old_child.uri = folders_new.uri +); +`); + await txn.execAsync('DROP TABLE folders;'); + await txn.execAsync('ALTER TABLE folders_new RENAME TO folders;'); + await txn.execAsync(` +CREATE TABLE files_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + resource_id TEXT NOT NULL UNIQUE, + uri TEXT, + name TEXT NOT NULL, + folder_resource_id TEXT NOT NULL REFERENCES folders(resource_id) ON DELETE CASCADE, + extension TEXT, + size INTEGER, + "type" TEXT, + "exists" INTEGER, + last_modified INTEGER, + owner_id TEXT NOT NULL, + sync_status TEXT NOT NULL DEFAULT 'local', + added_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); +`); + await txn.execAsync(` +INSERT INTO files_new (id, resource_id, uri, name, folder_resource_id, extension, size, "type", "exists", last_modified, owner_id, sync_status, added_at, updated_at) +SELECT f.id, lower(hex(randomblob(16))), f.uri, f.name, + fn.resource_id, + f.extension, f.size, f."type", f."exists", f.last_modified, + (SELECT "value" FROM user_preferences WHERE "key" = '${DEVICE_USER_ID_KEY}'), + f.sync_status, f.added_at, f.added_at +FROM files f +JOIN folders fn ON fn.uri = f.folder_uri; +`); + await txn.execAsync('DROP TABLE files;'); + await txn.execAsync('ALTER TABLE files_new RENAME TO files;'); + await txn.execAsync(` +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); +`); + await txn.execAsync(`PRAGMA user_version = 4;`); + }); + await db.execAsync('PRAGMA foreign_keys = ON;'); + }, + }, ]; -async function readUserVersion(db: SQLiteDatabase): Promise { +async function readUserVersion(db: MigrationDb): Promise { const row = await db.getFirstAsync<{ user_version: number }>('PRAGMA user_version'); return row?.user_version ?? 0; } -async function writeUserVersion(db: SQLiteDatabase, version: number): Promise { +async function writeUserVersion(db: MigrationDb, version: number): Promise { await db.execAsync(`PRAGMA user_version = ${version}`); } +const migrationPromises = new WeakMap>(); + export async function migrateDatabase( - db: SQLiteDatabase, + db: MigrationDb, targetVersion: number = DATABASE_VERSION, ): Promise { + const existing = migrationPromises.get(db); + if (existing) return existing; + + const promise = runMigrations(db, targetVersion).finally(() => { + migrationPromises.delete(db); + }); + migrationPromises.set(db, promise); + return promise; +} + +async function runMigrations(db: MigrationDb, targetVersion: number): Promise { let currentVersion = await readUserVersion(db); for (const migration of MIGRATIONS) { diff --git a/mobile/services/db/repositories/files.ts b/mobile/services/db/repositories/files.ts index e83d6c9..08ca378 100644 --- a/mobile/services/db/repositories/files.ts +++ b/mobile/services/db/repositories/files.ts @@ -1,60 +1,108 @@ import { getDatabase } from '../client'; +import { newResourceId } from '../id'; import { FILE_COLUMNS_SQL } from '../schema'; -import type { FileEntry, FileRow, StoredFile } from '../types'; +import { getDeviceUserId } from './preferences'; +import type { FileEntry, FileRow, StoredFile, SyncStatus } from '../types'; -export async function saveFile(file: FileEntry, folderUri: string): Promise { +export type SaveFileOptions = { + syncStatus?: SyncStatus; +}; + +export async function saveFile( + file: FileEntry, + folderResourceId: string, + options: SaveFileOptions = {}, +): Promise { const db = await getDatabase(); + const now = Date.now(); const exists = file.exists ? 1 : 0; + const ownerId = await getDeviceUserId(); + + const existing = await db.getFirstAsync( + `SELECT ${FILE_COLUMNS_SQL} FROM files WHERE uri = ?`, + file.uri, + ); + + const resourceId = existing?.resource_id ?? (await newResourceId()); + const baseSync = existing?.sync_status ?? options.syncStatus ?? 'local'; await db.runAsync( - `INSERT INTO files (uri, name, folder_uri, extension, size, "type", "exists", last_modified, added_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(uri) DO UPDATE SET - name = excluded.name, folder_uri = excluded.folder_uri, extension = excluded.extension, - size = excluded.size, "type" = excluded."type", "exists" = excluded."exists", - last_modified = excluded.last_modified`, + `INSERT INTO files + (resource_id, uri, name, folder_resource_id, extension, size, "type", "exists", last_modified, owner_id, sync_status, added_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(resource_id) DO UPDATE SET + uri = excluded.uri, + name = excluded.name, + folder_resource_id = excluded.folder_resource_id, + extension = excluded.extension, + size = excluded.size, + "type" = excluded."type", + "exists" = excluded."exists", + last_modified = excluded.last_modified, + sync_status = excluded.sync_status, + updated_at = excluded.updated_at`, + resourceId, file.uri, file.name, - folderUri, + folderResourceId, file.extension ?? null, file.size, file.type ?? null, exists, - file.lastModified, - Date.now(), + file.lastModified ?? null, + ownerId, + baseSync, + existing?.added_at ?? now, + now, ); + + const row = await db.getFirstAsync( + `SELECT ${FILE_COLUMNS_SQL} FROM files WHERE resource_id = ?`, + resourceId, + ); + return toStoredFile(row!); } -export async function getFiles(folderUri?: string): Promise { +export async function getFiles(folderResourceId?: string): Promise { const db = await getDatabase(); - const rows = folderUri - ? await db.getAllAsync( - `SELECT ${FILE_COLUMNS_SQL} FROM files WHERE folder_uri = ? ORDER BY name ASC`, - folderUri, - ) - : await db.getAllAsync( - `SELECT ${FILE_COLUMNS_SQL} FROM files ORDER BY name ASC`, - ); + const rows = + folderResourceId === undefined + ? await db.getAllAsync(`SELECT ${FILE_COLUMNS_SQL} FROM files ORDER BY name ASC`) + : await db.getAllAsync( + `SELECT ${FILE_COLUMNS_SQL} FROM files WHERE folder_resource_id = ? ORDER BY name ASC`, + folderResourceId, + ); return rows.map(toStoredFile); } -export async function removeFile(uri: string): Promise { +export async function getFile(resourceId: string): Promise { const db = await getDatabase(); - await db.runAsync('DELETE FROM files WHERE uri = ?', uri); + const row = await db.getFirstAsync( + `SELECT ${FILE_COLUMNS_SQL} FROM files WHERE resource_id = ?`, + resourceId, + ); + return row ? toStoredFile(row) : null; +} + +export async function removeFile(resourceId: string): Promise { + const db = await getDatabase(); + await db.runAsync('DELETE FROM files WHERE resource_id = ?', resourceId); } function toStoredFile(row: FileRow): StoredFile { return { + resource_id: row.resource_id, uri: row.uri, name: row.name, - isDirectory: false, + folder_resource_id: row.folder_resource_id, extension: row.extension ?? '', exists: row.exists === 1, size: row.size, type: row.type ?? '', lastModified: row.last_modified, - folderUri: row.folder_uri, + owner_id: row.owner_id, syncStatus: row.sync_status, addedAt: row.added_at, + updatedAt: row.updated_at, }; } \ No newline at end of file diff --git a/mobile/services/db/repositories/folders.ts b/mobile/services/db/repositories/folders.ts index 94415c0..9eea708 100644 --- a/mobile/services/db/repositories/folders.ts +++ b/mobile/services/db/repositories/folders.ts @@ -1,69 +1,129 @@ import { getDatabase } from '../client'; +import { newResourceId } from '../id'; import { FOLDER_COLUMNS_SQL } from '../schema'; -import type { Folder, FolderContext, FolderRow, SyncStatus } from '../types'; +import { getDeviceUserId } from './preferences'; +import type { FolderRow, StoredFolder, SyncStatus } from '../types'; -type SaveFolderOptions = { - parentUri?: string; +export type SaveFolderInput = { + uri: string | null; + name: string; + exists?: boolean; + resource_id?: string; +}; + +export type SaveFolderOptions = { + parentResourceId?: string | null; syncStatus?: SyncStatus; }; export async function saveFolder( - folder: Folder, - options?: SaveFolderOptions, -): Promise { + input: SaveFolderInput, + options: SaveFolderOptions = {}, +): Promise { const db = await getDatabase(); - const exists = folder.exists === undefined ? null : folder.exists ? 1 : 0; + 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 + ? await db.getFirstAsync( + `SELECT ${FOLDER_COLUMNS_SQL} FROM folders WHERE uri = ?`, + input.uri, + ) + : input.resource_id + ? await db.getFirstAsync( + `SELECT ${FOLDER_COLUMNS_SQL} FROM folders WHERE resource_id = ?`, + input.resource_id, + ) + : null; + + const resourceId = existing?.resource_id ?? input.resource_id ?? (await newResourceId()); + const baseSync = existing?.sync_status ?? options.syncStatus ?? 'local'; await db.runAsync( - `INSERT INTO folders (uri, name, "exists", sync_status, added_at, parent_uri) - VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT(uri) DO UPDATE SET - name = excluded.name, "exists" = excluded."exists", sync_status = excluded.sync_status`, - folder.uri, - folder.name, + `INSERT INTO folders + (resource_id, uri, name, "exists", parent_resource_id, owner_id, sync_status, added_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(resource_id) DO UPDATE SET + uri = excluded.uri, + name = excluded.name, + "exists" = excluded."exists", + parent_resource_id = CASE + WHEN excluded.parent_resource_id IS NULL THEN folders.parent_resource_id + ELSE excluded.parent_resource_id + END, + sync_status = excluded.sync_status, + updated_at = excluded.updated_at`, + resourceId, + input.uri, + input.name, exists, - options?.syncStatus ?? 'local', - Date.now(), - options?.parentUri ?? null, + options.parentResourceId ?? null, + ownerId, + baseSync, + existing?.added_at ?? now, + now, ); + + const row = await db.getFirstAsync( + `SELECT ${FOLDER_COLUMNS_SQL} FROM folders WHERE resource_id = ?`, + resourceId, + ); + return toStoredFolder(row!); } -export async function saveDirectory(folder: Folder): Promise { - await saveFolder(folder); - return true; +export async function saveDirectory(folder: { + uri: string; + name: string; + exists?: boolean; +}): Promise { + return saveFolder({ uri: folder.uri, name: folder.name, exists: folder.exists }); } -export async function getFolders(): Promise { +export async function getFolders(): Promise { const db = await getDatabase(); const rows = await db.getAllAsync( - `SELECT ${FOLDER_COLUMNS_SQL} FROM folders ORDER BY added_at ASC, name ASC`, + `SELECT ${FOLDER_COLUMNS_SQL} FROM folders ORDER BY name ASC`, ); - return rows.map(toFolderContext); + return rows.map(toStoredFolder); } -export async function getFolderFolders(parentUri: string): Promise { +export async function getFolderFolders( + parentResourceId: string | null, +): Promise { const db = await getDatabase(); const rows = await db.getAllAsync( - `SELECT ${FOLDER_COLUMNS_SQL} FROM folders WHERE parent_uri = ? ORDER BY name ASC`, - parentUri, + `SELECT ${FOLDER_COLUMNS_SQL} FROM folders WHERE parent_resource_id IS ? ORDER BY name ASC`, + parentResourceId, ); - return rows.map(toFolderContext); + return rows.map(toStoredFolder); } -export async function removeFolder(uri: string): Promise { +export async function getFolder(resourceId: string): Promise { const db = await getDatabase(); - await db.runAsync('DELETE FROM folders WHERE uri = ?', uri); + const row = await db.getFirstAsync( + `SELECT ${FOLDER_COLUMNS_SQL} FROM folders WHERE resource_id = ?`, + resourceId, + ); + return row ? toStoredFolder(row) : null; } -function toFolderContext(row: FolderRow): FolderContext { +export async function removeFolder(resourceId: string): Promise { + const db = await getDatabase(); + await db.runAsync('DELETE FROM folders WHERE resource_id = ?', resourceId); +} + +function toStoredFolder(row: FolderRow): StoredFolder { return { + resource_id: row.resource_id, + uri: row.uri, + name: row.name, + exists: row.exists === 1, + parent_resource_id: row.parent_resource_id, + owner_id: row.owner_id, syncStatus: row.sync_status, addedAt: row.added_at, - folder: { - uri: row.uri, - name: row.name, - isDirectory: true, - exists: row.exists === null ? undefined : row.exists === 1, - }, + updatedAt: row.updated_at, }; } \ No newline at end of file diff --git a/mobile/services/db/repositories/index.ts b/mobile/services/db/repositories/index.ts index b8ab289..171b244 100644 --- a/mobile/services/db/repositories/index.ts +++ b/mobile/services/db/repositories/index.ts @@ -3,7 +3,36 @@ export { saveDirectory, getFolders, getFolderFolders, + getFolder, removeFolder, } from './folders'; -export { saveFile, getFiles, removeFile } from './files'; -export { saveUserPreferences, getUserPreferences } from './preferences'; \ No newline at end of file +export { saveFile, getFiles, getFile, removeFile } from './files'; +export { + saveUserPreferences, + getUserPreferences, + getDeviceUserId, +} from './preferences'; +export { + getResourcePermission, + saveResourcePermission, + canAccess, + canWrite, + isOwner, +} from './permissions'; +export { saveShare, getShare, getShares, removeShare, removeSharesForResource } from './shares'; +export { + createShareLink, + getShareLinkById, + getShareLinkByToken, + getShareLinks, + incrementLinkDownloads, + revokeShareLink, + removeShareLinksForResource, +} from './shareLinks'; +export { saveRecipient, getRecipients, setRecipientActive } from './recipients'; +export { + enqueuePendingOperation, + getPendingOperations, + getNextQueuedOperation, + markPendingOperation, +} from './pendingOps'; \ No newline at end of file diff --git a/mobile/services/db/repositories/pendingOps.ts b/mobile/services/db/repositories/pendingOps.ts new file mode 100644 index 0000000..dfddcb1 --- /dev/null +++ b/mobile/services/db/repositories/pendingOps.ts @@ -0,0 +1,128 @@ +import { getDatabase } from '../client'; +import type { + NewPendingOperation, + PendingOperation, + PendingOperationRow, + PendingOperationStatus, +} from '../types'; + +const PENDING_OPERATION_COLUMNS = ` + id, resource_id, resource_type, ref_type, ref_id, operation, payload, + status, attempts, error, created_at, next_retry_at, last_error_at`; + +const MAX_BACKOFF_MS = 24 * 60 * 60 * 1000; +const BASE_BACKOFF_MS = 30 * 1000; + +export async function enqueuePendingOperation( + operation: NewPendingOperation, +): Promise { + const db = await getDatabase(); + 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) + VALUES (?, ?, ?, ?, ?, ?, 'pending', 0, ?) + RETURNING id`, + operation.resourceId ?? null, + operation.resourceType ?? null, + operation.refType ?? null, + operation.refId ?? null, + operation.operation, + JSON.stringify(operation.payload ?? {}), + Date.now(), + ); + return row!.id; +} + +export async function getPendingOperations( + status?: PendingOperationStatus, +): Promise { + const db = await getDatabase(); + const rows = status + ? await db.getAllAsync( + `SELECT ${PENDING_OPERATION_COLUMNS} FROM pending_operations + WHERE status = ? ORDER BY created_at ASC, id ASC`, + status, + ) + : await db.getAllAsync( + `SELECT ${PENDING_OPERATION_COLUMNS} FROM pending_operations + ORDER BY created_at ASC, id ASC`, + ); + return rows.map(toPendingOperation); +} + +export async function getNextQueuedOperation(): Promise { + const db = await getDatabase(); + const row = await db.getFirstAsync( + `SELECT ${PENDING_OPERATION_COLUMNS} FROM pending_operations + WHERE status = 'pending' + AND (next_retry_at IS NULL OR next_retry_at <= ?) + ORDER BY created_at ASC, id ASC + LIMIT 1`, + Date.now(), + ); + return row ? toPendingOperation(row) : null; +} + +export async function markPendingOperation( + id: number, + status: PendingOperationStatus, + error?: string | null, +): Promise { + const db = await getDatabase(); + const now = Date.now(); + + if (status === 'failed') { + const current = await db.getFirstAsync<{ attempts: number }>( + 'SELECT attempts FROM pending_operations WHERE id = ?', + id, + ); + if (!current) return; + const attempts = current.attempts + 1; + 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 = ? + WHERE id = ?`, + status, + attempts, + error ?? null, + now + backoff, + now, + id, + ); + return; + } + + await db.runAsync( + `UPDATE pending_operations SET status = ?, error = ?, next_retry_at = NULL, last_error_at = NULL WHERE id = ?`, + status, + status === 'in_progress' ? null : error ?? null, + id, + ); +} + +function toPendingOperation(row: PendingOperationRow): PendingOperation { + return { + id: row.id, + resourceId: row.resource_id, + resourceType: row.resource_type, + refType: row.ref_type, + refId: row.ref_id, + operation: row.operation, + payload: safeParse(row.payload), + status: row.status, + attempts: row.attempts, + error: row.error, + createdAt: row.created_at, + nextRetryAt: row.next_retry_at, + lastErrorAt: row.last_error_at, + }; +} + +function safeParse(json: string): unknown { + try { + return JSON.parse(json); + } catch { + return {}; + } +} \ No newline at end of file diff --git a/mobile/services/db/repositories/permissions.ts b/mobile/services/db/repositories/permissions.ts new file mode 100644 index 0000000..d9b5579 --- /dev/null +++ b/mobile/services/db/repositories/permissions.ts @@ -0,0 +1,197 @@ +import type { SQLiteDatabase } from 'expo-sqlite'; +import { getDatabase } from '../client'; +import { PERMISSION_TTL_MS } from '../schema'; +import { getDeviceUserId } from './preferences'; +import type { + AccessLevel, + NewResourcePermission, + ResourcePermission, + ResourcePermissionRow, + ResourceType, +} from '../types'; + +const ACCESS_RANK: Record = { + viewer: 1, + commenter: 2, + editor: 3, + owner: 4, +}; + +export type AccessSource = 'none' | 'cache' | 'owner' | 'inherited'; + +export type AccessCheck = { + allowed: boolean; + access: AccessLevel | null; + source: AccessSource; + stale: boolean; + expiresAt: number | null; +}; + +type LineageNode = { + resource_id: string; + parent_resource_id: string | null; + owner_id: string; +}; + +function rank(level: AccessLevel): number { + return ACCESS_RANK[level]; +} + +export async function getResourcePermission( + resourceId: string, + resourceType: ResourceType, +): Promise { + const db = await getDatabase(); + const row = await db.getFirstAsync( + `SELECT * FROM resource_permissions WHERE resource_id = ? AND resource_type = ?`, + resourceId, + resourceType, + ); + return row ? toResourcePermission(row) : null; +} + +export async function saveResourcePermission( + permission: NewResourcePermission, +): Promise { + const db = await getDatabase(); + const now = Date.now(); + await db.runAsync( + `INSERT INTO resource_permissions + (resource_id, resource_type, effective_access, inherit, owner_id, shared_by_id, expires_at, cached_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(resource_id, resource_type) DO UPDATE SET + effective_access = excluded.effective_access, + inherit = excluded.inherit, + owner_id = excluded.owner_id, + shared_by_id = excluded.shared_by_id, + expires_at = excluded.expires_at, + cached_at = excluded.cached_at, + updated_at = excluded.updated_at`, + permission.resource_id, + permission.resourceType, + permission.effectiveAccess, + permission.inherit === false ? 0 : 1, + permission.ownerId ?? null, + permission.sharedById ?? null, + permission.expiresAt ?? null, + now, + now, + ); +} + +async function resourceLineage( + resourceId: string, + resourceType: ResourceType, +): Promise { + const db = await getDatabase(); + + 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); + } + return folderLineage(db, resourceId); +} + +async function folderLineage( + db: SQLiteDatabase, + startResourceId: string, +): Promise { + const rows = await db.getAllAsync( + `WITH RECURSIVE lineage(resource_id, parent_resource_id, owner_id) AS ( + SELECT resource_id, parent_resource_id, owner_id FROM folders WHERE resource_id = ? + UNION ALL + SELECT f.resource_id, f.parent_resource_id, f.owner_id + FROM folders f + JOIN lineage l ON f.resource_id = l.parent_resource_id + ) + SELECT resource_id, 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, + }; +} + +export async function canAccess( + resourceId: string, + resourceType: ResourceType, + required: AccessLevel, +): Promise { + const db = await getDatabase(); + const now = Date.now(); + const deviceUserId = await getDeviceUserId(); + + const cached = await getResourcePermission(resourceId, resourceType); + if (cached) return decideFromPermission(cached, required, now); + + const lineage = await resourceLineage(resourceId, resourceType); + for (const node of lineage) { + 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' }; + } + } + } + + return { allowed: false, access: null, source: 'none', stale: false, expiresAt: null }; +} + +export async function canWrite( + resourceId: string, + resourceType: ResourceType, +): Promise { + return canAccess(resourceId, resourceType, 'editor'); +} + +export async function isOwner( + resourceId: string, + resourceType: ResourceType, +): Promise { + const check = await canAccess(resourceId, resourceType, 'owner'); + return check.allowed; +} + +function toResourcePermission(row: ResourcePermissionRow): ResourcePermission { + return { + resource_id: row.resource_id, + resourceType: row.resource_type, + effectiveAccess: row.effective_access, + inherit: row.inherit === 1, + ownerId: row.owner_id, + sharedById: row.shared_by_id, + expiresAt: row.expires_at, + cachedAt: row.cached_at, + updatedAt: row.updated_at, + }; +} \ No newline at end of file diff --git a/mobile/services/db/repositories/preferences.ts b/mobile/services/db/repositories/preferences.ts index 2b61e26..a94eb03 100644 --- a/mobile/services/db/repositories/preferences.ts +++ b/mobile/services/db/repositories/preferences.ts @@ -1,11 +1,32 @@ import { getDatabase } from '../client'; -import { PREFERENCES_KEY } from '../schema'; +import { DEVICE_USER_ID_KEY, PREFERENCES_KEY } from '../schema'; import type { UserPreferences } from '../types'; const DEFAULT_PREFERENCES: UserPreferences = { syncMode: 'full', }; +export async function getDeviceUserId(): Promise { + const db = await getDatabase(); + const row = await db.getFirstAsync<{ value: string }>( + 'SELECT "value" FROM user_preferences WHERE "key" = ?', + DEVICE_USER_ID_KEY, + ); + if (row) return row.value; + + await db.runAsync( + `INSERT OR IGNORE INTO user_preferences ("key", "value", updated_at) + VALUES (?, lower(hex(randomblob(16))), ?)`, + DEVICE_USER_ID_KEY, + Date.now(), + ); + const seeded = await db.getFirstAsync<{ value: string }>( + 'SELECT "value" FROM user_preferences WHERE "key" = ?', + DEVICE_USER_ID_KEY, + ); + return seeded!.value; +} + export async function saveUserPreferences(preferences: UserPreferences): Promise { const db = await getDatabase(); await db.runAsync( diff --git a/mobile/services/db/repositories/recipients.ts b/mobile/services/db/repositories/recipients.ts new file mode 100644 index 0000000..a3971f4 --- /dev/null +++ b/mobile/services/db/repositories/recipients.ts @@ -0,0 +1,64 @@ +import { getDatabase } from '../client'; +import type { Recipient, RecipientRow, RecipientType } from '../types'; + +export async function saveRecipient( + recipientType: RecipientType, + recipientId: string, + displayName: string, +): Promise { + const db = await getDatabase(); + await db.runAsync( + `INSERT INTO recipients (recipient_type, recipient_id, display_name, is_active, updated_at) + VALUES (?, ?, ?, 1, ?) + ON CONFLICT(recipient_id) DO UPDATE SET + recipient_type = excluded.recipient_type, + display_name = excluded.display_name, + is_active = 1, + updated_at = excluded.updated_at`, + recipientType, + recipientId, + displayName, + Date.now(), + ); + const row = await db.getFirstAsync( + 'SELECT * FROM recipients WHERE recipient_id = ?', + recipientId, + ); + return toRecipient(row!); +} + +export async function getRecipients(activeOnly = true): Promise { + const db = await getDatabase(); + const rows = activeOnly + ? await db.getAllAsync( + 'SELECT * FROM recipients WHERE is_active = 1 ORDER BY display_name ASC', + ) + : await db.getAllAsync('SELECT * FROM recipients ORDER BY display_name ASC'); + return rows.map(toRecipient); +} + +export async function setRecipientActive( + recipientType: RecipientType, + recipientId: string, + active: boolean, +): Promise { + const db = await getDatabase(); + await db.runAsync( + `UPDATE recipients SET is_active = ?, updated_at = ? + WHERE recipient_type = ? AND recipient_id = ?`, + active ? 1 : 0, + Date.now(), + recipientType, + recipientId, + ); +} + +function toRecipient(row: RecipientRow): Recipient { + return { + recipientType: row.recipient_type, + recipientId: row.recipient_id, + displayName: row.display_name, + isActive: row.is_active === 1, + updatedAt: row.updated_at, + }; +} \ No newline at end of file diff --git a/mobile/services/db/repositories/shareLinks.ts b/mobile/services/db/repositories/shareLinks.ts new file mode 100644 index 0000000..940f81b --- /dev/null +++ b/mobile/services/db/repositories/shareLinks.ts @@ -0,0 +1,131 @@ +import { getDatabase } from '../client'; +import type { NewShareLink, ResourceType, ShareLink, ShareLinkRow } from '../types'; + +const PUSH_STATUS_SQL = `( + SELECT CASE + WHEN EXISTS ( + SELECT 1 FROM pending_operations p + WHERE p.ref_type = 'share_link' AND p.ref_id = sl.id AND p.status IN ('pending', 'in_progress') + ) THEN 'pending' + WHEN EXISTS ( + SELECT 1 FROM pending_operations p + WHERE p.ref_type = 'share_link' AND p.ref_id = sl.id AND p.status = 'failed' + ) THEN 'failed' + ELSE 'synced' + END +) AS push_status`; + +export async function createShareLink(input: NewShareLink): Promise { + const db = await getDatabase(); + const now = Date.now(); + + let token = input.token; + if (!token) { + const tokenRow = await db.getFirstAsync<{ token: string }>( + 'SELECT lower(hex(randomblob(16))) AS token', + ); + token = tokenRow!.token; + } + + const row = await db.getFirstAsync<{ id: number }>( + `INSERT INTO share_links + (token, resource_id, resource_type, has_password, allow_download, expires_at, max_downloads, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + RETURNING id`, + token, + input.resourceId, + input.resourceType, + input.hasPassword ? 1 : 0, + input.allowDownload === false ? 0 : 1, + input.expiresAt ?? null, + input.maxDownloads ?? null, + now, + now, + ); + + const created = await getShareLinkById(row!.id); + if (!created) throw new Error('share link insert failed'); + return created; +} + +export async function getShareLinkById(id: number): Promise { + const db = await getDatabase(); + const row = await db.getFirstAsync( + `SELECT sl.*, ${PUSH_STATUS_SQL} FROM share_links sl WHERE sl.id = ?`, + id, + ); + return row ? toShareLink(row) : null; +} + +export async function getShareLinkByToken(token: string): Promise { + const db = await getDatabase(); + const row = await db.getFirstAsync( + `SELECT sl.*, ${PUSH_STATUS_SQL} FROM share_links sl WHERE sl.token = ?`, + token, + ); + return row ? toShareLink(row) : null; +} + +export async function getShareLinks( + resourceId?: string, + resourceType?: ResourceType, +): Promise { + const db = await getDatabase(); + let sql = `SELECT sl.*, ${PUSH_STATUS_SQL} FROM share_links sl`; + const params: string[] = []; + if (resourceId) { + sql += ' WHERE sl.resource_id = ?'; + params.push(resourceId); + if (resourceType) { + sql += ' AND sl.resource_type = ?'; + params.push(resourceType); + } + } + sql += ' ORDER BY sl.created_at ASC'; + const rows = await db.getAllAsync(sql, ...params); + return rows.map(toShareLink); +} + +export async function incrementLinkDownloads(id: number): Promise { + const db = await getDatabase(); + await db.runAsync( + `UPDATE share_links SET downloads_count = downloads_count + 1, updated_at = ? WHERE id = ?`, + Date.now(), + id, + ); +} + +export async function revokeShareLink(id: number): Promise { + const db = await getDatabase(); + await db.runAsync( + `UPDATE share_links SET is_revoked = 1, updated_at = ? WHERE id = ?`, + Date.now(), + id, + ); +} + +export async function removeShareLinksForResource( + resourceId: string, + resourceType: ResourceType, +): Promise { + const db = await getDatabase(); + await db.runAsync('DELETE FROM share_links WHERE resource_id = ? AND resource_type = ?', resourceId, resourceType); +} + +function toShareLink(row: ShareLinkRow & { push_status: ShareLink['pushStatus'] }): ShareLink { + return { + id: row.id, + token: row.token, + resourceId: row.resource_id, + resourceType: row.resource_type, + hasPassword: row.has_password === 1, + allowDownload: row.allow_download === 1, + expiresAt: row.expires_at, + maxDownloads: row.max_downloads, + downloadsCount: row.downloads_count, + isRevoked: row.is_revoked === 1, + createdAt: row.created_at, + updatedAt: row.updated_at, + pushStatus: row.push_status, + }; +} \ No newline at end of file diff --git a/mobile/services/db/repositories/shares.ts b/mobile/services/db/repositories/shares.ts new file mode 100644 index 0000000..bb38493 --- /dev/null +++ b/mobile/services/db/repositories/shares.ts @@ -0,0 +1,130 @@ +import { getDatabase } from '../client'; +import type { NewShare, ResourceType, Share, ShareRow } from '../types'; + +const PUSH_STATUS_SQL = `( + SELECT CASE + WHEN EXISTS ( + SELECT 1 FROM pending_operations p + WHERE p.ref_type = 'share' AND p.ref_id = s.id AND p.status IN ('pending', 'in_progress') + ) THEN 'pending' + WHEN EXISTS ( + SELECT 1 FROM pending_operations p + WHERE p.ref_type = 'share' AND p.ref_id = s.id AND p.status = 'failed' + ) THEN 'failed' + ELSE 'synced' + END +) AS push_status`; + +export async function saveShare(share: NewShare): Promise { + const db = await getDatabase(); + const now = Date.now(); + + await db.runAsync( + `INSERT INTO shares + (resource_id, resource_type, recipient_type, recipient_id, relation, inherit, expires_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(resource_id, resource_type, recipient_type, recipient_id) DO UPDATE SET + relation = excluded.relation, + inherit = excluded.inherit, + expires_at = excluded.expires_at, + updated_at = excluded.updated_at`, + share.resourceId, + share.resourceType, + share.recipientType, + share.recipientId, + share.relation, + share.inherit === false ? 0 : 1, + share.expiresAt ?? null, + now, + now, + ); + + const row = await db.getFirstAsync( + `SELECT s.*, ${PUSH_STATUS_SQL} FROM shares s + WHERE s.resource_id = ? AND s.resource_type = ? AND s.recipient_type = ? AND s.recipient_id = ?`, + share.resourceId, + share.resourceType, + share.recipientType, + share.recipientId, + ); + return toShare(row!); +} + +export async function getShare( + resourceId: string, + resourceType: ResourceType, + recipientType: 'user' | 'group', + recipientId: string, +): Promise { + const db = await getDatabase(); + const row = await db.getFirstAsync( + `SELECT s.*, ${PUSH_STATUS_SQL} FROM shares s + WHERE s.resource_id = ? AND s.resource_type = ? AND s.recipient_type = ? AND s.recipient_id = ?`, + resourceId, + resourceType, + recipientType, + recipientId, + ); + return row ? toShare(row) : null; +} + +export async function getShares( + resourceId?: string, + resourceType?: ResourceType, +): Promise { + const db = await getDatabase(); + let sql = `SELECT s.*, ${PUSH_STATUS_SQL} FROM shares s`; + const params: string[] = []; + if (resourceId) { + sql += ' WHERE s.resource_id = ?'; + params.push(resourceId); + if (resourceType) { + sql += ' AND s.resource_type = ?'; + params.push(resourceType); + } + } + sql += ' ORDER BY s.created_at ASC'; + const rows = await db.getAllAsync(sql, ...params); + return rows.map(toShare); +} + +export async function removeShare( + resourceId: string, + resourceType: ResourceType, + recipientType: 'user' | 'group', + recipientId: string, +): Promise { + const db = await getDatabase(); + await db.runAsync( + `DELETE FROM shares + WHERE resource_id = ? AND resource_type = ? AND recipient_type = ? AND recipient_id = ?`, + resourceId, + resourceType, + recipientType, + recipientId, + ); +} + +export async function removeSharesForResource( + resourceId: string, + resourceType: ResourceType, +): Promise { + const db = await getDatabase(); + await db.runAsync('DELETE FROM shares WHERE resource_id = ? AND resource_type = ?', resourceId, resourceType); +} + +function toShare(row: ShareRow & { push_status: Share['pushStatus'] }): Share { + return { + id: row.id, + resourceId: row.resource_id, + resourceType: row.resource_type, + recipientType: row.recipient_type, + recipientId: row.recipient_id, + relation: row.relation, + inherit: row.inherit === 1, + expiresAt: row.expires_at, + createdAt: row.created_at, + updatedAt: row.updated_at, + pushStatus: row.push_status, + }; +} \ No newline at end of file diff --git a/mobile/services/db/schema.ts b/mobile/services/db/schema.ts index 114d25f..35a06c3 100644 --- a/mobile/services/db/schema.ts +++ b/mobile/services/db/schema.ts @@ -1,24 +1,30 @@ export const DATABASE_NAME = 'dot.db'; -export const DATABASE_VERSION = 2; +export const DATABASE_VERSION = 4; export const PREFERENCES_KEY = 'user_preferences'; +export const DEVICE_USER_ID_KEY = 'device_user_id'; + export const FOLDER_COLUMNS = [ + 'resource_id', 'uri', 'name', '"exists"', 'sync_status', 'added_at', - 'parent_uri', + 'updated_at', + 'parent_resource_id', + 'owner_id', ] as const; export const FOLDER_COLUMNS_SQL = FOLDER_COLUMNS.join(', '); export const FILE_COLUMNS = [ + 'resource_id', 'uri', 'name', - 'folder_uri', + 'folder_resource_id', 'extension', 'size', '"type"', @@ -26,6 +32,10 @@ export const FILE_COLUMNS = [ 'last_modified', 'sync_status', 'added_at', + 'updated_at', + 'owner_id', ] as const; -export const FILE_COLUMNS_SQL = FILE_COLUMNS.join(', '); \ No newline at end of file +export const FILE_COLUMNS_SQL = FILE_COLUMNS.join(', '); + +export const PERMISSION_TTL_MS = 24 * 60 * 60 * 1000; \ No newline at end of file diff --git a/mobile/services/db/transitions.ts b/mobile/services/db/transitions.ts new file mode 100644 index 0000000..9616294 --- /dev/null +++ b/mobile/services/db/transitions.ts @@ -0,0 +1,48 @@ +import type { SyncStatus } from './types'; + +export type SyncEvent = + | 'created_local' + | 'uploaded' + | 'downloaded' + | 'deleted_cloud'; + +export type SyncTransitionResult = SyncStatus | 'gone'; + +export function transitionSyncStatus( + from: SyncStatus, + event: SyncEvent, +): SyncTransitionResult { + switch (from) { + case 'local': + switch (event) { + case 'created_local': + return 'local'; + case 'uploaded': + return 'cloud'; + case 'downloaded': + return 'local-cloud'; + case 'deleted_cloud': + return 'gone'; + } + case 'cloud': + switch (event) { + case 'created_local': + return 'local-cloud'; + case 'uploaded': + return 'cloud'; + case 'downloaded': + return 'local-cloud'; + case 'deleted_cloud': + return 'gone'; + } + case 'local-cloud': + switch (event) { + case 'created_local': + case 'uploaded': + case 'downloaded': + return 'local-cloud'; + case 'deleted_cloud': + return 'local'; + } + } +} \ No newline at end of file diff --git a/mobile/services/db/types.ts b/mobile/services/db/types.ts index 6398b00..374fc91 100644 --- a/mobile/services/db/types.ts +++ b/mobile/services/db/types.ts @@ -4,40 +4,263 @@ export type { FileEntry, Folder } from '../safDirectory.types'; export type SyncStatus = 'local' | 'cloud' | 'local-cloud'; -export type FolderContext = { - syncStatus: SyncStatus; - addedAt: number; - folder: Folder; -}; +export type AccessLevel = 'owner' | 'editor' | 'commenter' | 'viewer'; -export type StoredFile = FileEntry & { - folderUri: string; - syncStatus: SyncStatus; - addedAt: number; -}; +export type ResourceType = 'folder' | 'file'; + +export type RecipientType = 'user' | 'group'; + +export type PushStatus = 'pending' | 'synced' | 'failed'; export type UserPreferences = { syncMode: 'full' | 'manual' | 'none'; }; export type FolderRow = { - uri: string; + id: number; + resource_id: string; + uri: string | null; name: string; exists: number | null; + parent_resource_id: string | null; + owner_id: string; sync_status: SyncStatus; added_at: number; - parent_uri: string | null; + updated_at: number; }; export type FileRow = { - uri: string; + id: number; + resource_id: string; + uri: string | null; name: string; - folder_uri: string; + folder_resource_id: string; extension: string | null; size: number; type: string | null; exists: number; last_modified: number | null; + owner_id: string; sync_status: SyncStatus; added_at: number; + updated_at: number; +}; + +export type StoredFolder = { + resource_id: string; + uri: string | null; + name: string; + exists: boolean; + parent_resource_id: string | null; + owner_id: string; + syncStatus: SyncStatus; + addedAt: number; + updatedAt: number; +}; + +export type StoredFile = { + resource_id: string; + uri: string | null; + name: string; + folder_resource_id: string; + extension: string; + exists: boolean; + size: number; + type: string; + lastModified: number | null; + owner_id: string; + syncStatus: SyncStatus; + addedAt: number; + updatedAt: number; +}; + +export type ResourcePermissionRow = { + id: number; + resource_id: string; + resource_type: ResourceType; + effective_access: AccessLevel; + inherit: number; + owner_id: string | null; + shared_by_id: string | null; + expires_at: number | null; + cached_at: number; + updated_at: number; +}; + +export type ResourcePermission = { + resource_id: string; + resourceType: ResourceType; + effectiveAccess: AccessLevel; + inherit: boolean; + ownerId: string | null; + sharedById: string | null; + expiresAt: number | null; + cachedAt: number; + updatedAt: number; +}; + +export type NewResourcePermission = { + resource_id: string; + resourceType: ResourceType; + effectiveAccess: AccessLevel; + inherit?: boolean; + ownerId?: string | null; + sharedById?: string | null; + expiresAt?: number | null; +}; + +export type ShareRow = { + id: number; + resource_id: string; + resource_type: ResourceType; + recipient_type: RecipientType; + recipient_id: string; + relation: AccessLevel; + inherit: number; + expires_at: number | null; + created_at: number; + updated_at: number; +}; + +export type Share = { + id: number; + resourceId: string; + resourceType: ResourceType; + recipientType: RecipientType; + recipientId: string; + relation: AccessLevel; + inherit: boolean; + expiresAt: number | null; + createdAt: number; + updatedAt: number; + pushStatus: PushStatus; +}; + +export type NewShare = { + resourceId: string; + resourceType: ResourceType; + recipientType: RecipientType; + recipientId: string; + relation: AccessLevel; + inherit?: boolean; + expiresAt?: number | null; +}; + +export type RecipientRow = { + id: number; + recipient_type: RecipientType; + recipient_id: string; + display_name: string; + is_active: number; + updated_at: number; +}; + +export type Recipient = { + recipientType: RecipientType; + recipientId: string; + displayName: string; + isActive: boolean; + updatedAt: number; +}; + +export type ShareLinkRow = { + id: number; + token: string; + resource_id: string; + resource_type: ResourceType; + has_password: number; + allow_download: number; + expires_at: number | null; + max_downloads: number | null; + downloads_count: number; + is_revoked: number; + created_at: number; + updated_at: number; +}; + +export type ShareLink = { + id: number; + token: string; + resourceId: string; + resourceType: ResourceType; + hasPassword: boolean; + allowDownload: boolean; + expiresAt: number | null; + maxDownloads: number | null; + downloadsCount: number; + isRevoked: boolean; + createdAt: number; + updatedAt: number; + pushStatus: PushStatus; +}; + +export type NewShareLink = { + token?: string; + resourceId: string; + resourceType: ResourceType; + hasPassword?: boolean; + allowDownload?: boolean; + expiresAt?: number | null; + maxDownloads?: number | null; +}; + +export type PendingOperationStatus = + | 'pending' + | 'in_progress' + | 'completed' + | 'failed' + | 'cancelled'; + +export type PendingOperationType = + | 'create_resource' + | 'update_metadata' + | 'delete_resource' + | 'move_resource' + | 'share' + | 'revoke_share' + | 'update_share' + | 'create_link' + | 'revoke_link'; + +export type PendingOperationRefType = 'resource' | 'share' | 'share_link'; + +export type PendingOperationRow = { + id: number; + resource_id: string | null; + resource_type: ResourceType | null; + ref_type: PendingOperationRefType | null; + ref_id: number | null; + operation: PendingOperationType; + payload: string; + status: PendingOperationStatus; + attempts: number; + error: string | null; + created_at: number; + next_retry_at: number | null; + last_error_at: number | null; +}; + +export type PendingOperation = { + id: number; + resourceId: string | null; + resourceType: ResourceType | null; + refType: PendingOperationRefType | null; + refId: number | null; + operation: PendingOperationType; + payload: unknown; + status: PendingOperationStatus; + attempts: number; + error: string | null; + createdAt: number; + nextRetryAt: number | null; + lastErrorAt: number | null; +}; + +export type NewPendingOperation = { + resourceId?: string | null; + resourceType?: ResourceType | null; + refType?: PendingOperationRefType | null; + refId?: number | null; + operation: PendingOperationType; + payload?: unknown; }; \ No newline at end of file diff --git a/mobile/services/localStorage.ts b/mobile/services/localStorage.ts index 395b239..03222ea 100644 --- a/mobile/services/localStorage.ts +++ b/mobile/services/localStorage.ts @@ -1,14 +1,61 @@ export { getUserPreferences, + getDeviceUserId, getFolders, getFolderFolders, + getFolder, getFiles, + getFile, removeFolder, removeFile, saveFile, saveFolder, saveDirectory, saveUserPreferences, + getResourcePermission, + saveResourcePermission, + canAccess, + canWrite, + isOwner, + saveShare, + getShare, + getShares, + removeShare, + removeSharesForResource, + createShareLink, + getShareLinkById, + getShareLinkByToken, + getShareLinks, + incrementLinkDownloads, + revokeShareLink, + removeShareLinksForResource, + saveRecipient, + getRecipients, + setRecipientActive, + enqueuePendingOperation, + getPendingOperations, + getNextQueuedOperation, + markPendingOperation, + transitionSyncStatus, } from './db'; -export type { FolderContext, StoredFile, SyncStatus, UserPreferences } from './db'; \ No newline at end of file +export type { + AccessLevel, + AccessCheck, + AccessSource, + StoredFile, + StoredFolder, + SyncStatus, + PushStatus, + UserPreferences, + ResourceType, + ResourcePermission, + RecipientType, + Recipient, + Share, + ShareLink, + PendingOperation, + PendingOperationStatus, + SyncEvent, + SyncTransitionResult, +} from './db'; \ No newline at end of file diff --git a/mobile/tests/migrations.test.ts b/mobile/tests/migrations.test.ts new file mode 100644 index 0000000..bae9bcc --- /dev/null +++ b/mobile/tests/migrations.test.ts @@ -0,0 +1,259 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import Database from 'better-sqlite3'; +import { MIGRATIONS, migrateDatabase } from '../services/db/migrations'; +import { DATABASE_VERSION, DEVICE_USER_ID_KEY } from '../services/db/schema'; + +type Harness = { + execAsync(sql: string): Promise; + runAsync(sql: string, ...params: unknown[]): Promise; + getFirstAsync(sql: string, ...params: unknown[]): Promise; + getAllAsync(sql: string, ...params: unknown[]): Promise; + withExclusiveTransactionAsync(task: (txn: Harness) => Promise): Promise; + raw: Database.Database; +}; + +function createHarness(): Harness { + const db = new Database(':memory:'); + const self: Harness = { + execAsync: async (sql) => { + db.exec(sql); + }, + runAsync: async (sql, ...params) => { + db.prepare(sql).run(...params); + }, + getFirstAsync: async (sql, ...params) => + (db.prepare(sql).get(...params) ?? null) as never, + getAllAsync: async (sql, ...params) => db.prepare(sql).all(...params) as never, + withExclusiveTransactionAsync: async (task) => { + db.exec('BEGIN'); + try { + const result = await task(self); + db.exec('COMMIT'); + return result; + } catch (err) { + db.exec('ROLLBACK'); + throw err; + } + }, + raw: db, + }; + return self; +} + +async function userVersion(h: Harness): Promise { + const row = await h.getFirstAsync<{ user_version: number }>('PRAGMA user_version'); + return row?.user_version ?? 0; +} + +async function columnNames(h: Harness, table: string): Promise { + const rows = await h.getAllAsync<{ name: string }>(`PRAGMA table_info(${table})`); + return rows.map((r) => r.name); +} + +async function foreignKeyViolations(h: Harness): Promise { + return h.getAllAsync('PRAGMA foreign_key_check'); +} + +const ROOT_URI = 'content://com.android.providers.documents/tree/primary%3ADocuments'; +const SUB_URI = 'content://com.android.providers.documents/tree/primary%3ADocuments%2Fphotos%20encod%2520ed'; +const EMPTY_URI = 'content://com.android.providers.documents/document/empty'; +const MUSIC_URI = 'content://com.android.providers.documents/tree/primary%3AMusic'; +const NOTE_URI = 'content://com.android.providers.documents/document/primary%20note.txt'; +const PIC_URI = 'content://com.android.providers.documents/document/primary%3Aphotos%2Fpic%20.jpg'; +const SONG_URI = 'content://com.android.providers.documents/document/primary%3ASong%20encod%2520.mp3'; + +async function seedLegacyTree(h: Harness): Promise { + await h.execAsync(`INSERT INTO folders (uri, name, "exists", sync_status, added_at, parent_uri) + VALUES ('${ROOT_URI}', 'Docs', 1, 'local', 1000, NULL);`); + await h.execAsync(`INSERT INTO folders (uri, name, "exists", sync_status, added_at, parent_uri) + VALUES ('${SUB_URI}', 'Photos 2024', 1, 'local', 2000, '${ROOT_URI}');`); + await h.execAsync(`INSERT INTO folders (uri, name, "exists", sync_status, added_at, parent_uri) + VALUES ('${EMPTY_URI}', 'Empty', 1, 'cloud', 3000, NULL);`); + await h.execAsync(`INSERT INTO folders (uri, name, "exists", sync_status, added_at, parent_uri) + VALUES ('${MUSIC_URI}', 'Music', 0, 'local-cloud', 4000, NULL);`); + await h.execAsync(`INSERT INTO files (uri, name, folder_uri, extension, size, "type", "exists", last_modified, sync_status, added_at) + VALUES ('${NOTE_URI}', 'note.txt', '${ROOT_URI}', 'txt', 10, 'text/plain', 1, 1000, 'local', 1000);`); + await h.execAsync(`INSERT INTO files (uri, name, folder_uri, extension, size, "type", "exists", last_modified, sync_status, added_at) + VALUES ('${PIC_URI}', 'pic.jpg', '${SUB_URI}', 'jpg', 20, 'image/jpeg', 1, 2000, 'local-cloud', 2000);`); + await h.execAsync(`INSERT INTO files (uri, name, folder_uri, extension, size, "type", "exists", last_modified, sync_status, added_at) + VALUES ('${SONG_URI}', 'song.mp3', '${MUSIC_URI}', 'mp3', 30, 'audio/mpeg', 0, 3000, 'cloud', 3000);`); +} + +test('fresh migrate v0 → v4 creates full schema and seeds device id', async () => { + const h = createHarness(); + await migrateDatabase(h); + + assert.equal(await userVersion(h), DATABASE_VERSION, `user_version should be ${DATABASE_VERSION}`); + + const folderCols = await columnNames(h, 'folders'); + for (const col of ['resource_id', 'uri', 'parent_resource_id', 'owner_id', 'updated_at']) { + assert.ok(folderCols.includes(col), `folders should have column ${col}`); + } + assert.ok(!folderCols.includes('parent_uri'), 'parent_uri must be replaced by parent_resource_id'); + + const fileCols = await columnNames(h, 'files'); + for (const col of ['resource_id', 'folder_resource_id', 'owner_id', 'updated_at']) { + assert.ok(fileCols.includes(col), `files should have column ${col}`); + } + + for (const table of [ + 'resource_permissions', + 'shares', + 'recipients', + 'share_links', + 'pending_operations', + ]) { + const names = await columnNames(h, table); + assert.ok(names.length > 0, `table ${table} must exist`); + } + + const device = await h.getFirstAsync<{ value: string }>( + 'SELECT "value" FROM user_preferences WHERE "key" = ?', + DEVICE_USER_ID_KEY, + ); + assert.ok(device && /^[0-9a-f]{32}$/.test(device.value), 'device_user_id seeded as 32-hex'); + + assert.deepEqual(await foreignKeyViolations(h), [], 'no orphaned FKs after fresh migrate'); +}); + +test('user_version is transactional (rollback restores previous version)', async () => { + const h = createHarness(); + await migrateDatabase(h); + const before = await userVersion(h); + + h.raw.exec('BEGIN'); + h.raw.exec('PRAGMA user_version = 999'); + h.raw.exec('ROLLBACK'); + + assert.equal(await userVersion(h), before, 'user_version must be rolled back'); +}); + +test('realistic v2 → v4 tree is preserved with correct reparenting', async () => { + const h = createHarness(); + await migrateDatabase(h, 2); + await seedLegacyTree(h); + + await migrateDatabase(h); + + assert.equal(await userVersion(h), DATABASE_VERSION); + + const folders = await h.getAllAsync<{ + resource_id: string; + uri: string | null; + name: string; + parent_resource_id: string | null; + owner_id: string; + added_at: number; + updated_at: number; + }>('SELECT resource_id, uri, name, parent_resource_id, owner_id, added_at, updated_at FROM folders'); + const files = await h.getAllAsync<{ + resource_id: string; + uri: string | null; + name: string; + folder_resource_id: string; + owner_id: string; + added_at: number; + updated_at: number; + }>('SELECT resource_id, uri, name, folder_resource_id, owner_id, added_at, updated_at FROM files'); + + assert.equal(folders.length, 4, 'folders count preserved'); + assert.equal(files.length, 3, 'files count preserved'); + + for (const f of folders) { + assert.match(f.resource_id, /^[0-9a-f]{32}$/, 'resource_id must be 32-hex once'); + assert.ok(f.owner_id, 'owner_id must be set (never NULL implicit)'); + assert.equal(f.updated_at, f.added_at, 'backfilled updated_at = added_at'); + } + for (const f of files) { + assert.match(f.resource_id, /^[0-9a-f]{32}$/, 'resource_id must be 32-hex'); + assert.ok(f.owner_id, 'owner_id must be set'); + } + const resourceIds = [...folders.map((f) => f.resource_id), ...files.map((f) => f.resource_id)]; + assert.equal(new Set(resourceIds).size, resourceIds.length, 'all resource ids unique in the same table'); + + const device = await h.getFirstAsync<{ value: string }>( + 'SELECT "value" FROM user_preferences WHERE "key" = ?', + DEVICE_USER_ID_KEY, + ); + for (const f of [...folders, ...files]) { + assert.equal(f.owner_id, device!.value, `owner_id must be device_user_id, got ${f.owner_id}`); + } + + const byUri = new Map(folders.map((f) => [f.uri, f])); + const root = byUri.get(ROOT_URI); + const sub = byUri.get(SUB_URI); + assert.ok(root && sub, 'local uris preserved on local rows'); + assert.equal(sub.parent_resource_id, root.resource_id, 'child folder reparented to root resource_id'); + + const byName = new Map(folders.map((f) => [f.name, f])); + const empty = byName.get('Empty'); + assert.equal(empty!.parent_resource_id, null, 'root folder stays root'); + + const note = files.find((f) => f.name === 'note.txt'); + const pic = files.find((f) => f.name === 'pic.jpg'); + const song = files.find((f) => f.name === 'song.mp3'); + assert.equal(note!.folder_resource_id, root.resource_id, 'file linked to root folder'); + assert.equal(pic!.folder_resource_id, sub.resource_id, 'file linked to nested folder'); + assert.equal(song!.folder_resource_id, byName.get('Music')!.resource_id, 'cloud file linked to cloud folder'); + + assert.deepEqual(await foreignKeyViolations(h), [], 'no orphaned FKs after v2→v4'); +}); + +test('cascade delete works through the rebuilt self-FK', async () => { + const h = createHarness(); + await migrateDatabase(h, 2); + await seedLegacyTree(h); + await migrateDatabase(h); + + const folders = await h.getAllAsync<{ resource_id: string; name: string; parent_resource_id: string | null }>( + 'SELECT resource_id, name, parent_resource_id FROM folders', + ); + const root = folders.find((f) => f.name === 'Docs')!; + + await h.runAsync('DELETE FROM folders WHERE resource_id = ?', root.resource_id); + + const names = await h.getAllAsync<{ name: string }>('SELECT name FROM folders'); + assert.equal(names.length, 2, 'root and its subtree removed (Docs, Photos 2024)'); + const files = await h.getAllAsync<{ name: string }>('SELECT name FROM files'); + assert.equal(files.length, 1, 'only Music file remains (note + pic cascaded)'); + assert.deepEqual(await foreignKeyViolations(h), [], 'no orphans after cascade'); +}); + +test('v4 crash is atomic: rollback keeps v3 schema and data, rerun succeeds', async () => { + const h = createHarness(); + await migrateDatabase(h, 3); + await seedLegacyTree(h); + + const originalExec = h.execAsync.bind(h); + h.execAsync = async (sql) => { + if (sql.startsWith('DROP TABLE files;')) { + throw new Error('simulated crash during rebuild'); + } + return originalExec(sql); + }; + + const v4 = MIGRATIONS.find((m) => m.version === 4); + assert.ok(v4, 'v4 migration exists'); + await assert.rejects(() => v4.up(h), /simulated crash/); + + assert.equal(await userVersion(h), 3, 'user_version unchanged after failed rebuild'); + assert.ok((await columnNames(h, 'folders')).includes('parent_uri'), 'old folders schema restored'); + const folders = await h.getAllAsync<{ name: string }>('SELECT name FROM folders'); + assert.equal(folders.length, 4, 'data intact after rollback'); + + h.execAsync = originalExec; + await migrateDatabase(h); + + assert.equal(await userVersion(h), DATABASE_VERSION, 'rerun completes to newest version'); + assert.ok((await columnNames(h, 'folders')).includes('resource_id')); + const files = await h.getAllAsync<{ name: string }>('SELECT name FROM files'); + assert.equal(files.length, 3, 'files preserved after rerun'); + assert.deepEqual(await foreignKeyViolations(h), [], 'no orphans after rerun'); +}); + +test('concurrent migrateDatabase calls are single-flight', async () => { + const h = createHarness(); + await Promise.all([migrateDatabase(h), migrateDatabase(h)]); + assert.equal(await userVersion(h), DATABASE_VERSION); +}); \ No newline at end of file