sqlite local
This commit is contained in:
+11
-7
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
})();
|
||||
}, []);
|
||||
|
||||
|
||||
Generated
+554
@@ -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",
|
||||
|
||||
+5
-1
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
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';
|
||||
@@ -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<void>;
|
||||
export type MigrationDb = {
|
||||
execAsync(source: string): 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,
|
||||
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');
|
||||
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}`);
|
||||
}
|
||||
|
||||
const migrationPromises = new WeakMap<MigrationDb, Promise<void>>();
|
||||
|
||||
export async function migrateDatabase(
|
||||
db: SQLiteDatabase,
|
||||
db: MigrationDb,
|
||||
targetVersion: number = DATABASE_VERSION,
|
||||
): 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);
|
||||
|
||||
for (const migration of MIGRATIONS) {
|
||||
|
||||
@@ -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<void> {
|
||||
export type SaveFileOptions = {
|
||||
syncStatus?: SyncStatus;
|
||||
};
|
||||
|
||||
export async function saveFile(
|
||||
file: FileEntry,
|
||||
folderResourceId: string,
|
||||
options: SaveFileOptions = {},
|
||||
): Promise<StoredFile> {
|
||||
const db = await getDatabase();
|
||||
const now = Date.now();
|
||||
const exists = file.exists ? 1 : 0;
|
||||
const ownerId = await getDeviceUserId();
|
||||
|
||||
const existing = await db.getFirstAsync<FileRow>(
|
||||
`SELECT ${FILE_COLUMNS_SQL} FROM files WHERE uri = ?`,
|
||||
file.uri,
|
||||
);
|
||||
|
||||
const 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<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 rows = folderUri
|
||||
? await db.getAllAsync<FileRow>(
|
||||
`SELECT ${FILE_COLUMNS_SQL} FROM files WHERE folder_uri = ? ORDER BY name ASC`,
|
||||
folderUri,
|
||||
)
|
||||
const rows =
|
||||
folderResourceId === undefined
|
||||
? await db.getAllAsync<FileRow>(`SELECT ${FILE_COLUMNS_SQL} FROM files ORDER BY name ASC`)
|
||||
: await db.getAllAsync<FileRow>(
|
||||
`SELECT ${FILE_COLUMNS_SQL} FROM files ORDER BY name ASC`,
|
||||
`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<void> {
|
||||
export async function getFile(resourceId: string): Promise<StoredFile | null> {
|
||||
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 {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<void> {
|
||||
input: SaveFolderInput,
|
||||
options: SaveFolderOptions = {},
|
||||
): Promise<StoredFolder> {
|
||||
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(
|
||||
`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<FolderRow>(
|
||||
`SELECT ${FOLDER_COLUMNS_SQL} FROM folders WHERE resource_id = ?`,
|
||||
resourceId,
|
||||
);
|
||||
return toStoredFolder(row!);
|
||||
}
|
||||
|
||||
export async function saveDirectory(folder: Folder): Promise<boolean> {
|
||||
await saveFolder(folder);
|
||||
return true;
|
||||
export async function saveDirectory(folder: {
|
||||
uri: string;
|
||||
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 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 rows = await db.getAllAsync<FolderRow>(
|
||||
`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<void> {
|
||||
export async function getFolder(resourceId: string): Promise<StoredFolder | null> {
|
||||
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 {
|
||||
syncStatus: row.sync_status,
|
||||
addedAt: row.added_at,
|
||||
folder: {
|
||||
resource_id: row.resource_id,
|
||||
uri: row.uri,
|
||||
name: row.name,
|
||||
isDirectory: true,
|
||||
exists: row.exists === null ? undefined : row.exists === 1,
|
||||
},
|
||||
exists: 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,
|
||||
};
|
||||
}
|
||||
@@ -3,7 +3,36 @@ export {
|
||||
saveDirectory,
|
||||
getFolders,
|
||||
getFolderFolders,
|
||||
getFolder,
|
||||
removeFolder,
|
||||
} from './folders';
|
||||
export { saveFile, getFiles, removeFile } from './files';
|
||||
export { saveUserPreferences, getUserPreferences } from './preferences';
|
||||
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';
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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<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> {
|
||||
const db = await getDatabase();
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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(', ');
|
||||
|
||||
export const PERMISSION_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
@@ -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';
|
||||
export type {
|
||||
AccessLevel,
|
||||
AccessCheck,
|
||||
AccessSource,
|
||||
StoredFile,
|
||||
StoredFolder,
|
||||
SyncStatus,
|
||||
PushStatus,
|
||||
UserPreferences,
|
||||
ResourceType,
|
||||
ResourcePermission,
|
||||
RecipientType,
|
||||
Recipient,
|
||||
Share,
|
||||
ShareLink,
|
||||
PendingOperation,
|
||||
PendingOperationStatus,
|
||||
SyncEvent,
|
||||
SyncTransitionResult,
|
||||
} from './db';
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user