Compare commits
90 Commits
benjamin-j
...
jazz-vue@0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
073d8992f5 | ||
|
|
7b400835a2 | ||
|
|
bf287d0709 | ||
|
|
b0072e63e3 | ||
|
|
a479ece032 | ||
|
|
e0dbe46d64 | ||
|
|
1f1fc56720 | ||
|
|
64fa74a6d9 | ||
|
|
66538fdcf5 | ||
|
|
543f91277d | ||
|
|
42112ec46c | ||
|
|
f4170eb879 | ||
|
|
8278055e33 | ||
|
|
65c236571e | ||
|
|
b53cc9930e | ||
|
|
9c5b34d91c | ||
|
|
a6c119b98d | ||
|
|
6e3565d20f | ||
|
|
e5e21718f9 | ||
|
|
1b49bd8be4 | ||
|
|
70a93ab093 | ||
|
|
dacaa02a01 | ||
|
|
55cc248d91 | ||
|
|
534fce6796 | ||
|
|
a223a3a5ab | ||
|
|
b0df041a24 | ||
|
|
357698f4fb | ||
|
|
a6e5a72b97 | ||
|
|
b4dd2add45 | ||
|
|
f3824dfb76 | ||
|
|
ac7b388ca5 | ||
|
|
ad3641861b | ||
|
|
3d601deaa0 | ||
|
|
7824e6b36a | ||
|
|
9b80278b71 | ||
|
|
46f2ab801d | ||
|
|
a6b6ccf814 | ||
|
|
8928e9e10d | ||
|
|
0e1c38f2d1 | ||
|
|
08ad01d6b5 | ||
|
|
e9eec78ce5 | ||
|
|
5bab5091c3 | ||
|
|
1663b4aa59 | ||
|
|
7f6637e235 | ||
|
|
f320e6821a | ||
|
|
b491a3c638 | ||
|
|
35d03f2f2c | ||
|
|
43c19307cd | ||
|
|
1e642d4454 | ||
|
|
2c931dd57e | ||
|
|
8eec42814a | ||
|
|
0e0590d25b | ||
|
|
58a3ad2951 | ||
|
|
9f834eba97 | ||
|
|
4a02dbed28 | ||
|
|
ad920158d6 | ||
|
|
3f15a23219 | ||
|
|
1b60fc7095 | ||
|
|
9db577def9 | ||
|
|
641483f40b | ||
|
|
9212ab89ec | ||
|
|
915fa5ea4e | ||
|
|
6b4cb357ce | ||
|
|
d6638742b0 | ||
|
|
ddb158d5fa | ||
|
|
8b87117e0f | ||
|
|
e5000c2b6b | ||
|
|
0347e52118 | ||
|
|
bb9ba33e73 | ||
|
|
92c63d94b9 | ||
|
|
75339c0939 | ||
|
|
22102deabc | ||
|
|
043e2acae4 | ||
|
|
1b7ef1c2c0 | ||
|
|
996092c26f | ||
|
|
3c794bba0a | ||
|
|
2d3d53d144 | ||
|
|
69d05c8c15 | ||
|
|
d11aeee083 | ||
|
|
4d5848161d | ||
|
|
8ee456d4e4 | ||
|
|
7b2c2e6084 | ||
|
|
133f75d34e | ||
|
|
68c9114896 | ||
|
|
57ff9e2d1f | ||
|
|
1cac820ec6 | ||
|
|
eb4646beca | ||
|
|
5447d6f10b | ||
|
|
dbb040eb07 | ||
|
|
9960320645 |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"jazz-svelte": patch
|
||||
---
|
||||
|
||||
Returns Provider from `createJazzApp` instead of lib
|
||||
@@ -1,5 +1,15 @@
|
||||
# jazz-example-book-shelf
|
||||
|
||||
## 0.1.27
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [9212ab8]
|
||||
- Updated dependencies [8b87117]
|
||||
- jazz-react@0.8.35
|
||||
- jazz-tools@0.8.35
|
||||
- jazz-browser-media-images@0.8.35
|
||||
|
||||
## 0.1.26
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jazz-example-book-shelf",
|
||||
"version": "0.1.26",
|
||||
"version": "0.1.27",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -11,9 +11,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"clsx": "^2.0.0",
|
||||
"jazz-browser-media-images": "workspace:0.8.34",
|
||||
"jazz-react": "workspace:0.8.34",
|
||||
"jazz-tools": "workspace:0.8.34",
|
||||
"jazz-browser-media-images": "workspace:0.8.35",
|
||||
"jazz-react": "workspace:0.8.35",
|
||||
"jazz-tools": "workspace:0.8.35",
|
||||
"next": "14.2.5",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# chat-rn-clerk
|
||||
|
||||
## 1.0.27
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [8b87117]
|
||||
- jazz-tools@0.8.35
|
||||
- jazz-react-auth-clerk@0.8.35
|
||||
- jazz-react-native@0.8.35
|
||||
- jazz-react-native-media-images@0.8.26
|
||||
|
||||
## 1.0.26
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "chat-rn-clerk",
|
||||
"main": "index.js",
|
||||
"version": "1.0.26",
|
||||
"version": "1.0.27",
|
||||
"scripts": {
|
||||
"build": "expo export -p ios",
|
||||
"start": "expo start",
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# chat-rn
|
||||
|
||||
## 1.0.25
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [8b87117]
|
||||
- jazz-tools@0.8.35
|
||||
- jazz-react-native@0.8.35
|
||||
|
||||
## 1.0.24
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "chat-rn",
|
||||
"version": "1.0.24",
|
||||
"version": "1.0.25",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"build": "expo export -p ios",
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# chat-vue
|
||||
|
||||
## 0.0.18
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [8b87117]
|
||||
- jazz-tools@0.8.35
|
||||
- jazz-browser@0.8.35
|
||||
- jazz-vue@0.8.23
|
||||
|
||||
## 0.0.17
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "chat-vue",
|
||||
"version": "0.0.17",
|
||||
"version": "0.0.18",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
# jazz-example-chat
|
||||
|
||||
## 0.0.113
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3f15a23]
|
||||
- Updated dependencies [9212ab8]
|
||||
- Updated dependencies [46f2ab8]
|
||||
- Updated dependencies [8b87117]
|
||||
- Updated dependencies [a6b6ccf]
|
||||
- cojson@0.8.35
|
||||
- jazz-react@0.8.35
|
||||
- jazz-tools@0.8.35
|
||||
|
||||
## 0.0.112
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "jazz-example-chat",
|
||||
"private": true,
|
||||
"version": "0.0.112",
|
||||
"version": "0.0.113",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -18,10 +18,10 @@
|
||||
"@radix-ui/react-toast": "^1.1.4",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.0.0",
|
||||
"cojson": "workspace:0.8.34",
|
||||
"cojson": "workspace:0.8.35",
|
||||
"hash-slash": "workspace:0.2.1",
|
||||
"jazz-react": "workspace:0.8.34",
|
||||
"jazz-tools": "workspace:0.8.34",
|
||||
"jazz-react": "workspace:0.8.35",
|
||||
"jazz-tools": "workspace:0.8.35",
|
||||
"lucide-react": "^0.274.0",
|
||||
"qrcode": "^1.5.3",
|
||||
"react": "^18.2.0",
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# minimal-auth-clerk
|
||||
|
||||
## 0.0.12
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [9212ab8]
|
||||
- Updated dependencies [8b87117]
|
||||
- jazz-react@0.8.35
|
||||
- jazz-tools@0.8.35
|
||||
- jazz-react-auth-clerk@0.8.35
|
||||
|
||||
## 0.0.11
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "clerk",
|
||||
"private": true,
|
||||
"version": "0.0.11",
|
||||
"version": "0.0.12",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -14,7 +14,7 @@
|
||||
"@clerk/clerk-react": "^5.4.1",
|
||||
"jazz-tools": "workspace:*",
|
||||
"jazz-react": "workspace:*",
|
||||
"jazz-react-auth-clerk": "workspace:0.8.34",
|
||||
"jazz-react-auth-clerk": "workspace:0.8.35",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
|
||||
11
examples/image-upload/CHANGELOG.md
Normal file
11
examples/image-upload/CHANGELOG.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# image-upload
|
||||
|
||||
## 0.0.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [9212ab8]
|
||||
- Updated dependencies [8b87117]
|
||||
- jazz-react@0.8.35
|
||||
- jazz-tools@0.8.35
|
||||
- jazz-browser-media-images@0.8.35
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "image-upload",
|
||||
"private": true,
|
||||
"version": "0.0.9",
|
||||
"version": "0.0.10",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
# jazz-example-inspector
|
||||
|
||||
## 0.0.82
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3f15a23]
|
||||
- Updated dependencies [46f2ab8]
|
||||
- Updated dependencies [8b87117]
|
||||
- Updated dependencies [a6b6ccf]
|
||||
- cojson@0.8.35
|
||||
- cojson-transport-ws@0.8.35
|
||||
|
||||
## 0.0.81
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "jazz-inspector",
|
||||
"private": true,
|
||||
"version": "0.0.81",
|
||||
"version": "0.0.82",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -16,8 +16,8 @@
|
||||
"@radix-ui/react-toast": "^1.1.4",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.0.0",
|
||||
"cojson": "workspace:0.8.34",
|
||||
"cojson-transport-ws": "workspace:0.8.34",
|
||||
"cojson": "workspace:0.8.35",
|
||||
"cojson-transport-ws": "workspace:0.8.35",
|
||||
"hash-slash": "workspace:0.2.1",
|
||||
"lucide-react": "^0.274.0",
|
||||
"qrcode": "^1.5.3",
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# jazz-example-musicplayer
|
||||
|
||||
## 0.0.33
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [9212ab8]
|
||||
- Updated dependencies [8b87117]
|
||||
- jazz-react@0.8.35
|
||||
- jazz-tools@0.8.35
|
||||
|
||||
## 0.0.32
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "jazz-example-music-player",
|
||||
"private": true,
|
||||
"version": "0.0.32",
|
||||
"version": "0.0.33",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -18,8 +18,8 @@
|
||||
"@radix-ui/react-toast": "^1.1.4",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.0.0",
|
||||
"jazz-react": "workspace:0.8.34",
|
||||
"jazz-tools": "workspace:0.8.34",
|
||||
"jazz-react": "workspace:0.8.35",
|
||||
"jazz-tools": "workspace:0.8.35",
|
||||
"lucide-react": "^0.274.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
|
||||
@@ -26,13 +26,13 @@ export async function uploadMusicTracks(
|
||||
account: MusicaAccount,
|
||||
files: Iterable<File>,
|
||||
) {
|
||||
// The ownership object defines the user that owns the created coValues
|
||||
// by setting the ownership with "account" we configure the coValues to be private
|
||||
const ownership = {
|
||||
owner: account,
|
||||
};
|
||||
|
||||
for (const file of files) {
|
||||
// The ownership object defines the user that owns the created coValues
|
||||
// We are creating a group for each CoValue in order to be able to share them via Playlist
|
||||
const ownership = {
|
||||
owner: Group.create({ owner: account }),
|
||||
};
|
||||
|
||||
const data = await getAudioFileData(file);
|
||||
|
||||
// We transform the file blob into a FileStream
|
||||
@@ -86,16 +86,31 @@ export async function addTrackToPlaylist(
|
||||
) {
|
||||
if (!account) return;
|
||||
|
||||
if (playlist.tracks?.some((t) => t?._refs.sourceTrack.id === track.id))
|
||||
const alreadyAdded = playlist.tracks?.some(
|
||||
(t) => t?.id === track.id || t?._refs.sourceTrack?.id === track.id,
|
||||
);
|
||||
|
||||
if (alreadyAdded) return;
|
||||
|
||||
// Check if the track has been created after the Group inheritance was introduced
|
||||
if (track._owner._type === "Group" && playlist._owner._type === "Group") {
|
||||
/**
|
||||
* Extending the track with the Playlist group in order to make the music track
|
||||
* visible to the Playlist user
|
||||
*/
|
||||
const trackGroup = track._owner;
|
||||
trackGroup.extend(playlist._owner);
|
||||
|
||||
playlist.tracks?.push(track);
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Since musicTracks are created as private values (see uploadMusicTracks)
|
||||
* to make them shareable as part of the playlist we are cloning them
|
||||
* and setting the playlist group as owner of the clone
|
||||
*
|
||||
* In the future it will be possible to "inherit" the parent group so you
|
||||
* won't need to clone values to have this kind of sharing granularity
|
||||
* Doing this for backwards compatibility for when the Group inheritance wasn't possible
|
||||
*/
|
||||
const ownership = { owner: playlist._owner };
|
||||
const blob = await FileStream.loadAsBlob(track._refs.file.id, account);
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# jazz-example-onboarding
|
||||
|
||||
## 0.0.14
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [9212ab8]
|
||||
- Updated dependencies [8b87117]
|
||||
- jazz-react@0.8.35
|
||||
- jazz-tools@0.8.35
|
||||
- jazz-browser-media-images@0.8.35
|
||||
|
||||
## 0.0.13
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "jazz-example-onboarding",
|
||||
"private": true,
|
||||
"version": "0.0.13",
|
||||
"version": "0.0.14",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
8
examples/passkey-svelte/CHANGELOG.md
Normal file
8
examples/passkey-svelte/CHANGELOG.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# passkey-svelte
|
||||
|
||||
## 0.0.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [0e59e65]
|
||||
- jazz-svelte@0.0.2
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "passkey-svelte",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# minimal-auth-passkey
|
||||
|
||||
## 0.0.11
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [9212ab8]
|
||||
- Updated dependencies [8b87117]
|
||||
- jazz-react@0.8.35
|
||||
- jazz-tools@0.8.35
|
||||
|
||||
## 0.0.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "passkey",
|
||||
"private": true,
|
||||
"version": "0.0.10",
|
||||
"version": "0.0.11",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# jazz-password-manager
|
||||
|
||||
## 0.0.32
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [9212ab8]
|
||||
- Updated dependencies [8b87117]
|
||||
- jazz-react@0.8.35
|
||||
- jazz-tools@0.8.35
|
||||
|
||||
## 0.0.31
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "jazz-password-manager",
|
||||
"private": true,
|
||||
"version": "0.0.31",
|
||||
"version": "0.0.32",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -12,8 +12,8 @@
|
||||
"clean-install": "rm -rf node_modules pnpm-lock.yaml && pnpm install"
|
||||
},
|
||||
"dependencies": {
|
||||
"jazz-react": "workspace:0.8.34",
|
||||
"jazz-tools": "workspace:0.8.34",
|
||||
"jazz-react": "workspace:0.8.35",
|
||||
"jazz-tools": "workspace:0.8.35",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.41.5",
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# jazz-example-pets
|
||||
|
||||
## 0.0.130
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [9212ab8]
|
||||
- Updated dependencies [8b87117]
|
||||
- jazz-react@0.8.35
|
||||
- jazz-tools@0.8.35
|
||||
- jazz-browser-media-images@0.8.35
|
||||
|
||||
## 0.0.129
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "jazz-example-pets",
|
||||
"private": true,
|
||||
"version": "0.0.129",
|
||||
"version": "0.0.130",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -19,9 +19,9 @@
|
||||
"@radix-ui/react-toast": "^1.1.4",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.0.0",
|
||||
"jazz-browser-media-images": "workspace:0.8.34",
|
||||
"jazz-react": "workspace:0.8.34",
|
||||
"jazz-tools": "workspace:0.8.34",
|
||||
"jazz-browser-media-images": "workspace:0.8.35",
|
||||
"jazz-react": "workspace:0.8.35",
|
||||
"jazz-tools": "workspace:0.8.35",
|
||||
"lucide-react": "^0.274.0",
|
||||
"qrcode": "^1.5.3",
|
||||
"react": "^18.2.0",
|
||||
@@ -41,7 +41,7 @@
|
||||
"@vitejs/plugin-react-swc": "^3.3.2",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"is-ci": "^3.0.1",
|
||||
"jazz-run": "workspace:0.8.34",
|
||||
"jazz-run": "workspace:0.8.35",
|
||||
"postcss": "^8.4.27",
|
||||
"tailwindcss": "3.3.2",
|
||||
"typescript": "^5.3.3",
|
||||
|
||||
11
examples/reactions/CHANGELOG.md
Normal file
11
examples/reactions/CHANGELOG.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# reactions
|
||||
|
||||
## 0.0.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [9212ab8]
|
||||
- Updated dependencies [8b87117]
|
||||
- jazz-react@0.8.35
|
||||
- jazz-tools@0.8.35
|
||||
- jazz-browser-media-images@0.8.35
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "reactions",
|
||||
"private": true,
|
||||
"version": "0.0.9",
|
||||
"version": "0.0.10",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# todo-vue
|
||||
|
||||
## 0.0.16
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [8b87117]
|
||||
- jazz-tools@0.8.35
|
||||
- jazz-browser@0.8.35
|
||||
- jazz-vue@0.8.23
|
||||
|
||||
## 0.0.15
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "todo-vue",
|
||||
"version": "0.0.15",
|
||||
"version": "0.0.16",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# jazz-example-todo
|
||||
|
||||
## 0.0.129
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [9212ab8]
|
||||
- Updated dependencies [8b87117]
|
||||
- jazz-react@0.8.35
|
||||
- jazz-tools@0.8.35
|
||||
|
||||
## 0.0.128
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "jazz-example-todo",
|
||||
"private": true,
|
||||
"version": "0.0.128",
|
||||
"version": "0.0.129",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -16,8 +16,8 @@
|
||||
"@radix-ui/react-toast": "^1.1.4",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.0.0",
|
||||
"jazz-react": "workspace:0.8.34",
|
||||
"jazz-tools": "workspace:0.8.34",
|
||||
"jazz-react": "workspace:0.8.35",
|
||||
"jazz-tools": "workspace:0.8.35",
|
||||
"lucide-react": "^0.274.0",
|
||||
"qrcode": "^1.5.3",
|
||||
"react": "^18.2.0",
|
||||
|
||||
@@ -3,11 +3,13 @@ import clsx from "clsx";
|
||||
interface HeadingProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export function H1({ children, className }: HeadingProps) {
|
||||
export function H1({ children, className, id }: HeadingProps) {
|
||||
return (
|
||||
<h1
|
||||
id={id}
|
||||
className={clsx(
|
||||
className,
|
||||
"font-display",
|
||||
@@ -23,9 +25,10 @@ export function H1({ children, className }: HeadingProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function H2({ children, className }: HeadingProps) {
|
||||
export function H2({ children, className, id }: HeadingProps) {
|
||||
return (
|
||||
<h2
|
||||
id={id}
|
||||
className={clsx(
|
||||
className,
|
||||
"font-display",
|
||||
@@ -41,9 +44,10 @@ export function H2({ children, className }: HeadingProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function H3({ children, className }: HeadingProps) {
|
||||
export function H3({ children, className, id }: HeadingProps) {
|
||||
return (
|
||||
<h3
|
||||
id={id}
|
||||
className={clsx(
|
||||
className,
|
||||
"font-display",
|
||||
@@ -59,8 +63,12 @@ export function H3({ children, className }: HeadingProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function H4({ children, className }: HeadingProps) {
|
||||
return <h4 className={clsx(className, "text-bold")}>{children}</h4>;
|
||||
export function H4({ children, className, id }: HeadingProps) {
|
||||
return (
|
||||
<h4 id={id} className={clsx(className, "text-bold")}>
|
||||
{children}
|
||||
</h4>
|
||||
);
|
||||
}
|
||||
|
||||
export function Kicker({ children, className }: HeadingProps) {
|
||||
|
||||
@@ -361,21 +361,25 @@ const vueExamples: Example[] = [
|
||||
const categories = [
|
||||
{
|
||||
name: "React",
|
||||
id: "react",
|
||||
logo: ReactLogo,
|
||||
examples: reactExamples,
|
||||
},
|
||||
{
|
||||
name: "Next.js",
|
||||
id: "next",
|
||||
logo: NextjsLogo,
|
||||
examples: nextExamples,
|
||||
},
|
||||
{
|
||||
name: "React Native",
|
||||
id: "react-native",
|
||||
logo: ReactNativeLogo,
|
||||
examples: rnExamples,
|
||||
},
|
||||
{
|
||||
name: "Vue",
|
||||
id: "vue",
|
||||
logo: VueLogo,
|
||||
examples: vueExamples,
|
||||
},
|
||||
@@ -443,7 +447,9 @@ export default function Page() {
|
||||
<div key={category.name}>
|
||||
<div className="flex items-center gap-3 mb-5">
|
||||
<category.logo className="h-8 w-8" />
|
||||
<H2 className="!mb-0">{category.name}</H2>
|
||||
<H2 id={category.id} className="!mb-0">
|
||||
{category.name}
|
||||
</H2>
|
||||
</div>
|
||||
|
||||
<GappedGrid>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "jazz-monorepo",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"workspaces": ["packages/*", "examples/*", "e2e/*"],
|
||||
"workspaces": ["packages/*", "examples/*"],
|
||||
"packageManager": "pnpm@9.1.4",
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.9.4",
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# cojson-storage-indexeddb
|
||||
|
||||
## 0.8.35
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 8b87117: Implement Group Inheritance
|
||||
- 46f2ab8: Optimize the communication with the sync manager
|
||||
- Updated dependencies [3f15a23]
|
||||
- Updated dependencies [46f2ab8]
|
||||
- Updated dependencies [8b87117]
|
||||
- Updated dependencies [a6b6ccf]
|
||||
- cojson@0.8.35
|
||||
|
||||
## 0.8.34
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "cojson-storage-indexeddb",
|
||||
"version": "0.8.34",
|
||||
"version": "0.8.35",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
"types": "src/index.ts",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cojson": "workspace:0.8.34"
|
||||
"cojson": "workspace:0.8.35"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitest/browser": "^0.34.1",
|
||||
|
||||
237
packages/cojson-storage-indexeddb/src/idbClient.ts
Normal file
237
packages/cojson-storage-indexeddb/src/idbClient.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import { CojsonInternalTypes, SessionID } from "cojson";
|
||||
import { SyncPromise } from "./syncPromises";
|
||||
import RawCoID = CojsonInternalTypes.RawCoID;
|
||||
import Transaction = CojsonInternalTypes.Transaction;
|
||||
import Signature = CojsonInternalTypes.Signature;
|
||||
|
||||
export type CoValueRow = {
|
||||
id: CojsonInternalTypes.RawCoID;
|
||||
header: CojsonInternalTypes.CoValueHeader;
|
||||
};
|
||||
|
||||
export type StoredCoValueRow = CoValueRow & { rowID: number };
|
||||
|
||||
export type TransactionRow = {
|
||||
ses: number;
|
||||
idx: number;
|
||||
tx: CojsonInternalTypes.Transaction;
|
||||
};
|
||||
|
||||
export type SignatureAfterRow = {
|
||||
ses: number;
|
||||
idx: number;
|
||||
signature: CojsonInternalTypes.Signature;
|
||||
};
|
||||
|
||||
export type SessionRow = {
|
||||
coValue: number;
|
||||
sessionID: SessionID;
|
||||
lastIdx: number;
|
||||
lastSignature: CojsonInternalTypes.Signature;
|
||||
bytesSinceLastSignature?: number;
|
||||
};
|
||||
|
||||
export type StoredSessionRow = SessionRow & { rowID: number };
|
||||
|
||||
export class IDBClient {
|
||||
private db;
|
||||
|
||||
currentTx:
|
||||
| {
|
||||
id: number;
|
||||
tx: IDBTransaction;
|
||||
stores: {
|
||||
coValues: IDBObjectStore;
|
||||
sessions: IDBObjectStore;
|
||||
transactions: IDBObjectStore;
|
||||
signatureAfter: IDBObjectStore;
|
||||
};
|
||||
startedAt: number;
|
||||
pendingRequests: ((txEntry: {
|
||||
stores: {
|
||||
coValues: IDBObjectStore;
|
||||
sessions: IDBObjectStore;
|
||||
transactions: IDBObjectStore;
|
||||
signatureAfter: IDBObjectStore;
|
||||
};
|
||||
}) => void)[];
|
||||
}
|
||||
| undefined;
|
||||
|
||||
currentTxID = 0;
|
||||
|
||||
constructor(db: IDBDatabase) {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
makeRequest<T>(
|
||||
handler: (stores: {
|
||||
coValues: IDBObjectStore;
|
||||
sessions: IDBObjectStore;
|
||||
transactions: IDBObjectStore;
|
||||
signatureAfter: IDBObjectStore;
|
||||
}) => IDBRequest,
|
||||
): SyncPromise<T> {
|
||||
return new SyncPromise((resolve, reject) => {
|
||||
let txEntry = this.currentTx;
|
||||
|
||||
const requestEntry = ({
|
||||
stores,
|
||||
}: {
|
||||
stores: {
|
||||
coValues: IDBObjectStore;
|
||||
sessions: IDBObjectStore;
|
||||
transactions: IDBObjectStore;
|
||||
signatureAfter: IDBObjectStore;
|
||||
};
|
||||
}) => {
|
||||
const request = handler(stores);
|
||||
request.onerror = () => {
|
||||
console.error("Error in request", request.error);
|
||||
this.currentTx = undefined;
|
||||
reject(request.error);
|
||||
};
|
||||
request.onsuccess = () => {
|
||||
const value = request.result as T;
|
||||
resolve(value);
|
||||
|
||||
const next = txEntry!.pendingRequests.shift();
|
||||
|
||||
if (next) {
|
||||
next({ stores });
|
||||
} else {
|
||||
if (this.currentTx === txEntry) {
|
||||
this.currentTx = undefined;
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// Transaction batching
|
||||
if (!txEntry || performance.now() - txEntry.startedAt > 20) {
|
||||
const tx = this.db.transaction(
|
||||
["coValues", "sessions", "transactions", "signatureAfter"],
|
||||
"readwrite",
|
||||
);
|
||||
txEntry = {
|
||||
id: this.currentTxID++,
|
||||
tx,
|
||||
stores: {
|
||||
coValues: tx.objectStore("coValues"),
|
||||
sessions: tx.objectStore("sessions"),
|
||||
transactions: tx.objectStore("transactions"),
|
||||
signatureAfter: tx.objectStore("signatureAfter"),
|
||||
},
|
||||
startedAt: performance.now(),
|
||||
pendingRequests: [],
|
||||
};
|
||||
|
||||
this.currentTx = txEntry;
|
||||
|
||||
requestEntry(txEntry);
|
||||
} else {
|
||||
txEntry.pendingRequests.push(requestEntry);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async getCoValue(coValueId: RawCoID): Promise<StoredCoValueRow | undefined> {
|
||||
return this.makeRequest<StoredCoValueRow | undefined>(({ coValues }) =>
|
||||
coValues.index("coValuesById").get(coValueId),
|
||||
);
|
||||
}
|
||||
|
||||
async getCoValueSessions(coValueRowId: number): Promise<StoredSessionRow[]> {
|
||||
return this.makeRequest<StoredSessionRow[]>(({ sessions }) =>
|
||||
sessions.index("sessionsByCoValue").getAll(coValueRowId),
|
||||
);
|
||||
}
|
||||
|
||||
async getNewTransactionInSession(
|
||||
sessionRow: StoredSessionRow,
|
||||
firstNewTxIdx: number,
|
||||
): Promise<TransactionRow[]> {
|
||||
return this.makeRequest<TransactionRow[]>(({ transactions }) =>
|
||||
transactions.getAll(
|
||||
IDBKeyRange.bound(
|
||||
[sessionRow.rowID, firstNewTxIdx],
|
||||
[sessionRow.rowID, Infinity],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async getSignatures(
|
||||
sessionRow: StoredSessionRow,
|
||||
firstNewTxIdx: number,
|
||||
): Promise<SignatureAfterRow[]> {
|
||||
return this.makeRequest<SignatureAfterRow[]>(
|
||||
({ signatureAfter }: { signatureAfter: IDBObjectStore }) =>
|
||||
signatureAfter.getAll(
|
||||
IDBKeyRange.bound(
|
||||
[sessionRow.rowID, firstNewTxIdx],
|
||||
[sessionRow.rowID, Infinity],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async addCoValue(
|
||||
msg: CojsonInternalTypes.NewContentMessage,
|
||||
): Promise<number> {
|
||||
if (!msg.header) {
|
||||
throw new Error("Header is required, coId: " + msg.id);
|
||||
}
|
||||
|
||||
return (await this.makeRequest<IDBValidKey>(({ coValues }) =>
|
||||
coValues.put({
|
||||
id: msg.id,
|
||||
header: msg.header!,
|
||||
} satisfies CoValueRow),
|
||||
)) as number;
|
||||
}
|
||||
|
||||
async addSessionUpdate(
|
||||
sessionRow: StoredSessionRow | undefined,
|
||||
sessionUpdate: SessionRow,
|
||||
): Promise<number> {
|
||||
return this.makeRequest<number>(({ sessions }) =>
|
||||
sessions.put(
|
||||
sessionRow?.rowID
|
||||
? {
|
||||
rowID: sessionRow.rowID,
|
||||
...sessionUpdate,
|
||||
}
|
||||
: sessionUpdate,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
addTransaction(
|
||||
sessionRowID: number,
|
||||
idx: number,
|
||||
newTransaction: Transaction,
|
||||
) {
|
||||
return this.makeRequest(({ transactions }) =>
|
||||
transactions.add({
|
||||
ses: sessionRowID,
|
||||
idx,
|
||||
tx: newTransaction,
|
||||
} satisfies TransactionRow),
|
||||
);
|
||||
}
|
||||
|
||||
addSignatureAfter({
|
||||
sessionRowID,
|
||||
idx,
|
||||
signature,
|
||||
}: { sessionRowID: number; idx: number; signature: Signature }) {
|
||||
return this.makeRequest(({ signatureAfter }) =>
|
||||
signatureAfter.put({
|
||||
ses: sessionRowID,
|
||||
idx,
|
||||
signature,
|
||||
} satisfies SignatureAfterRow),
|
||||
);
|
||||
}
|
||||
}
|
||||
123
packages/cojson-storage-indexeddb/src/idbNode.ts
Normal file
123
packages/cojson-storage-indexeddb/src/idbNode.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import {
|
||||
IncomingSyncStream,
|
||||
OutgoingSyncQueue,
|
||||
Peer,
|
||||
cojsonInternals,
|
||||
} from "cojson";
|
||||
import { IDBClient } from "./idbClient";
|
||||
import { SyncManager } from "./syncManager";
|
||||
|
||||
export class IDBNode {
|
||||
private dbClient: IDBClient;
|
||||
private syncManager: SyncManager;
|
||||
|
||||
constructor(
|
||||
db: IDBDatabase,
|
||||
fromLocalNode: IncomingSyncStream,
|
||||
toLocalNode: OutgoingSyncQueue,
|
||||
) {
|
||||
this.dbClient = new IDBClient(db);
|
||||
this.syncManager = new SyncManager(this.dbClient, toLocalNode);
|
||||
|
||||
const processMessages = async () => {
|
||||
for await (const msg of fromLocalNode) {
|
||||
try {
|
||||
if (msg === "Disconnected" || msg === "PingTimeout") {
|
||||
throw new Error("Unexpected Disconnected message");
|
||||
}
|
||||
await this.syncManager.handleSyncMessage(msg);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
new Error(
|
||||
`Error reading from localNode, handling msg\n\n${JSON.stringify(
|
||||
msg,
|
||||
(k, v) =>
|
||||
k === "changes" || k === "encryptedChanges"
|
||||
? v.slice(0, 20) + "..."
|
||||
: v,
|
||||
)}`,
|
||||
{ cause: e },
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
processMessages().catch((e) =>
|
||||
console.error("Error in processMessages in IndexedDB", e),
|
||||
);
|
||||
}
|
||||
|
||||
static async asPeer(
|
||||
{
|
||||
trace,
|
||||
localNodeName = "local",
|
||||
}: { trace?: boolean; localNodeName?: string } | undefined = {
|
||||
localNodeName: "local",
|
||||
},
|
||||
): Promise<Peer> {
|
||||
const [localNodeAsPeer, storageAsPeer] = cojsonInternals.connectedPeers(
|
||||
localNodeName,
|
||||
"indexedDB",
|
||||
{
|
||||
peer1role: "client",
|
||||
peer2role: "storage",
|
||||
trace,
|
||||
crashOnClose: true,
|
||||
},
|
||||
);
|
||||
|
||||
await IDBNode.open(localNodeAsPeer.incoming, localNodeAsPeer.outgoing);
|
||||
|
||||
return { ...storageAsPeer, priority: 100 };
|
||||
}
|
||||
|
||||
static async open(
|
||||
fromLocalNode: IncomingSyncStream,
|
||||
toLocalNode: OutgoingSyncQueue,
|
||||
) {
|
||||
const dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open("jazz-storage", 4);
|
||||
request.onerror = () => {
|
||||
reject(request.error);
|
||||
};
|
||||
request.onsuccess = () => {
|
||||
resolve(request.result);
|
||||
};
|
||||
request.onupgradeneeded = async (ev) => {
|
||||
const db = request.result;
|
||||
if (ev.oldVersion === 0) {
|
||||
const coValues = db.createObjectStore("coValues", {
|
||||
autoIncrement: true,
|
||||
keyPath: "rowID",
|
||||
});
|
||||
|
||||
coValues.createIndex("coValuesById", "id", {
|
||||
unique: true,
|
||||
});
|
||||
|
||||
const sessions = db.createObjectStore("sessions", {
|
||||
autoIncrement: true,
|
||||
keyPath: "rowID",
|
||||
});
|
||||
|
||||
sessions.createIndex("sessionsByCoValue", "coValue");
|
||||
sessions.createIndex("uniqueSessions", ["coValue", "sessionID"], {
|
||||
unique: true,
|
||||
});
|
||||
|
||||
db.createObjectStore("transactions", {
|
||||
keyPath: ["ses", "idx"],
|
||||
});
|
||||
}
|
||||
if (ev.oldVersion <= 1) {
|
||||
db.createObjectStore("signatureAfter", {
|
||||
keyPath: ["ses", "idx"],
|
||||
});
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
return new IDBNode(await dbPromise, fromLocalNode, toLocalNode);
|
||||
}
|
||||
}
|
||||
@@ -1,733 +1 @@
|
||||
import {
|
||||
CojsonInternalTypes,
|
||||
IncomingSyncStream,
|
||||
MAX_RECOMMENDED_TX_SIZE,
|
||||
OutgoingSyncQueue,
|
||||
Peer,
|
||||
RawAccountID,
|
||||
SessionID,
|
||||
SyncMessage,
|
||||
cojsonInternals,
|
||||
} from "cojson";
|
||||
import { SyncPromise } from "./syncPromises.js";
|
||||
|
||||
type CoValueRow = {
|
||||
id: CojsonInternalTypes.RawCoID;
|
||||
header: CojsonInternalTypes.CoValueHeader;
|
||||
};
|
||||
|
||||
type StoredCoValueRow = CoValueRow & { rowID: number };
|
||||
|
||||
type SessionRow = {
|
||||
coValue: number;
|
||||
sessionID: SessionID;
|
||||
lastIdx: number;
|
||||
lastSignature: CojsonInternalTypes.Signature;
|
||||
bytesSinceLastSignature?: number;
|
||||
};
|
||||
|
||||
type StoredSessionRow = SessionRow & { rowID: number };
|
||||
|
||||
type TransactionRow = {
|
||||
ses: number;
|
||||
idx: number;
|
||||
tx: CojsonInternalTypes.Transaction;
|
||||
};
|
||||
|
||||
type SignatureAfterRow = {
|
||||
ses: number;
|
||||
idx: number;
|
||||
signature: CojsonInternalTypes.Signature;
|
||||
};
|
||||
|
||||
export class IDBStorage {
|
||||
db: IDBDatabase;
|
||||
toLocalNode: OutgoingSyncQueue;
|
||||
|
||||
constructor(
|
||||
db: IDBDatabase,
|
||||
fromLocalNode: IncomingSyncStream,
|
||||
toLocalNode: OutgoingSyncQueue,
|
||||
) {
|
||||
this.db = db;
|
||||
this.toLocalNode = toLocalNode;
|
||||
|
||||
const processMessages = async () => {
|
||||
for await (const msg of fromLocalNode) {
|
||||
try {
|
||||
if (msg === "Disconnected" || msg === "PingTimeout") {
|
||||
throw new Error("Unexpected Disconnected message");
|
||||
}
|
||||
await this.handleSyncMessage(msg);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
new Error(
|
||||
`Error reading from localNode, handling msg\n\n${JSON.stringify(
|
||||
msg,
|
||||
(k, v) =>
|
||||
k === "changes" || k === "encryptedChanges"
|
||||
? v.slice(0, 20) + "..."
|
||||
: v,
|
||||
)}`,
|
||||
{ cause: e },
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
processMessages().catch((e) =>
|
||||
console.error("Error in processMessages in IndexedDB", e),
|
||||
);
|
||||
}
|
||||
|
||||
static async asPeer(
|
||||
{
|
||||
trace,
|
||||
localNodeName = "local",
|
||||
}: { trace?: boolean; localNodeName?: string } | undefined = {
|
||||
localNodeName: "local",
|
||||
},
|
||||
): Promise<Peer> {
|
||||
const [localNodeAsPeer, storageAsPeer] = cojsonInternals.connectedPeers(
|
||||
localNodeName,
|
||||
"indexedDB",
|
||||
{
|
||||
peer1role: "client",
|
||||
peer2role: "storage",
|
||||
trace,
|
||||
crashOnClose: true,
|
||||
},
|
||||
);
|
||||
|
||||
await IDBStorage.open(localNodeAsPeer.incoming, localNodeAsPeer.outgoing);
|
||||
|
||||
return { ...storageAsPeer, priority: 100 };
|
||||
}
|
||||
|
||||
static async open(
|
||||
fromLocalNode: IncomingSyncStream,
|
||||
toLocalNode: OutgoingSyncQueue,
|
||||
) {
|
||||
const dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open("jazz-storage", 4);
|
||||
request.onerror = () => {
|
||||
reject(request.error);
|
||||
};
|
||||
request.onsuccess = () => {
|
||||
resolve(request.result);
|
||||
};
|
||||
request.onupgradeneeded = async (ev) => {
|
||||
const db = request.result;
|
||||
if (ev.oldVersion === 0) {
|
||||
const coValues = db.createObjectStore("coValues", {
|
||||
autoIncrement: true,
|
||||
keyPath: "rowID",
|
||||
});
|
||||
|
||||
coValues.createIndex("coValuesById", "id", {
|
||||
unique: true,
|
||||
});
|
||||
|
||||
const sessions = db.createObjectStore("sessions", {
|
||||
autoIncrement: true,
|
||||
keyPath: "rowID",
|
||||
});
|
||||
|
||||
sessions.createIndex("sessionsByCoValue", "coValue");
|
||||
sessions.createIndex("uniqueSessions", ["coValue", "sessionID"], {
|
||||
unique: true,
|
||||
});
|
||||
|
||||
db.createObjectStore("transactions", {
|
||||
keyPath: ["ses", "idx"],
|
||||
});
|
||||
}
|
||||
if (ev.oldVersion <= 1) {
|
||||
db.createObjectStore("signatureAfter", {
|
||||
keyPath: ["ses", "idx"],
|
||||
});
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
return new IDBStorage(await dbPromise, fromLocalNode, toLocalNode);
|
||||
}
|
||||
|
||||
async handleSyncMessage(msg: SyncMessage) {
|
||||
switch (msg.action) {
|
||||
case "load":
|
||||
await this.handleLoad(msg);
|
||||
break;
|
||||
case "content":
|
||||
await this.handleContent(msg);
|
||||
break;
|
||||
case "known":
|
||||
await this.handleKnown(msg);
|
||||
break;
|
||||
case "done":
|
||||
await this.handleDone(msg);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
currentTx:
|
||||
| {
|
||||
id: number;
|
||||
tx: IDBTransaction;
|
||||
stores: {
|
||||
coValues: IDBObjectStore;
|
||||
sessions: IDBObjectStore;
|
||||
transactions: IDBObjectStore;
|
||||
signatureAfter: IDBObjectStore;
|
||||
};
|
||||
startedAt: number;
|
||||
pendingRequests: ((txEntry: {
|
||||
stores: {
|
||||
coValues: IDBObjectStore;
|
||||
sessions: IDBObjectStore;
|
||||
transactions: IDBObjectStore;
|
||||
signatureAfter: IDBObjectStore;
|
||||
};
|
||||
}) => void)[];
|
||||
}
|
||||
| undefined;
|
||||
currentTxID = 0;
|
||||
|
||||
makeRequest<T>(
|
||||
handler: (stores: {
|
||||
coValues: IDBObjectStore;
|
||||
sessions: IDBObjectStore;
|
||||
transactions: IDBObjectStore;
|
||||
signatureAfter: IDBObjectStore;
|
||||
}) => IDBRequest,
|
||||
): SyncPromise<T> {
|
||||
return new SyncPromise((resolve, reject) => {
|
||||
let txEntry = this.currentTx;
|
||||
|
||||
const requestEntry = ({
|
||||
stores,
|
||||
}: {
|
||||
stores: {
|
||||
coValues: IDBObjectStore;
|
||||
sessions: IDBObjectStore;
|
||||
transactions: IDBObjectStore;
|
||||
signatureAfter: IDBObjectStore;
|
||||
};
|
||||
}) => {
|
||||
const request = handler(stores);
|
||||
request.onerror = () => {
|
||||
console.error("Error in request", request.error);
|
||||
this.currentTx = undefined;
|
||||
reject(request.error);
|
||||
// TODO: recover pending requests in new tx
|
||||
};
|
||||
request.onsuccess = () => {
|
||||
const value = request.result as T;
|
||||
resolve(value);
|
||||
|
||||
const next = txEntry!.pendingRequests.shift();
|
||||
|
||||
if (next) {
|
||||
next({ stores });
|
||||
} else {
|
||||
if (this.currentTx === txEntry) {
|
||||
this.currentTx = undefined;
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
if (!txEntry || performance.now() - txEntry.startedAt > 20) {
|
||||
const tx = this.db.transaction(
|
||||
["coValues", "sessions", "transactions", "signatureAfter"],
|
||||
"readwrite",
|
||||
);
|
||||
txEntry = {
|
||||
id: this.currentTxID++,
|
||||
tx,
|
||||
stores: {
|
||||
coValues: tx.objectStore("coValues"),
|
||||
sessions: tx.objectStore("sessions"),
|
||||
transactions: tx.objectStore("transactions"),
|
||||
signatureAfter: tx.objectStore("signatureAfter"),
|
||||
},
|
||||
startedAt: performance.now(),
|
||||
pendingRequests: [],
|
||||
};
|
||||
|
||||
// console.time("IndexedDB TX" + txEntry.id);
|
||||
|
||||
// txEntry.tx.oncomplete = () => {
|
||||
// console.timeEnd("IndexedDB TX" + txEntry!.id);
|
||||
// };
|
||||
|
||||
this.currentTx = txEntry;
|
||||
|
||||
requestEntry(txEntry);
|
||||
} else {
|
||||
txEntry.pendingRequests.push(requestEntry);
|
||||
// console.log(
|
||||
// "Queued request in TX " + txEntry.id,
|
||||
// txEntry.pendingRequests.length
|
||||
// );
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
sendNewContentAfter(
|
||||
theirKnown: CojsonInternalTypes.CoValueKnownState,
|
||||
asDependencyOf?: CojsonInternalTypes.RawCoID,
|
||||
): SyncPromise<void> {
|
||||
return this.makeRequest<StoredCoValueRow | undefined>(({ coValues }) =>
|
||||
coValues.index("coValuesById").get(theirKnown.id),
|
||||
)
|
||||
.then((coValueRow) => {
|
||||
return (
|
||||
coValueRow
|
||||
? this.makeRequest<StoredSessionRow[]>(({ sessions }) =>
|
||||
sessions.index("sessionsByCoValue").getAll(coValueRow.rowID),
|
||||
)
|
||||
: SyncPromise.resolve([])
|
||||
).then((allOurSessions) => {
|
||||
const ourKnown: CojsonInternalTypes.CoValueKnownState = {
|
||||
id: theirKnown.id,
|
||||
header: !!coValueRow,
|
||||
sessions: {},
|
||||
};
|
||||
|
||||
const newContentPieces: CojsonInternalTypes.NewContentMessage[] = [
|
||||
{
|
||||
action: "content",
|
||||
id: theirKnown.id,
|
||||
header: theirKnown.header ? undefined : coValueRow?.header,
|
||||
new: {},
|
||||
priority: cojsonInternals.getPriorityFromHeader(
|
||||
coValueRow?.header,
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return SyncPromise.all(
|
||||
allOurSessions.map((sessionRow) => {
|
||||
ourKnown.sessions[sessionRow.sessionID] = sessionRow.lastIdx;
|
||||
|
||||
if (
|
||||
sessionRow.lastIdx >
|
||||
(theirKnown.sessions[sessionRow.sessionID] || 0)
|
||||
) {
|
||||
const firstNewTxIdx =
|
||||
theirKnown.sessions[sessionRow.sessionID] || 0;
|
||||
|
||||
return this.makeRequest<SignatureAfterRow[]>(
|
||||
({ signatureAfter }) =>
|
||||
signatureAfter.getAll(
|
||||
IDBKeyRange.bound(
|
||||
[sessionRow.rowID, firstNewTxIdx],
|
||||
[sessionRow.rowID, Infinity],
|
||||
),
|
||||
),
|
||||
).then((signaturesAndIdxs) => {
|
||||
// console.log(
|
||||
// theirKnown.id,
|
||||
// "signaturesAndIdxs",
|
||||
// JSON.stringify(signaturesAndIdxs)
|
||||
// );
|
||||
|
||||
return this.makeRequest<TransactionRow[]>(
|
||||
({ transactions }) =>
|
||||
transactions.getAll(
|
||||
IDBKeyRange.bound(
|
||||
[sessionRow.rowID, firstNewTxIdx],
|
||||
[sessionRow.rowID, Infinity],
|
||||
),
|
||||
),
|
||||
).then((newTxsInSession) => {
|
||||
collectNewTxs(
|
||||
newTxsInSession,
|
||||
newContentPieces,
|
||||
sessionRow,
|
||||
signaturesAndIdxs,
|
||||
theirKnown,
|
||||
firstNewTxIdx,
|
||||
);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
return SyncPromise.resolve();
|
||||
}
|
||||
}),
|
||||
).then(() => {
|
||||
const dependedOnCoValues = getDependedOnCoValues(
|
||||
coValueRow,
|
||||
newContentPieces,
|
||||
theirKnown,
|
||||
);
|
||||
|
||||
return SyncPromise.all(
|
||||
dependedOnCoValues.map((dependedOnCoValue) =>
|
||||
this.sendNewContentAfter(
|
||||
{
|
||||
id: dependedOnCoValue,
|
||||
header: false,
|
||||
sessions: {},
|
||||
},
|
||||
asDependencyOf || theirKnown.id,
|
||||
),
|
||||
),
|
||||
).then(() => {
|
||||
// we're done with IndexedDB stuff here so can use native Promises again
|
||||
setTimeout(() => {
|
||||
this.toLocalNode
|
||||
.push({
|
||||
action: "known",
|
||||
...ourKnown,
|
||||
asDependencyOf,
|
||||
})
|
||||
.catch((e) => console.error("Error sending known state", e));
|
||||
|
||||
const nonEmptyNewContentPieces = newContentPieces.filter(
|
||||
(piece) => piece.header || Object.keys(piece.new).length > 0,
|
||||
);
|
||||
|
||||
// console.log(theirKnown.id, nonEmptyNewContentPieces);
|
||||
|
||||
for (const piece of nonEmptyNewContentPieces) {
|
||||
this.toLocalNode
|
||||
.push(piece)
|
||||
.catch((e) =>
|
||||
console.error("Error sending new content piece", e),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return Promise.resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
})
|
||||
.then(() => {});
|
||||
}
|
||||
|
||||
handleLoad(msg: CojsonInternalTypes.LoadMessage) {
|
||||
return this.sendNewContentAfter(msg);
|
||||
}
|
||||
|
||||
handleContent(msg: CojsonInternalTypes.NewContentMessage): SyncPromise<void> {
|
||||
return this.makeRequest<StoredCoValueRow | undefined>(({ coValues }) =>
|
||||
coValues.index("coValuesById").get(msg.id),
|
||||
)
|
||||
.then((coValueRow) => {
|
||||
if (coValueRow?.rowID === undefined) {
|
||||
const header = msg.header;
|
||||
if (!header) {
|
||||
console.error("Expected to be sent header first");
|
||||
this.toLocalNode
|
||||
.push({
|
||||
action: "known",
|
||||
id: msg.id,
|
||||
header: false,
|
||||
sessions: {},
|
||||
isCorrection: true,
|
||||
})
|
||||
.catch((e) => console.error("Error sending known state", e));
|
||||
return SyncPromise.resolve();
|
||||
}
|
||||
|
||||
return this.makeRequest<IDBValidKey>(({ coValues }) =>
|
||||
coValues.put({
|
||||
id: msg.id,
|
||||
header: header,
|
||||
} satisfies CoValueRow),
|
||||
) as SyncPromise<number>;
|
||||
} else {
|
||||
return SyncPromise.resolve(coValueRow.rowID);
|
||||
}
|
||||
})
|
||||
.then((storedCoValueRowID: number) => {
|
||||
void this.makeRequest<StoredSessionRow[]>(({ sessions }) =>
|
||||
sessions.index("sessionsByCoValue").getAll(storedCoValueRowID),
|
||||
).then((allOurSessionsEntries) => {
|
||||
const allOurSessions: {
|
||||
[sessionID: SessionID]: StoredSessionRow;
|
||||
} = Object.fromEntries(
|
||||
allOurSessionsEntries.map((row) => [row.sessionID, row]),
|
||||
);
|
||||
|
||||
const ourKnown: CojsonInternalTypes.CoValueKnownState = {
|
||||
id: msg.id,
|
||||
header: true,
|
||||
sessions: {},
|
||||
};
|
||||
let invalidAssumptions = false;
|
||||
|
||||
return Promise.all(
|
||||
(Object.keys(msg.new) as SessionID[]).map((sessionID) => {
|
||||
const sessionRow = allOurSessions[sessionID];
|
||||
if (sessionRow) {
|
||||
ourKnown.sessions[sessionRow.sessionID] = sessionRow.lastIdx;
|
||||
}
|
||||
|
||||
if (
|
||||
(sessionRow?.lastIdx || 0) < (msg.new[sessionID]?.after || 0)
|
||||
) {
|
||||
invalidAssumptions = true;
|
||||
} else {
|
||||
return this.putNewTxs(
|
||||
msg,
|
||||
sessionID,
|
||||
sessionRow,
|
||||
storedCoValueRowID,
|
||||
);
|
||||
}
|
||||
}),
|
||||
).then(() => {
|
||||
if (invalidAssumptions) {
|
||||
this.toLocalNode
|
||||
.push({
|
||||
action: "known",
|
||||
...ourKnown,
|
||||
isCorrection: invalidAssumptions,
|
||||
})
|
||||
.catch((e) => console.error("Error sending known state", e));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private putNewTxs(
|
||||
msg: CojsonInternalTypes.NewContentMessage,
|
||||
sessionID: SessionID,
|
||||
sessionRow: StoredSessionRow | undefined,
|
||||
storedCoValueRowID: number,
|
||||
) {
|
||||
const newTransactions = msg.new[sessionID]?.newTransactions || [];
|
||||
|
||||
const actuallyNewOffset =
|
||||
(sessionRow?.lastIdx || 0) - (msg.new[sessionID]?.after || 0);
|
||||
|
||||
const actuallyNewTransactions = newTransactions.slice(actuallyNewOffset);
|
||||
|
||||
let newBytesSinceLastSignature =
|
||||
(sessionRow?.bytesSinceLastSignature || 0) +
|
||||
actuallyNewTransactions.reduce(
|
||||
(sum, tx) =>
|
||||
sum +
|
||||
(tx.privacy === "private"
|
||||
? tx.encryptedChanges.length
|
||||
: tx.changes.length),
|
||||
0,
|
||||
);
|
||||
|
||||
const newLastIdx =
|
||||
(sessionRow?.lastIdx || 0) + actuallyNewTransactions.length;
|
||||
|
||||
let shouldWriteSignature = false;
|
||||
|
||||
if (newBytesSinceLastSignature > MAX_RECOMMENDED_TX_SIZE) {
|
||||
shouldWriteSignature = true;
|
||||
newBytesSinceLastSignature = 0;
|
||||
}
|
||||
|
||||
const nextIdx = sessionRow?.lastIdx || 0;
|
||||
|
||||
const sessionUpdate = {
|
||||
coValue: storedCoValueRowID,
|
||||
sessionID: sessionID,
|
||||
lastIdx: newLastIdx,
|
||||
lastSignature: msg.new[sessionID]!.lastSignature,
|
||||
bytesSinceLastSignature: newBytesSinceLastSignature,
|
||||
};
|
||||
|
||||
return this.makeRequest<number>(({ sessions }) =>
|
||||
sessions.put(
|
||||
sessionRow?.rowID
|
||||
? {
|
||||
rowID: sessionRow.rowID,
|
||||
...sessionUpdate,
|
||||
}
|
||||
: sessionUpdate,
|
||||
),
|
||||
).then((sessionRowID) => {
|
||||
let maybePutRequest;
|
||||
if (shouldWriteSignature) {
|
||||
maybePutRequest = this.makeRequest(({ signatureAfter }) =>
|
||||
signatureAfter.put({
|
||||
ses: sessionRowID,
|
||||
// TODO: newLastIdx is a misnomer, it's actually more like nextIdx or length
|
||||
idx: newLastIdx - 1,
|
||||
signature: msg.new[sessionID]!.lastSignature,
|
||||
} satisfies SignatureAfterRow),
|
||||
);
|
||||
} else {
|
||||
maybePutRequest = SyncPromise.resolve();
|
||||
}
|
||||
|
||||
return maybePutRequest.then(() =>
|
||||
Promise.all(
|
||||
actuallyNewTransactions.map((newTransaction, i) => {
|
||||
return this.makeRequest(({ transactions }) =>
|
||||
transactions.add({
|
||||
ses: sessionRowID,
|
||||
idx: nextIdx + i,
|
||||
tx: newTransaction,
|
||||
} satisfies TransactionRow),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
handleKnown(msg: CojsonInternalTypes.KnownStateMessage) {
|
||||
return this.sendNewContentAfter(msg);
|
||||
}
|
||||
|
||||
handleDone(_msg: CojsonInternalTypes.DoneMessage) {}
|
||||
|
||||
// inTransaction(mode: "readwrite" | "readonly"): {
|
||||
// coValues: IDBObjectStore;
|
||||
// sessions: IDBObjectStore;
|
||||
// transactions: IDBObjectStore;
|
||||
// signatureAfter: IDBObjectStore;
|
||||
// } {
|
||||
// const tx = this.db.transaction(
|
||||
// ["coValues", "sessions", "transactions", "signatureAfter"],
|
||||
// mode
|
||||
// );
|
||||
|
||||
// const txID = lastTx;
|
||||
// lastTx++;
|
||||
// console.time("IndexedDB TX" + txID);
|
||||
|
||||
// tx.onerror = (event) => {
|
||||
// const target = event.target as unknown as {
|
||||
// error: DOMException;
|
||||
// source?: { name: string };
|
||||
// } | null;
|
||||
// throw new Error(
|
||||
// `Error in transaction (${target?.source?.name}): ${target?.error}`,
|
||||
// { cause: target?.error }
|
||||
// );
|
||||
// };
|
||||
// tx.oncomplete = () => {
|
||||
// console.timeEnd("IndexedDB TX" + txID);
|
||||
// }
|
||||
// const coValues = tx.objectStore("coValues");
|
||||
// const sessions = tx.objectStore("sessions");
|
||||
// const transactions = tx.objectStore("transactions");
|
||||
// const signatureAfter = tx.objectStore("signatureAfter");
|
||||
|
||||
// return { coValues, sessions, transactions, signatureAfter };
|
||||
// }
|
||||
}
|
||||
|
||||
function collectNewTxs(
|
||||
newTxsInSession: TransactionRow[],
|
||||
newContentPieces: CojsonInternalTypes.NewContentMessage[],
|
||||
sessionRow: StoredSessionRow,
|
||||
signaturesAndIdxs: SignatureAfterRow[],
|
||||
theirKnown: CojsonInternalTypes.CoValueKnownState,
|
||||
firstNewTxIdx: number,
|
||||
) {
|
||||
let idx = firstNewTxIdx;
|
||||
|
||||
// console.log(
|
||||
// theirKnown.id,
|
||||
// "newTxInSession",
|
||||
// newTxInSession.length
|
||||
// );
|
||||
for (const tx of newTxsInSession) {
|
||||
let sessionEntry =
|
||||
newContentPieces[newContentPieces.length - 1]!.new[sessionRow.sessionID];
|
||||
if (!sessionEntry) {
|
||||
sessionEntry = {
|
||||
after: idx,
|
||||
lastSignature: "WILL_BE_REPLACED" as CojsonInternalTypes.Signature,
|
||||
newTransactions: [],
|
||||
};
|
||||
newContentPieces[newContentPieces.length - 1]!.new[sessionRow.sessionID] =
|
||||
sessionEntry;
|
||||
}
|
||||
|
||||
sessionEntry.newTransactions.push(tx.tx);
|
||||
|
||||
if (signaturesAndIdxs[0] && idx === signaturesAndIdxs[0].idx) {
|
||||
sessionEntry.lastSignature = signaturesAndIdxs[0].signature;
|
||||
signaturesAndIdxs.shift();
|
||||
newContentPieces.push({
|
||||
action: "content",
|
||||
id: theirKnown.id,
|
||||
new: {},
|
||||
priority: cojsonInternals.getPriorityFromHeader(undefined),
|
||||
});
|
||||
} else if (idx === firstNewTxIdx + newTxsInSession.length - 1) {
|
||||
sessionEntry.lastSignature = sessionRow.lastSignature;
|
||||
}
|
||||
idx += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function getDependedOnCoValues(
|
||||
coValueRow: StoredCoValueRow | undefined,
|
||||
newContentPieces: CojsonInternalTypes.NewContentMessage[],
|
||||
theirKnown: CojsonInternalTypes.CoValueKnownState,
|
||||
) {
|
||||
return coValueRow?.header.ruleset.type === "group"
|
||||
? newContentPieces
|
||||
.flatMap((piece) => Object.values(piece.new))
|
||||
.flatMap((sessionEntry) =>
|
||||
sessionEntry.newTransactions.flatMap((tx) => {
|
||||
if (tx.privacy !== "trusting") return [];
|
||||
// TODO: avoid parse here?
|
||||
return cojsonInternals
|
||||
.parseJSON(tx.changes)
|
||||
.map(
|
||||
(change) =>
|
||||
change &&
|
||||
typeof change === "object" &&
|
||||
"op" in change &&
|
||||
change.op === "set" &&
|
||||
"key" in change &&
|
||||
change.key,
|
||||
)
|
||||
.filter(
|
||||
(key): key is CojsonInternalTypes.RawCoID =>
|
||||
typeof key === "string" && key.startsWith("co_"),
|
||||
);
|
||||
}),
|
||||
)
|
||||
: coValueRow?.header.ruleset.type === "ownedByGroup"
|
||||
? [
|
||||
coValueRow?.header.ruleset.group,
|
||||
...new Set(
|
||||
newContentPieces.flatMap((piece) =>
|
||||
Object.keys(piece.new)
|
||||
.map((sessionID) =>
|
||||
cojsonInternals.accountOrAgentIDfromSessionID(
|
||||
sessionID as SessionID,
|
||||
),
|
||||
)
|
||||
.filter(
|
||||
(accountID): accountID is RawAccountID =>
|
||||
cojsonInternals.isAccountID(accountID) &&
|
||||
accountID !== theirKnown.id,
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
: [];
|
||||
}
|
||||
// let lastTx = 0;
|
||||
|
||||
// function promised<T>(request: IDBRequest<T>): Promise<T> {
|
||||
// return new Promise<T>((resolve, reject) => {
|
||||
// request.onsuccess = () => {
|
||||
// resolve(request.result);
|
||||
// };
|
||||
// request.onerror = () => {
|
||||
// reject(request.error);
|
||||
// };
|
||||
// });
|
||||
// }
|
||||
export { IDBNode, IDBNode as IDBStorage } from "./idbNode";
|
||||
|
||||
336
packages/cojson-storage-indexeddb/src/syncManager.ts
Normal file
336
packages/cojson-storage-indexeddb/src/syncManager.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
import {
|
||||
CojsonInternalTypes,
|
||||
MAX_RECOMMENDED_TX_SIZE,
|
||||
OutgoingSyncQueue,
|
||||
SessionID,
|
||||
SyncMessage,
|
||||
cojsonInternals,
|
||||
emptyKnownState,
|
||||
} from "cojson";
|
||||
import { IDBClient, StoredSessionRow } from "./idbClient";
|
||||
import { SyncPromise } from "./syncPromises.js";
|
||||
import { collectNewTxs, getDependedOnCoValues } from "./syncUtils";
|
||||
import NewContentMessage = CojsonInternalTypes.NewContentMessage;
|
||||
import KnownStateMessage = CojsonInternalTypes.KnownStateMessage;
|
||||
import RawCoID = CojsonInternalTypes.RawCoID;
|
||||
|
||||
type OutputMessageMap = Record<
|
||||
RawCoID,
|
||||
{ knownMessage: KnownStateMessage; contentMessages?: NewContentMessage[] }
|
||||
>;
|
||||
|
||||
export class SyncManager {
|
||||
private readonly toLocalNode: OutgoingSyncQueue;
|
||||
private readonly idbClient: IDBClient;
|
||||
|
||||
constructor(idbClient: IDBClient, toLocalNode: OutgoingSyncQueue) {
|
||||
this.toLocalNode = toLocalNode;
|
||||
this.idbClient = idbClient;
|
||||
}
|
||||
|
||||
async handleSyncMessage(msg: SyncMessage) {
|
||||
switch (msg.action) {
|
||||
case "load":
|
||||
await this.handleLoad(msg);
|
||||
break;
|
||||
case "content":
|
||||
await this.handleContent(msg);
|
||||
break;
|
||||
case "known":
|
||||
await this.handleKnown(msg);
|
||||
break;
|
||||
case "done":
|
||||
await this.handleDone(msg);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async handleSessionUpdate({
|
||||
sessionRow,
|
||||
peerKnownState,
|
||||
newContentMessages,
|
||||
}: {
|
||||
sessionRow: StoredSessionRow;
|
||||
peerKnownState: CojsonInternalTypes.CoValueKnownState;
|
||||
newContentMessages: CojsonInternalTypes.NewContentMessage[];
|
||||
}) {
|
||||
if (
|
||||
sessionRow.lastIdx <= (peerKnownState.sessions[sessionRow.sessionID] || 0)
|
||||
)
|
||||
return;
|
||||
|
||||
const firstNewTxIdx = peerKnownState.sessions[sessionRow.sessionID] || 0;
|
||||
const signaturesAndIdxs = await this.idbClient.getSignatures(
|
||||
sessionRow,
|
||||
firstNewTxIdx,
|
||||
);
|
||||
const newTxsInSession = await this.idbClient.getNewTransactionInSession(
|
||||
sessionRow,
|
||||
firstNewTxIdx,
|
||||
);
|
||||
|
||||
collectNewTxs(
|
||||
newTxsInSession,
|
||||
newContentMessages,
|
||||
sessionRow,
|
||||
signaturesAndIdxs,
|
||||
peerKnownState,
|
||||
firstNewTxIdx,
|
||||
);
|
||||
}
|
||||
|
||||
async sendNewContent(
|
||||
coValueKnownState: CojsonInternalTypes.CoValueKnownState,
|
||||
): Promise<void> {
|
||||
const outputMessages: OutputMessageMap =
|
||||
await this.collectCoValueData(coValueKnownState);
|
||||
|
||||
// reverse it to send the top level id the last in the order
|
||||
const collectedMessages = Object.values(outputMessages).reverse();
|
||||
collectedMessages.forEach(({ knownMessage, contentMessages }) => {
|
||||
this.sendStateMessage(knownMessage);
|
||||
|
||||
contentMessages?.length &&
|
||||
contentMessages.forEach((msg) => this.sendStateMessage(msg));
|
||||
});
|
||||
}
|
||||
|
||||
private async collectCoValueData(
|
||||
peerKnownState: CojsonInternalTypes.CoValueKnownState,
|
||||
messageMap: OutputMessageMap = {},
|
||||
asDependencyOf?: CojsonInternalTypes.RawCoID,
|
||||
) {
|
||||
if (messageMap[peerKnownState.id]) {
|
||||
return messageMap;
|
||||
}
|
||||
|
||||
const coValueRow = await this.idbClient.getCoValue(peerKnownState.id);
|
||||
|
||||
if (!coValueRow) {
|
||||
const emptyKnownMessage: KnownStateMessage = {
|
||||
action: "known",
|
||||
...emptyKnownState(peerKnownState.id),
|
||||
};
|
||||
asDependencyOf && (emptyKnownMessage.asDependencyOf = asDependencyOf);
|
||||
messageMap[peerKnownState.id] = { knownMessage: emptyKnownMessage };
|
||||
return messageMap;
|
||||
}
|
||||
|
||||
const allCoValueSessions = await this.idbClient.getCoValueSessions(
|
||||
coValueRow.rowID,
|
||||
);
|
||||
|
||||
const newCoValueKnownState: CojsonInternalTypes.CoValueKnownState = {
|
||||
id: coValueRow.id,
|
||||
header: true,
|
||||
sessions: {},
|
||||
};
|
||||
|
||||
const newContentMessages: CojsonInternalTypes.NewContentMessage[] = [
|
||||
{
|
||||
action: "content",
|
||||
id: coValueRow.id,
|
||||
header: coValueRow.header,
|
||||
new: {},
|
||||
priority: cojsonInternals.getPriorityFromHeader(coValueRow.header),
|
||||
},
|
||||
];
|
||||
|
||||
await Promise.all(
|
||||
allCoValueSessions.map((sessionRow) => {
|
||||
newCoValueKnownState.sessions[sessionRow.sessionID] =
|
||||
sessionRow.lastIdx;
|
||||
// Collect new sessions data into newContentMessages
|
||||
return this.handleSessionUpdate({
|
||||
sessionRow,
|
||||
peerKnownState,
|
||||
newContentMessages,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const dependedOnCoValuesList = getDependedOnCoValues({
|
||||
coValueRow,
|
||||
newContentMessages,
|
||||
});
|
||||
|
||||
const knownMessage: KnownStateMessage = {
|
||||
action: "known",
|
||||
...newCoValueKnownState,
|
||||
};
|
||||
asDependencyOf && (knownMessage.asDependencyOf = asDependencyOf);
|
||||
messageMap[newCoValueKnownState.id] = {
|
||||
knownMessage: knownMessage,
|
||||
contentMessages: newContentMessages,
|
||||
};
|
||||
|
||||
await Promise.all(
|
||||
dependedOnCoValuesList.map((dependedOnCoValue) =>
|
||||
this.collectCoValueData(
|
||||
{
|
||||
id: dependedOnCoValue,
|
||||
header: false,
|
||||
sessions: {},
|
||||
},
|
||||
messageMap,
|
||||
asDependencyOf || coValueRow.id,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return messageMap;
|
||||
}
|
||||
|
||||
handleLoad(msg: CojsonInternalTypes.LoadMessage) {
|
||||
return this.sendNewContent(msg);
|
||||
}
|
||||
|
||||
async handleContent(
|
||||
msg: CojsonInternalTypes.NewContentMessage,
|
||||
): Promise<void | unknown> {
|
||||
const coValueRow = await this.idbClient.getCoValue(msg.id);
|
||||
|
||||
// We have no info about coValue header
|
||||
const invalidAssumptionOnHeaderPresence = !msg.header && !coValueRow;
|
||||
|
||||
if (invalidAssumptionOnHeaderPresence) {
|
||||
return this.sendStateMessage({
|
||||
action: "known",
|
||||
id: msg.id,
|
||||
header: false,
|
||||
sessions: {},
|
||||
isCorrection: true,
|
||||
});
|
||||
}
|
||||
|
||||
const storedCoValueRowID: number = coValueRow
|
||||
? coValueRow.rowID
|
||||
: await this.idbClient.addCoValue(msg);
|
||||
|
||||
const allOurSessionsEntries =
|
||||
await this.idbClient.getCoValueSessions(storedCoValueRowID);
|
||||
|
||||
const allOurSessions: {
|
||||
[sessionID: SessionID]: StoredSessionRow;
|
||||
} = Object.fromEntries(
|
||||
allOurSessionsEntries.map((row) => [row.sessionID, row]),
|
||||
);
|
||||
|
||||
const ourKnown: CojsonInternalTypes.CoValueKnownState = {
|
||||
id: msg.id,
|
||||
header: true,
|
||||
sessions: {},
|
||||
};
|
||||
let invalidAssumptions = false;
|
||||
|
||||
await Promise.all(
|
||||
(Object.keys(msg.new) as SessionID[]).map((sessionID) => {
|
||||
const sessionRow = allOurSessions[sessionID];
|
||||
if (sessionRow) {
|
||||
ourKnown.sessions[sessionRow.sessionID] = sessionRow.lastIdx;
|
||||
}
|
||||
|
||||
if ((sessionRow?.lastIdx || 0) < (msg.new[sessionID]?.after || 0)) {
|
||||
invalidAssumptions = true;
|
||||
} else {
|
||||
return this.putNewTxs(msg, sessionID, sessionRow, storedCoValueRowID);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
if (invalidAssumptions) {
|
||||
this.sendStateMessage({
|
||||
action: "known",
|
||||
...ourKnown,
|
||||
isCorrection: invalidAssumptions,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async putNewTxs(
|
||||
msg: CojsonInternalTypes.NewContentMessage,
|
||||
sessionID: SessionID,
|
||||
sessionRow: StoredSessionRow | undefined,
|
||||
storedCoValueRowID: number,
|
||||
) {
|
||||
const newTransactions = msg.new[sessionID]?.newTransactions || [];
|
||||
|
||||
const actuallyNewOffset =
|
||||
(sessionRow?.lastIdx || 0) - (msg.new[sessionID]?.after || 0);
|
||||
|
||||
const actuallyNewTransactions = newTransactions.slice(actuallyNewOffset);
|
||||
|
||||
let newBytesSinceLastSignature =
|
||||
(sessionRow?.bytesSinceLastSignature || 0) +
|
||||
actuallyNewTransactions.reduce(
|
||||
(sum, tx) =>
|
||||
sum +
|
||||
(tx.privacy === "private"
|
||||
? tx.encryptedChanges.length
|
||||
: tx.changes.length),
|
||||
0,
|
||||
);
|
||||
|
||||
const newLastIdx =
|
||||
(sessionRow?.lastIdx || 0) + actuallyNewTransactions.length;
|
||||
|
||||
let shouldWriteSignature = false;
|
||||
|
||||
if (newBytesSinceLastSignature > MAX_RECOMMENDED_TX_SIZE) {
|
||||
shouldWriteSignature = true;
|
||||
newBytesSinceLastSignature = 0;
|
||||
}
|
||||
|
||||
const nextIdx = sessionRow?.lastIdx || 0;
|
||||
|
||||
const sessionUpdate = {
|
||||
coValue: storedCoValueRowID,
|
||||
sessionID: sessionID,
|
||||
lastIdx: newLastIdx,
|
||||
lastSignature: msg.new[sessionID]!.lastSignature,
|
||||
bytesSinceLastSignature: newBytesSinceLastSignature,
|
||||
};
|
||||
|
||||
const sessionRowID: number = await this.idbClient.addSessionUpdate(
|
||||
sessionRow,
|
||||
sessionUpdate,
|
||||
);
|
||||
|
||||
let maybePutRequest;
|
||||
if (shouldWriteSignature) {
|
||||
maybePutRequest = this.idbClient.addSignatureAfter({
|
||||
sessionRowID,
|
||||
idx: newLastIdx - 1,
|
||||
signature: msg.new[sessionID]!.lastSignature,
|
||||
});
|
||||
} else {
|
||||
maybePutRequest = SyncPromise.resolve();
|
||||
}
|
||||
|
||||
return maybePutRequest.then(() =>
|
||||
Promise.all(
|
||||
actuallyNewTransactions.map((newTransaction, i) => {
|
||||
return this.idbClient.addTransaction(
|
||||
sessionRowID,
|
||||
nextIdx + i,
|
||||
newTransaction,
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
handleKnown(_msg: KnownStateMessage) {
|
||||
// We don't intend to use IndexedDB itself as a synchronisation mechanism, so we can ignore the known messages
|
||||
}
|
||||
|
||||
async sendStateMessage(msg: any): Promise<unknown> {
|
||||
return this.toLocalNode
|
||||
.push(msg)
|
||||
.catch((e) =>
|
||||
console.error(`Error sending ${msg.action} state, id ${msg.id}`, e),
|
||||
);
|
||||
}
|
||||
|
||||
handleDone(_msg: CojsonInternalTypes.DoneMessage) {}
|
||||
}
|
||||
146
packages/cojson-storage-indexeddb/src/syncUtils.ts
Normal file
146
packages/cojson-storage-indexeddb/src/syncUtils.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import {
|
||||
CojsonInternalTypes,
|
||||
JsonValue,
|
||||
SessionID,
|
||||
Stringified,
|
||||
cojsonInternals,
|
||||
} from "cojson";
|
||||
import {
|
||||
SignatureAfterRow,
|
||||
StoredCoValueRow,
|
||||
StoredSessionRow,
|
||||
TransactionRow,
|
||||
} from "./idbClient";
|
||||
|
||||
export function collectNewTxs(
|
||||
newTxsInSession: TransactionRow[],
|
||||
newContentMessages: CojsonInternalTypes.NewContentMessage[],
|
||||
sessionRow: StoredSessionRow,
|
||||
signaturesAndIdxs: SignatureAfterRow[],
|
||||
theirKnown: CojsonInternalTypes.CoValueKnownState,
|
||||
firstNewTxIdx: number,
|
||||
) {
|
||||
let idx = firstNewTxIdx;
|
||||
|
||||
for (const tx of newTxsInSession) {
|
||||
let sessionEntry =
|
||||
newContentMessages[newContentMessages.length - 1]!.new[
|
||||
sessionRow.sessionID
|
||||
];
|
||||
if (!sessionEntry) {
|
||||
sessionEntry = {
|
||||
after: idx,
|
||||
lastSignature: "WILL_BE_REPLACED" as CojsonInternalTypes.Signature,
|
||||
newTransactions: [],
|
||||
};
|
||||
newContentMessages[newContentMessages.length - 1]!.new[
|
||||
sessionRow.sessionID
|
||||
] = sessionEntry;
|
||||
}
|
||||
|
||||
sessionEntry.newTransactions.push(tx.tx);
|
||||
|
||||
if (signaturesAndIdxs[0] && idx === signaturesAndIdxs[0].idx) {
|
||||
sessionEntry.lastSignature = signaturesAndIdxs[0].signature;
|
||||
signaturesAndIdxs.shift();
|
||||
newContentMessages.push({
|
||||
action: "content",
|
||||
id: theirKnown.id,
|
||||
new: {},
|
||||
priority: cojsonInternals.getPriorityFromHeader(undefined),
|
||||
});
|
||||
} else if (idx === firstNewTxIdx + newTxsInSession.length - 1) {
|
||||
sessionEntry.lastSignature = sessionRow.lastSignature;
|
||||
}
|
||||
idx += 1;
|
||||
}
|
||||
}
|
||||
|
||||
export function getDependedOnCoValues({
|
||||
coValueRow,
|
||||
newContentMessages,
|
||||
}: {
|
||||
coValueRow: StoredCoValueRow;
|
||||
newContentMessages: CojsonInternalTypes.NewContentMessage[];
|
||||
}) {
|
||||
return coValueRow.header.ruleset.type === "group"
|
||||
? getGroupDependedOnCoValues(newContentMessages)
|
||||
: coValueRow.header.ruleset.type === "ownedByGroup"
|
||||
? getOwnedByGroupDependedOnCoValues(coValueRow, newContentMessages)
|
||||
: [];
|
||||
}
|
||||
|
||||
function getGroupDependedOnCoValues(
|
||||
newContentMessages: CojsonInternalTypes.NewContentMessage[],
|
||||
) {
|
||||
const keys: CojsonInternalTypes.RawCoID[] = [];
|
||||
|
||||
/**
|
||||
* Collect all the signing keys inside the transactions to list all the
|
||||
* dependencies required to correctly access the CoValue.
|
||||
*/
|
||||
for (const piece of newContentMessages) {
|
||||
for (const sessionEntry of Object.values(piece.new)) {
|
||||
for (const tx of sessionEntry.newTransactions) {
|
||||
if (tx.privacy !== "trusting") continue;
|
||||
|
||||
const changes = safeParseChanges(tx.changes);
|
||||
for (const change of changes) {
|
||||
if (
|
||||
change &&
|
||||
typeof change === "object" &&
|
||||
"op" in change &&
|
||||
change.op === "set" &&
|
||||
"key" in change &&
|
||||
change.key
|
||||
) {
|
||||
const key = cojsonInternals.getGroupDependentKey(change.key);
|
||||
|
||||
if (key) {
|
||||
keys.push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
function getOwnedByGroupDependedOnCoValues(
|
||||
coValueRow: StoredCoValueRow,
|
||||
newContentMessages: CojsonInternalTypes.NewContentMessage[],
|
||||
) {
|
||||
if (coValueRow.header.ruleset.type !== "ownedByGroup") return [];
|
||||
|
||||
const keys: CojsonInternalTypes.RawCoID[] = [coValueRow.header.ruleset.group];
|
||||
|
||||
/**
|
||||
* Collect all the signing keys inside the transactions to list all the
|
||||
* dependencies required to correctly access the CoValue.
|
||||
*/
|
||||
for (const piece of newContentMessages) {
|
||||
for (const sessionID of Object.keys(piece.new) as SessionID[]) {
|
||||
const accountId =
|
||||
cojsonInternals.accountOrAgentIDfromSessionID(sessionID);
|
||||
|
||||
if (
|
||||
cojsonInternals.isAccountID(accountId) &&
|
||||
accountId !== coValueRow.id
|
||||
) {
|
||||
keys.push(accountId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
function safeParseChanges(changes: Stringified<JsonValue[]>) {
|
||||
try {
|
||||
return cojsonInternals.parseJSON(changes);
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
export const fixtures = {
|
||||
co_zKwG8NyfZ8GXqcjDHY4NS3SbU2m: {
|
||||
getContent: ({ after = 0 }: { after?: number }) => ({
|
||||
action: "content",
|
||||
id: "co_zKwG8NyfZ8GXqcjDHY4NS3SbU2m",
|
||||
header: {
|
||||
type: "comap",
|
||||
ruleset: {
|
||||
type: "group",
|
||||
initialAdmin:
|
||||
"sealer_zRKetKBH6tdGP8poA2rV9JDejXqTyAmpusCT4jRcXa4m/signer_z6bcctDRiWxtgmuqLRR6rVhM54DA3xJ2pWCEs6DVf4PSy",
|
||||
},
|
||||
meta: {
|
||||
type: "account",
|
||||
},
|
||||
createdAt: null,
|
||||
uniqueness: null,
|
||||
},
|
||||
new: {
|
||||
"sealer_zRKetKBH6tdGP8poA2rV9JDejXqTyAmpusCT4jRcXa4m/signer_z6bcctDRiWxtgmuqLRR6rVhM54DA3xJ2pWCEs6DVf4PSy_session_zbcBS6rHy8kA":
|
||||
{
|
||||
after,
|
||||
lastSignature:
|
||||
"signature_z2kcFHUPe1qGFYDY4ayvvFR2unFc4jeYph93nSCSjZYS14vnGN4uAw7pKZx1PEhwnspJcDizMRbLaFC8v13i6S79A",
|
||||
newTransactions: [
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1732368535089,
|
||||
changes:
|
||||
'[{"key":"sealer_zRKetKBH6tdGP8poA2rV9JDejXqTyAmpusCT4jRcXa4m/signer_z6bcctDRiWxtgmuqLRR6rVhM54DA3xJ2pWCEs6DVf4PSy","op":"set","value":"admin"}]',
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1732368535096,
|
||||
changes:
|
||||
'[{"key":"key_z2YMuLXEfXG44Z2jGk_for_sealer_zRKetKBH6tdGP8poA2rV9JDejXqTyAmpusCT4jRcXa4m/signer_z6bcctDRiWxtgmuqLRR6rVhM54DA3xJ2pWCEs6DVf4PSy","op":"set","value":"sealed_UAIpJTby8EovZW6WPtAqdaczA2_r6PEWRBuEtLN93-Dh9xDJFaGUNTXK1Cck61tjvA3GoGn9EyQdNN2fU6tnmWP2M09a83dG41Q=="}]',
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1732368535099,
|
||||
changes:
|
||||
'[{"key":"readKey","op":"set","value":"key_z2YMuLXEfXG44Z2jGk"}]',
|
||||
},
|
||||
],
|
||||
},
|
||||
"sealer_zRKetKBH6tdGP8poA2rV9JDejXqTyAmpusCT4jRcXa4m/signer_z6bcctDRiWxtgmuqLRR6rVhM54DA3xJ2pWCEs6DVf4PSy_session_zXgW54i2cCNA":
|
||||
{
|
||||
after,
|
||||
lastSignature:
|
||||
"signature_z5FsinkJCpqZfozVBkEMSchCQarsAjvMYpWN4d227PZtqCiM7KRBNukND3B25Q73idBLdY2MsghbmYFz5JHXk3d4D",
|
||||
newTransactions: [
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1732368535113,
|
||||
changes:
|
||||
'[{"key":"profile","op":"set","value":"co_zMKhQJs5rAeGjta3JX2qEdBS6hS"}]',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
priority: 0,
|
||||
}),
|
||||
known: {
|
||||
action: "known",
|
||||
id: "co_zKwG8NyfZ8GXqcjDHY4NS3SbU2m",
|
||||
header: true,
|
||||
sessions: {
|
||||
"sealer_zRKetKBH6tdGP8poA2rV9JDejXqTyAmpusCT4jRcXa4m/signer_z6bcctDRiWxtgmuqLRR6rVhM54DA3xJ2pWCEs6DVf4PSy_session_zbcBS6rHy8kA": 3,
|
||||
"sealer_zRKetKBH6tdGP8poA2rV9JDejXqTyAmpusCT4jRcXa4m/signer_z6bcctDRiWxtgmuqLRR6rVhM54DA3xJ2pWCEs6DVf4PSy_session_zXgW54i2cCNA": 1,
|
||||
},
|
||||
},
|
||||
sessionRecords: [
|
||||
{
|
||||
bytesSinceLastSignature: 479,
|
||||
coValue: 2,
|
||||
lastIdx: 3,
|
||||
lastSignature:
|
||||
"signature_z2kcFHUPe1qGFYDY4ayvvFR2unFc4jeYph93nSCSjZYS14vnGN4uAw7pKZx1PEhwnspJcDizMRbLaFC8v13i6S79A",
|
||||
rowID: 2,
|
||||
sessionID:
|
||||
"sealer_zRKetKBH6tdGP8poA2rV9JDejXqTyAmpusCT4jRcXa4m/signer_z6bcctDRiWxtgmuqLRR6rVhM54DA3xJ2pWCEs6DVf4PSy_session_zbcBS6rHy8kA",
|
||||
},
|
||||
{
|
||||
bytesSinceLastSignature: 71,
|
||||
coValue: 2,
|
||||
lastIdx: 1,
|
||||
lastSignature:
|
||||
"signature_z5FsinkJCpqZfozVBkEMSchCQarsAjvMYpWN4d227PZtqCiM7KRBNukND3B25Q73idBLdY2MsghbmYFz5JHXk3d4D",
|
||||
rowID: 3,
|
||||
sessionID:
|
||||
"sealer_zRKetKBH6tdGP8poA2rV9JDejXqTyAmpusCT4jRcXa4m/signer_z6bcctDRiWxtgmuqLRR6rVhM54DA3xJ2pWCEs6DVf4PSy_session_zXgW54i2cCNA",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,179 @@
|
||||
import { CojsonInternalTypes, SessionID, Stringified } from "cojson";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { getDependedOnCoValues } from "../syncUtils";
|
||||
|
||||
function getMockedSessionID(accountId?: `co_z${string}`) {
|
||||
return `${accountId ?? getMockedCoValueId()}_session_z${Math.random().toString(36).substring(2, 15)}`;
|
||||
}
|
||||
|
||||
function getMockedCoValueId() {
|
||||
return `co_z${Math.random().toString(36).substring(2, 15)}` as const;
|
||||
}
|
||||
|
||||
function generateNewContentMessage(
|
||||
privacy: "trusting" | "private",
|
||||
changes: any[],
|
||||
accountId?: `co_z${string}`,
|
||||
) {
|
||||
return {
|
||||
action: "content",
|
||||
id: getMockedCoValueId(),
|
||||
new: {
|
||||
[getMockedSessionID(accountId)]: {
|
||||
after: 0,
|
||||
lastSignature: "signature_z123",
|
||||
newTransactions: [
|
||||
{
|
||||
privacy,
|
||||
madeAt: 0,
|
||||
changes: JSON.stringify(changes) as any,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
priority: 0,
|
||||
} as CojsonInternalTypes.NewContentMessage;
|
||||
}
|
||||
|
||||
describe("getDependedOnCoValues", () => {
|
||||
it("should return dependencies for group ruleset", () => {
|
||||
const coValueRow = {
|
||||
id: "co_test",
|
||||
header: {
|
||||
ruleset: {
|
||||
type: "group",
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
|
||||
const result = getDependedOnCoValues({
|
||||
coValueRow,
|
||||
newContentMessages: [
|
||||
generateNewContentMessage("trusting", [
|
||||
{ op: "set", key: "co_zabc123", value: "test" },
|
||||
{ op: "set", key: "parent_co_zdef456", value: "test" },
|
||||
{ op: "set", key: "normal_key", value: "test" },
|
||||
]),
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toEqual(["co_zabc123", "co_zdef456"]);
|
||||
});
|
||||
|
||||
it("should not throw on malformed JSON", () => {
|
||||
const coValueRow = {
|
||||
id: "co_test",
|
||||
header: {
|
||||
ruleset: {
|
||||
type: "group",
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
|
||||
const message = generateNewContentMessage("trusting", [
|
||||
{ op: "set", key: "co_zabc123", value: "test" },
|
||||
]);
|
||||
|
||||
message.new["invalid_session" as SessionID] = {
|
||||
after: 0,
|
||||
lastSignature: "signature_z123",
|
||||
newTransactions: [
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 0,
|
||||
changes: "}{-:)" as Stringified<CojsonInternalTypes.JsonObject[]>,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = getDependedOnCoValues({
|
||||
coValueRow,
|
||||
newContentMessages: [message],
|
||||
});
|
||||
|
||||
expect(result).toEqual(["co_zabc123"]);
|
||||
});
|
||||
|
||||
it("should return dependencies for ownedByGroup ruleset", () => {
|
||||
const groupId = getMockedCoValueId();
|
||||
const coValueRow = {
|
||||
id: "co_owner",
|
||||
header: {
|
||||
ruleset: {
|
||||
type: "ownedByGroup",
|
||||
group: groupId,
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
|
||||
const accountId = getMockedCoValueId();
|
||||
const message = generateNewContentMessage(
|
||||
"trusting",
|
||||
[
|
||||
{ op: "set", key: "co_zabc123", value: "test" },
|
||||
{ op: "set", key: "parent_co_zdef456", value: "test" },
|
||||
{ op: "set", key: "normal_key", value: "test" },
|
||||
],
|
||||
accountId,
|
||||
);
|
||||
|
||||
message.new["invalid_session" as SessionID] = {
|
||||
after: 0,
|
||||
lastSignature: "signature_z123",
|
||||
newTransactions: [],
|
||||
};
|
||||
|
||||
const result = getDependedOnCoValues({
|
||||
coValueRow,
|
||||
newContentMessages: [message],
|
||||
});
|
||||
|
||||
expect(result).toEqual([groupId, accountId]);
|
||||
});
|
||||
|
||||
it("should return empty array for other ruleset types", () => {
|
||||
const coValueRow = {
|
||||
id: "co_test",
|
||||
header: {
|
||||
ruleset: {
|
||||
type: "other",
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
|
||||
const result = getDependedOnCoValues({
|
||||
coValueRow,
|
||||
newContentMessages: [
|
||||
generateNewContentMessage("trusting", [
|
||||
{ op: "set", key: "co_zabc123", value: "test" },
|
||||
{ op: "set", key: "parent_co_zdef456", value: "test" },
|
||||
{ op: "set", key: "normal_key", value: "test" },
|
||||
]),
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should ignore non-trusting transactions in group ruleset", () => {
|
||||
const coValueRow = {
|
||||
id: "co_test",
|
||||
header: {
|
||||
ruleset: {
|
||||
type: "group",
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
|
||||
const result = getDependedOnCoValues({
|
||||
coValueRow,
|
||||
newContentMessages: [
|
||||
generateNewContentMessage("private", [
|
||||
{ op: "set", key: "co_zabc123", value: "test" },
|
||||
]),
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import { IDBStorage } from "../index.js";
|
||||
|
||||
const Crypto = await WasmCrypto.create();
|
||||
|
||||
test.skip("Should be able to initialize and load from empty DB", async () => {
|
||||
test("Should be able to initialize and load from empty DB", async () => {
|
||||
const agentSecret = Crypto.newRandomAgentSecret();
|
||||
|
||||
const node = new LocalNode(
|
||||
@@ -23,7 +23,7 @@ test.skip("Should be able to initialize and load from empty DB", async () => {
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
expect(node.syncManager.peers["storage"]).toBeDefined();
|
||||
expect(node.syncManager.peers["indexedDB"]).toBeDefined();
|
||||
});
|
||||
|
||||
test("Should be able to sync data to database and then load that from a new node", async () => {
|
||||
337
packages/cojson-storage-indexeddb/src/tests/syncManager.test.ts
Normal file
337
packages/cojson-storage-indexeddb/src/tests/syncManager.test.ts
Normal file
@@ -0,0 +1,337 @@
|
||||
import {
|
||||
Mocked,
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
test,
|
||||
vi,
|
||||
} from "vitest";
|
||||
|
||||
import {
|
||||
CojsonInternalTypes,
|
||||
OutgoingSyncQueue,
|
||||
SessionID,
|
||||
SyncMessage,
|
||||
} from "cojson";
|
||||
import { IDBClient } from "../idbClient";
|
||||
import { SyncManager } from "../syncManager";
|
||||
import { getDependedOnCoValues } from "../syncUtils";
|
||||
import { fixtures } from "./fixtureMessages";
|
||||
import RawCoID = CojsonInternalTypes.RawCoID;
|
||||
import NewContentMessage = CojsonInternalTypes.NewContentMessage;
|
||||
|
||||
vi.mock("../syncUtils");
|
||||
|
||||
const coValueIdToLoad = "co_zKwG8NyfZ8GXqcjDHY4NS3SbU2m";
|
||||
const createEmptyLoadMsg = (id: string) =>
|
||||
({
|
||||
action: "load",
|
||||
id,
|
||||
header: false,
|
||||
sessions: {},
|
||||
}) as SyncMessage;
|
||||
|
||||
const sessionsData = fixtures[coValueIdToLoad].sessionRecords;
|
||||
const coValueHeader = fixtures[coValueIdToLoad].getContent({ after: 0 }).header;
|
||||
const incomingContentMessage = fixtures[coValueIdToLoad].getContent({
|
||||
after: 0,
|
||||
}) as SyncMessage;
|
||||
|
||||
describe("IDB sync manager", () => {
|
||||
let syncManager: SyncManager;
|
||||
let queue: OutgoingSyncQueue = {} as unknown as OutgoingSyncQueue;
|
||||
|
||||
const IDBClient = vi.fn();
|
||||
IDBClient.prototype.getCoValue = vi.fn();
|
||||
IDBClient.prototype.getCoValueSessions = vi.fn();
|
||||
IDBClient.prototype.addSessionUpdate = vi.fn();
|
||||
IDBClient.prototype.addTransaction = vi.fn();
|
||||
|
||||
beforeEach(async () => {
|
||||
const idbClient = new IDBClient() as unknown as Mocked<IDBClient>;
|
||||
syncManager = new SyncManager(idbClient, queue);
|
||||
syncManager.sendStateMessage = vi.fn();
|
||||
|
||||
// No dependencies found
|
||||
vi.mocked(getDependedOnCoValues).mockReturnValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("Incoming known messages are not processed", async () => {
|
||||
await syncManager.handleSyncMessage({ action: "known" } as SyncMessage);
|
||||
expect(syncManager.sendStateMessage).not.toBeCalled();
|
||||
});
|
||||
|
||||
describe("Handle load incoming message", () => {
|
||||
test("sends empty known message for unknown coValue", async () => {
|
||||
const loadMsg = createEmptyLoadMsg(coValueIdToLoad);
|
||||
|
||||
IDBClient.prototype.getCoValue.mockResolvedValueOnce(undefined);
|
||||
|
||||
await syncManager.handleSyncMessage(loadMsg);
|
||||
|
||||
expect(syncManager.sendStateMessage).toBeCalledWith({
|
||||
action: "known",
|
||||
header: false,
|
||||
id: coValueIdToLoad,
|
||||
sessions: {},
|
||||
});
|
||||
});
|
||||
|
||||
test("Sends known and content message for known coValue with no sessions", async () => {
|
||||
const loadMsg = createEmptyLoadMsg(coValueIdToLoad);
|
||||
|
||||
IDBClient.prototype.getCoValue.mockResolvedValueOnce({
|
||||
id: coValueIdToLoad,
|
||||
header: coValueHeader,
|
||||
rowID: 3,
|
||||
});
|
||||
IDBClient.prototype.getCoValueSessions.mockResolvedValueOnce([]);
|
||||
|
||||
await syncManager.handleSyncMessage(loadMsg);
|
||||
|
||||
expect(syncManager.sendStateMessage).toBeCalledTimes(2);
|
||||
expect(syncManager.sendStateMessage).toBeCalledWith({
|
||||
action: "known",
|
||||
header: true,
|
||||
id: coValueIdToLoad,
|
||||
sessions: {},
|
||||
});
|
||||
expect(syncManager.sendStateMessage).toBeCalledWith({
|
||||
action: "content",
|
||||
header: expect.objectContaining({
|
||||
type: expect.any(String),
|
||||
ruleset: expect.any(Object),
|
||||
}),
|
||||
id: coValueIdToLoad,
|
||||
new: {},
|
||||
priority: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test("Sends both known and content messages when we have new sessions info for the requested coValue ", async () => {
|
||||
const loadMsg = createEmptyLoadMsg(coValueIdToLoad);
|
||||
|
||||
IDBClient.prototype.getCoValue.mockResolvedValueOnce({
|
||||
id: coValueIdToLoad,
|
||||
header: coValueHeader,
|
||||
rowID: 3,
|
||||
});
|
||||
IDBClient.prototype.getCoValueSessions.mockResolvedValueOnce(
|
||||
sessionsData,
|
||||
);
|
||||
|
||||
const newTxData = {
|
||||
newTransactions: [
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1732368535089,
|
||||
changes: "",
|
||||
} as CojsonInternalTypes.Transaction,
|
||||
],
|
||||
after: 0,
|
||||
lastSignature: "signature_z111",
|
||||
} satisfies CojsonInternalTypes.SessionNewContent;
|
||||
|
||||
// mock content data combined with session updates
|
||||
syncManager.handleSessionUpdate = vi.fn(
|
||||
async ({ sessionRow, newContentMessages }) => {
|
||||
newContentMessages[0]!.new[sessionRow.sessionID] = newTxData;
|
||||
},
|
||||
);
|
||||
|
||||
await syncManager.handleSyncMessage(loadMsg);
|
||||
|
||||
expect(syncManager.sendStateMessage).toBeCalledTimes(2);
|
||||
|
||||
expect(syncManager.sendStateMessage).toHaveBeenNthCalledWith(1, {
|
||||
action: "known",
|
||||
header: true,
|
||||
id: coValueIdToLoad,
|
||||
sessions: sessionsData.reduce(
|
||||
(acc, sessionRow) => ({
|
||||
...acc,
|
||||
[sessionRow.sessionID]: sessionRow.lastIdx,
|
||||
}),
|
||||
{},
|
||||
),
|
||||
});
|
||||
|
||||
expect(syncManager.sendStateMessage).toHaveBeenNthCalledWith(2, {
|
||||
action: "content",
|
||||
header: coValueHeader,
|
||||
id: coValueIdToLoad,
|
||||
new: sessionsData.reduce(
|
||||
(acc, sessionRow) => ({
|
||||
...acc,
|
||||
[sessionRow.sessionID]: {
|
||||
after: expect.any(Number),
|
||||
lastSignature: expect.any(String),
|
||||
newTransactions: expect.any(Array),
|
||||
},
|
||||
}),
|
||||
{},
|
||||
),
|
||||
priority: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test("Sends messages for unique coValue dependencies only, leaving out circular dependencies", async () => {
|
||||
const loadMsg = createEmptyLoadMsg(coValueIdToLoad);
|
||||
const dependency1 = "co_zMKhQJs5rAeGjta3JX2qEdBS6hS";
|
||||
const dependency2 = "co_zP51HdyAVCuRY9ptq5iu8DhMyAb";
|
||||
const dependency3 = "co_zGyBniuJmKkcirCKYrccWpjQEFY";
|
||||
const dependenciesTreeWithLoop: Record<RawCoID, RawCoID[]> = {
|
||||
[coValueIdToLoad]: [dependency1, dependency2],
|
||||
[dependency1]: [],
|
||||
[dependency2]: [coValueIdToLoad, dependency3],
|
||||
[dependency3]: [dependency1],
|
||||
};
|
||||
|
||||
IDBClient.prototype.getCoValue.mockImplementation(
|
||||
(coValueId: RawCoID) => ({
|
||||
id: coValueId,
|
||||
header: coValueHeader,
|
||||
rowID: 3,
|
||||
}),
|
||||
);
|
||||
|
||||
IDBClient.prototype.getCoValueSessions.mockResolvedValue([]);
|
||||
|
||||
// Fetch dependencies of the current dependency for the future recursion iterations
|
||||
vi.mocked(getDependedOnCoValues).mockImplementation(
|
||||
({ coValueRow }) => dependenciesTreeWithLoop[coValueRow.id] || [],
|
||||
);
|
||||
|
||||
await syncManager.handleSyncMessage(loadMsg);
|
||||
|
||||
// We send out pairs (known + content) messages only FOUR times - as many as the coValues number
|
||||
// and less than amount of interconnected dependencies to loop through in dependenciesTreeWithLoop
|
||||
expect(syncManager.sendStateMessage).toBeCalledTimes(4 * 2);
|
||||
|
||||
const knownExpected = {
|
||||
action: "known",
|
||||
header: true,
|
||||
sessions: {},
|
||||
};
|
||||
|
||||
const contentExpected = {
|
||||
action: "content",
|
||||
header: expect.any(Object),
|
||||
new: {},
|
||||
priority: 0,
|
||||
};
|
||||
|
||||
expect(syncManager.sendStateMessage).toHaveBeenNthCalledWith(1, {
|
||||
...knownExpected,
|
||||
id: dependency3,
|
||||
asDependencyOf: coValueIdToLoad,
|
||||
});
|
||||
expect(syncManager.sendStateMessage).toHaveBeenNthCalledWith(2, {
|
||||
...contentExpected,
|
||||
id: dependency3,
|
||||
});
|
||||
expect(syncManager.sendStateMessage).toHaveBeenNthCalledWith(3, {
|
||||
...knownExpected,
|
||||
id: dependency2,
|
||||
asDependencyOf: coValueIdToLoad,
|
||||
});
|
||||
expect(syncManager.sendStateMessage).toHaveBeenNthCalledWith(4, {
|
||||
...contentExpected,
|
||||
id: dependency2,
|
||||
});
|
||||
expect(syncManager.sendStateMessage).toHaveBeenNthCalledWith(5, {
|
||||
...knownExpected,
|
||||
id: dependency1,
|
||||
asDependencyOf: coValueIdToLoad,
|
||||
});
|
||||
expect(syncManager.sendStateMessage).toHaveBeenNthCalledWith(6, {
|
||||
...contentExpected,
|
||||
id: dependency1,
|
||||
});
|
||||
expect(syncManager.sendStateMessage).toHaveBeenNthCalledWith(7, {
|
||||
...knownExpected,
|
||||
id: coValueIdToLoad,
|
||||
});
|
||||
expect(syncManager.sendStateMessage).toHaveBeenNthCalledWith(8, {
|
||||
...contentExpected,
|
||||
id: coValueIdToLoad,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Handle content incoming message", () => {
|
||||
test("Sends correction message for unknown coValue", async () => {
|
||||
IDBClient.prototype.getCoValue.mockResolvedValueOnce(undefined);
|
||||
|
||||
await syncManager.handleSyncMessage({
|
||||
...incomingContentMessage,
|
||||
header: undefined,
|
||||
} as SyncMessage);
|
||||
|
||||
expect(syncManager.sendStateMessage).toBeCalledWith({
|
||||
action: "known",
|
||||
header: false,
|
||||
id: coValueIdToLoad,
|
||||
isCorrection: true,
|
||||
sessions: {},
|
||||
});
|
||||
});
|
||||
|
||||
test("Saves new transaction without sending message when IDB has fewer transactions", async () => {
|
||||
IDBClient.prototype.getCoValue.mockResolvedValueOnce({
|
||||
id: coValueIdToLoad,
|
||||
header: coValueHeader,
|
||||
rowID: 3,
|
||||
});
|
||||
IDBClient.prototype.getCoValueSessions.mockResolvedValueOnce([]);
|
||||
const msg = {
|
||||
...incomingContentMessage,
|
||||
header: undefined,
|
||||
} as NewContentMessage;
|
||||
|
||||
await syncManager.handleSyncMessage(msg);
|
||||
|
||||
const incomingTxCount = Object.keys(msg.new).reduce(
|
||||
(acc, sessionID) =>
|
||||
acc + msg.new[sessionID as SessionID]!.newTransactions.length,
|
||||
0,
|
||||
);
|
||||
expect(IDBClient.prototype.addTransaction).toBeCalledTimes(
|
||||
incomingTxCount,
|
||||
);
|
||||
|
||||
expect(syncManager.sendStateMessage).not.toBeCalled();
|
||||
});
|
||||
|
||||
test("Sends correction message when peer sends a message far ahead of our state due to invalid assumption", async () => {
|
||||
IDBClient.prototype.getCoValue.mockResolvedValueOnce({
|
||||
id: coValueIdToLoad,
|
||||
header: coValueHeader,
|
||||
rowID: 3,
|
||||
});
|
||||
IDBClient.prototype.getCoValueSessions.mockResolvedValueOnce(
|
||||
sessionsData,
|
||||
);
|
||||
|
||||
const farAheadContentMessage = fixtures[coValueIdToLoad].getContent({
|
||||
after: 10000,
|
||||
});
|
||||
await syncManager.handleSyncMessage(
|
||||
farAheadContentMessage as SyncMessage,
|
||||
);
|
||||
|
||||
expect(syncManager.sendStateMessage).toBeCalledWith({
|
||||
action: "known",
|
||||
header: true,
|
||||
id: coValueIdToLoad,
|
||||
isCorrection: true,
|
||||
sessions: expect.any(Object),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,16 @@
|
||||
# cojson-storage-sqlite
|
||||
|
||||
## 0.8.35
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 8b87117: Implement Group Inheritance
|
||||
- Updated dependencies [3f15a23]
|
||||
- Updated dependencies [46f2ab8]
|
||||
- Updated dependencies [8b87117]
|
||||
- Updated dependencies [a6b6ccf]
|
||||
- cojson@0.8.35
|
||||
|
||||
## 0.8.34
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"name": "cojson-storage-sqlite",
|
||||
"type": "module",
|
||||
"version": "0.8.34",
|
||||
"version": "0.8.35",
|
||||
"main": "dist/index.js",
|
||||
"types": "src/index.ts",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^8.5.2",
|
||||
"cojson": "workspace:0.8.34",
|
||||
"cojson": "workspace:0.8.35",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -382,8 +382,8 @@ export class SQLiteStorage {
|
||||
return [];
|
||||
}
|
||||
|
||||
return parsedChanges
|
||||
.map(
|
||||
return cojsonInternals.getGroupDependentKeyList(
|
||||
parsedChanges.map(
|
||||
(change) =>
|
||||
change &&
|
||||
typeof change === "object" &&
|
||||
@@ -391,11 +391,8 @@ export class SQLiteStorage {
|
||||
change.op === "set" &&
|
||||
"key" in change &&
|
||||
change.key,
|
||||
)
|
||||
.filter(
|
||||
(key): key is CojsonInternalTypes.RawCoID =>
|
||||
typeof key === "string" && key.startsWith("co_"),
|
||||
);
|
||||
),
|
||||
);
|
||||
}),
|
||||
)
|
||||
: parsedHeader?.ruleset.type === "ownedByGroup"
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# cojson-transport-nodejs-ws
|
||||
|
||||
## 0.8.35
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3f15a23]
|
||||
- Updated dependencies [46f2ab8]
|
||||
- Updated dependencies [8b87117]
|
||||
- Updated dependencies [a6b6ccf]
|
||||
- cojson@0.8.35
|
||||
|
||||
## 0.8.34
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "cojson-transport-ws",
|
||||
"type": "module",
|
||||
"version": "0.8.34",
|
||||
"version": "0.8.35",
|
||||
"main": "dist/index.js",
|
||||
"types": "src/index.ts",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cojson": "workspace:0.8.34",
|
||||
"cojson": "workspace:0.8.35",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"scripts": {
|
||||
@@ -19,6 +19,6 @@
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/ws": "^8.5.5"
|
||||
"@types/ws": "8.5.10"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# cojson
|
||||
|
||||
## 0.8.35
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 3f15a23: Resolve deadlock in cluster setup with multiple layers of sync & storage servers
|
||||
- 46f2ab8: Add emptyKnownState and SessionNewContent to the package exports
|
||||
- 8b87117: Implement Group Inheritance
|
||||
- a6b6ccf: Upload new coValues when a peer is added
|
||||
|
||||
## 0.8.34
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
},
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "0.8.34",
|
||||
"version": "0.8.35",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.3",
|
||||
"typescript": "^5.3.3",
|
||||
|
||||
@@ -13,7 +13,14 @@ import {
|
||||
SignerID,
|
||||
StreamingHash,
|
||||
} from "./crypto/crypto.js";
|
||||
import { RawCoID, SessionID, TransactionID } from "./ids.js";
|
||||
import {
|
||||
RawCoID,
|
||||
SessionID,
|
||||
TransactionID,
|
||||
getGroupDependentKeyList,
|
||||
getParentGroupId,
|
||||
isParentGroupReference,
|
||||
} from "./ids.js";
|
||||
import { Stringified, parseJSON, stableStringify } from "./jsonStringify.js";
|
||||
import { JsonObject, JsonValue } from "./jsonValue.js";
|
||||
import { LocalNode, ResolveAccountAgentError } from "./localNode.js";
|
||||
@@ -790,6 +797,48 @@ export class CoValueCore {
|
||||
}
|
||||
}
|
||||
|
||||
// try to find revelation to parent group read keys
|
||||
for (const co of content.keys()) {
|
||||
if (isParentGroupReference(co)) {
|
||||
const parentGroupID = getParentGroupId(co);
|
||||
const parentGroup = this.node.expectCoValueLoaded(
|
||||
parentGroupID,
|
||||
"Expected parent group to be loaded",
|
||||
);
|
||||
|
||||
const parentKeys = this.findValidParentKeys(
|
||||
keyID,
|
||||
content,
|
||||
parentGroup,
|
||||
);
|
||||
|
||||
for (const parentKey of parentKeys) {
|
||||
const revelationForParentKey = content.get(
|
||||
`${keyID}_for_${parentKey.id}`,
|
||||
);
|
||||
|
||||
if (revelationForParentKey) {
|
||||
const secret = parentGroup.crypto.decryptKeySecret(
|
||||
{
|
||||
encryptedID: keyID,
|
||||
encryptingID: parentKey.id,
|
||||
encrypted: revelationForParentKey,
|
||||
},
|
||||
parentKey.secret,
|
||||
);
|
||||
|
||||
if (secret) {
|
||||
return secret as KeySecret;
|
||||
} else {
|
||||
console.error(
|
||||
`Encrypting parent ${parentKey.id} key didn't decrypt ${keyID}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
} else if (this.header.ruleset.type === "ownedByGroup") {
|
||||
return this.node
|
||||
@@ -802,6 +851,28 @@ export class CoValueCore {
|
||||
}
|
||||
}
|
||||
|
||||
findValidParentKeys(keyID: KeyID, group: RawGroup, parentGroup: CoValueCore) {
|
||||
const validParentKeys: { id: KeyID; secret: KeySecret }[] = [];
|
||||
|
||||
for (const co of group.keys()) {
|
||||
if (isKeyForKeyField(co) && co.startsWith(keyID)) {
|
||||
const encryptingKeyID = co.split("_for_")[1] as KeyID;
|
||||
const encryptingKeySecret = parentGroup.getReadKey(encryptingKeyID);
|
||||
|
||||
if (!encryptingKeySecret) {
|
||||
continue;
|
||||
}
|
||||
|
||||
validParentKeys.push({
|
||||
id: encryptingKeyID,
|
||||
secret: encryptingKeySecret,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return validParentKeys;
|
||||
}
|
||||
|
||||
getGroup(): RawGroup {
|
||||
if (this.header.ruleset.type !== "ownedByGroup") {
|
||||
throw new Error("Only values owned by groups have groups");
|
||||
@@ -955,9 +1026,7 @@ export class CoValueCore {
|
||||
/** @internal */
|
||||
getDependedOnCoValuesUncached(): RawCoID[] {
|
||||
return this.header.ruleset.type === "group"
|
||||
? expectGroup(this.getCurrentContent())
|
||||
.keys()
|
||||
.filter((k): k is RawAccountID => k.startsWith("co_"))
|
||||
? getGroupDependentKeyList(expectGroup(this.getCurrentContent()).keys())
|
||||
: this.header.ruleset.type === "ownedByGroup"
|
||||
? [
|
||||
this.header.ruleset.group,
|
||||
|
||||
@@ -2,9 +2,19 @@ import { base58 } from "@scure/base";
|
||||
import { CoID } from "../coValue.js";
|
||||
import { CoValueUniqueness } from "../coValueCore.js";
|
||||
import { Encrypted, KeyID, KeySecret, Sealed } from "../crypto/crypto.js";
|
||||
import { AgentID, isAgentID } from "../ids.js";
|
||||
import {
|
||||
AgentID,
|
||||
ChildGroupReference,
|
||||
ParentGroupReference,
|
||||
getChildGroupId,
|
||||
getParentGroupId,
|
||||
isAgentID,
|
||||
isChildGroupReference,
|
||||
isParentGroupReference,
|
||||
} from "../ids.js";
|
||||
import { JsonObject } from "../jsonValue.js";
|
||||
import { Role } from "../permissions.js";
|
||||
import { expectGroup } from "../typeUtils/expectGroup.js";
|
||||
import {
|
||||
ControlledAccountOrAgent,
|
||||
RawAccount,
|
||||
@@ -29,6 +39,8 @@ export type GroupShape = {
|
||||
KeySecret,
|
||||
{ encryptedID: KeyID; encryptingID: KeyID }
|
||||
>;
|
||||
[parent: ParentGroupReference]: "extend";
|
||||
[child: ChildGroupReference]: "extend";
|
||||
};
|
||||
|
||||
/** A `Group` is a scope for permissions of its members (`"reader" | "writer" | "admin"`), applying to objects owned by that group.
|
||||
@@ -61,12 +73,109 @@ export class RawGroup<
|
||||
* @category 1. Role reading
|
||||
*/
|
||||
roleOf(accountID: RawAccountID): Role | undefined {
|
||||
return this.roleOfInternal(accountID);
|
||||
return this.roleOfInternal(accountID)?.role;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
roleOfInternal(accountID: RawAccountID | AgentID): Role | undefined {
|
||||
return this.get(accountID);
|
||||
roleOfInternal(
|
||||
accountID: RawAccountID | AgentID | typeof EVERYONE,
|
||||
): { role: Role; via: CoID<RawGroup> | undefined } | undefined {
|
||||
const roleHere = this.get(accountID);
|
||||
if (roleHere === "revoked") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let roleInfo:
|
||||
| {
|
||||
role: Exclude<Role, "revoked">;
|
||||
via: CoID<RawGroup> | undefined;
|
||||
}
|
||||
| undefined = roleHere && { role: roleHere, via: undefined };
|
||||
|
||||
const parentGroups = this.getParentGroups();
|
||||
|
||||
for (const parentGroup of parentGroups) {
|
||||
const roleInParent = parentGroup.roleOfInternal(accountID);
|
||||
|
||||
if (
|
||||
roleInParent &&
|
||||
roleInParent.role !== "revoked" &&
|
||||
isMorePermissiveAndShouldInherit(roleInParent.role, roleInfo?.role)
|
||||
) {
|
||||
roleInfo = { role: roleInParent.role, via: parentGroup.id };
|
||||
}
|
||||
}
|
||||
|
||||
return roleInfo;
|
||||
}
|
||||
|
||||
getParentGroups() {
|
||||
const groups: RawGroup[] = [];
|
||||
|
||||
for (const key of this.keys()) {
|
||||
if (isParentGroupReference(key)) {
|
||||
const parent = this.core.node.expectCoValueLoaded(
|
||||
getParentGroupId(key),
|
||||
"Expected parent group to be loaded",
|
||||
);
|
||||
groups.push(expectGroup(parent.getCurrentContent()));
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
loadAllChildGroups() {
|
||||
const requests: Promise<unknown>[] = [];
|
||||
const store = this.core.node.coValuesStore;
|
||||
const peers = this.core.node.syncManager.getServerAndStoragePeers();
|
||||
|
||||
for (const key of this.keys()) {
|
||||
if (!isChildGroupReference(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const id = getChildGroupId(key);
|
||||
const child = store.get(id);
|
||||
|
||||
if (
|
||||
child.state.type === "unknown" ||
|
||||
child.state.type === "unavailable"
|
||||
) {
|
||||
child.loadFromPeers(peers).catch(() => {
|
||||
console.error(`Failed to load child group ${id}`);
|
||||
});
|
||||
}
|
||||
|
||||
requests.push(
|
||||
child.getCoValue().then((coValue) => {
|
||||
if (coValue === "unavailable") {
|
||||
throw new Error(`Child group ${child.id} is unavailable`);
|
||||
}
|
||||
|
||||
// Recursively load child groups
|
||||
return expectGroup(coValue.getCurrentContent()).loadAllChildGroups();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.all(requests);
|
||||
}
|
||||
|
||||
getChildGroups() {
|
||||
const groups: RawGroup[] = [];
|
||||
|
||||
for (const key of this.keys()) {
|
||||
if (isChildGroupReference(key)) {
|
||||
const child = this.core.node.expectCoValueLoaded(
|
||||
getChildGroupId(key),
|
||||
"Expected child group to be loaded",
|
||||
);
|
||||
groups.push(expectGroup(child.getCurrentContent()));
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,7 +184,7 @@ export class RawGroup<
|
||||
* @category 1. Role reading
|
||||
*/
|
||||
myRole(): Role | undefined {
|
||||
return this.roleOfInternal(this.core.node.account.id);
|
||||
return this.roleOfInternal(this.core.node.account.id)?.role;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -158,6 +267,10 @@ export class RawGroup<
|
||||
}
|
||||
}) as (RawAccountID | AgentID)[];
|
||||
|
||||
// Get these early, so we fail fast if they are unavailable
|
||||
const parentGroups = this.getParentGroups();
|
||||
const childGroups = this.getChildGroups();
|
||||
|
||||
const maybeCurrentReadKey = this.core.getCurrentReadKey();
|
||||
|
||||
if (!maybeCurrentReadKey.secret) {
|
||||
@@ -204,6 +317,73 @@ export class RawGroup<
|
||||
);
|
||||
|
||||
this.set("readKey", newReadKey.id, "trusting");
|
||||
|
||||
// when we rotate our readKey (because someone got kicked out), we also need to (recursively)
|
||||
// rotate the readKeys of all child groups (so they are kicked out there as well)
|
||||
for (const parent of parentGroups) {
|
||||
const { id: parentReadKeyID, secret: parentReadKeySecret } =
|
||||
parent.core.getCurrentReadKey();
|
||||
|
||||
if (!parentReadKeySecret) {
|
||||
throw new Error(
|
||||
"Can't reveal new child key to parent where we don't have access to the parent read key",
|
||||
);
|
||||
}
|
||||
|
||||
this.set(
|
||||
`${newReadKey.id}_for_${parentReadKeyID}`,
|
||||
this.core.crypto.encryptKeySecret({
|
||||
encrypting: {
|
||||
id: parentReadKeyID,
|
||||
secret: parentReadKeySecret,
|
||||
},
|
||||
toEncrypt: newReadKey,
|
||||
}).encrypted,
|
||||
"trusting",
|
||||
);
|
||||
}
|
||||
|
||||
for (const child of childGroups) {
|
||||
child.rotateReadKey();
|
||||
}
|
||||
}
|
||||
|
||||
extend(parent: RawGroup) {
|
||||
if (parent.myRole() !== "admin" || this.myRole() !== "admin") {
|
||||
throw new Error(
|
||||
"To extend a group, the current account must have admin role in both groups",
|
||||
);
|
||||
}
|
||||
|
||||
this.set(`parent_${parent.id}`, "extend", "trusting");
|
||||
parent.set(`child_${this.id}`, "extend", "trusting");
|
||||
|
||||
const { id: parentReadKeyID, secret: parentReadKeySecret } =
|
||||
parent.core.getCurrentReadKey();
|
||||
if (!parentReadKeySecret) {
|
||||
throw new Error("Can't extend group without parent read key secret");
|
||||
}
|
||||
|
||||
const { id: childReadKeyID, secret: childReadKeySecret } =
|
||||
this.core.getCurrentReadKey();
|
||||
if (!childReadKeySecret) {
|
||||
throw new Error("Can't extend group without child read key secret");
|
||||
}
|
||||
|
||||
this.set(
|
||||
`${childReadKeyID}_for_${parentReadKeyID}`,
|
||||
this.core.crypto.encryptKeySecret({
|
||||
encrypting: {
|
||||
id: parentReadKeyID,
|
||||
secret: parentReadKeySecret,
|
||||
},
|
||||
toEncrypt: {
|
||||
id: childReadKeyID,
|
||||
secret: childReadKeySecret,
|
||||
},
|
||||
}).encrypted,
|
||||
"trusting",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -213,7 +393,12 @@ export class RawGroup<
|
||||
*
|
||||
* @category 2. Role changing
|
||||
*/
|
||||
removeMember(account: RawAccount | ControlledAccountOrAgent | Everyone) {
|
||||
async removeMember(
|
||||
account: RawAccount | ControlledAccountOrAgent | Everyone,
|
||||
) {
|
||||
// Ensure all child groups are loaded before removing a member
|
||||
await this.loadAllChildGroups();
|
||||
|
||||
this.removeMemberInternal(account);
|
||||
}
|
||||
|
||||
@@ -347,6 +532,34 @@ export class RawGroup<
|
||||
}
|
||||
}
|
||||
|
||||
function isMorePermissiveAndShouldInherit(
|
||||
roleInParent: Role,
|
||||
roleInChild: Exclude<Role, "revoked"> | undefined,
|
||||
) {
|
||||
// invites should never be inherited
|
||||
if (
|
||||
roleInParent === "adminInvite" ||
|
||||
roleInParent === "writerInvite" ||
|
||||
roleInParent === "readerInvite"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (roleInParent === "admin") {
|
||||
return !roleInChild || roleInChild !== "admin";
|
||||
}
|
||||
|
||||
if (roleInParent === "writer") {
|
||||
return !roleInChild || roleInChild === "reader";
|
||||
}
|
||||
|
||||
if (roleInParent === "reader") {
|
||||
return !roleInChild;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export type InviteSecret = `inviteSecret_z${string}`;
|
||||
|
||||
function inviteSecretFromSecretSeed(secretSeed: Uint8Array): InviteSecret {
|
||||
|
||||
@@ -23,8 +23,14 @@ import {
|
||||
secretSeedLength,
|
||||
shortHashLength,
|
||||
} from "./crypto/crypto.js";
|
||||
import { isRawCoID, rawCoIDfromBytes, rawCoIDtoBytes } from "./ids.js";
|
||||
import { parseJSON } from "./jsonStringify.js";
|
||||
import {
|
||||
getGroupDependentKey,
|
||||
getGroupDependentKeyList,
|
||||
isRawCoID,
|
||||
rawCoIDfromBytes,
|
||||
rawCoIDtoBytes,
|
||||
} from "./ids.js";
|
||||
import { Stringified, parseJSON } from "./jsonStringify.js";
|
||||
import { LocalNode } from "./localNode.js";
|
||||
import type { Role } from "./permissions.js";
|
||||
import { Channel, connectedPeers } from "./streamUtils.js";
|
||||
@@ -53,7 +59,11 @@ import type {
|
||||
Peer,
|
||||
SyncMessage,
|
||||
} from "./sync.js";
|
||||
import { DisconnectedError, PingTimeoutError } from "./sync.js";
|
||||
import {
|
||||
DisconnectedError,
|
||||
PingTimeoutError,
|
||||
emptyKnownState,
|
||||
} from "./sync.js";
|
||||
|
||||
type Value = JsonValue | AnyRawCoValue;
|
||||
|
||||
@@ -79,6 +89,8 @@ export const cojsonInternals = {
|
||||
StreamingHash,
|
||||
Channel,
|
||||
getPriorityFromHeader,
|
||||
getGroupDependentKeyList,
|
||||
getGroupDependentKey,
|
||||
};
|
||||
|
||||
export {
|
||||
@@ -116,6 +128,7 @@ export {
|
||||
SyncMessage,
|
||||
isRawCoID,
|
||||
LSMStorage,
|
||||
emptyKnownState,
|
||||
};
|
||||
|
||||
export type {
|
||||
@@ -128,6 +141,7 @@ export type {
|
||||
DisconnectedError,
|
||||
PingTimeoutError,
|
||||
CoValueUniqueness,
|
||||
Stringified,
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
@@ -137,6 +151,7 @@ export namespace CojsonInternalTypes {
|
||||
export type KnownStateMessage = import("./sync.js").KnownStateMessage;
|
||||
export type LoadMessage = import("./sync.js").LoadMessage;
|
||||
export type NewContentMessage = import("./sync.js").NewContentMessage;
|
||||
export type SessionNewContent = import("./sync.js").SessionNewContent;
|
||||
export type CoValueHeader = import("./coValueCore.js").CoValueHeader;
|
||||
export type Transaction = import("./coValueCore.js").Transaction;
|
||||
export type TransactionID = import("./ids.js").TransactionID;
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { base58 } from "@scure/base";
|
||||
import { CoID } from "./coValue.js";
|
||||
import { RawAccountID } from "./coValues/account.js";
|
||||
import { shortHashLength } from "./crypto/crypto.js";
|
||||
import { RawGroup } from "./exports.js";
|
||||
|
||||
export type RawCoID = `co_z${string}`;
|
||||
export type ParentGroupReference = `parent_${CoID<RawGroup>}`;
|
||||
export type ChildGroupReference = `child_${CoID<RawGroup>}`;
|
||||
|
||||
export function isRawCoID(id: unknown): id is RawCoID {
|
||||
return typeof id === "string" && id.startsWith("co_z");
|
||||
@@ -29,3 +33,47 @@ export function isAgentID(id: string): id is AgentID {
|
||||
}
|
||||
|
||||
export type SessionID = `${RawAccountID | AgentID}_session_z${string}`;
|
||||
|
||||
export function isParentGroupReference(
|
||||
key: string,
|
||||
): key is ParentGroupReference {
|
||||
return key.startsWith("parent_");
|
||||
}
|
||||
|
||||
export function getParentGroupId(key: ParentGroupReference): CoID<RawGroup> {
|
||||
return key.slice("parent_".length) as CoID<RawGroup>;
|
||||
}
|
||||
|
||||
export function isChildGroupReference(key: string): key is ChildGroupReference {
|
||||
return key.startsWith("child_");
|
||||
}
|
||||
|
||||
export function getChildGroupId(key: ChildGroupReference): CoID<RawGroup> {
|
||||
return key.slice("child_".length) as CoID<RawGroup>;
|
||||
}
|
||||
|
||||
export function getGroupDependentKey(key: unknown) {
|
||||
if (typeof key !== "string") return undefined;
|
||||
|
||||
if (isParentGroupReference(key)) {
|
||||
return getParentGroupId(key);
|
||||
} else if (key.startsWith("co_")) {
|
||||
return key as RawCoID;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getGroupDependentKeyList(keys: unknown[]) {
|
||||
const groupDependentKeys: RawCoID[] = [];
|
||||
|
||||
for (const key of keys) {
|
||||
const value = getGroupDependentKey(key);
|
||||
|
||||
if (value) {
|
||||
groupDependentKeys.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
return groupDependentKeys;
|
||||
}
|
||||
|
||||
@@ -2,9 +2,16 @@ import { CoID } from "./coValue.js";
|
||||
import { CoValueCore, Transaction } from "./coValueCore.js";
|
||||
import { RawAccount, RawAccountID, RawProfile } from "./coValues/account.js";
|
||||
import { MapOpPayload } from "./coValues/coMap.js";
|
||||
import { EVERYONE, Everyone } from "./coValues/group.js";
|
||||
import { EVERYONE, Everyone, RawGroup } from "./coValues/group.js";
|
||||
import { KeyID } from "./crypto/crypto.js";
|
||||
import { AgentID, RawCoID, SessionID, TransactionID } from "./ids.js";
|
||||
import {
|
||||
AgentID,
|
||||
ParentGroupReference,
|
||||
RawCoID,
|
||||
SessionID,
|
||||
TransactionID,
|
||||
getParentGroupId,
|
||||
} from "./ids.js";
|
||||
import { parseJSON } from "./jsonStringify.js";
|
||||
import { JsonValue } from "./jsonValue.js";
|
||||
import { accountOrAgentIDfromSessionID } from "./typeUtils/accountOrAgentIDfromSessionID.js";
|
||||
@@ -24,202 +31,20 @@ export type Role =
|
||||
| "writerInvite"
|
||||
| "readerInvite";
|
||||
|
||||
type ValidTransactionsResult = { txID: TransactionID; tx: Transaction };
|
||||
type MemberState = { [agent: RawAccountID | AgentID]: Role; [EVERYONE]?: Role };
|
||||
|
||||
export function determineValidTransactions(
|
||||
coValue: CoValueCore,
|
||||
): { txID: TransactionID; tx: Transaction }[] {
|
||||
if (coValue.header.ruleset.type === "group") {
|
||||
const allTransactionsSorted = [...coValue.sessionLogs.entries()].flatMap(
|
||||
([sessionID, sessionLog]) => {
|
||||
return sessionLog.transactions.map((tx, txIndex) => ({
|
||||
sessionID,
|
||||
txIndex,
|
||||
tx,
|
||||
})) as {
|
||||
sessionID: SessionID;
|
||||
txIndex: number;
|
||||
tx: Transaction;
|
||||
}[];
|
||||
},
|
||||
);
|
||||
|
||||
allTransactionsSorted.sort((a, b) => {
|
||||
return a.tx.madeAt - b.tx.madeAt;
|
||||
});
|
||||
|
||||
const initialAdmin = coValue.header.ruleset.initialAdmin;
|
||||
|
||||
if (!initialAdmin) {
|
||||
throw new Error("Group must have initialAdmin");
|
||||
}
|
||||
|
||||
const memberState: {
|
||||
[agent: RawAccountID | AgentID]: Role;
|
||||
[EVERYONE]?: Role;
|
||||
} = {};
|
||||
|
||||
const validTransactions: { txID: TransactionID; tx: Transaction }[] = [];
|
||||
|
||||
for (const { sessionID, txIndex, tx } of allTransactionsSorted) {
|
||||
// console.log("before", { memberState, validTransactions });
|
||||
const transactor = accountOrAgentIDfromSessionID(sessionID);
|
||||
|
||||
if (tx.privacy === "private") {
|
||||
if (memberState[transactor] === "admin") {
|
||||
validTransactions.push({
|
||||
txID: { sessionID, txIndex },
|
||||
tx,
|
||||
});
|
||||
continue;
|
||||
} else {
|
||||
console.warn("Only admins can make private transactions in groups");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let changes;
|
||||
|
||||
try {
|
||||
changes = parseJSON(tx.changes);
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
coValue.id,
|
||||
"Invalid JSON in transaction",
|
||||
e,
|
||||
tx,
|
||||
JSON.stringify(tx.changes, (k, v) =>
|
||||
k === "changes" || k === "encryptedChanges"
|
||||
? v.slice(0, 20) + "..."
|
||||
: v,
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const change = changes[0] as
|
||||
| MapOpPayload<RawAccountID | AgentID | Everyone, Role>
|
||||
| MapOpPayload<"readKey", JsonValue>
|
||||
| MapOpPayload<"profile", CoID<RawProfile>>;
|
||||
if (changes.length !== 1) {
|
||||
console.warn("Group transaction must have exactly one change");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (change.op !== "set") {
|
||||
console.warn("Group transaction must set a role or readKey");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (change.key === "readKey") {
|
||||
if (memberState[transactor] !== "admin") {
|
||||
console.warn("Only admins can set readKeys");
|
||||
continue;
|
||||
}
|
||||
|
||||
validTransactions.push({ txID: { sessionID, txIndex }, tx });
|
||||
continue;
|
||||
} else if (change.key === "profile") {
|
||||
if (memberState[transactor] !== "admin") {
|
||||
console.warn("Only admins can set profile");
|
||||
continue;
|
||||
}
|
||||
|
||||
validTransactions.push({ txID: { sessionID, txIndex }, tx });
|
||||
continue;
|
||||
} else if (
|
||||
isKeyForKeyField(change.key) ||
|
||||
isKeyForAccountField(change.key)
|
||||
) {
|
||||
if (
|
||||
memberState[transactor] !== "admin" &&
|
||||
memberState[transactor] !== "adminInvite" &&
|
||||
memberState[transactor] !== "writerInvite" &&
|
||||
memberState[transactor] !== "readerInvite"
|
||||
) {
|
||||
console.warn("Only admins can reveal keys");
|
||||
continue;
|
||||
}
|
||||
|
||||
// TODO: check validity of agents who the key is revealed to?
|
||||
|
||||
validTransactions.push({ txID: { sessionID, txIndex }, tx });
|
||||
continue;
|
||||
}
|
||||
|
||||
const affectedMember = change.key;
|
||||
const assignedRole = change.value;
|
||||
|
||||
if (
|
||||
change.value !== "admin" &&
|
||||
change.value !== "writer" &&
|
||||
change.value !== "reader" &&
|
||||
change.value !== "revoked" &&
|
||||
change.value !== "adminInvite" &&
|
||||
change.value !== "writerInvite" &&
|
||||
change.value !== "readerInvite"
|
||||
) {
|
||||
console.warn("Group transaction must set a valid role");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
affectedMember === EVERYONE &&
|
||||
!(
|
||||
change.value === "reader" ||
|
||||
change.value === "writer" ||
|
||||
change.value === "revoked"
|
||||
)
|
||||
) {
|
||||
console.warn("Everyone can only be set to reader, writer or revoked");
|
||||
continue;
|
||||
}
|
||||
|
||||
const isFirstSelfAppointment =
|
||||
!memberState[transactor] &&
|
||||
transactor === initialAdmin &&
|
||||
change.op === "set" &&
|
||||
change.key === transactor &&
|
||||
change.value === "admin";
|
||||
|
||||
if (!isFirstSelfAppointment) {
|
||||
if (memberState[transactor] === "admin") {
|
||||
if (
|
||||
memberState[affectedMember] === "admin" &&
|
||||
affectedMember !== transactor &&
|
||||
assignedRole !== "admin"
|
||||
) {
|
||||
console.warn("Admins can only demote themselves.");
|
||||
continue;
|
||||
}
|
||||
} else if (memberState[transactor] === "adminInvite") {
|
||||
if (change.value !== "admin") {
|
||||
console.warn("AdminInvites can only create admins.");
|
||||
continue;
|
||||
}
|
||||
} else if (memberState[transactor] === "writerInvite") {
|
||||
if (change.value !== "writer") {
|
||||
console.warn("WriterInvites can only create writers.");
|
||||
continue;
|
||||
}
|
||||
} else if (memberState[transactor] === "readerInvite") {
|
||||
if (change.value !== "reader") {
|
||||
console.warn("ReaderInvites can only create reader.");
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
console.warn(
|
||||
"Group transaction must be made by current admin or invite",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
memberState[affectedMember] = change.value;
|
||||
validTransactions.push({ txID: { sessionID, txIndex }, tx });
|
||||
|
||||
// console.log("after", { memberState, validTransactions });
|
||||
}
|
||||
|
||||
return validTransactions;
|
||||
return determineValidTransactionsForGroup(coValue, initialAdmin)
|
||||
.validTransactions;
|
||||
} else if (coValue.header.ruleset.type === "ownedByGroup") {
|
||||
const groupContent = expectGroup(
|
||||
coValue.node
|
||||
@@ -241,27 +66,18 @@ export function determineValidTransactions(
|
||||
return sessionLog.transactions
|
||||
.filter((tx) => {
|
||||
const groupAtTime = groupContent.atTime(tx.madeAt);
|
||||
const effectiveTransactor =
|
||||
transactor === groupContent.id &&
|
||||
groupAtTime instanceof RawAccount
|
||||
? groupAtTime.currentAgentID().match(
|
||||
(agentID) => agentID,
|
||||
(e) => {
|
||||
console.error(
|
||||
"Error while determining current agent ID in valid transactions",
|
||||
e,
|
||||
);
|
||||
return undefined;
|
||||
},
|
||||
)
|
||||
: transactor;
|
||||
const effectiveTransactor = agentInAccountOrMemberInGroup(
|
||||
transactor,
|
||||
groupAtTime,
|
||||
);
|
||||
|
||||
if (!effectiveTransactor) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const transactorRoleAtTxTime =
|
||||
groupAtTime.get(effectiveTransactor) || groupAtTime.get(EVERYONE);
|
||||
groupAtTime.roleOfInternal(effectiveTransactor)?.role ||
|
||||
groupAtTime.roleOfInternal(EVERYONE)?.role;
|
||||
|
||||
return (
|
||||
transactorRoleAtTxTime === "admin" ||
|
||||
@@ -291,6 +107,275 @@ export function determineValidTransactions(
|
||||
}
|
||||
}
|
||||
|
||||
function isHigherRole(a: Role, b: Role | undefined) {
|
||||
if (a === undefined) return false;
|
||||
if (b === undefined) return true;
|
||||
if (b === "admin") return false;
|
||||
if (a === "admin") return true;
|
||||
|
||||
return a === "writer" && b === "reader";
|
||||
}
|
||||
|
||||
function resolveMemberStateFromParentReference(
|
||||
coValue: CoValueCore,
|
||||
memberState: MemberState,
|
||||
parentReference: ParentGroupReference,
|
||||
) {
|
||||
const parentGroup = coValue.node.expectCoValueLoaded(
|
||||
getParentGroupId(parentReference),
|
||||
"Expected parent group to be loaded",
|
||||
);
|
||||
|
||||
if (parentGroup.header.ruleset.type !== "group") {
|
||||
return;
|
||||
}
|
||||
|
||||
const initialAdmin = parentGroup.header.ruleset.initialAdmin;
|
||||
|
||||
if (!initialAdmin) {
|
||||
throw new Error("Group must have initialAdmin");
|
||||
}
|
||||
|
||||
const { memberState: parentGroupMemberState } =
|
||||
determineValidTransactionsForGroup(parentGroup, initialAdmin);
|
||||
|
||||
for (const agent of Object.keys(parentGroupMemberState) as Array<
|
||||
keyof MemberState
|
||||
>) {
|
||||
const parentRole = parentGroupMemberState[agent];
|
||||
const currentRole = memberState[agent];
|
||||
|
||||
if (parentRole && isHigherRole(parentRole, currentRole)) {
|
||||
memberState[agent] = parentRole;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function determineValidTransactionsForGroup(
|
||||
coValue: CoValueCore,
|
||||
initialAdmin: RawAccountID | AgentID,
|
||||
): { validTransactions: ValidTransactionsResult[]; memberState: MemberState } {
|
||||
const allTransactionsSorted = [...coValue.sessionLogs.entries()].flatMap(
|
||||
([sessionID, sessionLog]) => {
|
||||
return sessionLog.transactions.map((tx, txIndex) => ({
|
||||
sessionID,
|
||||
txIndex,
|
||||
tx,
|
||||
})) as {
|
||||
sessionID: SessionID;
|
||||
txIndex: number;
|
||||
tx: Transaction;
|
||||
}[];
|
||||
},
|
||||
);
|
||||
|
||||
allTransactionsSorted.sort((a, b) => {
|
||||
return a.tx.madeAt - b.tx.madeAt;
|
||||
});
|
||||
|
||||
const memberState: MemberState = {};
|
||||
const validTransactions: ValidTransactionsResult[] = [];
|
||||
|
||||
for (const { sessionID, txIndex, tx } of allTransactionsSorted) {
|
||||
// console.log("before", { memberState, validTransactions });
|
||||
const transactor = accountOrAgentIDfromSessionID(sessionID);
|
||||
|
||||
if (tx.privacy === "private") {
|
||||
if (memberState[transactor] === "admin") {
|
||||
validTransactions.push({
|
||||
txID: { sessionID, txIndex },
|
||||
tx,
|
||||
});
|
||||
continue;
|
||||
} else {
|
||||
console.warn("Only admins can make private transactions in groups");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let changes;
|
||||
|
||||
try {
|
||||
changes = parseJSON(tx.changes);
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
coValue.id,
|
||||
"Invalid JSON in transaction",
|
||||
e,
|
||||
tx,
|
||||
JSON.stringify(tx.changes, (k, v) =>
|
||||
k === "changes" || k === "encryptedChanges"
|
||||
? v.slice(0, 20) + "..."
|
||||
: v,
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const change = changes[0] as
|
||||
| MapOpPayload<RawAccountID | AgentID | Everyone, Role>
|
||||
| MapOpPayload<"readKey", JsonValue>
|
||||
| MapOpPayload<"profile", CoID<RawProfile>>
|
||||
| MapOpPayload<`parent_${CoID<RawGroup>}`, CoID<RawGroup>>
|
||||
| MapOpPayload<`child_${CoID<RawGroup>}`, CoID<RawGroup>>;
|
||||
|
||||
if (changes.length !== 1) {
|
||||
console.warn("Group transaction must have exactly one change");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (change.op !== "set") {
|
||||
console.warn("Group transaction must set a role or readKey");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (change.key === "readKey") {
|
||||
if (memberState[transactor] !== "admin") {
|
||||
console.warn("Only admins can set readKeys");
|
||||
continue;
|
||||
}
|
||||
|
||||
validTransactions.push({ txID: { sessionID, txIndex }, tx });
|
||||
continue;
|
||||
} else if (change.key === "profile") {
|
||||
if (memberState[transactor] !== "admin") {
|
||||
console.warn("Only admins can set profile");
|
||||
continue;
|
||||
}
|
||||
|
||||
validTransactions.push({ txID: { sessionID, txIndex }, tx });
|
||||
continue;
|
||||
} else if (
|
||||
isKeyForKeyField(change.key) ||
|
||||
isKeyForAccountField(change.key)
|
||||
) {
|
||||
if (
|
||||
memberState[transactor] !== "admin" &&
|
||||
memberState[transactor] !== "adminInvite" &&
|
||||
memberState[transactor] !== "writerInvite" &&
|
||||
memberState[transactor] !== "readerInvite"
|
||||
) {
|
||||
console.warn("Only admins can reveal keys");
|
||||
continue;
|
||||
}
|
||||
|
||||
// TODO: check validity of agents who the key is revealed to?
|
||||
|
||||
validTransactions.push({ txID: { sessionID, txIndex }, tx });
|
||||
continue;
|
||||
} else if (isParentExtension(change.key)) {
|
||||
if (memberState[transactor] !== "admin") {
|
||||
console.warn("Only admins can set parent extensions");
|
||||
continue;
|
||||
}
|
||||
resolveMemberStateFromParentReference(coValue, memberState, change.key);
|
||||
validTransactions.push({ txID: { sessionID, txIndex }, tx });
|
||||
continue;
|
||||
} else if (isChildExtension(change.key)) {
|
||||
if (memberState[transactor] !== "admin") {
|
||||
console.warn("Only admins can set child extensions");
|
||||
continue;
|
||||
}
|
||||
validTransactions.push({ txID: { sessionID, txIndex }, tx });
|
||||
continue;
|
||||
}
|
||||
|
||||
const affectedMember = change.key;
|
||||
const assignedRole = change.value;
|
||||
|
||||
if (
|
||||
change.value !== "admin" &&
|
||||
change.value !== "writer" &&
|
||||
change.value !== "reader" &&
|
||||
change.value !== "revoked" &&
|
||||
change.value !== "adminInvite" &&
|
||||
change.value !== "writerInvite" &&
|
||||
change.value !== "readerInvite"
|
||||
) {
|
||||
console.warn("Group transaction must set a valid role");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
affectedMember === EVERYONE &&
|
||||
!(
|
||||
change.value === "reader" ||
|
||||
change.value === "writer" ||
|
||||
change.value === "revoked"
|
||||
)
|
||||
) {
|
||||
console.warn("Everyone can only be set to reader, writer or revoked");
|
||||
continue;
|
||||
}
|
||||
|
||||
const isFirstSelfAppointment =
|
||||
!memberState[transactor] &&
|
||||
transactor === initialAdmin &&
|
||||
change.op === "set" &&
|
||||
change.key === transactor &&
|
||||
change.value === "admin";
|
||||
|
||||
if (!isFirstSelfAppointment) {
|
||||
if (memberState[transactor] === "admin") {
|
||||
if (
|
||||
memberState[affectedMember] === "admin" &&
|
||||
affectedMember !== transactor &&
|
||||
assignedRole !== "admin"
|
||||
) {
|
||||
console.warn("Admins can only demote themselves.");
|
||||
continue;
|
||||
}
|
||||
} else if (memberState[transactor] === "adminInvite") {
|
||||
if (change.value !== "admin") {
|
||||
console.warn("AdminInvites can only create admins.");
|
||||
continue;
|
||||
}
|
||||
} else if (memberState[transactor] === "writerInvite") {
|
||||
if (change.value !== "writer") {
|
||||
console.warn("WriterInvites can only create writers.");
|
||||
continue;
|
||||
}
|
||||
} else if (memberState[transactor] === "readerInvite") {
|
||||
if (change.value !== "reader") {
|
||||
console.warn("ReaderInvites can only create reader.");
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
console.warn(
|
||||
"Group transaction must be made by current admin or invite",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
memberState[affectedMember] = change.value;
|
||||
validTransactions.push({ txID: { sessionID, txIndex }, tx });
|
||||
|
||||
// console.log("after", { memberState, validTransactions });
|
||||
}
|
||||
|
||||
return { validTransactions, memberState };
|
||||
}
|
||||
|
||||
function agentInAccountOrMemberInGroup(
|
||||
transactor: RawAccountID | AgentID,
|
||||
groupAtTime: RawGroup,
|
||||
): RawAccountID | AgentID | undefined {
|
||||
if (transactor === groupAtTime.id && groupAtTime instanceof RawAccount) {
|
||||
return groupAtTime.currentAgentID().match(
|
||||
(agentID) => agentID,
|
||||
(e) => {
|
||||
console.error(
|
||||
"Error while determining current agent ID in valid transactions",
|
||||
e,
|
||||
);
|
||||
return undefined;
|
||||
},
|
||||
);
|
||||
}
|
||||
return transactor;
|
||||
}
|
||||
|
||||
export function isKeyForKeyField(co: string): co is `${KeyID}_for_${KeyID}` {
|
||||
return co.startsWith("key_") && co.includes("_for_key");
|
||||
}
|
||||
@@ -304,3 +389,11 @@ export function isKeyForAccountField(
|
||||
co.includes("_for_everyone")
|
||||
);
|
||||
}
|
||||
|
||||
function isParentExtension(key: string): key is `parent_${CoID<RawGroup>}` {
|
||||
return key.startsWith("parent_");
|
||||
}
|
||||
|
||||
function isChildExtension(key: string): key is `child_${CoID<RawGroup>}` {
|
||||
return key.startsWith("child_");
|
||||
}
|
||||
|
||||
@@ -146,7 +146,7 @@ export class LSMStorage<WH, RH, FS extends FileSystem<WH, RH>> {
|
||||
asDependencyOf || id,
|
||||
);
|
||||
} else if (!known?.header && coValue.header?.ruleset.type === "group") {
|
||||
const dependedOnAccounts = new Set();
|
||||
const dependedOnAccountsAndGroups = new Set();
|
||||
for (const session of Object.values(coValue.sessionEntries)) {
|
||||
for (const entry of session) {
|
||||
for (const tx of entry.transactions) {
|
||||
@@ -154,16 +154,24 @@ export class LSMStorage<WH, RH, FS extends FileSystem<WH, RH>> {
|
||||
const parsedChanges = JSON.parse(tx.changes);
|
||||
for (const change of parsedChanges) {
|
||||
if (change.op === "set" && change.key.startsWith("co_")) {
|
||||
dependedOnAccounts.add(change.key);
|
||||
dependedOnAccountsAndGroups.add(change.key);
|
||||
}
|
||||
if (
|
||||
change.op === "set" &&
|
||||
change.key.startsWith("parent_co_")
|
||||
) {
|
||||
dependedOnAccountsAndGroups.add(
|
||||
change.key.replace("parent_", ""),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const account of dependedOnAccounts) {
|
||||
for (const accountOrGroup of dependedOnAccountsAndGroups) {
|
||||
await this.sendNewContent(
|
||||
account as CoID<RawCoValue>,
|
||||
accountOrGroup as CoID<RawCoValue>,
|
||||
undefined,
|
||||
asDependencyOf || id,
|
||||
);
|
||||
|
||||
@@ -304,14 +304,19 @@ export class SyncManager {
|
||||
|
||||
if (peerState.isServerOrStoragePeer()) {
|
||||
const initialSync = async () => {
|
||||
for (const id of this.local.coValuesStore.getKeys()) {
|
||||
// console.log("subscribing to after peer added", id, peer.id)
|
||||
await this.subscribeToIncludingDependencies(id, peerState);
|
||||
for (const entry of this.local.coValuesStore.getValues()) {
|
||||
await this.subscribeToIncludingDependencies(entry.id, peerState);
|
||||
|
||||
peerState.optimisticKnownStates.dispatch({
|
||||
type: "SET_AS_EMPTY",
|
||||
id,
|
||||
});
|
||||
if (entry.state.type === "available") {
|
||||
await this.sendNewContentIncludingDependencies(entry.id, peerState);
|
||||
}
|
||||
|
||||
if (!peerState.optimisticKnownStates.has(entry.id)) {
|
||||
peerState.optimisticKnownStates.dispatch({
|
||||
type: "SET_AS_EMPTY",
|
||||
id: entry.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
void initialSync();
|
||||
@@ -403,27 +408,39 @@ export class SyncManager {
|
||||
}
|
||||
|
||||
if (entry.state.type === "loading") {
|
||||
const value = await entry.getCoValue();
|
||||
// We need to return from handleLoad immediately and wait for the CoValue to be loaded
|
||||
// in a new task, otherwise we might block further incoming content messages that would
|
||||
// resolve the CoValue as available. This can happen when we receive fresh
|
||||
// content from a client, but we are a server with our own upstream server(s)
|
||||
entry
|
||||
.getCoValue()
|
||||
.then(async (value) => {
|
||||
if (value === "unavailable") {
|
||||
peer.dispatchToKnownStates({
|
||||
type: "SET",
|
||||
id: msg.id,
|
||||
value: knownStateIn(msg),
|
||||
});
|
||||
peer.toldKnownState.add(msg.id);
|
||||
|
||||
if (value === "unavailable") {
|
||||
peer.dispatchToKnownStates({
|
||||
type: "SET",
|
||||
id: msg.id,
|
||||
value: knownStateIn(msg),
|
||||
this.trySendToPeer(peer, {
|
||||
action: "known",
|
||||
id: msg.id,
|
||||
header: false,
|
||||
sessions: {},
|
||||
}).catch((e) => {
|
||||
console.error("Error sending known state back", e);
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await this.tellUntoldKnownStateIncludingDependencies(msg.id, peer);
|
||||
await this.sendNewContentIncludingDependencies(msg.id, peer);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error("Error loading coValue in handleLoad loading state", e);
|
||||
});
|
||||
peer.toldKnownState.add(msg.id);
|
||||
|
||||
this.trySendToPeer(peer, {
|
||||
action: "known",
|
||||
id: msg.id,
|
||||
header: false,
|
||||
sessions: {},
|
||||
}).catch((e) => {
|
||||
console.error("Error sending known state back", e);
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.state.type === "available") {
|
||||
|
||||
@@ -5,7 +5,13 @@ import { RawCoStream } from "../coValues/coStream.js";
|
||||
import { RawBinaryCoStream } from "../coValues/coStream.js";
|
||||
import { WasmCrypto } from "../crypto/WasmCrypto.js";
|
||||
import { LocalNode } from "../localNode.js";
|
||||
import { randomAnonymousAccountAndSessionID } from "./testUtils.js";
|
||||
import {
|
||||
createThreeConnectedNodes,
|
||||
createTwoConnectedNodes,
|
||||
loadCoValueOrFail,
|
||||
randomAnonymousAccountAndSessionID,
|
||||
waitFor,
|
||||
} from "./testUtils.js";
|
||||
|
||||
const Crypto = await WasmCrypto.create();
|
||||
|
||||
@@ -53,3 +59,148 @@ test("Can create a FileStream in a group", () => {
|
||||
expect(stream.headerMeta.type).toEqual("binary");
|
||||
expect(stream instanceof RawBinaryCoStream).toEqual(true);
|
||||
});
|
||||
|
||||
test("Remove a member from a group where the admin role is inherited", async () => {
|
||||
const { node1, node2, node3, node1ToNode2Peer, node2ToNode3Peer } =
|
||||
createThreeConnectedNodes("server", "server", "server");
|
||||
|
||||
const group = node1.createGroup();
|
||||
|
||||
group.addMember(node2.account, "admin");
|
||||
group.addMember(node3.account, "reader");
|
||||
|
||||
const groupOnNode2 = await loadCoValueOrFail(node2, group.id);
|
||||
|
||||
// The account of node2 create a child group and extend the initial group
|
||||
// This way the node1 account should become "admin" of the child group
|
||||
// by inheriting the admin role from the initial group
|
||||
const childGroup = node2.createGroup();
|
||||
childGroup.extend(groupOnNode2);
|
||||
|
||||
const map = childGroup.createMap();
|
||||
map.set("test", "Available to everyone");
|
||||
|
||||
const mapOnNode3 = await loadCoValueOrFail(node3, map.id);
|
||||
|
||||
// Check that the sync between node2 and node3 worked
|
||||
expect(mapOnNode3.get("test")).toEqual("Available to everyone");
|
||||
|
||||
// The node1 account removes the reader from the group
|
||||
// The reader should be automatically kicked out of the child group
|
||||
await group.removeMember(node3.account);
|
||||
|
||||
await node1.syncManager.waitForUploadIntoPeer(node1ToNode2Peer.id, group.id);
|
||||
|
||||
// Update the map to check that node3 can't read updates anymore
|
||||
map.set("test", "Hidden to node3");
|
||||
|
||||
await node2.syncManager.waitForUploadIntoPeer(node2ToNode3Peer.id, map.id);
|
||||
|
||||
// Check that the value has not been updated on node3
|
||||
expect(mapOnNode3.get("test")).toEqual("Available to everyone");
|
||||
|
||||
const mapOnNode1 = await loadCoValueOrFail(node1, map.id);
|
||||
|
||||
expect(mapOnNode1.get("test")).toEqual("Hidden to node3");
|
||||
});
|
||||
|
||||
test("An admin should be able to rotate the readKey on child groups and keep access to new coValues", async () => {
|
||||
const { node1, node2, node3, node1ToNode2Peer, node2ToNode1Peer } =
|
||||
createThreeConnectedNodes("server", "server", "server");
|
||||
|
||||
const group = node1.createGroup();
|
||||
|
||||
group.addMember(node2.account, "admin");
|
||||
group.addMember(node3.account, "reader");
|
||||
|
||||
const groupOnNode2 = await loadCoValueOrFail(node2, group.id);
|
||||
|
||||
// The account of node2 create a child group and extend the initial group
|
||||
// This way the node1 account should become "admin" of the child group
|
||||
// by inheriting the admin role from the initial group
|
||||
const childGroup = node2.createGroup();
|
||||
childGroup.extend(groupOnNode2);
|
||||
|
||||
await node2.syncManager.waitForUploadIntoPeer(
|
||||
node2ToNode1Peer.id,
|
||||
childGroup.id,
|
||||
);
|
||||
|
||||
// The node1 account removes the reader from the group
|
||||
// In this case we want to ensure that node1 is still able to read new coValues
|
||||
// Even if some childs are not available when the readKey is rotated
|
||||
await group.removeMember(node3.account);
|
||||
await node1.syncManager.waitForUploadIntoPeer(node1ToNode2Peer.id, group.id);
|
||||
|
||||
const map = childGroup.createMap();
|
||||
map.set("test", "Available to node1");
|
||||
|
||||
const mapOnNode1 = await loadCoValueOrFail(node1, map.id);
|
||||
expect(mapOnNode1.get("test")).toEqual("Available to node1");
|
||||
});
|
||||
|
||||
test("An admin should be able to rotate the readKey on child groups even if it was unavailable when kicking out a member from a parent group", async () => {
|
||||
const { node1, node2, node3, node1ToNode2Peer, node2ToNode1Peer } =
|
||||
createThreeConnectedNodes("server", "server", "server");
|
||||
|
||||
const group = node1.createGroup();
|
||||
|
||||
group.addMember(node2.account, "admin");
|
||||
group.addMember(node3.account, "reader");
|
||||
|
||||
const groupOnNode2 = await loadCoValueOrFail(node2, group.id);
|
||||
|
||||
// The account of node2 create a child group and extend the initial group
|
||||
// This way the node1 account should become "admin" of the child group
|
||||
// by inheriting the admin role from the initial group
|
||||
const childGroup = node2.createGroup();
|
||||
childGroup.extend(groupOnNode2);
|
||||
|
||||
// The node1 account removes the reader from the group
|
||||
// In this case we want to ensure that node1 is still able to read new coValues
|
||||
// Even if some childs are not available when the readKey is rotated
|
||||
await group.removeMember(node3.account);
|
||||
await node1.syncManager.waitForUploadIntoPeer(node1ToNode2Peer.id, group.id);
|
||||
|
||||
const map = childGroup.createMap();
|
||||
map.set("test", "Available to node1");
|
||||
|
||||
const mapOnNode1 = await loadCoValueOrFail(node1, map.id);
|
||||
expect(mapOnNode1.get("test")).toEqual("Available to node1");
|
||||
});
|
||||
|
||||
test("An admin should be able to rotate the readKey on child groups even if it was unavailable when kicking out a member from a parent group (grandChild)", async () => {
|
||||
const { node1, node2, node3, node1ToNode2Peer } = createThreeConnectedNodes(
|
||||
"server",
|
||||
"server",
|
||||
"server",
|
||||
);
|
||||
|
||||
const group = node1.createGroup();
|
||||
|
||||
group.addMember(node2.account, "admin");
|
||||
group.addMember(node3.account, "reader");
|
||||
|
||||
const groupOnNode2 = await loadCoValueOrFail(node2, group.id);
|
||||
|
||||
// The account of node2 create a child group and extend the initial group
|
||||
// This way the node1 account should become "admin" of the child group
|
||||
// by inheriting the admin role from the initial group
|
||||
const childGroup = node2.createGroup();
|
||||
childGroup.extend(groupOnNode2);
|
||||
const grandChildGroup = node2.createGroup();
|
||||
grandChildGroup.extend(childGroup);
|
||||
|
||||
// The node1 account removes the reader from the group
|
||||
// In this case we want to ensure that node1 is still able to read new coValues
|
||||
// Even if some childs are not available when the readKey is rotated
|
||||
await group.removeMember(node3.account);
|
||||
await node1.syncManager.waitForUploadIntoPeer(node1ToNode2Peer.id, group.id);
|
||||
|
||||
const map = childGroup.createMap();
|
||||
map.set("test", "Available to node1");
|
||||
|
||||
const mapOnNode1 = await loadCoValueOrFail(node1, map.id);
|
||||
|
||||
expect(mapOnNode1.get("test")).toEqual("Available to node1");
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ControlledAgent } from "../coValues/account.js";
|
||||
import { WasmCrypto } from "../crypto/WasmCrypto.js";
|
||||
import { expectGroup } from "../typeUtils/expectGroup.js";
|
||||
import {
|
||||
createTwoConnectedNodes,
|
||||
groupWithTwoAdmins,
|
||||
groupWithTwoAdminsHighLevel,
|
||||
newGroup,
|
||||
@@ -1033,7 +1034,7 @@ test("Admins can set group read rey, make a private transaction in an owned obje
|
||||
).toEqual("bar2");
|
||||
});
|
||||
|
||||
test("Admins can set group read rey, make a private transaction in an owned object, rotate the read key, add two readers, rotate the read key again to kick out one reader, make another private transaction in the owned object, and only the remaining reader can read both transactions (high level)", () => {
|
||||
test("Admins can set group read rey, make a private transaction in an owned object, rotate the read key, add two readers, rotate the read key again to kick out one reader, make another private transaction in the owned object, and only the remaining reader can read both transactions (high level)", async () => {
|
||||
const { node, group } = newGroupHighLevel();
|
||||
|
||||
const childObject = group.createMap();
|
||||
@@ -1057,7 +1058,7 @@ test("Admins can set group read rey, make a private transaction in an owned obje
|
||||
childObject.set("foo2", "bar2", "private");
|
||||
expect(childObject.get("foo2")).toEqual("bar2");
|
||||
|
||||
group.removeMember(reader);
|
||||
await group.removeMember(reader);
|
||||
|
||||
expect(childObject.core.getCurrentReadKey()).not.toEqual(secondReadKey);
|
||||
|
||||
@@ -1708,3 +1709,785 @@ test("Can give write permissions to 'everyone' (high-level)", async () => {
|
||||
childContent2.set("foo", "bar2", "private");
|
||||
expect(childContent2.get("foo")).toEqual("bar2");
|
||||
});
|
||||
|
||||
test("Admins can set parent extensions", () => {
|
||||
const { group, node } = newGroupHighLevel();
|
||||
const parentGroup = node.createGroup();
|
||||
|
||||
group.set(`parent_${parentGroup.id}`, "extend", "trusting");
|
||||
expect(group.get(`parent_${parentGroup.id}`)).toEqual("extend");
|
||||
});
|
||||
|
||||
test("Writers, readers and invitees can not set parent extensions", () => {
|
||||
const { group, node } = newGroupHighLevel();
|
||||
const parentGroup = node.createGroup();
|
||||
|
||||
const writer = node.createAccount();
|
||||
const reader = node.createAccount();
|
||||
const adminInvite = node.createAccount();
|
||||
const writerInvite = node.createAccount();
|
||||
const readerInvite = node.createAccount();
|
||||
|
||||
group.addMember(writer, "writer");
|
||||
group.addMember(reader, "reader");
|
||||
group.addMember(adminInvite, "adminInvite");
|
||||
group.addMember(writerInvite, "writerInvite");
|
||||
group.addMember(readerInvite, "readerInvite");
|
||||
|
||||
const groupAsWriter = expectGroup(
|
||||
group.core
|
||||
.testWithDifferentAccount(writer, Crypto.newRandomSessionID(writer.id))
|
||||
.getCurrentContent(),
|
||||
);
|
||||
|
||||
groupAsWriter.set(`parent_${parentGroup.id}`, "extend", "trusting");
|
||||
expect(groupAsWriter.get(`parent_${parentGroup.id}`)).toBeUndefined();
|
||||
|
||||
const groupAsReader = expectGroup(
|
||||
group.core
|
||||
.testWithDifferentAccount(reader, Crypto.newRandomSessionID(reader.id))
|
||||
.getCurrentContent(),
|
||||
);
|
||||
|
||||
groupAsReader.set(`parent_${parentGroup.id}`, "extend", "trusting");
|
||||
expect(groupAsReader.get(`parent_${parentGroup.id}`)).toBeUndefined();
|
||||
|
||||
const groupAsAdminInvite = expectGroup(
|
||||
group.core
|
||||
.testWithDifferentAccount(
|
||||
adminInvite,
|
||||
Crypto.newRandomSessionID(adminInvite.currentAgentID()._unsafeUnwrap()),
|
||||
)
|
||||
.getCurrentContent(),
|
||||
);
|
||||
|
||||
groupAsAdminInvite.set(`parent_${parentGroup.id}`, "extend", "trusting");
|
||||
expect(groupAsAdminInvite.get(`parent_${parentGroup.id}`)).toBeUndefined();
|
||||
|
||||
const groupAsWriterInvite = expectGroup(
|
||||
group.core
|
||||
.testWithDifferentAccount(
|
||||
writerInvite,
|
||||
Crypto.newRandomSessionID(
|
||||
writerInvite.currentAgentID()._unsafeUnwrap(),
|
||||
),
|
||||
)
|
||||
.getCurrentContent(),
|
||||
);
|
||||
|
||||
groupAsWriterInvite.set(`parent_${parentGroup.id}`, "extend", "trusting");
|
||||
expect(groupAsWriterInvite.get(`parent_${parentGroup.id}`)).toBeUndefined();
|
||||
|
||||
const groupAsReaderInvite = expectGroup(
|
||||
group.core
|
||||
.testWithDifferentAccount(
|
||||
readerInvite,
|
||||
Crypto.newRandomSessionID(
|
||||
readerInvite.currentAgentID()._unsafeUnwrap(),
|
||||
),
|
||||
)
|
||||
.getCurrentContent(),
|
||||
);
|
||||
|
||||
groupAsReaderInvite.set(`parent_${parentGroup.id}`, "extend", "trusting");
|
||||
expect(groupAsReaderInvite.get(`parent_${parentGroup.id}`)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("Admins can set child extensions", () => {
|
||||
const { group, node } = newGroupHighLevel();
|
||||
const childGroup = node.createGroup();
|
||||
|
||||
group.set(`child_${childGroup.id}`, "extend", "trusting");
|
||||
expect(group.get(`child_${childGroup.id}`)).toEqual("extend");
|
||||
});
|
||||
|
||||
test("Admins can set child extensions when the admin role is inherited", async () => {
|
||||
const { node1, node2 } = createTwoConnectedNodes("server", "server");
|
||||
|
||||
const node2Account = node2.account;
|
||||
const group = node1.createGroup();
|
||||
|
||||
group.addMember(node2Account, "admin");
|
||||
|
||||
const groupOnNode2 = await node2.load(group.id);
|
||||
|
||||
if (groupOnNode2 === "unavailable") {
|
||||
throw new Error("Group not found on node2");
|
||||
}
|
||||
|
||||
const childGroup = node2.createGroup();
|
||||
childGroup.extend(groupOnNode2);
|
||||
|
||||
const childGroupOnNode1 = await node1.load(childGroup.id);
|
||||
|
||||
if (childGroupOnNode1 === "unavailable") {
|
||||
throw new Error("Child group not found on node1");
|
||||
}
|
||||
|
||||
const grandChildGroup = node2.createGroup();
|
||||
grandChildGroup.extend(childGroupOnNode1);
|
||||
|
||||
expect(childGroupOnNode1.get(`child_${grandChildGroup.id}`)).toEqual(
|
||||
"extend",
|
||||
);
|
||||
expect(grandChildGroup.get(`parent_${childGroupOnNode1.id}`)).toEqual(
|
||||
"extend",
|
||||
);
|
||||
});
|
||||
|
||||
test("Writers, readers and invitees can not set child extensions", () => {
|
||||
const { group, node } = newGroupHighLevel();
|
||||
const childGroup = node.createGroup();
|
||||
|
||||
const writer = node.createAccount();
|
||||
const reader = node.createAccount();
|
||||
const adminInvite = node.createAccount();
|
||||
const writerInvite = node.createAccount();
|
||||
const readerInvite = node.createAccount();
|
||||
|
||||
group.addMember(writer, "writer");
|
||||
group.addMember(reader, "reader");
|
||||
group.addMember(adminInvite, "adminInvite");
|
||||
group.addMember(writerInvite, "writerInvite");
|
||||
group.addMember(readerInvite, "readerInvite");
|
||||
|
||||
const groupAsWriter = expectGroup(
|
||||
group.core
|
||||
.testWithDifferentAccount(writer, Crypto.newRandomSessionID(writer.id))
|
||||
.getCurrentContent(),
|
||||
);
|
||||
|
||||
groupAsWriter.set(`child_${childGroup.id}`, "extend", "trusting");
|
||||
expect(groupAsWriter.get(`child_${childGroup.id}`)).toBeUndefined();
|
||||
|
||||
const groupAsReader = expectGroup(
|
||||
group.core
|
||||
.testWithDifferentAccount(reader, Crypto.newRandomSessionID(reader.id))
|
||||
.getCurrentContent(),
|
||||
);
|
||||
|
||||
groupAsReader.set(`child_${childGroup.id}`, "extend", "trusting");
|
||||
expect(groupAsReader.get(`child_${childGroup.id}`)).toBeUndefined();
|
||||
|
||||
const groupAsAdminInvite = expectGroup(
|
||||
group.core
|
||||
.testWithDifferentAccount(
|
||||
adminInvite,
|
||||
Crypto.newRandomSessionID(adminInvite.currentAgentID()._unsafeUnwrap()),
|
||||
)
|
||||
.getCurrentContent(),
|
||||
);
|
||||
|
||||
groupAsAdminInvite.set(`child_${childGroup.id}`, "extend", "trusting");
|
||||
expect(groupAsAdminInvite.get(`child_${childGroup.id}`)).toBeUndefined();
|
||||
|
||||
const groupAsWriterInvite = expectGroup(
|
||||
group.core
|
||||
.testWithDifferentAccount(
|
||||
writerInvite,
|
||||
Crypto.newRandomSessionID(
|
||||
writerInvite.currentAgentID()._unsafeUnwrap(),
|
||||
),
|
||||
)
|
||||
.getCurrentContent(),
|
||||
);
|
||||
|
||||
groupAsWriterInvite.set(`child_${childGroup.id}`, "extend", "trusting");
|
||||
expect(groupAsWriterInvite.get(`child_${childGroup.id}`)).toBeUndefined();
|
||||
|
||||
const groupAsReaderInvite = expectGroup(
|
||||
group.core
|
||||
.testWithDifferentAccount(
|
||||
readerInvite,
|
||||
Crypto.newRandomSessionID(
|
||||
readerInvite.currentAgentID()._unsafeUnwrap(),
|
||||
),
|
||||
)
|
||||
.getCurrentContent(),
|
||||
);
|
||||
|
||||
groupAsReaderInvite.set(`child_${childGroup.id}`, "extend", "trusting");
|
||||
expect(groupAsReaderInvite.get(`child_${childGroup.id}`)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("Member roles are inherited by child groups (except invites)", () => {
|
||||
const { group, node, admin } = newGroupHighLevel();
|
||||
const parentGroup = node.createGroup();
|
||||
|
||||
group.set(`parent_${parentGroup.id}`, "extend", "trusting");
|
||||
|
||||
const writer = node.createAccount();
|
||||
const reader = node.createAccount();
|
||||
const adminInvite = node.createAccount();
|
||||
const writerInvite = node.createAccount();
|
||||
const readerInvite = node.createAccount();
|
||||
|
||||
parentGroup.addMember(writer, "writer");
|
||||
parentGroup.addMember(reader, "reader");
|
||||
parentGroup.addMember(adminInvite, "adminInvite");
|
||||
parentGroup.addMember(writerInvite, "writerInvite");
|
||||
parentGroup.addMember(readerInvite, "readerInvite");
|
||||
|
||||
expect(group.roleOfInternal(admin.id)).toEqual({
|
||||
role: "admin",
|
||||
via: undefined,
|
||||
});
|
||||
|
||||
expect(group.roleOfInternal(writer.id)).toEqual({
|
||||
role: "writer",
|
||||
via: parentGroup.id,
|
||||
});
|
||||
expect(group.roleOf(writer.id)).toEqual("writer");
|
||||
|
||||
expect(group.roleOfInternal(reader.id)).toEqual({
|
||||
role: "reader",
|
||||
via: parentGroup.id,
|
||||
});
|
||||
expect(group.roleOf(reader.id)).toEqual("reader");
|
||||
|
||||
expect(group.roleOfInternal(adminInvite.id)).toEqual(undefined);
|
||||
expect(group.roleOf(adminInvite.id)).toEqual(undefined);
|
||||
|
||||
expect(group.roleOfInternal(writerInvite.id)).toEqual(undefined);
|
||||
expect(group.roleOf(writerInvite.id)).toEqual(undefined);
|
||||
|
||||
expect(group.roleOfInternal(readerInvite.id)).toEqual(undefined);
|
||||
expect(group.roleOf(readerInvite.id)).toEqual(undefined);
|
||||
});
|
||||
|
||||
test("Member roles are inherited by grand-children groups (except invites)", () => {
|
||||
const { group, node, admin } = newGroupHighLevel();
|
||||
const parentGroup = node.createGroup();
|
||||
const grandParentGroup = node.createGroup();
|
||||
|
||||
group.set(`parent_${parentGroup.id}`, "extend", "trusting");
|
||||
parentGroup.set(`parent_${grandParentGroup.id}`, "extend", "trusting");
|
||||
|
||||
const writer = node.createAccount();
|
||||
const reader = node.createAccount();
|
||||
const adminInvite = node.createAccount();
|
||||
const writerInvite = node.createAccount();
|
||||
const readerInvite = node.createAccount();
|
||||
|
||||
grandParentGroup.addMember(writer, "writer");
|
||||
grandParentGroup.addMember(reader, "reader");
|
||||
grandParentGroup.addMember(adminInvite, "adminInvite");
|
||||
grandParentGroup.addMember(writerInvite, "writerInvite");
|
||||
grandParentGroup.addMember(readerInvite, "readerInvite");
|
||||
|
||||
expect(group.roleOfInternal(admin.id)).toEqual({
|
||||
role: "admin",
|
||||
via: undefined,
|
||||
});
|
||||
|
||||
expect(group.roleOfInternal(writer.id)).toEqual({
|
||||
role: "writer",
|
||||
via: parentGroup.id,
|
||||
});
|
||||
expect(group.roleOf(writer.id)).toEqual("writer");
|
||||
|
||||
expect(group.roleOfInternal(reader.id)).toEqual({
|
||||
role: "reader",
|
||||
via: parentGroup.id,
|
||||
});
|
||||
expect(group.roleOf(reader.id)).toEqual("reader");
|
||||
|
||||
expect(group.roleOfInternal(adminInvite.id)).toEqual(undefined);
|
||||
expect(group.roleOf(adminInvite.id)).toEqual(undefined);
|
||||
|
||||
expect(group.roleOfInternal(writerInvite.id)).toEqual(undefined);
|
||||
expect(group.roleOf(writerInvite.id)).toEqual(undefined);
|
||||
|
||||
expect(group.roleOfInternal(readerInvite.id)).toEqual(undefined);
|
||||
expect(group.roleOf(readerInvite.id)).toEqual(undefined);
|
||||
});
|
||||
|
||||
test("Admins can reveal parent read keys to child groups", () => {
|
||||
const { group, node } = newGroupHighLevel();
|
||||
const parentGroup = node.createGroup();
|
||||
|
||||
const parentReadKeyID = parentGroup.get("readKey");
|
||||
if (!parentReadKeyID) {
|
||||
throw new Error("Can't get parent group read key");
|
||||
}
|
||||
|
||||
const readKeyID = group.get("readKey");
|
||||
if (!readKeyID) {
|
||||
throw new Error("Can't get group read key");
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const encrypted = "fake_encrypted_key_secret" as any;
|
||||
|
||||
group.set(`${readKeyID}_for_${parentReadKeyID}`, encrypted, "trusting");
|
||||
expect(group.get(`${readKeyID}_for_${parentReadKeyID}`)).toEqual(encrypted);
|
||||
});
|
||||
|
||||
test("Writers, readers and invites can't reveal parent read keys to child groups", () => {
|
||||
const { group, node } = newGroupHighLevel();
|
||||
const parentGroup = node.createGroup();
|
||||
|
||||
const parentReadKeyID = parentGroup.get("readKey");
|
||||
if (!parentReadKeyID) {
|
||||
throw new Error("Can't get parent group read key");
|
||||
}
|
||||
|
||||
const readKeyID = group.get("readKey");
|
||||
if (!readKeyID) {
|
||||
throw new Error("Can't get group read key");
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const encrypted = "fake_encrypted_key_secret" as any;
|
||||
|
||||
const writer = node.createAccount();
|
||||
const reader = node.createAccount();
|
||||
const adminInvite = node.createAccount();
|
||||
const writerInvite = node.createAccount();
|
||||
const readerInvite = node.createAccount();
|
||||
|
||||
group.addMember(writer, "writer");
|
||||
group.addMember(reader, "reader");
|
||||
group.addMember(adminInvite, "adminInvite");
|
||||
group.addMember(writerInvite, "writerInvite");
|
||||
group.addMember(readerInvite, "readerInvite");
|
||||
|
||||
const groupAsWriter = expectGroup(
|
||||
group.core
|
||||
.testWithDifferentAccount(writer, Crypto.newRandomSessionID(writer.id))
|
||||
.getCurrentContent(),
|
||||
);
|
||||
|
||||
groupAsWriter.set(
|
||||
`${readKeyID}_for_${parentReadKeyID}`,
|
||||
encrypted,
|
||||
"trusting",
|
||||
);
|
||||
expect(
|
||||
groupAsWriter.get(`${readKeyID}_for_${parentReadKeyID}`),
|
||||
).toBeUndefined();
|
||||
|
||||
const groupAsReader = expectGroup(
|
||||
group.core
|
||||
.testWithDifferentAccount(reader, Crypto.newRandomSessionID(reader.id))
|
||||
.getCurrentContent(),
|
||||
);
|
||||
|
||||
groupAsReader.set(
|
||||
`${readKeyID}_for_${parentReadKeyID}`,
|
||||
encrypted,
|
||||
"trusting",
|
||||
);
|
||||
expect(
|
||||
groupAsReader.get(`${readKeyID}_for_${parentReadKeyID}`),
|
||||
).toBeUndefined();
|
||||
|
||||
const groupAsAdminInvite = expectGroup(
|
||||
group.core
|
||||
.testWithDifferentAccount(
|
||||
adminInvite,
|
||||
Crypto.newRandomSessionID(adminInvite.currentAgentID()._unsafeUnwrap()),
|
||||
)
|
||||
.getCurrentContent(),
|
||||
);
|
||||
|
||||
groupAsAdminInvite.set(
|
||||
`${readKeyID}_for_${parentReadKeyID}`,
|
||||
encrypted,
|
||||
"trusting",
|
||||
);
|
||||
expect(
|
||||
groupAsAdminInvite.get(`${readKeyID}_for_${parentReadKeyID}`),
|
||||
).toBeUndefined();
|
||||
|
||||
const groupAsWriterInvite = expectGroup(
|
||||
group.core
|
||||
.testWithDifferentAccount(
|
||||
writerInvite,
|
||||
Crypto.newRandomSessionID(
|
||||
writerInvite.currentAgentID()._unsafeUnwrap(),
|
||||
),
|
||||
)
|
||||
.getCurrentContent(),
|
||||
);
|
||||
|
||||
groupAsWriterInvite.set(
|
||||
`${readKeyID}_for_${parentReadKeyID}`,
|
||||
encrypted,
|
||||
"trusting",
|
||||
);
|
||||
expect(
|
||||
groupAsWriterInvite.get(`${readKeyID}_for_${parentReadKeyID}`),
|
||||
).toBeUndefined();
|
||||
|
||||
const groupAsReaderInvite = expectGroup(
|
||||
group.core
|
||||
.testWithDifferentAccount(
|
||||
readerInvite,
|
||||
Crypto.newRandomSessionID(
|
||||
readerInvite.currentAgentID()._unsafeUnwrap(),
|
||||
),
|
||||
)
|
||||
.getCurrentContent(),
|
||||
);
|
||||
|
||||
groupAsReaderInvite.set(
|
||||
`${readKeyID}_for_${parentReadKeyID}`,
|
||||
encrypted,
|
||||
"trusting",
|
||||
);
|
||||
expect(
|
||||
groupAsReaderInvite.get(`${readKeyID}_for_${parentReadKeyID}`),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
test("Writers and readers in a parent group can read from an object owned by a child group", () => {
|
||||
const { group, node } = newGroupHighLevel();
|
||||
const parentGroup = node.createGroup();
|
||||
|
||||
group.set(`parent_${parentGroup.id}`, "extend", "trusting");
|
||||
|
||||
const parentReadKeyID = parentGroup.get("readKey");
|
||||
const parentKey =
|
||||
parentReadKeyID && parentGroup.core.getReadKey(parentReadKeyID);
|
||||
if (!parentReadKeyID || !parentKey) {
|
||||
throw new Error("Can't get parent group read key");
|
||||
}
|
||||
|
||||
const readKeyID = group.get("readKey");
|
||||
const readKey = readKeyID && group.core.getReadKey(readKeyID);
|
||||
if (!readKeyID || !readKey) {
|
||||
throw new Error("Can't get group read key");
|
||||
}
|
||||
|
||||
const encrypted = node.crypto.encryptKeySecret({
|
||||
toEncrypt: {
|
||||
id: readKeyID,
|
||||
secret: readKey,
|
||||
},
|
||||
encrypting: {
|
||||
id: parentReadKeyID,
|
||||
secret: parentKey,
|
||||
},
|
||||
}).encrypted;
|
||||
|
||||
group.set(`${readKeyID}_for_${parentReadKeyID}`, encrypted, "trusting");
|
||||
|
||||
const writer = node.createAccount();
|
||||
const reader = node.createAccount();
|
||||
parentGroup.addMember(writer, "writer");
|
||||
parentGroup.addMember(reader, "reader");
|
||||
|
||||
const childObject = node.createCoValue({
|
||||
type: "comap",
|
||||
ruleset: { type: "ownedByGroup", group: group.id },
|
||||
meta: null,
|
||||
...Crypto.createdNowUnique(),
|
||||
});
|
||||
|
||||
const childContent = expectMap(childObject.getCurrentContent());
|
||||
|
||||
childContent.set("foo", "bar", "private");
|
||||
expect(childContent.get("foo")).toEqual("bar");
|
||||
|
||||
const childContentAsWriter = expectMap(
|
||||
childObject
|
||||
.testWithDifferentAccount(writer, Crypto.newRandomSessionID(writer.id))
|
||||
.getCurrentContent(),
|
||||
);
|
||||
|
||||
expect(childContentAsWriter.get("foo")).toEqual("bar");
|
||||
|
||||
const childContentAsReader = expectMap(
|
||||
childObject
|
||||
.testWithDifferentAccount(reader, Crypto.newRandomSessionID(reader.id))
|
||||
.getCurrentContent(),
|
||||
);
|
||||
|
||||
expect(childContentAsReader.get("foo")).toEqual("bar");
|
||||
});
|
||||
|
||||
test("Writers in a parent group can write to an object owned by a child group", () => {
|
||||
const { group, node } = newGroupHighLevel();
|
||||
const parentGroup = node.createGroup();
|
||||
|
||||
group.set(`parent_${parentGroup.id}`, "extend", "trusting");
|
||||
|
||||
const parentReadKeyID = parentGroup.get("readKey");
|
||||
const parentKey =
|
||||
parentReadKeyID && parentGroup.core.getReadKey(parentReadKeyID);
|
||||
if (!parentReadKeyID || !parentKey) {
|
||||
throw new Error("Can't get parent group read key");
|
||||
}
|
||||
|
||||
const readKeyID = group.get("readKey");
|
||||
const readKey = readKeyID && group.core.getReadKey(readKeyID);
|
||||
if (!readKeyID || !readKey) {
|
||||
throw new Error("Can't get group read key");
|
||||
}
|
||||
|
||||
const encrypted = node.crypto.encryptKeySecret({
|
||||
toEncrypt: {
|
||||
id: readKeyID,
|
||||
secret: readKey,
|
||||
},
|
||||
encrypting: {
|
||||
id: parentReadKeyID,
|
||||
secret: parentKey,
|
||||
},
|
||||
}).encrypted;
|
||||
|
||||
group.set(`${readKeyID}_for_${parentReadKeyID}`, encrypted, "trusting");
|
||||
|
||||
const writer = node.createAccount();
|
||||
parentGroup.addMember(writer, "writer");
|
||||
|
||||
const childObject = node.createCoValue({
|
||||
type: "comap",
|
||||
ruleset: { type: "ownedByGroup", group: group.id },
|
||||
meta: null,
|
||||
...Crypto.createdNowUnique(),
|
||||
});
|
||||
|
||||
const childContentAsWriter = expectMap(
|
||||
childObject
|
||||
.testWithDifferentAccount(writer, Crypto.newRandomSessionID(writer.id))
|
||||
.getCurrentContent(),
|
||||
);
|
||||
|
||||
childContentAsWriter.set("foo", "bar", "private");
|
||||
expect(childContentAsWriter.get("foo")).toEqual("bar");
|
||||
});
|
||||
|
||||
test("When rotating the key of a child group, the new child key is exposed to the parent group", () => {
|
||||
const { group, node } = newGroupHighLevel();
|
||||
const parentGroup = node.createGroup();
|
||||
|
||||
group.set(`parent_${parentGroup.id}`, "extend", "trusting");
|
||||
|
||||
const currentReadKeyID = group.get("readKey");
|
||||
if (!currentReadKeyID) {
|
||||
throw new Error("Can't get group read key");
|
||||
}
|
||||
|
||||
group.rotateReadKey();
|
||||
|
||||
const newReadKeyID = group.get("readKey");
|
||||
if (!newReadKeyID) {
|
||||
throw new Error("Can't get new group read key");
|
||||
}
|
||||
expect(newReadKeyID).not.toEqual(currentReadKeyID);
|
||||
|
||||
const parentReadKeyID = parentGroup.get("readKey");
|
||||
if (!parentReadKeyID) {
|
||||
throw new Error("Can't get parent group read key");
|
||||
}
|
||||
|
||||
console.log("Checking", `${newReadKeyID}_for_${parentReadKeyID}`);
|
||||
|
||||
expect(group.get(`${newReadKeyID}_for_${parentReadKeyID}`)).toBeDefined();
|
||||
});
|
||||
|
||||
test("When rotating the key of a parent group, the keys of all child groups are also rotated", () => {
|
||||
const { group, node } = newGroupHighLevel();
|
||||
const parentGroup = node.createGroup();
|
||||
|
||||
parentGroup.set(`child_${group.id}`, "extend", "trusting");
|
||||
group.set(`parent_${parentGroup.id}`, "extend", "trusting");
|
||||
|
||||
group.rotateReadKey();
|
||||
|
||||
const currentChildReadKeyID = group.get("readKey");
|
||||
if (!currentChildReadKeyID) {
|
||||
throw new Error("Can't get group read key");
|
||||
}
|
||||
|
||||
console.log("child id", group.id);
|
||||
parentGroup.rotateReadKey();
|
||||
|
||||
const newChildReadKeyID = expectGroup(group.core.getCurrentContent()).get(
|
||||
"readKey",
|
||||
);
|
||||
if (!newChildReadKeyID) {
|
||||
throw new Error("Can't get new group read key");
|
||||
}
|
||||
|
||||
expect(newChildReadKeyID).not.toEqual(currentChildReadKeyID);
|
||||
});
|
||||
|
||||
test("When rotating the key of a grand-parent group, the keys of all child and grand-child groups are also rotated", () => {
|
||||
const { group, node } = newGroupHighLevel();
|
||||
const grandParentGroup = node.createGroup();
|
||||
const parentGroup = node.createGroup();
|
||||
|
||||
grandParentGroup.set(`child_${parentGroup.id}`, "extend", "trusting");
|
||||
parentGroup.set(`child_${group.id}`, "extend", "trusting");
|
||||
parentGroup.set(`parent_${grandParentGroup.id}`, "extend", "trusting");
|
||||
group.set(`parent_${grandParentGroup.id}`, "extend", "trusting");
|
||||
|
||||
const currentGrandParentReadKeyID = grandParentGroup.get("readKey");
|
||||
if (!currentGrandParentReadKeyID) {
|
||||
throw new Error("Can't get grand-parent group read key");
|
||||
}
|
||||
|
||||
const currentParentReadKeyID = parentGroup.get("readKey");
|
||||
if (!currentParentReadKeyID) {
|
||||
throw new Error("Can't get parent group read key");
|
||||
}
|
||||
|
||||
const currentChildReadKeyID = group.get("readKey");
|
||||
if (!currentChildReadKeyID) {
|
||||
throw new Error("Can't get group read key");
|
||||
}
|
||||
|
||||
grandParentGroup.rotateReadKey();
|
||||
|
||||
const newGrandParentReadKeyID = grandParentGroup.get("readKey");
|
||||
if (!newGrandParentReadKeyID) {
|
||||
throw new Error("Can't get new grand-parent group read key");
|
||||
}
|
||||
|
||||
expect(newGrandParentReadKeyID).not.toEqual(currentGrandParentReadKeyID);
|
||||
|
||||
const newParentReadKeyID = expectGroup(
|
||||
parentGroup.core.getCurrentContent(),
|
||||
).get("readKey");
|
||||
if (!newParentReadKeyID) {
|
||||
throw new Error("Can't get new parent group read key");
|
||||
}
|
||||
|
||||
expect(newParentReadKeyID).not.toEqual(currentParentReadKeyID);
|
||||
|
||||
const newChildReadKeyID = expectGroup(group.core.getCurrentContent()).get(
|
||||
"readKey",
|
||||
);
|
||||
if (!newChildReadKeyID) {
|
||||
throw new Error("Can't get new group read key");
|
||||
}
|
||||
|
||||
expect(newChildReadKeyID).not.toEqual(currentChildReadKeyID);
|
||||
});
|
||||
|
||||
test("Calling extend on group sets up parent and child references and reveals child key to parent", () => {
|
||||
const { group, node } = newGroupHighLevel();
|
||||
const parentGroup = node.createGroup();
|
||||
|
||||
group.extend(parentGroup);
|
||||
|
||||
expect(group.get(`parent_${parentGroup.id}`)).toEqual("extend");
|
||||
expect(parentGroup.get(`child_${group.id}`)).toEqual("extend");
|
||||
|
||||
const parentReadKeyID = parentGroup.get("readKey");
|
||||
if (!parentReadKeyID) {
|
||||
throw new Error("Can't get parent group read key");
|
||||
}
|
||||
|
||||
const childReadKeyID = group.get("readKey");
|
||||
if (!childReadKeyID) {
|
||||
throw new Error("Can't get group read key");
|
||||
}
|
||||
|
||||
expect(group.get(`${childReadKeyID}_for_${parentReadKeyID}`)).toBeDefined();
|
||||
|
||||
const reader = node.createAccount();
|
||||
parentGroup.addMember(reader, "reader");
|
||||
|
||||
const childObject = node.createCoValue({
|
||||
type: "comap",
|
||||
ruleset: { type: "ownedByGroup", group: group.id },
|
||||
meta: null,
|
||||
...Crypto.createdNowUnique(),
|
||||
});
|
||||
const childMap = expectMap(childObject.getCurrentContent());
|
||||
|
||||
childMap.set("foo", "bar", "private");
|
||||
|
||||
const childContentAsReader = expectMap(
|
||||
childObject
|
||||
.testWithDifferentAccount(reader, Crypto.newRandomSessionID(reader.id))
|
||||
.getCurrentContent(),
|
||||
);
|
||||
|
||||
expect(childContentAsReader.get("foo")).toEqual("bar");
|
||||
});
|
||||
|
||||
test("Calling extend to create grand-child groups parent and child references and reveals child key to parent(s)", () => {
|
||||
const { group, node } = newGroupHighLevel();
|
||||
const parentGroup = node.createGroup();
|
||||
const grandParentGroup = node.createGroup();
|
||||
|
||||
group.extend(parentGroup);
|
||||
parentGroup.extend(grandParentGroup);
|
||||
|
||||
expect(group.get(`parent_${parentGroup.id}`)).toEqual("extend");
|
||||
expect(parentGroup.get(`parent_${grandParentGroup.id}`)).toEqual("extend");
|
||||
expect(parentGroup.get(`child_${group.id}`)).toEqual("extend");
|
||||
expect(grandParentGroup.get(`child_${parentGroup.id}`)).toEqual("extend");
|
||||
|
||||
const reader = node.createAccount();
|
||||
grandParentGroup.addMember(reader, "reader");
|
||||
|
||||
const childObject = node.createCoValue({
|
||||
type: "comap",
|
||||
ruleset: { type: "ownedByGroup", group: group.id },
|
||||
meta: null,
|
||||
...Crypto.createdNowUnique(),
|
||||
});
|
||||
const childMap = expectMap(childObject.getCurrentContent());
|
||||
|
||||
childMap.set("foo", "bar", "private");
|
||||
|
||||
const childContentAsReader = expectMap(
|
||||
childObject
|
||||
.testWithDifferentAccount(reader, Crypto.newRandomSessionID(reader.id))
|
||||
.getCurrentContent(),
|
||||
);
|
||||
|
||||
expect(childContentAsReader.get("foo")).toEqual("bar");
|
||||
});
|
||||
|
||||
test("High-level permissions work correctly when a group is extended", async () => {
|
||||
const { group, node } = newGroupHighLevel();
|
||||
const parentGroup = node.createGroup();
|
||||
|
||||
group.extend(parentGroup);
|
||||
|
||||
const reader = node.createAccount();
|
||||
parentGroup.addMember(reader, "reader");
|
||||
|
||||
const mapCore = node.createCoValue({
|
||||
type: "comap",
|
||||
ruleset: { type: "ownedByGroup", group: group.id },
|
||||
meta: null,
|
||||
...Crypto.createdNowUnique(),
|
||||
});
|
||||
|
||||
const map = expectMap(mapCore.getCurrentContent());
|
||||
|
||||
map.set("foo", "bar", "private");
|
||||
|
||||
const mapAsReader = expectMap(
|
||||
mapCore
|
||||
.testWithDifferentAccount(reader, Crypto.newRandomSessionID(reader.id))
|
||||
.getCurrentContent(),
|
||||
);
|
||||
|
||||
expect(mapAsReader.get("foo")).toEqual("bar");
|
||||
|
||||
const groupKeyBeforeRemove = group.core.getCurrentReadKey().id;
|
||||
|
||||
await parentGroup.removeMember(reader);
|
||||
|
||||
const groupKeyAfterRemove = group.core.getCurrentReadKey().id;
|
||||
expect(groupKeyAfterRemove).not.toEqual(groupKeyBeforeRemove);
|
||||
|
||||
map.set("foo", "baz", "private");
|
||||
|
||||
const mapAsReaderAfterRemove = expectMap(
|
||||
mapCore
|
||||
.testWithDifferentAccount(reader, Crypto.newRandomSessionID(reader.id))
|
||||
.getCurrentContent(),
|
||||
);
|
||||
|
||||
expect(mapAsReaderAfterRemove.get("foo")).not.toEqual("baz");
|
||||
});
|
||||
|
||||
@@ -1437,6 +1437,7 @@ describe("sync - extra tests", () => {
|
||||
const reasonableMemoryUsage = 1; // 500 MB
|
||||
expect(memoryUsage).toBeLessThan(reasonableMemoryUsage);
|
||||
});
|
||||
|
||||
test("Node correctly handles and recovers from network partitions", async () => {
|
||||
// Create three nodes
|
||||
const [admin1, session1] = randomAnonymousAccountAndSessionID();
|
||||
@@ -1872,6 +1873,34 @@ describe("SyncManager.addPeer", () => {
|
||||
expect(closeSpy).not.toHaveBeenCalled();
|
||||
expect(firstPeer.closed).toBe(true);
|
||||
});
|
||||
|
||||
test("when adding a server peer the local coValues should be sent to it", async () => {
|
||||
// Setup nodes
|
||||
const client = createTestNode();
|
||||
const jazzCloud = createTestNode();
|
||||
|
||||
// Connect nodes initially
|
||||
const [connectionWithClientAsPeer, jazzCloudConnectionAsPeer] =
|
||||
connectedPeers("connectionWithClient", "jazzCloudConnection", {
|
||||
peer1role: "client",
|
||||
peer2role: "server",
|
||||
});
|
||||
|
||||
jazzCloud.syncManager.addPeer(connectionWithClientAsPeer);
|
||||
|
||||
const group = client.createGroup();
|
||||
const map = group.createMap();
|
||||
map.set("key1", "value1", "trusting");
|
||||
|
||||
client.syncManager.addPeer(jazzCloudConnectionAsPeer);
|
||||
|
||||
await client.syncManager.waitForUploadIntoPeer(
|
||||
jazzCloudConnectionAsPeer.id,
|
||||
map.core.id,
|
||||
);
|
||||
|
||||
expect(jazzCloud.coValuesStore.get(map.id).state.type).toBe("available");
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadCoValueCore with retry", () => {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { expect } from "vitest";
|
||||
import { ControlledAgent } from "../coValues/account.js";
|
||||
import { WasmCrypto } from "../crypto/WasmCrypto.js";
|
||||
import { CoID, RawCoValue } from "../exports.js";
|
||||
import { SessionID } from "../ids.js";
|
||||
import { LocalNode } from "../localNode.js";
|
||||
import { connectedPeers } from "../streamUtils.js";
|
||||
import { Peer } from "../sync.js";
|
||||
import { expectGroup } from "../typeUtils/expectGroup.js";
|
||||
|
||||
const Crypto = await WasmCrypto.create();
|
||||
@@ -23,6 +26,93 @@ export function createTestNode() {
|
||||
return new LocalNode(admin, session, Crypto);
|
||||
}
|
||||
|
||||
export function createTwoConnectedNodes(
|
||||
node1Role: Peer["role"],
|
||||
node2Role: Peer["role"],
|
||||
) {
|
||||
// Setup nodes
|
||||
const node1 = createTestNode();
|
||||
const node2 = createTestNode();
|
||||
|
||||
// Connect nodes initially
|
||||
const [node1ToNode2Peer, node2ToNode1Peer] = connectedPeers(
|
||||
"node1ToNode2",
|
||||
"node2ToNode1",
|
||||
{
|
||||
peer1role: node2Role,
|
||||
peer2role: node1Role,
|
||||
},
|
||||
);
|
||||
|
||||
node1.syncManager.addPeer(node1ToNode2Peer);
|
||||
node2.syncManager.addPeer(node2ToNode1Peer);
|
||||
|
||||
return {
|
||||
node1,
|
||||
node2,
|
||||
node1ToNode2Peer,
|
||||
node2ToNode1Peer,
|
||||
};
|
||||
}
|
||||
|
||||
export function createThreeConnectedNodes(
|
||||
node1Role: Peer["role"],
|
||||
node2Role: Peer["role"],
|
||||
node3Role: Peer["role"],
|
||||
) {
|
||||
// Setup nodes
|
||||
const node1 = createTestNode();
|
||||
const node2 = createTestNode();
|
||||
const node3 = createTestNode();
|
||||
|
||||
// Connect nodes initially
|
||||
const [node1ToNode2Peer, node2ToNode1Peer] = connectedPeers(
|
||||
"node1ToNode2",
|
||||
"node2ToNode1",
|
||||
{
|
||||
peer1role: node2Role,
|
||||
peer2role: node1Role,
|
||||
},
|
||||
);
|
||||
|
||||
const [node1ToNode3Peer, node3ToNode1Peer] = connectedPeers(
|
||||
"node1ToNode3",
|
||||
"node3ToNode1",
|
||||
{
|
||||
peer1role: node3Role,
|
||||
peer2role: node1Role,
|
||||
},
|
||||
);
|
||||
|
||||
const [node2ToNode3Peer, node3ToNode2Peer] = connectedPeers(
|
||||
"node2ToNode3",
|
||||
"node3ToNode2",
|
||||
{
|
||||
peer1role: node3Role,
|
||||
peer2role: node2Role,
|
||||
},
|
||||
);
|
||||
|
||||
node1.syncManager.addPeer(node1ToNode2Peer);
|
||||
node1.syncManager.addPeer(node1ToNode3Peer);
|
||||
node2.syncManager.addPeer(node2ToNode1Peer);
|
||||
node2.syncManager.addPeer(node2ToNode3Peer);
|
||||
node3.syncManager.addPeer(node3ToNode1Peer);
|
||||
node3.syncManager.addPeer(node3ToNode2Peer);
|
||||
|
||||
return {
|
||||
node1,
|
||||
node2,
|
||||
node3,
|
||||
node1ToNode2Peer,
|
||||
node2ToNode1Peer,
|
||||
node1ToNode3Peer,
|
||||
node3ToNode1Peer,
|
||||
node2ToNode3Peer,
|
||||
node3ToNode2Peer,
|
||||
};
|
||||
}
|
||||
|
||||
export function newGroup() {
|
||||
const [admin, sessionID] = randomAnonymousAccountAndSessionID();
|
||||
|
||||
@@ -58,7 +148,7 @@ export function groupWithTwoAdmins() {
|
||||
}
|
||||
|
||||
expect(group.get(otherAdmin.id)).toEqual("admin");
|
||||
return { groupCore, admin, otherAdmin, node };
|
||||
return { group, groupCore, admin, otherAdmin, node };
|
||||
}
|
||||
|
||||
export function newGroupHighLevel() {
|
||||
@@ -126,3 +216,14 @@ export function waitFor(callback: () => boolean | void) {
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadCoValueOrFail<V extends RawCoValue>(
|
||||
node: LocalNode,
|
||||
id: CoID<V>,
|
||||
): Promise<V> {
|
||||
const value = await node.load(id);
|
||||
if (value === "unavailable") {
|
||||
throw new Error("CoValue not found");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# jazz-browser-media-images
|
||||
|
||||
## 0.8.35
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3f15a23]
|
||||
- Updated dependencies [46f2ab8]
|
||||
- Updated dependencies [8b87117]
|
||||
- Updated dependencies [a6b6ccf]
|
||||
- cojson@0.8.35
|
||||
- jazz-tools@0.8.35
|
||||
- jazz-browser@0.8.35
|
||||
|
||||
## 0.8.34
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"name": "jazz-browser-auth-clerk",
|
||||
"version": "0.8.34",
|
||||
"version": "0.8.35",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "src/index.ts",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cojson": "workspace:0.8.34",
|
||||
"jazz-browser": "workspace:0.8.34",
|
||||
"jazz-tools": "workspace:0.8.34"
|
||||
"cojson": "workspace:0.8.35",
|
||||
"jazz-browser": "workspace:0.8.35",
|
||||
"jazz-tools": "workspace:0.8.35"
|
||||
},
|
||||
"scripts": {
|
||||
"format-and-lint": "biome check .",
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# jazz-browser-media-images
|
||||
|
||||
## 0.8.35
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [8b87117]
|
||||
- jazz-tools@0.8.35
|
||||
- jazz-browser@0.8.35
|
||||
|
||||
## 0.8.34
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jazz-browser-media-images",
|
||||
"version": "0.8.34",
|
||||
"version": "0.8.35",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "src/index.ts",
|
||||
@@ -8,8 +8,8 @@
|
||||
"dependencies": {
|
||||
"@types/image-blob-reduce": "^4.1.1",
|
||||
"image-blob-reduce": "^4.1.0",
|
||||
"jazz-browser": "workspace:0.8.34",
|
||||
"jazz-tools": "workspace:0.8.34",
|
||||
"jazz-browser": "workspace:0.8.35",
|
||||
"jazz-tools": "workspace:0.8.35",
|
||||
"pica": "^9.0.1",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# jazz-browser
|
||||
|
||||
## 0.8.35
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3f15a23]
|
||||
- Updated dependencies [46f2ab8]
|
||||
- Updated dependencies [8b87117]
|
||||
- Updated dependencies [a6b6ccf]
|
||||
- Updated dependencies [46f2ab8]
|
||||
- cojson@0.8.35
|
||||
- cojson-storage-indexeddb@0.8.35
|
||||
- jazz-tools@0.8.35
|
||||
- cojson-transport-ws@0.8.35
|
||||
|
||||
## 0.8.34
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"name": "jazz-browser",
|
||||
"version": "0.8.34",
|
||||
"version": "0.8.35",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "src/index.ts",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@scure/bip39": "^1.3.0",
|
||||
"cojson": "workspace:0.8.34",
|
||||
"cojson-storage-indexeddb": "workspace:0.8.34",
|
||||
"cojson-transport-ws": "workspace:0.8.34",
|
||||
"jazz-tools": "workspace:0.8.34",
|
||||
"cojson": "workspace:0.8.35",
|
||||
"cojson-storage-indexeddb": "workspace:0.8.35",
|
||||
"cojson-transport-ws": "workspace:0.8.35",
|
||||
"jazz-tools": "workspace:0.8.35",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# jazz-autosub
|
||||
|
||||
## 0.8.35
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3f15a23]
|
||||
- Updated dependencies [46f2ab8]
|
||||
- Updated dependencies [8b87117]
|
||||
- Updated dependencies [a6b6ccf]
|
||||
- cojson@0.8.35
|
||||
- jazz-tools@0.8.35
|
||||
- cojson-transport-ws@0.8.35
|
||||
|
||||
## 0.8.34
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -5,15 +5,15 @@
|
||||
"types": "src/index.ts",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "0.8.34",
|
||||
"version": "0.8.35",
|
||||
"dependencies": {
|
||||
"cojson": "workspace:0.8.34",
|
||||
"cojson-transport-ws": "workspace:0.8.34",
|
||||
"jazz-tools": "workspace:0.8.34",
|
||||
"cojson": "workspace:0.8.35",
|
||||
"cojson-transport-ws": "workspace:0.8.35",
|
||||
"jazz-tools": "workspace:0.8.35",
|
||||
"ws": "^8.14.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/ws": "^8.5.5",
|
||||
"@types/ws": "8.5.10",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# jazz-browser-media-images
|
||||
|
||||
## 0.8.35
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3f15a23]
|
||||
- Updated dependencies [9212ab8]
|
||||
- Updated dependencies [46f2ab8]
|
||||
- Updated dependencies [8b87117]
|
||||
- Updated dependencies [a6b6ccf]
|
||||
- cojson@0.8.35
|
||||
- jazz-react@0.8.35
|
||||
- jazz-tools@0.8.35
|
||||
- jazz-browser-auth-clerk@0.8.35
|
||||
|
||||
## 0.8.34
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"name": "jazz-react-auth-clerk",
|
||||
"version": "0.8.34",
|
||||
"version": "0.8.35",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "src/index.tsx",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cojson": "workspace:0.8.34",
|
||||
"jazz-browser-auth-clerk": "workspace:0.8.34",
|
||||
"jazz-react": "workspace:0.8.34",
|
||||
"jazz-tools": "workspace:0.8.34"
|
||||
"cojson": "workspace:0.8.35",
|
||||
"jazz-browser-auth-clerk": "workspace:0.8.35",
|
||||
"jazz-react": "workspace:0.8.35",
|
||||
"jazz-tools": "workspace:0.8.35"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.2.0"
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# jazz-browser-media-images
|
||||
|
||||
## 0.8.26
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [8b87117]
|
||||
- jazz-tools@0.8.35
|
||||
|
||||
## 0.8.25
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jazz-react-native-media-images",
|
||||
"version": "0.8.25",
|
||||
"version": "0.8.26",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "src/index.ts",
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# jazz-browser
|
||||
|
||||
## 0.8.35
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3f15a23]
|
||||
- Updated dependencies [46f2ab8]
|
||||
- Updated dependencies [8b87117]
|
||||
- Updated dependencies [a6b6ccf]
|
||||
- cojson@0.8.35
|
||||
- jazz-tools@0.8.35
|
||||
- cojson-transport-ws@0.8.35
|
||||
|
||||
## 0.8.34
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jazz-react-native",
|
||||
"version": "0.8.34",
|
||||
"version": "0.8.35",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
# jazz-react
|
||||
|
||||
## 0.8.35
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 9212ab8: fix: reset observable value when unsubscribed
|
||||
- Updated dependencies [3f15a23]
|
||||
- Updated dependencies [46f2ab8]
|
||||
- Updated dependencies [8b87117]
|
||||
- Updated dependencies [a6b6ccf]
|
||||
- cojson@0.8.35
|
||||
- jazz-tools@0.8.35
|
||||
- jazz-browser@0.8.35
|
||||
|
||||
## 0.8.34
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"name": "jazz-react",
|
||||
"version": "0.8.34",
|
||||
"version": "0.8.35",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "src/index.ts",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@scure/bip39": "^1.3.0",
|
||||
"cojson": "workspace:0.8.34",
|
||||
"jazz-browser": "workspace:0.8.34",
|
||||
"jazz-tools": "workspace:0.8.34"
|
||||
"cojson": "workspace:0.8.35",
|
||||
"jazz-browser": "workspace:0.8.35",
|
||||
"jazz-tools": "workspace:0.8.35"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.19",
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
# jazz-run
|
||||
|
||||
## 0.8.35
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3f15a23]
|
||||
- Updated dependencies [46f2ab8]
|
||||
- Updated dependencies [8b87117]
|
||||
- Updated dependencies [a6b6ccf]
|
||||
- cojson@0.8.35
|
||||
- cojson-storage-sqlite@0.8.35
|
||||
- jazz-tools@0.8.35
|
||||
- cojson-transport-ws@0.8.35
|
||||
|
||||
## 0.8.34
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"bin": "./dist/index.js",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "0.8.34",
|
||||
"version": "0.8.35",
|
||||
"scripts": {
|
||||
"format-and-lint": "biome check .",
|
||||
"format-and-lint:fix": "biome check . --write",
|
||||
@@ -18,15 +18,15 @@
|
||||
"@effect/printer-ansi": "^0.34.5",
|
||||
"@effect/schema": "^0.71.1",
|
||||
"@effect/typeclass": "^0.25.5",
|
||||
"cojson": "workspace:0.8.34",
|
||||
"cojson-storage-sqlite": "workspace:0.8.34",
|
||||
"cojson-transport-ws": "workspace:0.8.34",
|
||||
"cojson": "workspace:0.8.35",
|
||||
"cojson-storage-sqlite": "workspace:0.8.35",
|
||||
"cojson-transport-ws": "workspace:0.8.35",
|
||||
"effect": "^3.6.5",
|
||||
"jazz-tools": "workspace:0.8.34",
|
||||
"jazz-tools": "workspace:0.8.35",
|
||||
"ws": "^8.14.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/ws": "^8.5.5",
|
||||
"@types/ws": "8.5.10",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
|
||||
10
packages/jazz-svelte/CHANGELOG.md
Normal file
10
packages/jazz-svelte/CHANGELOG.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# jazz-svelte
|
||||
|
||||
## 0.0.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 0e59e65: Returns Provider from `createJazzApp` instead of lib
|
||||
- Updated dependencies [8b87117]
|
||||
- jazz-tools@0.8.35
|
||||
- jazz-browser@0.8.35
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jazz-svelte",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.2",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build && npm run package",
|
||||
@@ -14,8 +14,14 @@
|
||||
"format-and-lint": "pnpm run format && pnpm run lint",
|
||||
"format-and-lint:fix": "pnpm run format --write && pnpm run lint --fix"
|
||||
},
|
||||
"files": ["dist", "!dist/**/*.test.*", "!dist/**/*.spec.*"],
|
||||
"sideEffects": ["**/*.css"],
|
||||
"files": [
|
||||
"dist",
|
||||
"!dist/**/*.test.*",
|
||||
"!dist/**/*.spec.*"
|
||||
],
|
||||
"sideEffects": [
|
||||
"**/*.css"
|
||||
],
|
||||
"svelte": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"type": "module",
|
||||
@@ -29,7 +35,7 @@
|
||||
"svelte": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^3.0.0",
|
||||
"@sveltejs/adapter-vercel": "^5.5.0",
|
||||
"@sveltejs/kit": "^2.0.0",
|
||||
"@sveltejs/package": "^2.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import adapter from "@sveltejs/adapter-auto";
|
||||
import adapter from "@sveltejs/adapter-vercel";
|
||||
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
# jazz-tools
|
||||
|
||||
## 0.8.35
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 8b87117: Implement Group Inheritance
|
||||
- Updated dependencies [3f15a23]
|
||||
- Updated dependencies [46f2ab8]
|
||||
- Updated dependencies [8b87117]
|
||||
- Updated dependencies [a6b6ccf]
|
||||
- cojson@0.8.35
|
||||
|
||||
## 0.8.34
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
},
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "0.8.34",
|
||||
"version": "0.8.35",
|
||||
"dependencies": {
|
||||
"cojson": "workspace:*",
|
||||
"fast-check": "^3.17.2"
|
||||
|
||||
@@ -142,6 +142,11 @@ export class Group extends CoValueBase implements CoValue {
|
||||
return this;
|
||||
}
|
||||
|
||||
removeMember(member: Everyone | Account) {
|
||||
this._raw.removeMember(member === "everyone" ? member : member._raw);
|
||||
return this;
|
||||
}
|
||||
|
||||
get members() {
|
||||
return this._raw
|
||||
.keys()
|
||||
@@ -172,6 +177,11 @@ export class Group extends CoValueBase implements CoValue {
|
||||
});
|
||||
}
|
||||
|
||||
extend(parent: Group) {
|
||||
this._raw.extend(parent._raw);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** @category Subscription & Loading */
|
||||
static load<G extends Group, Depth>(
|
||||
this: CoValueClass<G>,
|
||||
|
||||
@@ -228,6 +228,7 @@ export function subscribeToCoValue<V extends CoValue, Depth>(
|
||||
|
||||
export function createCoValueObservable<V extends CoValue, Depth>() {
|
||||
let currentValue: DeeplyLoaded<V, Depth> | undefined = undefined;
|
||||
let subscriberCount = 0;
|
||||
|
||||
function subscribe(
|
||||
cls: CoValueClass<V>,
|
||||
@@ -237,6 +238,8 @@ export function createCoValueObservable<V extends CoValue, Depth>() {
|
||||
listener: () => void,
|
||||
onUnavailable?: () => void,
|
||||
) {
|
||||
subscriberCount++;
|
||||
|
||||
const unsubscribe = subscribeToCoValue(
|
||||
cls,
|
||||
id,
|
||||
@@ -249,7 +252,13 @@ export function createCoValueObservable<V extends CoValue, Depth>() {
|
||||
onUnavailable,
|
||||
);
|
||||
|
||||
return unsubscribe;
|
||||
return () => {
|
||||
unsubscribe();
|
||||
subscriberCount--;
|
||||
if (subscriberCount === 0) {
|
||||
currentValue = undefined;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const observable = {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { RawGroup } from "cojson";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { Account, CoMap, Group, WasmCrypto, co } from "../index.web.js";
|
||||
import { waitFor } from "./utils.js";
|
||||
|
||||
const Crypto = await WasmCrypto.create();
|
||||
|
||||
@@ -85,3 +87,144 @@ describe("Custom accounts and groups", async () => {
|
||||
expect(map._owner.castAs(CustomGroup).nMembers).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Group inheritance", () => {
|
||||
class TestMap extends CoMap {
|
||||
title = co.string;
|
||||
}
|
||||
|
||||
test("Group inheritance", async () => {
|
||||
const me = await Account.create({
|
||||
creationProps: { name: "Hermes Puggington" },
|
||||
crypto: Crypto,
|
||||
});
|
||||
|
||||
const parentGroup = Group.create({ owner: me });
|
||||
const group = Group.create({ owner: me });
|
||||
|
||||
group.extend(parentGroup);
|
||||
|
||||
const reader = await Account.createAs(me, {
|
||||
creationProps: { name: "Reader" },
|
||||
});
|
||||
|
||||
parentGroup.addMember(reader, "reader");
|
||||
|
||||
const mapInChild = TestMap.create({ title: "In Child" }, { owner: group });
|
||||
|
||||
const mapAsReader = await TestMap.load(mapInChild.id, reader, {});
|
||||
expect(mapAsReader?.title).toBe("In Child");
|
||||
|
||||
parentGroup.removeMember(reader);
|
||||
|
||||
mapInChild.title = "In Child (updated)";
|
||||
|
||||
const mapAsReaderAfterUpdate = await TestMap.load(
|
||||
mapInChild.id,
|
||||
reader,
|
||||
{},
|
||||
);
|
||||
expect(mapAsReaderAfterUpdate?.title).toBe("In Child");
|
||||
});
|
||||
|
||||
test("Group inheritance with grand-children", async () => {
|
||||
const me = await Account.create({
|
||||
creationProps: { name: "Hermes Puggington" },
|
||||
crypto: Crypto,
|
||||
});
|
||||
|
||||
const grandParentGroup = Group.create({ owner: me });
|
||||
const parentGroup = Group.create({ owner: me });
|
||||
const group = Group.create({ owner: me });
|
||||
|
||||
group.extend(parentGroup);
|
||||
parentGroup.extend(grandParentGroup);
|
||||
|
||||
const reader = await Account.createAs(me, {
|
||||
creationProps: { name: "Reader" },
|
||||
});
|
||||
|
||||
grandParentGroup.addMember(reader, "reader");
|
||||
|
||||
const mapInGrandChild = TestMap.create(
|
||||
{ title: "In Grand Child" },
|
||||
{ owner: group },
|
||||
);
|
||||
|
||||
const mapAsReader = await TestMap.load(mapInGrandChild.id, reader, {});
|
||||
expect(mapAsReader?.title).toBe("In Grand Child");
|
||||
|
||||
grandParentGroup.removeMember(reader);
|
||||
|
||||
mapInGrandChild.title = "In Grand Child (updated)";
|
||||
|
||||
const mapAsReaderAfterUpdate = await TestMap.load(
|
||||
mapInGrandChild.id,
|
||||
reader,
|
||||
{},
|
||||
);
|
||||
expect(mapAsReaderAfterUpdate?.title).toBe("In Grand Child");
|
||||
});
|
||||
|
||||
test("Group inheritance should fail if the current account doesn't have admin role in both groups", async () => {
|
||||
const me = await Account.create({
|
||||
creationProps: { name: "Hermes Puggington" },
|
||||
crypto: Crypto,
|
||||
});
|
||||
|
||||
const other = await Account.createAs(me, {
|
||||
creationProps: { name: "Another user" },
|
||||
});
|
||||
|
||||
const parentGroup = Group.create({ owner: me });
|
||||
parentGroup.addMember(other, "writer");
|
||||
const group = Group.create({ owner: me });
|
||||
group.addMember(other, "admin");
|
||||
|
||||
const parentGroupOnTheOtherSide = await Group.load(
|
||||
parentGroup.id,
|
||||
other,
|
||||
{},
|
||||
);
|
||||
const groupOnTheOtherSide = await Group.load(group.id, other, {});
|
||||
|
||||
if (!groupOnTheOtherSide || !parentGroupOnTheOtherSide) {
|
||||
throw new Error("CoValue not available");
|
||||
}
|
||||
|
||||
expect(() => groupOnTheOtherSide.extend(parentGroupOnTheOtherSide)).toThrow(
|
||||
"To extend a group, the current account must have admin role in both groups",
|
||||
);
|
||||
});
|
||||
|
||||
test("Group inheritance should work if the current account has admin role in both groups", async () => {
|
||||
const me = await Account.create({
|
||||
creationProps: { name: "Hermes Puggington" },
|
||||
crypto: Crypto,
|
||||
});
|
||||
|
||||
const other = await Account.createAs(me, {
|
||||
creationProps: { name: "Another user" },
|
||||
});
|
||||
|
||||
const parentGroup = Group.create({ owner: me });
|
||||
parentGroup.addMember(other, "admin");
|
||||
const group = Group.create({ owner: me });
|
||||
group.addMember(other, "admin");
|
||||
|
||||
const parentGroupOnTheOtherSide = await Group.load(
|
||||
parentGroup.id,
|
||||
other,
|
||||
{},
|
||||
);
|
||||
const groupOnTheOtherSide = await Group.load(group.id, other, {});
|
||||
|
||||
if (!groupOnTheOtherSide || !parentGroupOnTheOtherSide) {
|
||||
throw new Error("CoValue not available");
|
||||
}
|
||||
|
||||
expect(() =>
|
||||
groupOnTheOtherSide.extend(parentGroupOnTheOtherSide),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,23 +1,13 @@
|
||||
const Crypto = await WasmCrypto.create();
|
||||
import { connectedPeers } from "cojson/src/streamUtils.js";
|
||||
import { describe, expect, it, onTestFinished, vi } from "vitest";
|
||||
import { Account, CoFeed, CoList, CoMap, co } from "../index.web.js";
|
||||
import {
|
||||
Account,
|
||||
CoFeed,
|
||||
CoList,
|
||||
CoMap,
|
||||
WasmCrypto,
|
||||
co,
|
||||
createJazzContext,
|
||||
fixedCredentialsAuth,
|
||||
isControlledAccount,
|
||||
} from "../index.web.js";
|
||||
import {
|
||||
type DepthsIn,
|
||||
FileStream,
|
||||
Group,
|
||||
randomSessionProvider,
|
||||
createCoValueObservable,
|
||||
subscribeToCoValue,
|
||||
} from "../internal.js";
|
||||
import { setupAccount, waitFor } from "./utils.js";
|
||||
|
||||
class ChatRoom extends CoMap {
|
||||
messages = co.ref(MessagesList);
|
||||
@@ -33,34 +23,6 @@ class Message extends CoMap {
|
||||
class MessagesList extends CoList.Of(co.ref(Message)) {}
|
||||
class ReactionsStream extends CoFeed.Of(co.string) {}
|
||||
|
||||
async function setupAccount() {
|
||||
const me = await Account.create({
|
||||
creationProps: { name: "Hermes Puggington" },
|
||||
crypto: Crypto,
|
||||
});
|
||||
|
||||
const [initialAsPeer, secondPeer] = connectedPeers("initial", "second", {
|
||||
peer1role: "server",
|
||||
peer2role: "client",
|
||||
});
|
||||
|
||||
if (!isControlledAccount(me)) {
|
||||
throw "me is not a controlled account";
|
||||
}
|
||||
me._raw.core.node.syncManager.addPeer(secondPeer);
|
||||
const { account: meOnSecondPeer } = await createJazzContext({
|
||||
auth: fixedCredentialsAuth({
|
||||
accountID: me.id,
|
||||
secret: me._raw.agentSecret,
|
||||
}),
|
||||
sessionProvider: randomSessionProvider,
|
||||
peersToLoadFrom: [initialAsPeer],
|
||||
crypto: Crypto,
|
||||
});
|
||||
|
||||
return { me, meOnSecondPeer };
|
||||
}
|
||||
|
||||
function createChatRoom(me: Account | Group, name: string) {
|
||||
return ChatRoom.create(
|
||||
{ messages: MessagesList.create([], { owner: me }), name },
|
||||
@@ -332,30 +294,69 @@ describe("subscribeToCoValue", () => {
|
||||
});
|
||||
});
|
||||
|
||||
function waitFor(callback: () => boolean | void) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const checkPassed = () => {
|
||||
try {
|
||||
return { ok: callback(), error: null };
|
||||
} catch (error) {
|
||||
return { ok: false, error };
|
||||
}
|
||||
};
|
||||
describe("createCoValueObservable", () => {
|
||||
class TestMap extends CoMap {
|
||||
color = co.string;
|
||||
}
|
||||
|
||||
let retries = 0;
|
||||
function createTestMap(me: Account | Group) {
|
||||
return TestMap.create({ color: "red" }, { owner: me });
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
const { ok, error } = checkPassed();
|
||||
it("should return undefined when there are no subscribers", async () => {
|
||||
const observable = createCoValueObservable();
|
||||
|
||||
if (ok !== false) {
|
||||
clearInterval(interval);
|
||||
resolve();
|
||||
}
|
||||
|
||||
if (++retries > 10) {
|
||||
clearInterval(interval);
|
||||
reject(error);
|
||||
}
|
||||
}, 100);
|
||||
expect(observable.getCurrentValue()).toBeUndefined();
|
||||
});
|
||||
}
|
||||
|
||||
it("should update currentValue when subscribed", async () => {
|
||||
const { me, meOnSecondPeer } = await setupAccount();
|
||||
const testMap = createTestMap(me);
|
||||
const observable = createCoValueObservable<TestMap, DepthsIn<TestMap>>();
|
||||
const mockListener = vi.fn();
|
||||
|
||||
const unsubscribe = observable.subscribe(
|
||||
TestMap,
|
||||
testMap.id,
|
||||
meOnSecondPeer,
|
||||
{},
|
||||
() => {
|
||||
mockListener();
|
||||
},
|
||||
);
|
||||
|
||||
testMap.color = "blue";
|
||||
|
||||
await waitFor(() => mockListener.mock.calls.length > 0);
|
||||
|
||||
expect(observable.getCurrentValue()).toMatchObject({
|
||||
id: testMap.id,
|
||||
color: "blue",
|
||||
});
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it("should reset to undefined after unsubscribe", async () => {
|
||||
const { me, meOnSecondPeer } = await setupAccount();
|
||||
const testMap = createTestMap(me);
|
||||
const observable = createCoValueObservable<TestMap, DepthsIn<TestMap>>();
|
||||
const mockListener = vi.fn();
|
||||
|
||||
const unsubscribe = observable.subscribe(
|
||||
TestMap,
|
||||
testMap.id,
|
||||
meOnSecondPeer,
|
||||
{},
|
||||
() => {
|
||||
mockListener();
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => mockListener.mock.calls.length > 0);
|
||||
expect(observable.getCurrentValue()).toBeDefined();
|
||||
|
||||
unsubscribe();
|
||||
expect(observable.getCurrentValue()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
68
packages/jazz-tools/src/tests/utils.ts
Normal file
68
packages/jazz-tools/src/tests/utils.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { isControlledAccount } from "../coValues/account";
|
||||
|
||||
import { connectedPeers } from "cojson/src/streamUtils.js";
|
||||
import {
|
||||
Account,
|
||||
WasmCrypto,
|
||||
createJazzContext,
|
||||
fixedCredentialsAuth,
|
||||
randomSessionProvider,
|
||||
} from "../index.web";
|
||||
|
||||
const Crypto = await WasmCrypto.create();
|
||||
|
||||
export async function setupAccount() {
|
||||
const me = await Account.create({
|
||||
creationProps: { name: "Hermes Puggington" },
|
||||
crypto: Crypto,
|
||||
});
|
||||
|
||||
const [initialAsPeer, secondPeer] = connectedPeers("initial", "second", {
|
||||
peer1role: "server",
|
||||
peer2role: "client",
|
||||
});
|
||||
|
||||
if (!isControlledAccount(me)) {
|
||||
throw "me is not a controlled account";
|
||||
}
|
||||
me._raw.core.node.syncManager.addPeer(secondPeer);
|
||||
const { account: meOnSecondPeer } = await createJazzContext({
|
||||
auth: fixedCredentialsAuth({
|
||||
accountID: me.id,
|
||||
secret: me._raw.agentSecret,
|
||||
}),
|
||||
sessionProvider: randomSessionProvider,
|
||||
peersToLoadFrom: [initialAsPeer],
|
||||
crypto: Crypto,
|
||||
});
|
||||
|
||||
return { me, meOnSecondPeer };
|
||||
}
|
||||
|
||||
export function waitFor(callback: () => boolean | void) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const checkPassed = () => {
|
||||
try {
|
||||
return { ok: callback(), error: null };
|
||||
} catch (error) {
|
||||
return { ok: false, error };
|
||||
}
|
||||
};
|
||||
|
||||
let retries = 0;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
const { ok, error } = checkPassed();
|
||||
|
||||
if (ok !== false) {
|
||||
clearInterval(interval);
|
||||
resolve();
|
||||
}
|
||||
|
||||
if (++retries > 10) {
|
||||
clearInterval(interval);
|
||||
reject(error);
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,17 @@
|
||||
# jazz-react
|
||||
|
||||
## 0.8.23
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3f15a23]
|
||||
- Updated dependencies [46f2ab8]
|
||||
- Updated dependencies [8b87117]
|
||||
- Updated dependencies [a6b6ccf]
|
||||
- cojson@0.8.35
|
||||
- jazz-tools@0.8.35
|
||||
- jazz-browser@0.8.35
|
||||
|
||||
## 0.8.22
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jazz-vue",
|
||||
"version": "0.8.22",
|
||||
"version": "0.8.23",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "src/index.ts",
|
||||
|
||||
786
pnpm-lock.yaml
generated
786
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ import { AuthAndJazz } from "./jazz";
|
||||
import { FileStreamTest } from "./pages/FileStream";
|
||||
import { ResumeSyncState } from "./pages/ResumeSyncState";
|
||||
import { RetryUnavailable } from "./pages/RetryUnavailable";
|
||||
import { Sharing } from "./pages/Sharing";
|
||||
import { TestInput } from "./pages/TestInput";
|
||||
|
||||
function Index() {
|
||||
@@ -22,6 +23,9 @@ function Index() {
|
||||
<li>
|
||||
<Link to="/retry-unavailable">Retry Unavailable</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/sharing">Sharing</Link>
|
||||
</li>
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -43,6 +47,10 @@ const router = createBrowserRouter([
|
||||
path: "/file-stream",
|
||||
element: <FileStreamTest />,
|
||||
},
|
||||
{
|
||||
path: "/sharing",
|
||||
element: <Sharing />,
|
||||
},
|
||||
{
|
||||
path: "/",
|
||||
element: <Index />,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user