sqlite local

This commit is contained in:
m
2026-09-09 23:12:15 +02:00
parent e1360c19a2
commit 7dd29239b7
21 changed files with 2284 additions and 101 deletions
+11 -7
View File
@@ -4,14 +4,18 @@ Read the exact versioned docs at https://docs.expo.dev/versions/v57.0.0/ before
# Local storage # 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/client.ts` — connection lifecycle: `getDatabase()`, `closeDatabase()`, `withTransaction()`.
- `services/db/migrations.ts` — versioned, chained migrations via `PRAGMA user_version` (list of `{ version, up }`). - `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. - `services/db/schema.ts``DATABASE_NAME`, `DATABASE_VERSION`, column-list constants, `DEVICE_USER_ID_KEY`, `PERMISSION_TTL_MS` (24h offline stale-cache).
- `services/db/repositories/` — one module per table (`folders`, `files`, `user_preferences`). - `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. - `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`. - Tables: `folders`, `files`, `user_preferences`, `resource_permissions`, `shares`, `share_links`, `recipients`, `pending_operations`.
- Folder and file per-row sync status: `local` | `cloud` | `local-cloud`. - 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`.
- Query usage: `getFiles(folderUri?)`, `getFolders()`, `getFolderFolders(parentUri)`, `saveFolder`, `saveFile`, `saveUserPreferences`, `getUserPreferences`, `removeFolder`, `removeFile`. - 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. - Heavy processing stays server-side; SQLite only persists local metadata/state.
+1 -1
View File
@@ -11,7 +11,7 @@ export default function Index() {
useEffect(() => { useEffect(() => {
(async () => { (async () => {
const saved = await getFolders(); const saved = await getFolders();
setFolders(saved.map((ctx) => ctx.folder.name)); setFolders(saved.map((folder) => folder.name));
})(); })();
}, []); }, []);
+554
View File
@@ -22,8 +22,11 @@
"react-native-screens": "~4.26.0" "react-native-screens": "~4.26.0"
}, },
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^9.6.0",
"@types/react": "~19.2.2", "@types/react": "~19.2.2",
"babel-preset-expo": "^57.0.4", "babel-preset-expo": "^57.0.4",
"better-sqlite3": "^13.0.3",
"tsx": "^4.23.13",
"typescript": "~6.0.3" "typescript": "~6.0.3"
} }
}, },
@@ -1123,6 +1126,448 @@
"node": ">=6.9.0" "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": { "node_modules/@expo-google-fonts/material-symbols": {
"version": "0.4.46", "version": "0.4.46",
"resolved": "https://registry.npmjs.org/@expo-google-fonts/material-symbols/-/material-symbols-0.4.46.tgz", "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==", "integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==",
"license": "MIT" "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": { "node_modules/@types/istanbul-lib-coverage": {
"version": "2.0.6", "version": "2.0.6",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", "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": ">=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": { "node_modules/big-integer": {
"version": "1.6.52", "version": "1.6.52",
"resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz",
@@ -3384,6 +3852,48 @@
"node": ">= 0.4" "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": { "node_modules/escalade": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -4293,6 +4803,21 @@
"node": ">= 0.6" "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": { "node_modules/function-bind": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -5593,6 +6118,16 @@
"url": "https://opencollective.com/express" "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": { "node_modules/node-forge": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz",
@@ -7114,6 +7649,25 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD" "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": { "node_modules/type-fest": {
"version": "0.7.1", "version": "0.7.1",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.7.1.tgz", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.7.1.tgz",
+5 -1
View File
@@ -17,15 +17,19 @@
"react-native-screens": "~4.26.0" "react-native-screens": "~4.26.0"
}, },
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^9.6.0",
"@types/react": "~19.2.2", "@types/react": "~19.2.2",
"babel-preset-expo": "^57.0.4", "babel-preset-expo": "^57.0.4",
"better-sqlite3": "^13.0.3",
"tsx": "^4.23.13",
"typescript": "~6.0.3" "typescript": "~6.0.3"
}, },
"scripts": { "scripts": {
"start": "expo start", "start": "expo start",
"android": "expo run:android", "android": "expo run:android",
"ios": "expo run:ios", "ios": "expo run:ios",
"web": "expo start --web" "web": "expo start --web",
"test:migrations": "tsx --test tests/migrations.test.ts"
}, },
"private": true "private": true
} }
+9
View File
@@ -0,0 +1,9 @@
import { getDatabase } from './client';
export async function newResourceId(): Promise<string> {
const db = await getDatabase();
const row = await db.getFirstAsync<{ id: string }>(
'SELECT lower(hex(randomblob(16))) AS id',
);
return row!.id;
}
+42 -2
View File
@@ -1,4 +1,44 @@
export { getDatabase, closeDatabase, withTransaction } from './client'; export { getDatabase, closeDatabase, withTransaction } from './client';
export { migrateDatabase, type Migration, type MigrationDb } from './migrations';
export * from './repositories'; export * from './repositories';
export type { FolderContext, StoredFile, SyncStatus, UserPreferences } from './types'; export type { AccessCheck, AccessSource } from './repositories/permissions';
export { DATABASE_NAME, DATABASE_VERSION } from './schema'; 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';
+186 -9
View File
@@ -1,12 +1,17 @@
import type { SQLiteDatabase } from 'expo-sqlite'; import { DATABASE_VERSION, DEVICE_USER_ID_KEY } from './schema';
import { DATABASE_VERSION } from './schema';
type Migration = { export type MigrationDb = {
version: number; execAsync(source: string): Promise<void>;
up: (db: SQLiteDatabase) => Promise<void>; getFirstAsync<T>(source: string, ...params: unknown[]): Promise<T | null>;
withExclusiveTransactionAsync(task: (txn: MigrationDb) => Promise<void>): Promise<void>;
}; };
const MIGRATIONS: Migration[] = [ export type Migration = {
version: number;
up: (db: MigrationDb) => Promise<void>;
};
export const MIGRATIONS: Migration[] = [
{ {
version: 1, version: 1,
up: async (db) => { 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<number> { async function readUserVersion(db: MigrationDb): Promise<number> {
const row = await db.getFirstAsync<{ user_version: number }>('PRAGMA user_version'); const row = await db.getFirstAsync<{ user_version: number }>('PRAGMA user_version');
return row?.user_version ?? 0; return row?.user_version ?? 0;
} }
async function writeUserVersion(db: SQLiteDatabase, version: number): Promise<void> { async function writeUserVersion(db: MigrationDb, version: number): Promise<void> {
await db.execAsync(`PRAGMA user_version = ${version}`); await db.execAsync(`PRAGMA user_version = ${version}`);
} }
const migrationPromises = new WeakMap<MigrationDb, Promise<void>>();
export async function migrateDatabase( export async function migrateDatabase(
db: SQLiteDatabase, db: MigrationDb,
targetVersion: number = DATABASE_VERSION, targetVersion: number = DATABASE_VERSION,
): Promise<void> { ): Promise<void> {
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<void> {
let currentVersion = await readUserVersion(db); let currentVersion = await readUserVersion(db);
for (const migration of MIGRATIONS) { for (const migration of MIGRATIONS) {
+70 -22
View File
@@ -1,60 +1,108 @@
import { getDatabase } from '../client'; import { getDatabase } from '../client';
import { newResourceId } from '../id';
import { FILE_COLUMNS_SQL } from '../schema'; 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<void> { export type SaveFileOptions = {
syncStatus?: SyncStatus;
};
export async function saveFile(
file: FileEntry,
folderResourceId: string,
options: SaveFileOptions = {},
): Promise<StoredFile> {
const db = await getDatabase(); const db = await getDatabase();
const now = Date.now();
const exists = file.exists ? 1 : 0; 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 resourceId = existing?.resource_id ?? (await newResourceId());
const baseSync = existing?.sync_status ?? options.syncStatus ?? 'local';
await db.runAsync( await db.runAsync(
`INSERT INTO files (uri, name, folder_uri, extension, size, "type", "exists", last_modified, added_at) `INSERT INTO files
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) (resource_id, uri, name, folder_resource_id, extension, size, "type", "exists", last_modified, owner_id, sync_status, added_at, updated_at)
ON CONFLICT(uri) DO UPDATE SET VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
name = excluded.name, folder_uri = excluded.folder_uri, extension = excluded.extension, ON CONFLICT(resource_id) DO UPDATE SET
size = excluded.size, "type" = excluded."type", "exists" = excluded."exists", uri = excluded.uri,
last_modified = excluded.last_modified`, 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.uri,
file.name, file.name,
folderUri, folderResourceId,
file.extension ?? null, file.extension ?? null,
file.size, file.size,
file.type ?? null, file.type ?? null,
exists, exists,
file.lastModified, file.lastModified ?? null,
Date.now(), ownerId,
baseSync,
existing?.added_at ?? now,
now,
); );
const row = await db.getFirstAsync<FileRow>(
`SELECT ${FILE_COLUMNS_SQL} FROM files WHERE resource_id = ?`,
resourceId,
);
return toStoredFile(row!);
} }
export async function getFiles(folderUri?: string): Promise<StoredFile[]> { export async function getFiles(folderResourceId?: string): Promise<StoredFile[]> {
const db = await getDatabase(); const db = await getDatabase();
const rows = folderUri const rows =
? await db.getAllAsync<FileRow>( folderResourceId === undefined
`SELECT ${FILE_COLUMNS_SQL} FROM files WHERE folder_uri = ? ORDER BY name ASC`, ? await db.getAllAsync<FileRow>(`SELECT ${FILE_COLUMNS_SQL} FROM files ORDER BY name ASC`)
folderUri,
)
: await db.getAllAsync<FileRow>( : await db.getAllAsync<FileRow>(
`SELECT ${FILE_COLUMNS_SQL} FROM files ORDER BY name ASC`, `SELECT ${FILE_COLUMNS_SQL} FROM files WHERE folder_resource_id = ? ORDER BY name ASC`,
folderResourceId,
); );
return rows.map(toStoredFile); return rows.map(toStoredFile);
} }
export async function removeFile(uri: string): Promise<void> { export async function getFile(resourceId: string): Promise<StoredFile | null> {
const db = await getDatabase(); const db = await getDatabase();
await db.runAsync('DELETE FROM files WHERE uri = ?', uri); const row = await db.getFirstAsync<FileRow>(
`SELECT ${FILE_COLUMNS_SQL} FROM files WHERE resource_id = ?`,
resourceId,
);
return row ? toStoredFile(row) : null;
}
export async function removeFile(resourceId: string): Promise<void> {
const db = await getDatabase();
await db.runAsync('DELETE FROM files WHERE resource_id = ?', resourceId);
} }
function toStoredFile(row: FileRow): StoredFile { function toStoredFile(row: FileRow): StoredFile {
return { return {
resource_id: row.resource_id,
uri: row.uri, uri: row.uri,
name: row.name, name: row.name,
isDirectory: false, folder_resource_id: row.folder_resource_id,
extension: row.extension ?? '', extension: row.extension ?? '',
exists: row.exists === 1, exists: row.exists === 1,
size: row.size, size: row.size,
type: row.type ?? '', type: row.type ?? '',
lastModified: row.last_modified, lastModified: row.last_modified,
folderUri: row.folder_uri, owner_id: row.owner_id,
syncStatus: row.sync_status, syncStatus: row.sync_status,
addedAt: row.added_at, addedAt: row.added_at,
updatedAt: row.updated_at,
}; };
} }
+95 -35
View File
@@ -1,69 +1,129 @@
import { getDatabase } from '../client'; import { getDatabase } from '../client';
import { newResourceId } from '../id';
import { FOLDER_COLUMNS_SQL } from '../schema'; 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 = { export type SaveFolderInput = {
parentUri?: string; uri: string | null;
name: string;
exists?: boolean;
resource_id?: string;
};
export type SaveFolderOptions = {
parentResourceId?: string | null;
syncStatus?: SyncStatus; syncStatus?: SyncStatus;
}; };
export async function saveFolder( export async function saveFolder(
folder: Folder, input: SaveFolderInput,
options?: SaveFolderOptions, options: SaveFolderOptions = {},
): Promise<void> { ): Promise<StoredFolder> {
const db = await getDatabase(); 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<FolderRow>(
`SELECT ${FOLDER_COLUMNS_SQL} FROM folders WHERE uri = ?`,
input.uri,
)
: input.resource_id
? await db.getFirstAsync<FolderRow>(
`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( await db.runAsync(
`INSERT INTO folders (uri, name, "exists", sync_status, added_at, parent_uri) `INSERT INTO folders
VALUES (?, ?, ?, ?, ?, ?) (resource_id, uri, name, "exists", parent_resource_id, owner_id, sync_status, added_at, updated_at)
ON CONFLICT(uri) DO UPDATE SET VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
name = excluded.name, "exists" = excluded."exists", sync_status = excluded.sync_status`, ON CONFLICT(resource_id) DO UPDATE SET
folder.uri, uri = excluded.uri,
folder.name, 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, exists,
options?.syncStatus ?? 'local', options.parentResourceId ?? null,
Date.now(), ownerId,
options?.parentUri ?? null, baseSync,
existing?.added_at ?? now,
now,
); );
const row = await db.getFirstAsync<FolderRow>(
`SELECT ${FOLDER_COLUMNS_SQL} FROM folders WHERE resource_id = ?`,
resourceId,
);
return toStoredFolder(row!);
} }
export async function saveDirectory(folder: Folder): Promise<boolean> { export async function saveDirectory(folder: {
await saveFolder(folder); uri: string;
return true; name: string;
exists?: boolean;
}): Promise<StoredFolder> {
return saveFolder({ uri: folder.uri, name: folder.name, exists: folder.exists });
} }
export async function getFolders(): Promise<FolderContext[]> { export async function getFolders(): Promise<StoredFolder[]> {
const db = await getDatabase(); const db = await getDatabase();
const rows = await db.getAllAsync<FolderRow>( const rows = await db.getAllAsync<FolderRow>(
`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<FolderContext[]> { export async function getFolderFolders(
parentResourceId: string | null,
): Promise<StoredFolder[]> {
const db = await getDatabase(); const db = await getDatabase();
const rows = await db.getAllAsync<FolderRow>( const rows = await db.getAllAsync<FolderRow>(
`SELECT ${FOLDER_COLUMNS_SQL} FROM folders WHERE parent_uri = ? ORDER BY name ASC`, `SELECT ${FOLDER_COLUMNS_SQL} FROM folders WHERE parent_resource_id IS ? ORDER BY name ASC`,
parentUri, parentResourceId,
); );
return rows.map(toFolderContext); return rows.map(toStoredFolder);
} }
export async function removeFolder(uri: string): Promise<void> { export async function getFolder(resourceId: string): Promise<StoredFolder | null> {
const db = await getDatabase(); const db = await getDatabase();
await db.runAsync('DELETE FROM folders WHERE uri = ?', uri); const row = await db.getFirstAsync<FolderRow>(
`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<void> {
const db = await getDatabase();
await db.runAsync('DELETE FROM folders WHERE resource_id = ?', resourceId);
}
function toStoredFolder(row: FolderRow): StoredFolder {
return { return {
syncStatus: row.sync_status, resource_id: row.resource_id,
addedAt: row.added_at,
folder: {
uri: row.uri, uri: row.uri,
name: row.name, name: row.name,
isDirectory: true, exists: row.exists === 1,
exists: row.exists === null ? undefined : row.exists === 1, parent_resource_id: row.parent_resource_id,
}, owner_id: row.owner_id,
syncStatus: row.sync_status,
addedAt: row.added_at,
updatedAt: row.updated_at,
}; };
} }
+31 -2
View File
@@ -3,7 +3,36 @@ export {
saveDirectory, saveDirectory,
getFolders, getFolders,
getFolderFolders, getFolderFolders,
getFolder,
removeFolder, removeFolder,
} from './folders'; } from './folders';
export { saveFile, getFiles, removeFile } from './files'; export { saveFile, getFiles, getFile, removeFile } from './files';
export { saveUserPreferences, getUserPreferences } from './preferences'; 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';
@@ -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<number> {
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<PendingOperation[]> {
const db = await getDatabase();
const rows = status
? await db.getAllAsync<PendingOperationRow>(
`SELECT ${PENDING_OPERATION_COLUMNS} FROM pending_operations
WHERE status = ? ORDER BY created_at ASC, id ASC`,
status,
)
: await db.getAllAsync<PendingOperationRow>(
`SELECT ${PENDING_OPERATION_COLUMNS} FROM pending_operations
ORDER BY created_at ASC, id ASC`,
);
return rows.map(toPendingOperation);
}
export async function getNextQueuedOperation(): Promise<PendingOperation | null> {
const db = await getDatabase();
const row = await db.getFirstAsync<PendingOperationRow>(
`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<void> {
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 {};
}
}
@@ -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<AccessLevel, number> = {
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<ResourcePermission | null> {
const db = await getDatabase();
const row = await db.getFirstAsync<ResourcePermissionRow>(
`SELECT * FROM resource_permissions WHERE resource_id = ? AND resource_type = ?`,
resourceId,
resourceType,
);
return row ? toResourcePermission(row) : null;
}
export async function saveResourcePermission(
permission: NewResourcePermission,
): Promise<void> {
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<LineageNode[]> {
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<LineageNode[]> {
const rows = await db.getAllAsync<LineageNode>(
`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<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 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<AccessCheck> {
return canAccess(resourceId, resourceType, 'editor');
}
export async function isOwner(
resourceId: string,
resourceType: ResourceType,
): Promise<boolean> {
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,
};
}
+22 -1
View File
@@ -1,11 +1,32 @@
import { getDatabase } from '../client'; import { getDatabase } from '../client';
import { PREFERENCES_KEY } from '../schema'; import { DEVICE_USER_ID_KEY, PREFERENCES_KEY } from '../schema';
import type { UserPreferences } from '../types'; import type { UserPreferences } from '../types';
const DEFAULT_PREFERENCES: UserPreferences = { const DEFAULT_PREFERENCES: UserPreferences = {
syncMode: 'full', syncMode: 'full',
}; };
export async function getDeviceUserId(): Promise<string> {
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<void> { export async function saveUserPreferences(preferences: UserPreferences): Promise<void> {
const db = await getDatabase(); const db = await getDatabase();
await db.runAsync( await db.runAsync(
@@ -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<Recipient> {
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<RecipientRow>(
'SELECT * FROM recipients WHERE recipient_id = ?',
recipientId,
);
return toRecipient(row!);
}
export async function getRecipients(activeOnly = true): Promise<Recipient[]> {
const db = await getDatabase();
const rows = activeOnly
? await db.getAllAsync<RecipientRow>(
'SELECT * FROM recipients WHERE is_active = 1 ORDER BY display_name ASC',
)
: await db.getAllAsync<RecipientRow>('SELECT * FROM recipients ORDER BY display_name ASC');
return rows.map(toRecipient);
}
export async function setRecipientActive(
recipientType: RecipientType,
recipientId: string,
active: boolean,
): Promise<void> {
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,
};
}
@@ -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<ShareLink> {
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<ShareLink | null> {
const db = await getDatabase();
const row = await db.getFirstAsync<ShareLinkRow & { push_status: ShareLink['pushStatus'] }>(
`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<ShareLink | null> {
const db = await getDatabase();
const row = await db.getFirstAsync<ShareLinkRow & { push_status: ShareLink['pushStatus'] }>(
`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<ShareLink[]> {
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<ShareLinkRow & { push_status: ShareLink['pushStatus'] }>(sql, ...params);
return rows.map(toShareLink);
}
export async function incrementLinkDownloads(id: number): Promise<void> {
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<void> {
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<void> {
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,
};
}
+130
View File
@@ -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<Share> {
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<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 = ?`,
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<Share | null> {
const db = await getDatabase();
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 = ?`,
resourceId,
resourceType,
recipientType,
recipientId,
);
return row ? toShare(row) : null;
}
export async function getShares(
resourceId?: string,
resourceType?: ResourceType,
): Promise<Share[]> {
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<ShareRow & { push_status: Share['pushStatus'] }>(sql, ...params);
return rows.map(toShare);
}
export async function removeShare(
resourceId: string,
resourceType: ResourceType,
recipientType: 'user' | 'group',
recipientId: string,
): Promise<void> {
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<void> {
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,
};
}
+13 -3
View File
@@ -1,24 +1,30 @@
export const DATABASE_NAME = 'dot.db'; export const DATABASE_NAME = 'dot.db';
export const DATABASE_VERSION = 2; export const DATABASE_VERSION = 4;
export const PREFERENCES_KEY = 'user_preferences'; export const PREFERENCES_KEY = 'user_preferences';
export const DEVICE_USER_ID_KEY = 'device_user_id';
export const FOLDER_COLUMNS = [ export const FOLDER_COLUMNS = [
'resource_id',
'uri', 'uri',
'name', 'name',
'"exists"', '"exists"',
'sync_status', 'sync_status',
'added_at', 'added_at',
'parent_uri', 'updated_at',
'parent_resource_id',
'owner_id',
] as const; ] as const;
export const FOLDER_COLUMNS_SQL = FOLDER_COLUMNS.join(', '); export const FOLDER_COLUMNS_SQL = FOLDER_COLUMNS.join(', ');
export const FILE_COLUMNS = [ export const FILE_COLUMNS = [
'resource_id',
'uri', 'uri',
'name', 'name',
'folder_uri', 'folder_resource_id',
'extension', 'extension',
'size', 'size',
'"type"', '"type"',
@@ -26,6 +32,10 @@ export const FILE_COLUMNS = [
'last_modified', 'last_modified',
'sync_status', 'sync_status',
'added_at', 'added_at',
'updated_at',
'owner_id',
] as const; ] as const;
export const FILE_COLUMNS_SQL = FILE_COLUMNS.join(', '); export const FILE_COLUMNS_SQL = FILE_COLUMNS.join(', ');
export const PERMISSION_TTL_MS = 24 * 60 * 60 * 1000;
+48
View File
@@ -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';
}
}
}
+237 -14
View File
@@ -4,40 +4,263 @@ export type { FileEntry, Folder } from '../safDirectory.types';
export type SyncStatus = 'local' | 'cloud' | 'local-cloud'; export type SyncStatus = 'local' | 'cloud' | 'local-cloud';
export type FolderContext = { export type AccessLevel = 'owner' | 'editor' | 'commenter' | 'viewer';
syncStatus: SyncStatus;
addedAt: number;
folder: Folder;
};
export type StoredFile = FileEntry & { export type ResourceType = 'folder' | 'file';
folderUri: string;
syncStatus: SyncStatus; export type RecipientType = 'user' | 'group';
addedAt: number;
}; export type PushStatus = 'pending' | 'synced' | 'failed';
export type UserPreferences = { export type UserPreferences = {
syncMode: 'full' | 'manual' | 'none'; syncMode: 'full' | 'manual' | 'none';
}; };
export type FolderRow = { export type FolderRow = {
uri: string; id: number;
resource_id: string;
uri: string | null;
name: string; name: string;
exists: number | null; exists: number | null;
parent_resource_id: string | null;
owner_id: string;
sync_status: SyncStatus; sync_status: SyncStatus;
added_at: number; added_at: number;
parent_uri: string | null; updated_at: number;
}; };
export type FileRow = { export type FileRow = {
uri: string; id: number;
resource_id: string;
uri: string | null;
name: string; name: string;
folder_uri: string; folder_resource_id: string;
extension: string | null; extension: string | null;
size: number; size: number;
type: string | null; type: string | null;
exists: number; exists: number;
last_modified: number | null; last_modified: number | null;
owner_id: string;
sync_status: SyncStatus; sync_status: SyncStatus;
added_at: number; 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;
}; };
+48 -1
View File
@@ -1,14 +1,61 @@
export { export {
getUserPreferences, getUserPreferences,
getDeviceUserId,
getFolders, getFolders,
getFolderFolders, getFolderFolders,
getFolder,
getFiles, getFiles,
getFile,
removeFolder, removeFolder,
removeFile, removeFile,
saveFile, saveFile,
saveFolder, saveFolder,
saveDirectory, saveDirectory,
saveUserPreferences, 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'; } from './db';
export type { FolderContext, StoredFile, SyncStatus, UserPreferences } from './db'; export type {
AccessLevel,
AccessCheck,
AccessSource,
StoredFile,
StoredFolder,
SyncStatus,
PushStatus,
UserPreferences,
ResourceType,
ResourcePermission,
RecipientType,
Recipient,
Share,
ShareLink,
PendingOperation,
PendingOperationStatus,
SyncEvent,
SyncTransitionResult,
} from './db';
+259
View File
@@ -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<void>;
runAsync(sql: string, ...params: unknown[]): Promise<void>;
getFirstAsync<T>(sql: string, ...params: unknown[]): Promise<T | null>;
getAllAsync<T>(sql: string, ...params: unknown[]): Promise<T[]>;
withExclusiveTransactionAsync<T>(task: (txn: Harness) => Promise<T>): Promise<T>;
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<number> {
const row = await h.getFirstAsync<{ user_version: number }>('PRAGMA user_version');
return row?.user_version ?? 0;
}
async function columnNames(h: Harness, table: string): Promise<string[]> {
const rows = await h.getAllAsync<{ name: string }>(`PRAGMA table_info(${table})`);
return rows.map((r) => r.name);
}
async function foreignKeyViolations(h: Harness): Promise<unknown[]> {
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<void> {
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);
});