Compare commits

..

90 Commits

Author SHA1 Message Date
Anselm Eickhoff
073d8992f5 Merge pull request #914 from garden-co/changeset-release/main
Version Packages
2024-12-04 18:14:55 +00:00
github-actions[bot]
7b400835a2 Version Packages 2024-12-04 18:09:10 +00:00
Trisha Lim
bf287d0709 Examples: add id to headings 2024-12-04 18:07:27 +00:00
Anselm Eickhoff
b0072e63e3 Merge pull request #934 from garden-co/jazz-555-use-vercel-adapter
Use Vercel adapter for Svelte
2024-12-04 17:34:56 +00:00
Anselm Eickhoff
a479ece032 Merge pull request #601 from garden-co/aeplay-jazz-12
Group Inheritance
2024-12-04 17:27:58 +00:00
Benjamin S. Leveritt
e0dbe46d64 Use Vercel adapter for Svelte 2024-12-04 17:09:02 +00:00
Guido D'Orsi
1f1fc56720 test(sharing): cover the out-of-sync children 2024-12-04 18:04:10 +01:00
Guido D'Orsi
64fa74a6d9 chore: update tests 2024-12-04 17:52:09 +01:00
Anselm Eickhoff
66538fdcf5 Merge pull request #931 from garden-co/jazz-554-pin-typesws5810
Pin @types/ws to 8.5.10
2024-12-04 16:47:36 +00:00
Benjamin S. Leveritt
543f91277d Remove e2e workspace from package 2024-12-04 16:43:46 +00:00
Benjamin S. Leveritt
42112ec46c Pin @types/ws 2024-12-04 16:36:27 +00:00
Guido D'Orsi
f4170eb879 fix: fix parent key revelation when the key used isn't the current one 2024-12-04 17:15:54 +01:00
Guido D'Orsi
8278055e33 fix: correctly rotate keys on child groups 2024-12-04 16:31:20 +01:00
Guido D'Orsi
65c236571e test: cover role inheritance in the transactions checks 2024-12-04 15:07:47 +01:00
Guido D'Orsi
b53cc9930e fix(group-extend): resolve member state from parents when checking permissions 2024-12-04 12:40:35 +01:00
Guido D'Orsi
9c5b34d91c test(Sharing): improve the test and cover more logic 2024-12-04 12:40:01 +01:00
Guido D'Orsi
a6c119b98d fix: load all the child groups before starting to revoke access 2024-12-03 19:00:33 +01:00
Guido D'Orsi
6e3565d20f Merge remote-tracking branch 'origin/main' into aeplay-jazz-12 2024-12-03 16:57:28 +01:00
Guido D'Orsi
e5e21718f9 Merge pull request #918 from garden-co/create-obs-value-test
chore: merge subscribe and createCoValueObservable suites
2024-12-03 16:57:10 +01:00
Guido D'Orsi
1b49bd8be4 chore: merge subscribe and createCoValueObservable suites 2024-12-03 16:45:04 +01:00
Guido D'Orsi
70a93ab093 fix(group-extend): a user should be an admin in both groups to extend 2024-12-03 15:38:58 +01:00
Guido D'Orsi
dacaa02a01 chore(types): create the ParentGroupReference and ChildGroupReference type 2024-12-03 14:54:04 +01:00
Guido D'Orsi
55cc248d91 Merge remote-tracking branch 'origin/main' into aeplay-jazz-12 2024-12-03 14:45:48 +01:00
Guido D'Orsi
534fce6796 test(e2e): cover Group extension with a e2e test 2024-12-03 14:42:40 +01:00
Anselm Eickhoff
a223a3a5ab Merge pull request #915 from garden-co/fix/sync-covalue-addpeer
fix(sync): send new content when a storage/server peer is re-added
2024-12-03 13:37:53 +00:00
pax
b0df041a24 Merge pull request #890 from garden-co/JAZZ-537/hide-stale-state-in-usecostate-when-the-top-level-id-parameter-changes
fix: reset observable value when unsubscribed
2024-12-03 14:06:07 +02:00
Guido D'Orsi
357698f4fb chore: refactor parent/child keys extraction 2024-12-03 12:58:33 +01:00
pax-k
a6e5a72b97 chore: re-arranged imports 2024-12-03 13:58:33 +02:00
pax-k
b4dd2add45 chore: changeset 2024-12-03 13:34:14 +02:00
pax-k
f3824dfb76 chore: cleanup 2024-12-03 13:32:00 +02:00
pax-k
ac7b388ca5 Merge branch 'main' into JAZZ-537/hide-stale-state-in-usecostate-when-the-top-level-id-parameter-changes 2024-12-03 13:21:22 +02:00
pax-k
ad3641861b fix: implement createCoValueObservable tests 2024-12-03 13:20:28 +02:00
pax-k
3d601deaa0 fix: refactor createCoValueObservable to return undefined if there are no subscribers 2024-12-03 13:20:15 +02:00
pax-k
7824e6b36a fix: rollback useCoState changes related to stale data 2024-12-03 13:19:43 +02:00
Guido D'Orsi
9b80278b71 Merge remote-tracking branch 'origin/main' into aeplay-jazz-12 2024-12-03 11:32:18 +01:00
Guido D'Orsi
46f2ab801d chore: changeset 2024-12-03 11:16:53 +01:00
Guido D'Orsi
a6b6ccf814 chore: changeset 2024-12-03 11:12:52 +01:00
Guido D'Orsi
8928e9e10d Merge pull request #873 from garden-co/fuzzyobject-jazz-529
Refactor IDB to have a better control over the flow and reduce amount of messages
2024-12-03 11:06:59 +01:00
Marina Orlova
0e1c38f2d1 Fix empty content message not being sent 2024-12-02 23:47:06 +01:00
Marina Orlova
08ad01d6b5 Changes requested 2024-12-02 23:47:06 +01:00
Marina Orlova
e9eec78ce5 Incoming content msg tests 2024-12-02 23:47:06 +01:00
Marina Orlova
5bab5091c3 Tweaks to idbClient 2024-12-02 23:47:06 +01:00
Marina Orlova
1663b4aa59 Move make response to idbClient 2024-12-02 23:47:06 +01:00
Marina Orlova
7f6637e235 Add test for dependencies recursion 2024-12-02 23:47:06 +01:00
Marina Orlova
f320e6821a IDB handleLoad tests added 2024-12-02 23:47:06 +01:00
Marina Orlova
b491a3c638 tweaks 2024-12-02 23:47:06 +01:00
Marina Orlova
35d03f2f2c IDB refactor sending content and dependencies 2024-12-02 23:47:06 +01:00
Marina Orlova
43c19307cd Take out sending logic to sendStateMessage method 2024-12-02 23:47:06 +01:00
Marina Orlova
1e642d4454 take out send message to local node logic 2024-12-02 23:47:06 +01:00
Marina Orlova
2c931dd57e Refactor indexeddb - remove promise hell - rest 2024-12-02 23:47:06 +01:00
Marina Orlova
8eec42814a Refactor indexeddb - remove promise hell 2024-12-02 23:47:06 +01:00
Anselm Eickhoff
0e0590d25b Merge pull request #912 from garden-co/benjamin-jazz-545 2024-12-02 21:09:42 +00:00
Guido D'Orsi
58a3ad2951 test: cover the offline editing with some tests 2024-12-02 18:58:50 +01:00
Guido D'Orsi
9f834eba97 test(retry): increase the timeout to trigger more retries 2024-12-02 18:07:22 +01:00
Guido D'Orsi
4a02dbed28 fix(sync): send new content when a storage/server peer is re-added 2024-12-02 18:04:52 +01:00
Anselm Eickhoff
ad920158d6 Merge pull request #900 from garden-co/content-deadlock-fix
Content deadlock fix with client <-> edge <-> core setup
2024-12-02 16:34:27 +00:00
Anselm
3f15a23219 Add changset 2024-11-28 19:08:58 +00:00
Anselm
1b60fc7095 Add comment 2024-11-28 18:51:31 +00:00
Anselm
9db577def9 Fix deadlock on incoming content with upstream servers present 2024-11-28 18:48:36 +00:00
pax-k
641483f40b chore: cleanup 2024-11-27 17:15:45 +02:00
pax-k
9212ab89ec chore: changeset 2024-11-27 17:02:21 +02:00
pax-k
915fa5ea4e fix: useCoState() now returns undefined, if id changes, instead of stale data 2024-11-27 16:59:47 +02:00
Guido D'Orsi
6b4cb357ce Merge pull request #751 from gardencmp/music-player-with-group-inheritance
feat(music-player): use group inheritance for the Playlist sharing
2024-11-15 11:43:43 +01:00
Guido D'Orsi
d6638742b0 feat(music-player): use group inheritance for the Playlist sharing 2024-11-13 18:13:37 +01:00
Anselm
ddb158d5fa Pre-release 2024-11-13 13:53:00 +00:00
Anselm
8b87117e0f Changeset (pre-release) 2024-11-13 13:45:44 +00:00
Anselm
e5000c2b6b Ensure storage implementations send parent group content first 2024-11-13 13:43:29 +00:00
Anselm
0347e52118 Remove unused imports 2024-11-13 11:48:36 +00:00
Anselm
bb9ba33e73 Formatting 2024-11-13 11:47:43 +00:00
Anselm
92c63d94b9 Treat extended groups (parents) as depended on CoValues for syncing 2024-11-13 11:46:24 +00:00
Anselm
75339c0939 Merge branch 'main' into aeplay-jazz-12 2024-11-12 15:27:29 +00:00
Anselm
22102deabc Merge branch 'main' into aeplay-jazz-12 2024-11-08 17:21:06 +00:00
Anselm
043e2acae4 Format 2024-11-06 16:03:58 +00:00
Anselm
1b7ef1c2c0 Merge branch 'main' into aeplay-jazz-12 2024-11-06 16:03:48 +00:00
Anselm
996092c26f Failing high-level test 2024-11-04 17:37:46 +00:00
Anselm
3c794bba0a Test for extending more than one level deep 2024-11-04 17:35:52 +00:00
Anselm
2d3d53d144 Test key rotation more than one level deep 2024-11-04 17:31:56 +00:00
Anselm
69d05c8c15 More high-level tests 2024-11-04 17:00:26 +00:00
Anselm
d11aeee083 Test revocation in high-level test 2024-11-04 15:54:56 +00:00
Anselm
4d5848161d Failing high-level test 2024-11-01 16:53:08 +00:00
Anselm
8ee456d4e4 Implement and test extend() 2024-11-01 16:44:50 +00:00
Anselm
7b2c2e6084 Rotate child keys on parent group key rotation 2024-11-01 16:38:26 +00:00
Anselm
133f75d34e Reveal new child read keys to parent read key 2024-11-01 16:08:57 +00:00
Anselm
68c9114896 Merge branch 'main' into aeplay-jazz-12 2024-11-01 15:42:27 +00:00
Anselm
57ff9e2d1f Implement and test lookup/revelation of parent read keys 2024-10-18 14:18:33 +01:00
Anselm
1cac820ec6 Add test for grand-parent inheritance 2024-10-18 13:16:43 +01:00
Anselm
eb4646beca Role inheritance (one level) 2024-10-18 12:05:43 +01:00
Anselm
5447d6f10b Start accepting parent and child extension transactions 2024-10-18 11:49:39 +01:00
Anselm
dbb040eb07 Prepare role getter that will traverse parent groups 2024-10-18 11:18:22 +01:00
Anselm
9960320645 Small refactors for valid transactions 2024-10-18 11:10:34 +01:00
105 changed files with 5213 additions and 1359 deletions

View File

@@ -1,5 +0,0 @@
---
"jazz-svelte": patch
---
Returns Provider from `createJazzApp` instead of lib

View File

@@ -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

View File

@@ -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"

View File

@@ -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

View File

@@ -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",

View File

@@ -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

View File

@@ -1,6 +1,6 @@
{
"name": "chat-rn",
"version": "1.0.24",
"version": "1.0.25",
"main": "index.js",
"scripts": {
"build": "expo export -p ios",

View File

@@ -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

View File

@@ -1,6 +1,6 @@
{
"name": "chat-vue",
"version": "0.0.17",
"version": "0.0.18",
"private": true,
"type": "module",
"scripts": {

View File

@@ -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

View File

@@ -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",

View File

@@ -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

View File

@@ -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"
},

View 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

View File

@@ -1,7 +1,7 @@
{
"name": "image-upload",
"private": true,
"version": "0.0.9",
"version": "0.0.10",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -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

View File

@@ -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",

View File

@@ -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

View File

@@ -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",

View File

@@ -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);

View File

@@ -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

View File

@@ -1,7 +1,7 @@
{
"name": "jazz-example-onboarding",
"private": true,
"version": "0.0.13",
"version": "0.0.14",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -0,0 +1,8 @@
# passkey-svelte
## 0.0.2
### Patch Changes
- Updated dependencies [0e59e65]
- jazz-svelte@0.0.2

View File

@@ -1,6 +1,6 @@
{
"name": "passkey-svelte",
"version": "0.0.1",
"version": "0.0.2",
"type": "module",
"scripts": {
"dev": "vite dev",

View File

@@ -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

View File

@@ -1,7 +1,7 @@
{
"name": "passkey",
"private": true,
"version": "0.0.10",
"version": "0.0.11",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -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

View File

@@ -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",

View File

@@ -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

View File

@@ -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",

View 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

View File

@@ -1,7 +1,7 @@
{
"name": "reactions",
"private": true,
"version": "0.0.9",
"version": "0.0.10",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -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

View File

@@ -1,6 +1,6 @@
{
"name": "todo-vue",
"version": "0.0.15",
"version": "0.0.16",
"private": true,
"type": "module",
"scripts": {

View File

@@ -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

View File

@@ -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",

View File

@@ -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) {

View File

@@ -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>

View File

@@ -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",

View File

@@ -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

View File

@@ -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",

View 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),
);
}
}

View 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);
}
}

View File

@@ -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";

View 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) {}
}

View 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 [];
}
}

View File

@@ -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",
},
],
},
};

View File

@@ -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([]);
});
});

View File

@@ -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 () => {

View 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),
});
});
});
});

View File

@@ -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

View File

@@ -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": {

View File

@@ -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"

View File

@@ -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

View File

@@ -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"
}
}

View File

@@ -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

View File

@@ -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",

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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_");
}

View File

@@ -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,
);

View File

@@ -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") {

View File

@@ -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");
});

View File

@@ -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");
});

View File

@@ -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", () => {

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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 .",

View File

@@ -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

View File

@@ -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"
},

View File

@@ -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

View File

@@ -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": {

View File

@@ -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

View File

@@ -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": {

View File

@@ -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

View File

@@ -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"

View File

@@ -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

View File

@@ -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",

View File

@@ -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

View File

@@ -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",

View File

@@ -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

View File

@@ -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",

View File

@@ -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

View File

@@ -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"
}
}

View 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

View File

@@ -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",

View File

@@ -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} */

View File

@@ -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

View File

@@ -19,7 +19,7 @@
},
"type": "module",
"license": "MIT",
"version": "0.8.34",
"version": "0.8.35",
"dependencies": {
"cojson": "workspace:*",
"fast-check": "^3.17.2"

View File

@@ -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>,

View File

@@ -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 = {

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View 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);
});
}

View File

@@ -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

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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