Compare commits

..

19 Commits

Author SHA1 Message Date
Guido D'Orsi
85604ec4c5 Merge pull request #2228 from garden-co/changeset-release/main
Version Packages
2025-05-14 16:32:12 +02:00
github-actions[bot]
de783063e2 Version Packages 2025-05-14 12:46:57 +00:00
Guido D'Orsi
9681691701 Merge pull request #2226 from garden-co/feat/ws-connect-events
feat(worker): add waitForConnection and subscribeToConnectionChange APIs to handle connection drops
2025-05-14 14:42:50 +02:00
Guido D'Orsi
6c7ae1faee Merge pull request #2225 from garden-co/feat/dependencies-load
fix: recovery from missing dependencies when getting new content
2025-05-14 14:42:23 +02:00
Anselm Eickhoff
12e9837858 Update issue templates 2025-05-14 13:27:49 +01:00
Guido D'Orsi
422dbc4222 feat(worker): add waitForConnection and subscribeToConnectionChange APIs to handle connection drops 2025-05-14 13:09:10 +02:00
Guido D'Orsi
e7ccb2c054 fix: recovery from missing dependencies when getting new content 2025-05-14 12:58:39 +02:00
Guido D'Orsi
2f7046002d Merge pull request #2214 from garden-co/feat/sync-polish
feat: make the SyncManager async-free, support parallel load on server peers
2025-05-14 12:55:36 +02:00
Benjamin S. Leveritt
20c1588249 Merge pull request #2218 from garden-co/2217-type-check-accounts-and-migrations
Adds typechecking to Accounts and Migrations
2025-05-14 10:36:29 +01:00
Benjamin S. Leveritt
f3d3d4dc5d Adds typechecking to Accounts and Migrations 2025-05-14 09:50:38 +01:00
Anselm Eickhoff
3135d711d4 Merge pull request #2216 from garden-co/fix-account-resolve-docs
Fix account resolution in accounts-and-migrations.mdx
2025-05-14 09:02:58 +01:00
Anselm Eickhoff
14ad9622ea Update accounts-and-migrations.mdx 2025-05-14 09:02:24 +01:00
Guido D'Orsi
0fee2aa21b chore: make the SyncManager async-free, support parallel load on server peers 2025-05-13 21:44:10 +02:00
Guido D'Orsi
1e6581cd68 Merge pull request #2206 from garden-co/changeset-release/main
Version Packages
2025-05-13 17:48:05 +02:00
github-actions[bot]
aaacaf0130 Version Packages 2025-05-13 15:35:24 +00:00
Guido D'Orsi
7dcca057e7 Merge pull request #2205 from garden-co/feat/self-revoke
feat: allow accounts to self-remove from groups
2025-05-13 17:32:57 +02:00
Guido D'Orsi
63570520a3 feat: allow accounts to self-remove from groups 2025-05-13 17:27:51 +02:00
Trisha Lim
aeed9595ae Merge pull request #2203 from garden-co/docs/server-workers-example 2025-05-13 13:40:23 +01:00
Trisha Lim
6755e28d0f docs: link to server workers example 2025-05-13 12:28:18 +01:00
125 changed files with 2236 additions and 484 deletions

10
.github/ISSUE_TEMPLATE/docs-request.md vendored Normal file
View File

@@ -0,0 +1,10 @@
---
name: Docs request
about: Allow people to quickly report issues & improvements for the docs
title: 'Docs: '
labels: docs, requested
assignees: bensleveritt
---

View File

@@ -1,5 +1,21 @@
# chat-rn-expo-clerk
## 1.0.119
### Patch Changes
- jazz-expo@0.13.28
- jazz-tools@0.13.28
- jazz-react-native-media-images@0.13.28
## 1.0.118
### Patch Changes
- jazz-expo@0.13.27
- jazz-tools@0.13.27
- jazz-react-native-media-images@0.13.27
## 1.0.117
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "chat-rn-expo-clerk",
"main": "index.js",
"version": "1.0.117",
"version": "1.0.119",
"scripts": {
"build": "expo export -p ios",
"start": "expo start",

View File

@@ -1,5 +1,19 @@
# chat-rn-expo
## 1.0.106
### Patch Changes
- jazz-expo@0.13.28
- jazz-tools@0.13.28
## 1.0.105
### Patch Changes
- jazz-expo@0.13.27
- jazz-tools@0.13.27
## 1.0.104
### Patch Changes

View File

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

View File

@@ -1,5 +1,26 @@
# chat-rn
## 1.0.114
### Patch Changes
- Updated dependencies [e7ccb2c]
- Updated dependencies [422dbc4]
- cojson@0.13.28
- cojson-transport-ws@0.13.28
- jazz-react-native@0.13.28
- jazz-tools@0.13.28
## 1.0.113
### Patch Changes
- Updated dependencies [6357052]
- cojson@0.13.27
- cojson-transport-ws@0.13.27
- jazz-react-native@0.13.27
- jazz-tools@0.13.27
## 1.0.112
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "chat-rn",
"version": "1.0.112",
"version": "1.0.114",
"main": "index.js",
"scripts": {
"android": "react-native run-android",

View File

@@ -1,5 +1,21 @@
# chat-vue
## 0.0.97
### Patch Changes
- jazz-browser@0.13.28
- jazz-tools@0.13.28
- jazz-vue@0.13.28
## 0.0.96
### Patch Changes
- jazz-browser@0.13.27
- jazz-tools@0.13.27
- jazz-vue@0.13.27
## 0.0.95
### Patch Changes

View File

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

View File

@@ -1,5 +1,21 @@
# jazz-example-chat
## 0.0.195
### Patch Changes
- jazz-inspector@0.13.28
- jazz-react@0.13.28
- jazz-tools@0.13.28
## 0.0.194
### Patch Changes
- jazz-inspector@0.13.27
- jazz-react@0.13.27
- jazz-tools@0.13.27
## 0.0.193
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "jazz-example-chat",
"private": true,
"version": "0.0.193",
"version": "0.0.195",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -1,5 +1,21 @@
# minimal-auth-clerk
## 0.0.94
### Patch Changes
- jazz-react@0.13.28
- jazz-react-auth-clerk@0.13.28
- jazz-tools@0.13.28
## 0.0.93
### Patch Changes
- jazz-react@0.13.27
- jazz-react-auth-clerk@0.13.27
- jazz-tools@0.13.27
## 0.0.92
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "clerk",
"private": true,
"version": "0.0.92",
"version": "0.0.94",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -1,5 +1,21 @@
# file-share-svelte
## 0.0.78
### Patch Changes
- jazz-svelte@0.13.28
- jazz-tools@0.13.28
- jazz-inspector-element@0.13.28
## 0.0.77
### Patch Changes
- jazz-svelte@0.13.27
- jazz-tools@0.13.27
- jazz-inspector-element@0.13.27
## 0.0.76
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "file-share-svelte",
"version": "0.0.76",
"version": "0.0.78",
"private": true,
"type": "module",
"scripts": {

View File

@@ -1,5 +1,21 @@
# jazz-tailwind-demo-auth-starter
## 0.0.34
### Patch Changes
- jazz-inspector@0.13.28
- jazz-react@0.13.28
- jazz-tools@0.13.28
## 0.0.33
### Patch Changes
- jazz-inspector@0.13.27
- jazz-react@0.13.27
- jazz-tools@0.13.27
## 0.0.32
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "filestream",
"private": true,
"version": "0.0.32",
"version": "0.0.34",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -1,5 +1,19 @@
# form
## 0.1.35
### Patch Changes
- jazz-react@0.13.28
- jazz-tools@0.13.28
## 0.1.34
### Patch Changes
- jazz-react@0.13.27
- jazz-tools@0.13.27
## 0.1.33
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "form",
"private": true,
"version": "0.1.33",
"version": "0.1.35",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -1,5 +1,19 @@
# image-upload
## 0.0.91
### Patch Changes
- jazz-react@0.13.28
- jazz-tools@0.13.28
## 0.0.90
### Patch Changes
- jazz-react@0.13.27
- jazz-tools@0.13.27
## 0.0.89
### Patch Changes

View File

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

View File

@@ -1,5 +1,24 @@
# jazz-example-inspector
## 0.0.145
### Patch Changes
- Updated dependencies [e7ccb2c]
- Updated dependencies [422dbc4]
- cojson@0.13.28
- cojson-transport-ws@0.13.28
- jazz-inspector@0.13.28
## 0.0.144
### Patch Changes
- Updated dependencies [6357052]
- cojson@0.13.27
- cojson-transport-ws@0.13.27
- jazz-inspector@0.13.27
## 0.0.143
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "jazz-inspector-app",
"private": true,
"version": "0.0.143",
"version": "0.0.145",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -1,5 +1,19 @@
# multi-cursors
## 0.0.87
### Patch Changes
- jazz-react@0.13.28
- jazz-tools@0.13.28
## 0.0.86
### Patch Changes
- jazz-react@0.13.27
- jazz-tools@0.13.27
## 0.0.85
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "multi-cursors",
"private": true,
"version": "0.0.85",
"version": "0.0.87",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -1,5 +1,21 @@
# multiauth
## 0.0.35
### Patch Changes
- jazz-react@0.13.28
- jazz-react-auth-clerk@0.13.28
- jazz-tools@0.13.28
## 0.0.34
### Patch Changes
- jazz-react@0.13.27
- jazz-react-auth-clerk@0.13.27
- jazz-tools@0.13.27
## 0.0.33
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "multiauth",
"private": true,
"version": "0.0.33",
"version": "0.0.35",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -1,5 +1,21 @@
# jazz-example-musicplayer
## 0.0.116
### Patch Changes
- jazz-inspector@0.13.28
- jazz-react@0.13.28
- jazz-tools@0.13.28
## 0.0.115
### Patch Changes
- jazz-inspector@0.13.27
- jazz-react@0.13.27
- jazz-tools@0.13.27
## 0.0.114
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "jazz-example-music-player",
"private": true,
"version": "0.0.114",
"version": "0.0.116",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -1,5 +1,19 @@
# organization
## 0.0.87
### Patch Changes
- jazz-react@0.13.28
- jazz-tools@0.13.28
## 0.0.86
### Patch Changes
- jazz-react@0.13.27
- jazz-tools@0.13.27
## 0.0.85
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "organization",
"private": true,
"version": "0.0.85",
"version": "0.0.87",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -1,5 +1,19 @@
# passkey-svelte
## 0.0.82
### Patch Changes
- jazz-svelte@0.13.28
- jazz-tools@0.13.28
## 0.0.81
### Patch Changes
- jazz-svelte@0.13.27
- jazz-tools@0.13.27
## 0.0.80
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "passkey-svelte",
"version": "0.0.80",
"version": "0.0.82",
"type": "module",
"private": true,
"scripts": {

View File

@@ -1,5 +1,19 @@
# minimal-auth-passkey
## 0.0.92
### Patch Changes
- jazz-react@0.13.28
- jazz-tools@0.13.28
## 0.0.91
### Patch Changes
- jazz-react@0.13.27
- jazz-tools@0.13.27
## 0.0.90
### Patch Changes

View File

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

View File

@@ -1,5 +1,19 @@
# passphrase
## 0.0.89
### Patch Changes
- jazz-react@0.13.28
- jazz-tools@0.13.28
## 0.0.88
### Patch Changes
- jazz-react@0.13.27
- jazz-tools@0.13.27
## 0.0.87
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "passphrase",
"private": true,
"version": "0.0.87",
"version": "0.0.89",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -1,5 +1,19 @@
# jazz-password-manager
## 0.0.113
### Patch Changes
- jazz-react@0.13.28
- jazz-tools@0.13.28
## 0.0.112
### Patch Changes
- jazz-react@0.13.27
- jazz-tools@0.13.27
## 0.0.111
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "jazz-password-manager",
"private": true,
"version": "0.0.111",
"version": "0.0.113",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -1,5 +1,19 @@
# jazz-example-pets
## 0.0.211
### Patch Changes
- jazz-react@0.13.28
- jazz-tools@0.13.28
## 0.0.210
### Patch Changes
- jazz-react@0.13.27
- jazz-tools@0.13.27
## 0.0.209
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "jazz-example-pets",
"private": true,
"version": "0.0.209",
"version": "0.0.211",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -1,5 +1,19 @@
# reactions
## 0.0.91
### Patch Changes
- jazz-react@0.13.28
- jazz-tools@0.13.28
## 0.0.90
### Patch Changes
- jazz-react@0.13.27
- jazz-tools@0.13.27
## 0.0.89
### Patch Changes

View File

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

View File

@@ -1,5 +1,21 @@
# richtext-tiptap
## 0.1.4
### Patch Changes
- jazz-react@0.13.28
- jazz-tools@0.13.28
- jazz-richtext-tiptap@0.1.4
## 0.1.3
### Patch Changes
- jazz-react@0.13.27
- jazz-tools@0.13.27
- jazz-richtext-tiptap@0.1.3
## 0.1.2
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "richtext-tiptap",
"private": true,
"version": "0.1.2",
"version": "0.1.4",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -1,5 +1,21 @@
# richtext
## 0.0.81
### Patch Changes
- jazz-react@0.13.28
- jazz-tools@0.13.28
- jazz-richtext-prosemirror@0.1.15
## 0.0.80
### Patch Changes
- jazz-react@0.13.27
- jazz-tools@0.13.27
- jazz-richtext-prosemirror@0.1.14
## 0.0.79
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "richtext",
"private": true,
"version": "0.0.79",
"version": "0.0.81",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -1,5 +1,21 @@
# todo-vue
## 0.0.95
### Patch Changes
- jazz-browser@0.13.28
- jazz-tools@0.13.28
- jazz-vue@0.13.28
## 0.0.94
### Patch Changes
- jazz-browser@0.13.27
- jazz-tools@0.13.27
- jazz-vue@0.13.27
## 0.0.93
### Patch Changes

View File

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

View File

@@ -1,5 +1,19 @@
# jazz-example-todo
## 0.0.210
### Patch Changes
- jazz-react@0.13.28
- jazz-tools@0.13.28
## 0.0.209
### Patch Changes
- jazz-react@0.13.27
- jazz-tools@0.13.27
## 0.0.208
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "jazz-example-todo",
"private": true,
"version": "0.0.208",
"version": "0.0.210",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -1,5 +1,21 @@
# version-history
## 0.0.89
### Patch Changes
- jazz-inspector@0.13.28
- jazz-react@0.13.28
- jazz-tools@0.13.28
## 0.0.88
### Patch Changes
- jazz-inspector@0.13.27
- jazz-react@0.13.27
- jazz-tools@0.13.27
## 0.0.87
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "version-history",
"private": true,
"version": "0.0.87",
"version": "0.0.89",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -8,6 +8,8 @@ The main detail to understand when using Jazz server-side is that Server Workers
This lets you share CoValues with Server Workers, having precise access control by adding the Worker to `Groups` with specific roles just like you would with other users.
[See the full example here.](https://github.com/garden-co/jazz/tree/main/examples/jazz-paper-scissors)
## Generating credentials
Server Workers typically have static credentials, consisting of a public Account ID and a private Account Secret.

View File

@@ -20,8 +20,13 @@ These root references are modeled explicitly in your schema, distinguishing betw
Every Jazz app that wants to refer to per-user data needs to define a custom root `CoMap` schema and declare it in a custom `Account` schema as the `root` field:
<CodeGroup>
{/* prettier-ignore */}
```ts
```ts twoslash
import { co, CoList } from "jazz-tools";
class Chat extends CoMap {};
class ListOfChats extends CoList.Of(Chat) {};
class ListOfAccounts extends CoList.Of(Account) {};
import "jazz-react";
// ---cut---
import { Account, CoMap } from "jazz-tools";
export class MyAppAccount extends Account {
@@ -50,9 +55,11 @@ that is set up for you based on the username the `AuthMethod` provides on accoun
Their pre-defined schemas roughly look like this:
<CodeGroup>
{/* prettier-ignore */}
```ts
// ...somehwere in jazz-tools itself...
```ts twoslash
// @noErrors: 2416
import { co, Group, CoMap } from "jazz-tools";
// ---cut---
// ...somewhere in jazz-tools itself...
export class Account extends Group {
profile = co.ref(Profile);
}
@@ -68,8 +75,10 @@ If you want to keep the default `Profile` schema, but customise your account's p
(You don't have to explicitly re-define the `profile` field, but it makes it more readable that the Account contains both `profile` and `root`)
<CodeGroup>
{/* prettier-ignore */}
```ts
```ts twoslash
import { co, CoMap } from "jazz-tools";
class MyAppRoot extends CoMap {};
// ---cut---
import { Account, Profile } from "jazz-tools";
export class MyAppAccount extends Account {
@@ -78,11 +87,17 @@ export class MyAppAccount extends Account {
}
```
</CodeGroup>
If you want to extend the `profile` to contain additional fields (such as an avatar `ImageDefinition`), you can declare your own profile schema class that extends `Profile`:
<CodeGroup>
{/* prettier-ignore */}
```ts
```ts twoslash
import "jazz-react";
import { co, CoMap, CoList } from "jazz-tools";
class Chat extends CoMap {};
class ListOfChats extends CoList.Of(Chat) {};
class ListOfAccounts extends CoList.Of(Account) {};
// ---cut---
import { Account, Profile, ImageDefinition } from "jazz-tools"; // [!code ++]
export class MyAppAccount extends Account {
@@ -102,9 +117,9 @@ export class MyAppProfile extends Profile { // [!code ++:4]
// Register the Account schema so `useAccount` returns our custom `MyAppAccount`
declare module "jazz-react" {
interface Register {
Account: MyAppAccount;
}
interface Register {
Account: MyAppAccount;
}
}
```
</CodeGroup>
@@ -115,23 +130,65 @@ declare module "jazz-react" {
To use per-user data in your app, you typically use `useAccount` somewhere in a high-level component, specifying which references to resolve using a resolve query (see [Subscribing & deep loading](/docs/using-covalues/subscription-and-loading)).
<CodeGroup>
{/* prettier-ignore */}
```tsx
```tsx twoslash
import * as React from "react";
import { Account, co, CoMap, Profile, CoList } from "jazz-tools";
class Chat extends CoMap {};
class ListOfChats extends CoList.Of(co.ref(Chat)) {};
class ListOfAccounts extends CoList.Of(co.ref(Account)) {};
class MyAppRoot extends CoMap {
myChats = co.ref(ListOfChats);
myContacts = co.ref(ListOfAccounts);
};
class MyAppProfile extends Profile {};
class MyAppAccount extends Account {
root = co.ref(MyAppRoot);
profile = co.ref(MyAppProfile);
};
declare module "jazz-react" {
interface Register {
Account: MyAppAccount;
}
}
class ChatPreview extends React.Component<{ chat: Chat }> {};
class ContactPreview extends React.Component<{ contact: Account }> {};
// ---cut---
import { useAccount } from "jazz-react";
function DashboardPageComponent() {
const { me } = useAccount({ profile: {}, root: { myChats: {}, myContacts: {}}});
const { me } = useAccount({ resolve: {
profile: true,
root: {
myChats: { $each: true },
myContacts: { $each: true }
}
}});
return <div>
<h1>Dashboard</h1>
{me ? <div>
<p>Logged in as {me.profile.name}</p>
<h2>My chats</h2>
{me.root.myChats.map((chat) => <ChatPreview key={chat.id} chat={chat} />)}
<h2>My contacts</h2>
{me.root.myContacts.map((contact) => <ContactPreview key={contact.id} contact={contact} />)}
</div> : "Loading..."}
</div>
return (
<div>
<h1>Dashboard</h1>
{me ? (
<div>
<p>Logged in as {me.profile.name}</p>
<h2>My chats</h2>
{me.root.myChats.map((chat) => (
<ChatPreview key={chat.id} chat={chat} />
))}
<h2>My contacts</h2>
{me.root.myContacts.map((contact) => (
<ContactPreview key={contact.id} contact={contact} />
))}
</div>
) : (
"Loading..."
)}
</div>
);
}
```
@@ -156,8 +213,21 @@ Jazz waits for the migration to finish before passing the account to your app's
### Initialising user data after account creation
<CodeGroup>
{/* prettier-ignore */}
```ts
```ts twoslash
import { Account, co, Group, CoList, CoMap, Profile } from "jazz-tools";
class Chat extends CoMap {};
class Bookmark extends CoMap {};
class ListOfChats extends CoList.Of(co.ref(Chat)) {};
class ListOfAccounts extends CoList.Of(co.ref(Account)) {};
class ListOfBookmarks extends CoList.Of(co.ref(Bookmark)) {};
class MyAppRoot extends CoMap {};
class MyAppProfile extends Profile {
name = co.string;
bookmarks = co.ref(ListOfBookmarks);
};
// ---cut---
export class MyAppAccount extends Account {
root = co.ref(MyAppRoot);
profile = co.ref(MyAppProfile);
@@ -181,7 +251,7 @@ export class MyAppAccount extends Account {
profileGroup.addMember("everyone", "reader");
this.profile = MyAppProfile.create({
name: creationProps?.name,
name: creationProps?.name ?? "New user",
bookmarks: ListOfBookmarks.create([], profileGroup),
}, profileGroup);
}
@@ -200,8 +270,22 @@ To do deeply nested migrations, you might need to use the asynchronous `ensureLo
Now let's say we want to add a `myBookmarks` field to the `root` schema:
<CodeGroup>
{/* prettier-ignore */}
```ts
```ts twoslash
import { Account, co, Group, CoList, CoMap, Profile } from "jazz-tools";
class Chat extends CoMap {};
class Bookmark extends CoMap {};
class ListOfChats extends CoList.Of(co.ref(Chat)) {};
class ListOfAccounts extends CoList.Of(co.ref(Account)) {};
class ListOfBookmarks extends CoList.Of(co.ref(Bookmark)) {};
class MyAppRoot extends CoMap {
myChats = co.ref(ListOfChats);
myContacts = co.ref(ListOfAccounts);
myBookmarks = co.optional.ref(ListOfBookmarks);
};
// ---cut---
export class MyAppAccount extends Account {
root = co.ref(MyAppRoot);

View File

@@ -1,5 +1,21 @@
# cojson-storage-indexeddb
## 0.13.28
### Patch Changes
- Updated dependencies [e7ccb2c]
- cojson@0.13.28
- cojson-storage@0.13.28
## 0.13.27
### Patch Changes
- Updated dependencies [6357052]
- cojson@0.13.27
- cojson-storage@0.13.27
## 0.13.25
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "cojson-storage-indexeddb",
"version": "0.13.25",
"version": "0.13.28",
"main": "dist/index.js",
"type": "module",
"types": "dist/index.d.ts",

View File

@@ -1,5 +1,21 @@
# cojson-storage-sqlite
## 0.13.28
### Patch Changes
- Updated dependencies [e7ccb2c]
- cojson@0.13.28
- cojson-storage@0.13.28
## 0.13.27
### Patch Changes
- Updated dependencies [6357052]
- cojson@0.13.27
- cojson-storage@0.13.27
## 0.13.25
### Patch Changes

View File

@@ -1,13 +1,13 @@
{
"name": "cojson-storage-sqlite",
"type": "module",
"version": "0.13.25",
"version": "0.13.28",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"license": "MIT",
"dependencies": {
"better-sqlite3": "^11.7.0",
"cojson": "workspace:0.13.25",
"cojson": "workspace:0.13.28",
"cojson-storage": "workspace:*"
},
"devDependencies": {

View File

@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
import { unlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { LocalNode } from "cojson";
import { LocalNode, cojsonInternals } from "cojson";
import { SyncManager } from "cojson-storage";
import { WasmCrypto } from "cojson/crypto/WasmCrypto";
import { expect, onTestFinished, test, vi } from "vitest";
@@ -403,3 +403,92 @@ test("should recover from data loss", async () => {
]
`);
});
test("should recover missing dependencies from storage", async () => {
const agentSecret = Crypto.newRandomAgentSecret();
const account = LocalNode.internalCreateAccount({
crypto: Crypto,
});
const node1 = account.core.node;
const serverNode = new LocalNode(
agentSecret,
Crypto.newRandomSessionID(Crypto.getAgentID(agentSecret)),
Crypto,
);
const [serverPeer, clientPeer] = cojsonInternals.connectedPeers(
node1.agentSecret,
serverNode.agentSecret,
{
peer1role: "server",
peer2role: "client",
},
);
node1.syncManager.addPeer(serverPeer);
serverNode.syncManager.addPeer(clientPeer);
const handleSyncMessage = SyncManager.prototype.handleSyncMessage;
const mock = vi
.spyOn(SyncManager.prototype, "handleSyncMessage")
.mockImplementation(function (this: SyncManager, msg) {
if (
msg.action === "content" &&
[group.core.id, account.core.id].includes(msg.id)
) {
return Promise.resolve();
}
return handleSyncMessage.call(this, msg);
});
const { peer, dbPath } = await createSQLiteStorage();
node1.syncManager.addPeer(peer);
const group = node1.createGroup();
group.addMember("everyone", "writer");
const map = group.createMap();
map.set("0", 0);
mock.mockReset();
await new Promise((resolve) => setTimeout(resolve, 200));
const node2 = new LocalNode(
Crypto.newRandomAgentSecret(),
Crypto.newRandomSessionID(Crypto.getAgentID(agentSecret)),
Crypto,
);
const [serverPeer2, clientPeer2] = cojsonInternals.connectedPeers(
node1.agentSecret,
serverNode.agentSecret,
{
peer1role: "server",
peer2role: "client",
},
);
node2.syncManager.addPeer(serverPeer2);
serverNode.syncManager.addPeer(clientPeer2);
const { peer: peer2 } = await createSQLiteStorage(dbPath);
node2.syncManager.addPeer(peer2);
const map2 = await node2.load(map.id);
if (map2 === "unavailable") {
throw new Error("Map is unavailable");
}
expect(map2.toJSON()).toEqual({
"0": 0,
});
});

View File

@@ -1,5 +1,19 @@
# cojson-storage
## 0.13.28
### Patch Changes
- Updated dependencies [e7ccb2c]
- cojson@0.13.28
## 0.13.27
### Patch Changes
- Updated dependencies [6357052]
- cojson@0.13.27
## 0.13.25
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "cojson-storage",
"version": "0.13.25",
"version": "0.13.28",
"main": "dist/index.js",
"type": "module",
"types": "dist/index.d.ts",

View File

@@ -1,5 +1,20 @@
# cojson-transport-nodejs-ws
## 0.13.28
### Patch Changes
- 422dbc4: Add waitUntilConnected and subscribe APIs on WebSocketPeerWithReconnection
- Updated dependencies [e7ccb2c]
- cojson@0.13.28
## 0.13.27
### Patch Changes
- Updated dependencies [6357052]
- cojson@0.13.27
## 0.13.25
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "cojson-transport-ws",
"type": "module",
"version": "0.13.25",
"version": "0.13.28",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"license": "MIT",

View File

@@ -1,27 +1,36 @@
import { type Peer, logger } from "cojson";
import { createWebSocketPeer } from "./createWebSocketPeer.js";
import type { AnyWebSocketConstructor } from "./types.js";
export class WebSocketPeerWithReconnection {
private peer: string;
private reconnectionTimeout: number;
private addPeer: (peer: Peer) => void;
private removePeer: (peer: Peer) => void;
private WebSocketConstructor: AnyWebSocketConstructor;
private pingTimeout: number;
constructor(opts: {
peer: string;
reconnectionTimeout: number | undefined;
addPeer: (peer: Peer) => void;
removePeer: (peer: Peer) => void;
WebSocketConstructor?: AnyWebSocketConstructor;
pingTimeout?: number;
}) {
this.peer = opts.peer;
this.reconnectionTimeout = opts.reconnectionTimeout || 500;
this.addPeer = opts.addPeer;
this.removePeer = opts.removePeer;
this.WebSocketConstructor = opts.WebSocketConstructor || WebSocket;
this.pingTimeout = opts.pingTimeout || 10_000;
}
state: "disabled" | "enabled" = "disabled";
enabled = false;
closed = true;
currentPeer: Peer | undefined = undefined;
unsubscribeNetworkChange: (() => void) | undefined = undefined;
private unsubscribeNetworkChange: (() => void) | undefined = undefined;
// Basic implementation for environments that don't support network change events (e.g. Node.js)
// Needs to be extended to handle platform specific APIs
@@ -30,7 +39,7 @@ export class WebSocketPeerWithReconnection {
return () => {};
}
waitForOnline(timeout: number) {
private waitForOnline(timeout: number) {
return new Promise<void>((resolve) => {
const unsubscribeNetworkChange = this.onNetworkChange((connected) => {
if (connected) {
@@ -40,7 +49,7 @@ export class WebSocketPeerWithReconnection {
function handleTimeoutOrOnline() {
clearTimeout(timer);
unsubscribeNetworkChange();
unsubscribeNetworkChange?.();
resolve();
}
@@ -50,11 +59,38 @@ export class WebSocketPeerWithReconnection {
reconnectionAttempts = 0;
onConnectionChangeListeners = new Set<(connected: boolean) => void>();
waitUntilConnected = async () => {
if (this.closed) {
return new Promise<void>((resolve) => {
const listener = (connected: boolean) => {
if (connected) {
resolve();
this.onConnectionChangeListeners.delete(listener);
}
};
this.onConnectionChangeListeners.add(listener);
});
}
};
subscribe = (listener: (connected: boolean) => void) => {
this.onConnectionChangeListeners.add(listener);
listener(!this.closed);
};
unsubscribe = (listener: (connected: boolean) => void) => {
this.onConnectionChangeListeners.delete(listener);
};
startConnection = async () => {
if (this.state !== "enabled") return;
if (!this.enabled) return;
if (this.currentPeer) {
this.removePeer(this.currentPeer);
this.currentPeer.outgoing.close();
this.reconnectionAttempts++;
@@ -67,14 +103,25 @@ export class WebSocketPeerWithReconnection {
await this.waitForOnline(timeout);
}
if (this.state !== "enabled") return;
if (!this.enabled) return;
this.currentPeer = createWebSocketPeer({
websocket: new WebSocket(this.peer),
websocket: new this.WebSocketConstructor(this.peer),
pingTimeout: this.pingTimeout,
id: this.peer,
role: "server",
onClose: this.startConnection,
onClose: () => {
this.closed = true;
for (const listener of this.onConnectionChangeListeners) {
listener(false);
}
this.startConnection();
},
onSuccess: () => {
this.closed = false;
for (const listener of this.onConnectionChangeListeners) {
listener(true);
}
logger.debug("Websocket connection successful");
this.reconnectionAttempts = 0;
@@ -85,16 +132,16 @@ export class WebSocketPeerWithReconnection {
};
enable = () => {
if (this.state === "enabled") return;
if (this.enabled) return;
this.state = "enabled";
this.enabled = true;
this.startConnection();
};
disable = () => {
if (this.state === "disabled") return;
if (!this.enabled) return;
this.state = "disabled";
this.enabled = false;
this.reconnectionAttempts = 0;
this.unsubscribeNetworkChange?.();
@@ -102,6 +149,7 @@ export class WebSocketPeerWithReconnection {
if (this.currentPeer) {
this.removePeer(this.currentPeer);
this.currentPeer.outgoing.close();
this.currentPeer = undefined;
}
};

View File

@@ -140,4 +140,245 @@ describe("WebSocketPeerWithReconnection", () => {
peer.disable();
});
describe("waitUntilConnected", () => {
test("should wait until connected before resolving", async () => {
const addPeer = vi.fn();
const removePeer = vi.fn();
const peer = new WebSocketPeerWithReconnection({
peer: syncServerUrl,
reconnectionTimeout: 100,
addPeer,
removePeer,
});
// Start waiting for connection before enabling
const waitPromise = peer.waitUntilConnected();
// Enable the peer after a short delay
setTimeout(() => peer.enable(), 100);
// Wait for connection to be established
await waitPromise;
expect(addPeer).toHaveBeenCalledTimes(1);
expect(peer.closed).toBe(false);
peer.disable();
});
test("should resolve immediately if already connected", async () => {
const addPeer = vi.fn();
const removePeer = vi.fn();
const peer = new WebSocketPeerWithReconnection({
peer: syncServerUrl,
reconnectionTimeout: 100,
addPeer,
removePeer,
});
// Enable the peer first
peer.enable();
// Wait for initial connection
await new Promise((resolve) => setTimeout(resolve, 100));
// Now wait for connection again
const waitPromise = peer.waitUntilConnected();
// Should resolve immediately since we're already connected
await waitPromise;
expect(addPeer).toHaveBeenCalledTimes(1);
expect(peer.closed).toBe(false);
peer.disable();
});
test("should work when connection is lost and regained", async () => {
const addPeer = vi.fn();
const removePeer = vi.fn();
const peer = new WebSocketPeerWithReconnection({
peer: syncServerUrl,
reconnectionTimeout: 100,
addPeer,
removePeer,
});
peer.enable();
// Wait for initial connection
await new Promise((resolve) => setTimeout(resolve, 100));
// Close server to simulate connection loss
server.close();
// Wait for disconnect to be detected
await new Promise((resolve) => setTimeout(resolve, 200));
// Start server again
server = await startSyncServer(server.port);
// Wait for the waitUntilConnected promise to resolve
await peer.waitUntilConnected();
// Verify that we have a new connection
expect(addPeer).toHaveBeenCalledTimes(3); // Once for initial, once for reconnection
expect(peer.closed).toBe(false);
peer.disable();
});
});
describe("subscribe", () => {
test("should notify subscribers of initial connection state", async () => {
const addPeer = vi.fn();
const removePeer = vi.fn();
const listener = vi.fn();
const peer = new WebSocketPeerWithReconnection({
peer: syncServerUrl,
reconnectionTimeout: 100,
addPeer,
removePeer,
});
// Subscribe before enabling
peer.subscribe(listener);
// Initial state should be disconnected
expect(listener).toHaveBeenCalledWith(false);
// Enable the peer
peer.enable();
// Wait for connection
await new Promise((resolve) => setTimeout(resolve, 100));
// Should notify of connected state
expect(listener).toHaveBeenCalledWith(true);
peer.disable();
});
test("should notify subscribers of connection state changes", async () => {
const addPeer = vi.fn();
const removePeer = vi.fn();
const listener = vi.fn();
const peer = new WebSocketPeerWithReconnection({
peer: syncServerUrl,
reconnectionTimeout: 100,
addPeer,
removePeer,
});
peer.enable();
peer.subscribe(listener);
// Wait for initial connection
await new Promise((resolve) => setTimeout(resolve, 100));
// Should notify of connected state
expect(listener).toHaveBeenCalledWith(true);
// Close server to simulate disconnect
server.close();
// Wait for disconnect to be detected
await new Promise((resolve) => setTimeout(resolve, 200));
// Should notify of disconnected state
expect(listener).toHaveBeenCalledWith(false);
peer.disable();
});
test("should not notify unsubscribed listeners", async () => {
const addPeer = vi.fn();
const removePeer = vi.fn();
const listener1 = vi.fn();
const listener2 = vi.fn();
const peer = new WebSocketPeerWithReconnection({
peer: syncServerUrl,
reconnectionTimeout: 100,
addPeer,
removePeer,
});
peer.enable();
peer.subscribe(listener1);
peer.subscribe(listener2);
// Wait for initial connection
await new Promise((resolve) => setTimeout(resolve, 100));
// Both listeners should be notified of connection
expect(listener1).toHaveBeenCalledWith(true);
expect(listener2).toHaveBeenCalledWith(true);
// Unsubscribe listener1
peer.unsubscribe(listener1);
listener1.mockClear();
listener2.mockClear();
// Close server to simulate disconnect
server.close();
// Wait for disconnect to be detected
await new Promise((resolve) => setTimeout(resolve, 200));
// Only listener2 should be notified of disconnect
expect(listener1).not.toHaveBeenCalled();
expect(listener2).toHaveBeenCalledWith(false);
peer.disable();
});
test("should handle multiple subscribers correctly", async () => {
const addPeer = vi.fn();
const removePeer = vi.fn();
const listeners = Array.from({ length: 3 }, () => vi.fn());
const peer = new WebSocketPeerWithReconnection({
peer: syncServerUrl,
reconnectionTimeout: 100,
addPeer,
removePeer,
});
peer.enable();
// Subscribe all listeners
for (const listener of listeners) {
peer.subscribe(listener);
}
// Wait for initial connection
await new Promise((resolve) => setTimeout(resolve, 100));
// All listeners should be notified of connection
for (const listener of listeners) {
expect(listener).toHaveBeenCalledWith(true);
}
// Close server to simulate disconnect
server.close();
// Wait for disconnect to be detected
await new Promise((resolve) => setTimeout(resolve, 200));
// All listeners should be notified of disconnect
for (const listener of listeners) {
expect(listener).toHaveBeenCalledWith(false);
}
peer.disable();
});
});
});

View File

@@ -1,5 +1,17 @@
# cojson
## 0.13.28
### Patch Changes
- e7ccb2c: Recover missing dependencies when getting new content
## 0.13.27
### Patch Changes
- 6357052: Allow accounts to self-remove from groups
## 0.13.25
### Patch Changes

View File

@@ -25,7 +25,7 @@
},
"type": "module",
"license": "MIT",
"version": "0.13.25",
"version": "0.13.28",
"devDependencies": {
"@opentelemetry/sdk-metrics": "^2.0.0",
"typescript": "catalog:"

View File

@@ -109,7 +109,7 @@ export class CoValueCore {
private readonly _decryptionCache: {
[key: Encrypted<JsonValue[], JsonValue>]: JsonValue[] | undefined;
} = {};
private _cachedDependentOn?: RawCoID[];
private _cachedDependentOn?: Set<RawCoID>;
private counter: UpDownCounter;
private constructor(
@@ -897,39 +897,57 @@ export class CoValueCore {
];
}
getDependedOnCoValues(): RawCoID[] {
getDependedOnCoValues(): Set<RawCoID> {
if (this._cachedDependentOn) {
return this._cachedDependentOn;
} else {
const dependentOn = this.getDependedOnCoValuesUncached();
if (!this.verified) {
return new Set();
}
const dependentOn = this.getDependedOnCoValuesFromHeaderAndSessions(
this.verified.header,
this.verified.sessions.keys(),
);
this._cachedDependentOn = dependentOn;
return dependentOn;
}
}
/** @internal */
getDependedOnCoValuesUncached(): RawCoID[] {
if (!this.verified) {
return [];
getDependedOnCoValuesFromHeaderAndSessions(
header: CoValueHeader,
sessions: Iterable<SessionID>,
): Set<RawCoID> {
const deps = new Set<RawCoID>();
for (const session of sessions) {
const accountId = accountOrAgentIDfromSessionID(session);
if (isAccountID(accountId) && accountId !== this.id) {
deps.add(accountId);
}
}
return this.verified.header.ruleset.type === "group"
? getGroupDependentKeyList(expectGroup(this.getCurrentContent()).keys())
: this.verified.header.ruleset.type === "ownedByGroup"
? [
this.verified.header.ruleset.group,
...new Set(
[...this.verified.sessions.keys()]
.map((sessionID) =>
accountOrAgentIDfromSessionID(sessionID as SessionID),
)
.filter(
(session): session is RawAccountID =>
isAccountID(session) && session !== this.id,
),
),
]
: [];
if (header.ruleset.type === "group") {
if (isAccountID(header.ruleset.initialAdmin)) {
deps.add(header.ruleset.initialAdmin);
}
if (this.verified) {
for (const id of getGroupDependentKeyList(
expectGroup(this.getCurrentContent()).keys(),
)) {
deps.add(id);
}
}
}
if (header.ruleset.type === "ownedByGroup") {
deps.add(header.ruleset.group);
}
return deps;
}
waitForSync(options?: {
@@ -943,7 +961,11 @@ export class CoValueCore {
return;
}
const peersToActuallyLoadFrom = [];
const peersToActuallyLoadFrom = {
storage: [] as PeerState[],
server: [] as PeerState[],
};
for (const peer of peers) {
const currentState = this.peers.get(peer.id);
@@ -959,78 +981,87 @@ export class CoValueCore {
}
if (currentState?.type === "unavailable") {
if (peer.shouldRetryUnavailableCoValues()) {
if (peer.role === "server") {
peersToActuallyLoadFrom.server.push(peer);
this.markPending(peer.id);
peersToActuallyLoadFrom.push(peer);
}
continue;
}
if (!currentState || currentState?.type === "unknown") {
if (peer.role === "storage") {
peersToActuallyLoadFrom.storage.push(peer);
} else {
peersToActuallyLoadFrom.server.push(peer);
}
this.markPending(peer.id);
peersToActuallyLoadFrom.push(peer);
}
}
for (const peer of peersToActuallyLoadFrom) {
if (peer.closed) {
this.markNotFoundInPeer(peer.id);
continue;
}
peer.pushOutgoingMessage({
action: "load",
...this.knownState(),
});
peer.trackLoadRequestSent(this.id);
/**
* Use a very long timeout for storage peers, because under pressure
* they may take a long time to consume the messages queue
*
* TODO: Track errors on storage and do not rely on timeout
*/
const timeoutDuration =
peer.role === "storage"
? CO_VALUE_LOADING_CONFIG.TIMEOUT * 10
: CO_VALUE_LOADING_CONFIG.TIMEOUT;
const waitingForPeer = new Promise<void>((resolve) => {
const markNotFound = () => {
if (this.peers.get(peer.id)?.type === "pending") {
logger.warn("Timeout waiting for peer to load coValue", {
id: this.id,
peerID: peer.id,
});
this.markNotFoundInPeer(peer.id);
}
};
const timeout = setTimeout(markNotFound, timeoutDuration);
const removeCloseListener = peer.addCloseListener(markNotFound);
const listener = (state: CoValueCore) => {
const peerState = state.peers.get(peer.id);
if (
state.isAvailable() || // might have become available from another peer e.g. through handleNewContent
peerState?.type === "available" ||
peerState?.type === "errored" ||
peerState?.type === "unavailable"
) {
this.listeners.delete(listener);
removeCloseListener();
clearTimeout(timeout);
resolve();
}
};
this.listeners.add(listener);
listener(this);
});
await waitingForPeer;
// Load from storage peers first, then from server peers
if (peersToActuallyLoadFrom.storage.length > 0) {
await Promise.all(
peersToActuallyLoadFrom.storage.map((peer) =>
this.internalLoadFromPeer(peer),
),
);
}
if (peersToActuallyLoadFrom.server.length > 0) {
await Promise.all(
peersToActuallyLoadFrom.server.map((peer) =>
this.internalLoadFromPeer(peer),
),
);
}
}
internalLoadFromPeer(peer: PeerState) {
if (peer.closed) {
this.markNotFoundInPeer(peer.id);
return;
}
peer.pushOutgoingMessage({
action: "load",
...this.knownState(),
});
peer.trackLoadRequestSent(this.id);
return new Promise<void>((resolve) => {
const markNotFound = () => {
if (this.peers.get(peer.id)?.type === "pending") {
logger.warn("Timeout waiting for peer to load coValue", {
id: this.id,
peerID: peer.id,
});
this.markNotFoundInPeer(peer.id);
}
};
const timeout = setTimeout(markNotFound, CO_VALUE_LOADING_CONFIG.TIMEOUT);
const removeCloseListener = peer.addCloseListener(markNotFound);
const listener = (state: CoValueCore) => {
const peerState = state.peers.get(peer.id);
if (
state.isAvailable() || // might have become available from another peer e.g. through handleNewContent
peerState?.type === "available" ||
peerState?.type === "errored" ||
peerState?.type === "unavailable"
) {
this.listeners.delete(listener);
removeCloseListener();
clearTimeout(timeout);
resolve();
}
};
this.listeners.add(listener);
listener(this);
});
}
}

View File

@@ -773,7 +773,10 @@ export class RawGroup<
) {
const memberKey = typeof account === "string" ? account : account.id;
this.rotateReadKey(memberKey);
if (this.myRole() === "admin") {
this.rotateReadKey(memberKey);
}
this.set(memberKey, "revoked", "trusting");
}

View File

@@ -104,8 +104,12 @@ export class LocalNode {
this.coValues.delete(id);
}
getCurrentAccountOrAgentID(): RawAccountID | AgentID {
return accountOrAgentIDfromSessionID(this.currentSessionID);
}
getCurrentAgent(): ControlledAccountOrAgent {
const accountOrAgent = accountOrAgentIDfromSessionID(this.currentSessionID);
const accountOrAgent = this.getCurrentAccountOrAgentID();
if (isAgentID(accountOrAgent)) {
return new ControlledAgent(this.agentSecret, this.crypto);
}
@@ -118,7 +122,7 @@ export class LocalNode {
}
expectCurrentAccountID(reason: string): RawAccountID {
const accountOrAgent = accountOrAgentIDfromSessionID(this.currentSessionID);
const accountOrAgent = this.getCurrentAccountOrAgentID();
if (isAgentID(accountOrAgent)) {
throw new Error(
"Current account is an agent, but expected an account: " + reason,

View File

@@ -452,7 +452,12 @@ function determineValidTransactionsForGroup(
change.key === transactor &&
change.value === "admin";
if (!isFirstSelfAppointment) {
const currentAccountId = coValue.node.getCurrentAccountOrAgentID();
const isSelfRevoke =
currentAccountId === change.key && change.value === "revoked";
if (!isFirstSelfAppointment && !isSelfRevoke) {
if (memberState[transactor] === "admin") {
if (
memberState[affectedMember] === "admin" &&

View File

@@ -11,6 +11,8 @@ import { RawCoID, SessionID } from "./ids.js";
import { LocalNode } from "./localNode.js";
import { logger } from "./logger.js";
import { CoValuePriority } from "./priority.js";
import { accountOrAgentIDfromSessionID } from "./typeUtils/accountOrAgentIDfromSessionID.js";
import { isAccountID } from "./typeUtils/isAccountID.js";
export type CoValueKnownState = {
id: RawCoID;
@@ -211,9 +213,9 @@ export class SyncManager {
return;
}
coValue
.getDependedOnCoValues()
.map((id) => this.sendNewContentIncludingDependencies(id, peer));
for (const dependency of coValue.getDependedOnCoValues()) {
this.sendNewContentIncludingDependencies(dependency, peer);
}
const newContentPieces = coValue.verified.newContentSince(
peer.optimisticKnownStates.get(id),
@@ -298,7 +300,7 @@ export class SyncManager {
}
}
async addPeer(peer: Peer) {
addPeer(peer: Peer) {
const prevPeer = this.peers[peer.id];
if (prevPeer && !prevPeer.closed) {
@@ -379,65 +381,17 @@ export class SyncManager {
peer.setKnownState(msg.id, knownStateIn(msg));
const coValue = this.local.getCoValue(msg.id);
if (
coValue.loadingState === "unknown" ||
coValue.loadingState === "unavailable"
) {
const eligiblePeers = this.getServerAndStoragePeers(peer.id);
if (eligiblePeers.length === 0) {
// We don't have any eligible peers to load the coValue from
// so we send a known state back to the sender to let it know
// that the coValue is unavailable
peer.trackToldKnownState(msg.id);
this.trySendToPeer(peer, {
action: "known",
id: msg.id,
header: false,
sessions: {},
});
return;
} else {
// Syncronously updates the state loading is possible
coValue
.loadFromPeers(this.getServerAndStoragePeers(peer.id))
.catch((e) => {
logger.error("Error loading coValue in handleLoad", { err: e });
});
}
if (coValue.isAvailable()) {
this.sendNewContentIncludingDependencies(msg.id, peer);
return;
}
if (coValue.loadingState === "loading") {
// 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)
coValue
.waitForAvailableOrUnavailable()
.then(async (value) => {
if (!value.isAvailable()) {
peer.trackToldKnownState(msg.id);
this.trySendToPeer(peer, {
action: "known",
id: msg.id,
header: false,
sessions: {},
});
const eligiblePeers = this.getServerAndStoragePeers(peer.id);
return;
}
this.sendNewContentIncludingDependencies(msg.id, peer);
})
.catch((e) => {
logger.error("Error loading coValue in handleLoad loading state", {
err: e,
});
});
} else if (coValue.isAvailable()) {
this.sendNewContentIncludingDependencies(msg.id, peer);
} else {
if (eligiblePeers.length === 0) {
// We don't have any eligible peers to load the coValue from
// so we send a known state back to the sender to let it know
// that the coValue is unavailable
peer.trackToldKnownState(msg.id);
this.trySendToPeer(peer, {
action: "known",
@@ -445,9 +399,41 @@ export class SyncManager {
header: false,
sessions: {},
});
}
}
return;
}
coValue.loadFromPeers(eligiblePeers).catch((e) => {
logger.error("Error loading coValue in handleLoad", { err: e });
});
// 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)
coValue
.waitForAvailableOrUnavailable()
.then((value) => {
if (!value.isAvailable()) {
peer.trackToldKnownState(msg.id);
this.trySendToPeer(peer, {
action: "known",
id: msg.id,
header: false,
sessions: {},
});
return;
}
this.sendNewContentIncludingDependencies(msg.id, peer);
})
.catch((e) => {
logger.error("Error loading coValue in handleLoad loading state", {
err: e,
});
});
}
handleKnownState(msg: KnownStateMessage, peer: PeerState) {
const coValue = this.local.getCoValue(msg.id);
@@ -494,6 +480,52 @@ export class SyncManager {
return;
}
let dependencyMissing = false;
const sessionIDs = Object.keys(msg.new) as SessionID[];
for (const dependency of coValue.getDependedOnCoValuesFromHeaderAndSessions(
msg.header,
sessionIDs,
)) {
const dependencyCoValue = this.local.getCoValue(dependency);
if (!dependencyCoValue.isAvailable()) {
if (peer.role !== "storage") {
this.trySendToPeer(peer, {
action: "load",
id: dependency,
header: false,
sessions: {},
});
}
dependencyMissing = true;
}
}
if (dependencyMissing) {
if (peer.role !== "storage") {
/**
* If we have missing dependencies, we send a known state message to the peer
* to let it know that we need a correction update.
*
* Sync-wise is sub-optimal, but it gives us correctness until
* https://github.com/garden-co/jazz/issues/1917 is implemented.
*/
this.trySendToPeer(peer, {
action: "known",
isCorrection: true,
id: msg.id,
header: false,
sessions: {},
});
} else {
/** Cases of broken deps from storage are recovered by falling back to the server peers */
coValue.loadFromPeers(this.getServerAndStoragePeers(peer.id));
}
return;
}
peer.updateHeader(msg.id, true);
coValue.markAvailable(msg.header, peer.id);
}
@@ -528,6 +560,18 @@ export class SyncManager {
continue;
}
const accountId = accountOrAgentIDfromSessionID(sessionID);
if (isAccountID(accountId)) {
const account = this.local.getCoValue(accountId);
if (!account.isAvailable()) {
account.loadFromPeers([peer]);
invalidStateAssumed = true;
continue;
}
}
const result = coValue.tryAddTransactions(
sessionID,
newTransactions,
@@ -607,6 +651,8 @@ export class SyncManager {
// Check if there is a inflight load operation and we
// are waiting for other peers to send the load request
if (state === "unknown" || state === undefined) {
// Sending a load message to the peer to get to know how much content is missing
// before sending the new content
this.trySendToPeer(peer, {
action: "load",
...coValue.knownState(),
@@ -645,7 +691,7 @@ export class SyncManager {
this.requestedSyncs.add(coValue.id);
}
async syncCoValue(coValue: CoValueCore) {
syncCoValue(coValue: CoValueCore) {
this.requestedSyncs.delete(coValue.id);
for (const peer of this.peersInPriorityOrder()) {
@@ -668,21 +714,21 @@ export class SyncManager {
}
}
async waitForSyncWithPeer(peerId: PeerID, id: RawCoID, timeout: number) {
waitForSyncWithPeer(peerId: PeerID, id: RawCoID, timeout: number) {
const { syncState } = this;
const currentSyncState = syncState.getCurrentSyncState(peerId, id);
const isTheConditionAlreadyMet = currentSyncState.uploaded;
if (isTheConditionAlreadyMet) {
return true;
return;
}
const peerState = this.peers[peerId];
// The peer has been closed, so it isn't possible to sync
if (!peerState || peerState.closed) {
return true;
return;
}
// The client isn't subscribed to the coValue, so we won't sync it
@@ -690,7 +736,7 @@ export class SyncManager {
peerState.role === "client" &&
!peerState.optimisticKnownStates.has(id)
) {
return true;
return;
}
return new Promise((resolve, reject) => {
@@ -712,25 +758,25 @@ export class SyncManager {
});
}
async waitForStorageSync(id: RawCoID, timeout = 30_000) {
waitForStorageSync(id: RawCoID, timeout = 30_000) {
const peers = this.getPeers();
await Promise.all(
return Promise.all(
peers
.filter((peer) => peer.role === "storage")
.map((peer) => this.waitForSyncWithPeer(peer.id, id, timeout)),
);
}
async waitForSync(id: RawCoID, timeout = 30_000) {
waitForSync(id: RawCoID, timeout = 30_000) {
const peers = this.getPeers();
await Promise.all(
return Promise.all(
peers.map((peer) => this.waitForSyncWithPeer(peer.id, id, timeout)),
);
}
async waitForAllCoValuesSync(timeout = 60_000) {
waitForAllCoValuesSync(timeout = 60_000) {
const coValues = this.local.allCoValues();
const validCoValues = Array.from(coValues).filter(
(coValue) =>

View File

@@ -257,7 +257,7 @@ describe("SyncStateManager", () => {
const group = client.node.createGroup();
const map = group.createMap();
await expect(map.core.waitForSync()).resolves.toBeUndefined();
await map.core.waitForSync();
});
test("should skip client peers that are not subscribed to the coValue", async () => {

View File

@@ -0,0 +1,255 @@
import { beforeEach, describe, expect, test } from "vitest";
import { RawCoList } from "../coValues/coList.js";
import { RawCoMap } from "../coValues/coMap.js";
import { RawCoStream } from "../coValues/coStream.js";
import { RawBinaryCoStream } from "../coValues/coStream.js";
import { WasmCrypto } from "../crypto/WasmCrypto.js";
import { RawAccountID } from "../exports.js";
import {
SyncMessagesLog,
createTwoConnectedNodes,
loadCoValueOrFail,
setupTestAccount,
setupTestNode,
} from "./testUtils.js";
let jazzCloud = setupTestNode({ isSyncServer: true });
beforeEach(async () => {
SyncMessagesLog.clear();
jazzCloud = setupTestNode({ isSyncServer: true });
});
describe("Group.removeMember", () => {
test("a reader member should be able to revoke themselves", async () => {
const admin = await setupTestAccount({
connected: true,
});
const reader = await setupTestAccount({
connected: true,
});
const group = admin.node.createGroup();
const readerOnAdminNode = await loadCoValueOrFail(
admin.node,
reader.accountID,
);
group.addMember(readerOnAdminNode, "reader");
const groupOnReaderNode = await loadCoValueOrFail(reader.node, group.id);
expect(groupOnReaderNode.myRole()).toEqual("reader");
await groupOnReaderNode.removeMember(
reader.node.expectCurrentAccount("reader"),
);
expect(groupOnReaderNode.myRole()).toEqual(undefined);
});
test("a writer member should be able to revoke themselves", async () => {
const admin = await setupTestAccount({
connected: true,
});
const writer = await setupTestAccount({
connected: true,
});
const group = admin.node.createGroup();
const writerOnAdminNode = await loadCoValueOrFail(
admin.node,
writer.accountID,
);
group.addMember(writerOnAdminNode, "writer");
const groupOnWriterNode = await loadCoValueOrFail(writer.node, group.id);
expect(groupOnWriterNode.myRole()).toEqual("writer");
await groupOnWriterNode.removeMember(
writer.node.expectCurrentAccount("writer"),
);
expect(groupOnWriterNode.myRole()).toEqual(undefined);
});
test("a writeOnly member should be able to revoke themselves", async () => {
const admin = await setupTestAccount({
connected: true,
});
const writeOnly = await setupTestAccount({
connected: true,
});
const group = admin.node.createGroup();
const writeOnlyOnAdminNode = await loadCoValueOrFail(
admin.node,
writeOnly.accountID,
);
group.addMember(writeOnlyOnAdminNode, "writeOnly");
const groupOnWriteOnlyNode = await loadCoValueOrFail(
writeOnly.node,
group.id,
);
expect(groupOnWriteOnlyNode.myRole()).toEqual("writeOnly");
await groupOnWriteOnlyNode.removeMember(
writeOnly.node.expectCurrentAccount("writeOnly"),
);
expect(groupOnWriteOnlyNode.myRole()).toEqual(undefined);
});
test("an admin member should be able to revoke themselves", async () => {
const admin = await setupTestAccount({
connected: true,
});
const otherAdmin = await setupTestAccount({
connected: true,
});
const group = admin.node.createGroup();
const otherAdminOnAdminNode = await loadCoValueOrFail(
admin.node,
otherAdmin.accountID,
);
group.addMember(otherAdminOnAdminNode, "admin");
const groupOnOtherAdminNode = await loadCoValueOrFail(
otherAdmin.node,
group.id,
);
expect(groupOnOtherAdminNode.myRole()).toEqual("admin");
await groupOnOtherAdminNode.removeMember(
otherAdmin.node.expectCurrentAccount("admin"),
);
expect(groupOnOtherAdminNode.myRole()).toEqual(undefined);
});
test("a writer member cannot remove other accounts", async () => {
const admin = await setupTestAccount({
connected: true,
});
const writer = await setupTestAccount({
connected: true,
});
const otherMember = await setupTestAccount({
connected: true,
});
const group = admin.node.createGroup();
const writerOnAdminNode = await loadCoValueOrFail(
admin.node,
writer.accountID,
);
const otherMemberOnAdminNode = await loadCoValueOrFail(
admin.node,
otherMember.accountID,
);
group.addMember(writerOnAdminNode, "writer");
group.addMember(otherMemberOnAdminNode, "reader");
const groupOnWriterNode = await loadCoValueOrFail(writer.node, group.id);
expect(groupOnWriterNode.myRole()).toEqual("writer");
const otherMemberOnWriterNode = await loadCoValueOrFail(
writer.node,
otherMember.accountID,
);
await groupOnWriterNode.removeMember(otherMemberOnWriterNode);
expect(groupOnWriterNode.roleOf(otherMember.accountID)).toEqual("reader");
});
test("a writeOnly member cannot remove other accounts", async () => {
const admin = await setupTestAccount({
connected: true,
});
const writeOnly = await setupTestAccount({
connected: true,
});
const otherMember = await setupTestAccount({
connected: true,
});
const group = admin.node.createGroup();
const writeOnlyOnAdminNode = await loadCoValueOrFail(
admin.node,
writeOnly.accountID,
);
const otherMemberOnAdminNode = await loadCoValueOrFail(
admin.node,
otherMember.accountID,
);
group.addMember(writeOnlyOnAdminNode, "writeOnly");
group.addMember(otherMemberOnAdminNode, "reader");
const groupOnWriteOnlyNode = await loadCoValueOrFail(
writeOnly.node,
group.id,
);
expect(groupOnWriteOnlyNode.myRole()).toEqual("writeOnly");
const otherMemberOnWriteOnlyNode = await loadCoValueOrFail(
writeOnly.node,
otherMember.accountID,
);
await groupOnWriteOnlyNode.removeMember(otherMemberOnWriteOnlyNode);
expect(groupOnWriteOnlyNode.roleOf(otherMember.accountID)).toEqual(
"reader",
);
});
test("a reader member cannot remove other accounts", async () => {
const admin = await setupTestAccount({
connected: true,
});
const reader = await setupTestAccount({
connected: true,
});
const otherMember = await setupTestAccount({
connected: true,
});
const group = admin.node.createGroup();
const readerOnAdminNode = await loadCoValueOrFail(
admin.node,
reader.accountID,
);
const otherMemberOnAdminNode = await loadCoValueOrFail(
admin.node,
otherMember.accountID,
);
group.addMember(readerOnAdminNode, "reader");
group.addMember(otherMemberOnAdminNode, "writer");
const groupOnReaderNode = await loadCoValueOrFail(reader.node, group.id);
expect(groupOnReaderNode.myRole()).toEqual("reader");
const otherMemberOnReaderNode = await loadCoValueOrFail(
reader.node,
otherMember.accountID,
);
await groupOnReaderNode.removeMember(otherMemberOnReaderNode);
expect(groupOnReaderNode.roleOf(otherMember.accountID)).toEqual("writer");
});
});

View File

@@ -4,6 +4,7 @@ import { expectMap } from "../coValue";
import {
SyncMessagesLog,
blockMessageTypeOnOutgoingPeer,
connectedPeersWithMessagesTracking,
loadCoValueOrFail,
setupTestNode,
waitFor,
@@ -76,8 +77,8 @@ describe("multiple clients syncing with the a cloud-like server mesh", () => {
"core -> storage | CONTENT Group header: true new: After: 0 New: 3",
"storage -> core | KNOWN Group sessions: header/3",
"core -> storage | CONTENT Map header: true new: After: 0 New: 1",
"storage -> core | KNOWN Map sessions: header/1",
"client -> edge-italy | LOAD Map sessions: empty",
"storage -> core | KNOWN Map sessions: header/1",
"edge-italy -> core | LOAD Map sessions: empty",
"core -> edge-italy | CONTENT Group header: true new: After: 0 New: 3",
"edge-italy -> core | KNOWN Group sessions: header/3",
@@ -138,8 +139,8 @@ describe("multiple clients syncing with the a cloud-like server mesh", () => {
"core -> storage | CONTENT Group header: true new: After: 0 New: 5",
"storage -> core | KNOWN Group sessions: header/5",
"core -> storage | CONTENT Map header: true new: After: 0 New: 1",
"storage -> core | KNOWN Map sessions: header/1",
"client -> edge-italy | LOAD Map sessions: empty",
"storage -> core | KNOWN Map sessions: header/1",
"edge-italy -> core | LOAD Map sessions: empty",
"core -> edge-italy | CONTENT ParentGroup header: true new: After: 0 New: 6",
"edge-italy -> core | KNOWN ParentGroup sessions: header/6",
@@ -355,6 +356,7 @@ describe("multiple clients syncing with the a cloud-like server mesh", () => {
syncServer: storage.node,
});
storagePeer.role = "storage";
storagePeer.priority = 100;
const group = coreServer.node.createGroup();
@@ -402,4 +404,60 @@ describe("multiple clients syncing with the a cloud-like server mesh", () => {
expect(mapOnClient.get("hello")).toEqual("world");
});
test("a stuck server peer should not block the load from other server peers", async () => {
const client = setupTestNode();
const coreServer = setupTestNode({
isSyncServer: true,
});
const anotherServer = setupTestNode({});
const { peer: peerToCoreServer } = client.connectToSyncServer({
syncServerName: "core",
syncServer: coreServer.node,
});
const { peer1, peer2 } = connectedPeersWithMessagesTracking({
peer1: {
id: anotherServer.node.getCurrentAgent().id,
role: "server",
name: "another-server",
},
peer2: {
id: client.node.getCurrentAgent().id,
role: "client",
name: "client",
},
});
blockMessageTypeOnOutgoingPeer(peerToCoreServer, "load");
client.node.syncManager.addPeer(peer1);
anotherServer.node.syncManager.addPeer(peer2);
const group = anotherServer.node.createGroup();
const map = group.createMap();
map.set("hello", "world", "trusting");
const mapOnClient = await loadCoValueOrFail(client.node, map.id);
expect(
SyncMessagesLog.getMessages({
Group: group.core,
Map: map.core,
}),
).toMatchInlineSnapshot(`
[
"client -> another-server | LOAD Map sessions: empty",
"another-server -> client | CONTENT Group header: true new: After: 0 New: 3",
"client -> another-server | KNOWN Group sessions: header/3",
"another-server -> client | CONTENT Map header: true new: After: 0 New: 1",
"client -> another-server | KNOWN Map sessions: header/1",
]
`);
expect(mapOnClient.get("hello")).toEqual("world");
});
});

View File

@@ -1,7 +1,13 @@
import { assert, beforeEach, describe, expect, test } from "vitest";
import { expectMap } from "../coValue";
import { WasmCrypto } from "../crypto/WasmCrypto";
import { SyncMessagesLog, setupTestNode, waitFor } from "./testUtils";
import {
SyncMessagesLog,
loadCoValueOrFail,
setupTestAccount,
setupTestNode,
waitFor,
} from "./testUtils";
let jazzCloud = setupTestNode({ isSyncServer: true });
@@ -146,7 +152,9 @@ describe("peer reconciliation", () => {
});
test("correctly handle server restarts in the middle of a sync", async () => {
const client = setupTestNode();
const client = setupTestNode({
connected: true,
});
const group = client.node.createGroup();
const map = group.createMap();
@@ -182,9 +190,14 @@ describe("peer reconciliation", () => {
"server -> client | KNOWN Group sessions: empty",
"client -> server | LOAD Map sessions: header/2",
"server -> client | KNOWN Map sessions: empty",
"client -> server | CONTENT Group header: true new: After: 0 New: 3",
"server -> client | KNOWN Group sessions: header/3",
"client -> server | CONTENT Map header: false new: After: 1 New: 1",
"server -> client | KNOWN CORRECTION Map sessions: empty",
"client -> server | CONTENT Map header: true new: After: 0 New: 2",
"server -> client | LOAD Group sessions: empty",
"client -> server | CONTENT Group header: true new: After: 0 New: 3",
"server -> client | KNOWN CORRECTION Map sessions: empty",
"client -> server | CONTENT Map header: true new: After: 0 New: 2",
"server -> client | KNOWN Group sessions: header/3",
"server -> client | KNOWN Map sessions: header/2",
"client -> server | LOAD Group sessions: header/3",
"server -> client | KNOWN Group sessions: header/3",
@@ -194,6 +207,81 @@ describe("peer reconciliation", () => {
`);
});
test("correctly handle server restarts in the middle of a sync (2 - account)", async () => {
const client = await setupTestAccount({
connected: true,
});
const group = client.node.createGroup();
const map = group.createMap();
map.set("hello", "world", "trusting");
await map.core.waitForSync();
jazzCloud.restart();
SyncMessagesLog.clear();
client.connectToSyncServer();
map.set("hello", "updated", "trusting");
await new Promise((resolve) => setTimeout(resolve, 0));
client.connectToSyncServer();
await waitFor(() => {
const mapOnSyncServer = jazzCloud.node.getCoValue(map.id);
expect(mapOnSyncServer.loadingState).toBe("available");
});
expect(
SyncMessagesLog.getMessages({
Account: client.node.expectCurrentAccount("client account").core,
Profile: client.node.expectProfileLoaded(client.accountID).core,
ProfileGroup: client.node.expectProfileLoaded(client.accountID).group
.core,
Group: group.core,
Map: map.core,
}),
).toMatchInlineSnapshot(`
[
"client -> server | LOAD Account sessions: header/4",
"server -> client | KNOWN Account sessions: empty",
"client -> server | LOAD ProfileGroup sessions: header/5",
"server -> client | KNOWN ProfileGroup sessions: empty",
"client -> server | LOAD Profile sessions: header/1",
"server -> client | KNOWN Profile sessions: empty",
"client -> server | LOAD Group sessions: header/3",
"server -> client | KNOWN Group sessions: empty",
"client -> server | LOAD Map sessions: header/2",
"server -> client | KNOWN Map sessions: empty",
"client -> server | CONTENT Map header: false new: After: 1 New: 1",
"server -> client | KNOWN CORRECTION Map sessions: empty",
"client -> server | CONTENT Map header: true new: After: 0 New: 2",
"server -> client | LOAD Account sessions: empty",
"client -> server | CONTENT Account header: true new: After: 0 New: 4",
"server -> client | LOAD Group sessions: empty",
"client -> server | CONTENT Group header: true new: After: 0 New: 3",
"server -> client | KNOWN CORRECTION Map sessions: empty",
"client -> server | CONTENT Map header: true new: After: 0 New: 2",
"server -> client | KNOWN Account sessions: header/4",
"server -> client | KNOWN Group sessions: header/3",
"server -> client | KNOWN Map sessions: header/2",
"client -> server | LOAD Account sessions: header/4",
"server -> client | KNOWN Account sessions: header/4",
"client -> server | LOAD ProfileGroup sessions: header/5",
"server -> client | KNOWN ProfileGroup sessions: empty",
"client -> server | LOAD Profile sessions: header/1",
"server -> client | KNOWN Profile sessions: empty",
"client -> server | LOAD Group sessions: header/3",
"server -> client | KNOWN Group sessions: header/3",
"client -> server | LOAD Map sessions: header/2",
"server -> client | KNOWN Map sessions: header/2",
]
`);
});
test.skip("handle peer reconnections with data loss", async () => {
const client = setupTestNode();

View File

@@ -463,6 +463,7 @@ export function createMockStoragePeer(opts: {
},
});
peer1.role = "storage";
peer1.priority = 100;
storage.syncManager.addPeer(peer2);

View File

@@ -1,5 +1,23 @@
# jazz-auth-clerk
## 0.13.28
### Patch Changes
- Updated dependencies [e7ccb2c]
- cojson@0.13.28
- jazz-browser@0.13.28
- jazz-tools@0.13.28
## 0.13.27
### Patch Changes
- Updated dependencies [6357052]
- cojson@0.13.27
- jazz-browser@0.13.27
- jazz-tools@0.13.27
## 0.13.26
### Patch Changes

View File

@@ -1,14 +1,14 @@
{
"name": "jazz-auth-clerk",
"version": "0.13.26",
"version": "0.13.28",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"license": "MIT",
"dependencies": {
"cojson": "workspace:0.13.25",
"jazz-browser": "workspace:0.13.26",
"jazz-tools": "workspace:0.13.26"
"cojson": "workspace:0.13.28",
"jazz-browser": "workspace:0.13.28",
"jazz-tools": "workspace:0.13.28"
},
"scripts": {
"format-and-lint": "biome check .",

View File

@@ -1,5 +1,19 @@
# jazz-browser-media-images
## 0.13.28
### Patch Changes
- jazz-browser@0.13.28
- jazz-tools@0.13.28
## 0.13.27
### Patch Changes
- jazz-browser@0.13.27
- jazz-tools@0.13.27
## 0.13.26
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "jazz-browser-media-images",
"version": "0.13.26",
"version": "0.13.28",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
@@ -8,8 +8,8 @@
"dependencies": {
"@types/image-blob-reduce": "^4.1.1",
"image-blob-reduce": "^4.1.0",
"jazz-browser": "workspace:0.13.26",
"jazz-tools": "workspace:0.13.26",
"jazz-browser": "workspace:0.13.28",
"jazz-tools": "workspace:0.13.28",
"pica": "^9.0.1"
},
"scripts": {

View File

@@ -1,5 +1,26 @@
# jazz-browser
## 0.13.28
### Patch Changes
- Updated dependencies [e7ccb2c]
- Updated dependencies [422dbc4]
- cojson@0.13.28
- cojson-transport-ws@0.13.28
- cojson-storage-indexeddb@0.13.28
- jazz-tools@0.13.28
## 0.13.27
### Patch Changes
- Updated dependencies [6357052]
- cojson@0.13.27
- cojson-storage-indexeddb@0.13.27
- cojson-transport-ws@0.13.27
- jazz-tools@0.13.27
## 0.13.26
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "jazz-browser",
"version": "0.13.26",
"version": "0.13.28",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",

View File

@@ -1,5 +1,30 @@
# jazz-browser
## 0.13.28
### Patch Changes
- Updated dependencies [e7ccb2c]
- Updated dependencies [422dbc4]
- cojson@0.13.28
- cojson-transport-ws@0.13.28
- jazz-auth-clerk@0.13.28
- jazz-react-core@0.13.28
- jazz-react-native-core@0.13.28
- jazz-tools@0.13.28
## 0.13.27
### Patch Changes
- Updated dependencies [6357052]
- cojson@0.13.27
- cojson-transport-ws@0.13.27
- jazz-auth-clerk@0.13.27
- jazz-react-core@0.13.27
- jazz-react-native-core@0.13.27
- jazz-tools@0.13.27
## 0.13.26
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "jazz-expo",
"version": "0.13.26",
"version": "0.13.28",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",

View File

@@ -1,5 +1,19 @@
# jazz-inspector-element
## 0.13.28
### Patch Changes
- jazz-inspector@0.13.28
- jazz-tools@0.13.28
## 0.13.27
### Patch Changes
- jazz-inspector@0.13.27
- jazz-tools@0.13.27
## 0.13.26
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "jazz-inspector-element",
"version": "0.13.26",
"version": "0.13.28",
"type": "module",
"main": "./dist/main.js",
"types": "./dist/main.d.ts",

View File

@@ -1,5 +1,23 @@
# jazz-inspector
## 0.13.28
### Patch Changes
- Updated dependencies [e7ccb2c]
- cojson@0.13.28
- jazz-react-core@0.13.28
- jazz-tools@0.13.28
## 0.13.27
### Patch Changes
- Updated dependencies [6357052]
- cojson@0.13.27
- jazz-react-core@0.13.27
- jazz-tools@0.13.27
## 0.13.26
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "jazz-inspector",
"version": "0.13.26",
"version": "0.13.28",
"type": "module",
"main": "./dist/app.js",
"types": "./dist/app.d.ts",

View File

@@ -1,5 +1,25 @@
# jazz-autosub
## 0.13.28
### Patch Changes
- 422dbc4: Add waitForConnection and subscribeToConnectionChange APIs to handle connection drops
- Updated dependencies [e7ccb2c]
- Updated dependencies [422dbc4]
- cojson@0.13.28
- cojson-transport-ws@0.13.28
- jazz-tools@0.13.28
## 0.13.27
### Patch Changes
- Updated dependencies [6357052]
- cojson@0.13.27
- cojson-transport-ws@0.13.27
- jazz-tools@0.13.27
## 0.13.26
### Patch Changes

View File

@@ -5,7 +5,7 @@
"types": "dist/index.d.ts",
"type": "module",
"license": "MIT",
"version": "0.13.26",
"version": "0.13.28",
"dependencies": {
"cojson": "workspace:*",
"cojson-transport-ws": "workspace:*",

View File

@@ -1,5 +1,8 @@
import { AgentSecret, CryptoProvider, LocalNode } from "cojson";
import { type AnyWebSocketConstructor } from "cojson-transport-ws";
import { AgentSecret, CryptoProvider, LocalNode, Peer } from "cojson";
import {
type AnyWebSocketConstructor,
WebSocketPeerWithReconnection,
} from "cojson-transport-ws";
import { WasmCrypto } from "cojson/crypto/WasmCrypto";
import {
Account,
@@ -9,7 +12,6 @@ import {
createJazzContextFromExistingCredentials,
randomSessionProvider,
} from "jazz-tools";
import { webSocketWithReconnection } from "./webSocketWithReconnection.js";
type WorkerOptions<Acc extends Account> = {
accountID?: string;
@@ -32,13 +34,24 @@ export async function startWorker<Acc extends Account>(
} = options;
let node: LocalNode | undefined = undefined;
const wsPeer = webSocketWithReconnection(
syncServer,
(peer) => {
node?.syncManager.addPeer(peer);
const peersToLoadFrom: Peer[] = [];
const wsPeer = new WebSocketPeerWithReconnection({
peer: syncServer,
reconnectionTimeout: 100,
addPeer: (peer) => {
if (node) {
node.syncManager.addPeer(peer);
} else {
peersToLoadFrom.push(peer);
}
},
options.WebSocket,
);
removePeer: () => {},
WebSocketConstructor: options.WebSocket,
});
wsPeer.enable();
if (!accountID) {
throw new Error("No accountID provided");
@@ -61,7 +74,7 @@ export async function startWorker<Acc extends Account>(
AccountSchema,
// TODO: locked sessions similar to browser
sessionProvider: randomSessionProvider,
peersToLoadFrom: [wsPeer.peer],
peersToLoadFrom,
crypto: options.crypto ?? (await WasmCrypto.create()),
});
@@ -77,7 +90,7 @@ export async function startWorker<Acc extends Account>(
async function done() {
await context.account.waitForAllCoValuesSync();
wsPeer.done();
wsPeer.disable();
context.done();
}
@@ -90,6 +103,16 @@ export async function startWorker<Acc extends Account>(
experimental: {
inbox: inboxPublicApi,
},
waitForConnection() {
return wsPeer.waitUntilConnected();
},
subscribeToConnectionChange(listener: (connected: boolean) => void) {
wsPeer.subscribe(listener);
return () => {
wsPeer.unsubscribe(listener);
};
},
done,
};
}

View File

@@ -1,3 +1,7 @@
import { randomUUID } from "node:crypto";
import { unlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createWorkerAccount } from "jazz-run/createWorkerAccount";
import { startSyncServer } from "jazz-run/startSyncServer";
import {
@@ -8,24 +12,40 @@ import {
InboxSender,
co,
} from "jazz-tools";
import { describe, expect, onTestFinished, test } from "vitest";
import { afterAll, describe, expect, onTestFinished, test } from "vitest";
import { startWorker } from "../index.js";
import { waitFor } from "./utils.js";
const dbPath = join(tmpdir(), `test-${randomUUID()}.db`);
afterAll(() => {
unlinkSync(dbPath);
});
async function setup<Acc extends Account>(AccountSchema?: AccountClass<Acc>) {
const { server, port } = await setupSyncServer();
const syncServer = `ws://localhost:${port}`;
const { worker, done } = await setupWorker(syncServer, AccountSchema);
const { worker, done, waitForConnection, subscribeToConnectionChange } =
await setupWorker(syncServer, AccountSchema);
return { worker, done, syncServer, server, port };
return {
worker,
done,
syncServer,
server,
port,
waitForConnection,
subscribeToConnectionChange,
};
}
async function setupSyncServer(defaultPort = "0") {
const server = await startSyncServer({
port: defaultPort,
inMemory: true,
db: "",
inMemory: false,
db: dbPath,
});
const port = (server.address() as { port: number }).port.toString();
@@ -225,6 +245,8 @@ describe("startWorker integration", () => {
{ owner: group },
);
map.value = "updated while offline";
// Start a new sync server on the same port
const newServer = await startSyncServer({
port: worker1.port,
@@ -232,8 +254,11 @@ describe("startWorker integration", () => {
db: "",
});
// Wait for reconnection and sync
await map2.waitForSync();
// Wait for reconnection
await worker1.waitForConnection();
await worker2.waitForConnection();
await worker1.worker.waitForAllCoValuesSync();
// Verify both old and new values are synced
const mapOnWorker2 = await TestMap.load(map.id, { loadAs: worker2.worker });
@@ -241,11 +266,74 @@ describe("startWorker integration", () => {
loadAs: worker2.worker,
});
expect(mapOnWorker2?.value).toBe("initial value");
expect(mapOnWorker2?.value).toBe("updated while offline");
expect(map2OnWorker2?.value).toBe("created while offline");
// Cleanup
await worker2.done();
newServer.close();
});
test("waitForConnection resolves when connection is established", async () => {
const worker1 = await setup();
// Initially should be connected
await worker1.waitForConnection();
// Close the sync server
worker1.server.close();
// Start a new sync server on the same port
const newServer = await startSyncServer({
port: worker1.port,
inMemory: true,
db: "",
});
// Should reconnect and resolve
await worker1.waitForConnection();
// Cleanup
await worker1.done();
newServer.close();
});
test("subscribeToConnectionChange notifies on connection state changes", async () => {
const worker1 = await setup();
const connectionStates: boolean[] = [];
// Subscribe to connection changes
const unsubscribe = worker1.subscribeToConnectionChange((isConnected) => {
connectionStates.push(isConnected);
});
await waitFor(() => {
expect(connectionStates).toEqual([true]);
});
// Close the sync server
worker1.server.close();
await waitFor(() => {
expect(connectionStates).toEqual([true, false]);
});
// Start a new sync server on the same port
const newServer = await startSyncServer({
port: worker1.port,
inMemory: true,
db: "",
});
// Wait a bit for the reconnection to be detected
await waitFor(() => {
expect(connectionStates).toEqual([true, false, true]);
});
// Cleanup
unsubscribe();
await worker1.done();
newServer.close();
});
});

View File

@@ -1,127 +0,0 @@
import { createWebSocketPeer } from "cojson-transport-ws";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { webSocketWithReconnection } from "../webSocketWithReconnection.js";
// Mock dependencies
vi.mock("cojson-transport-ws", () => ({
createWebSocketPeer: vi.fn().mockImplementation(({ onClose }) => ({
id: "upstream",
incoming: { push: vi.fn() },
outgoing: { push: vi.fn(), close: vi.fn() },
onClose,
})),
}));
const WebSocketMock = vi.fn().mockImplementation(() => ({
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
close: vi.fn(),
readyState: 1,
})) as unknown as typeof WebSocket;
describe("webSocketWithReconnection", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
test("should create initial websocket connection", () => {
const addPeerMock = vi.fn();
const { peer } = webSocketWithReconnection(
"ws://localhost:8080",
addPeerMock,
WebSocketMock,
);
expect(WebSocketMock).toHaveBeenCalledWith("ws://localhost:8080");
expect(createWebSocketPeer).toHaveBeenCalledWith(
expect.objectContaining({
id: "upstream",
role: "server",
}),
);
expect(peer).toBeDefined();
});
test("should attempt reconnection when websocket closes", async () => {
const addPeerMock = vi.fn();
webSocketWithReconnection(
"ws://localhost:8080",
addPeerMock,
WebSocketMock,
);
// Get the onClose handler from the first createWebSocketPeer call
const initialPeer = vi.mocked(createWebSocketPeer).mock.results[0]!.value;
// Simulate websocket close
initialPeer.onClose();
// Fast-forward timer to trigger reconnection
await vi.advanceTimersByTimeAsync(1000);
expect(WebSocketMock).toHaveBeenCalledTimes(2);
expect(createWebSocketPeer).toHaveBeenCalledTimes(2);
expect(addPeerMock).toHaveBeenCalledWith(
expect.objectContaining({
id: "upstream",
}),
);
});
test("should clean up when done is called", () => {
const addPeerMock = vi.fn();
const { done } = webSocketWithReconnection(
"ws://localhost:8080",
addPeerMock,
WebSocketMock,
);
// Get the onClose handler
const initialPeer = vi.mocked(createWebSocketPeer).mock.results[0]!.value;
done();
// Simulate websocket close
initialPeer.onClose();
// Fast-forward timer
vi.advanceTimersByTime(1000);
// Should not attempt reconnection
expect(WebSocketMock).toHaveBeenCalledTimes(1);
expect(createWebSocketPeer).toHaveBeenCalledTimes(1);
});
test("should not attempt reconnection after done is called", async () => {
const addPeerMock = vi.fn();
const { done } = webSocketWithReconnection(
"ws://localhost:8080",
addPeerMock,
WebSocketMock,
);
// Get the onClose handler
const initialPeer = vi.mocked(createWebSocketPeer).mock.results[0]!.value;
// Simulate first close and reconnection
initialPeer.onClose();
await vi.advanceTimersByTimeAsync(1000);
expect(WebSocketMock).toHaveBeenCalledTimes(2);
// Call done
done();
// Simulate another close
vi.mocked(createWebSocketPeer).mock.results[1]!.value.onClose();
await vi.advanceTimersByTimeAsync(1000);
// Should not create another connection
expect(WebSocketMock).toHaveBeenCalledTimes(2);
});
});

View File

@@ -1,47 +0,0 @@
import { Peer } from "cojson";
import {
AnyWebSocketConstructor,
createWebSocketPeer,
} from "cojson-transport-ws";
export function webSocketWithReconnection(
peer: string,
addPeer: (peer: Peer) => void,
ws?: AnyWebSocketConstructor,
) {
let done = false;
const WebSocketConstructor = ws ?? WebSocket;
const wsPeer = createWebSocketPeer({
websocket: new WebSocketConstructor(peer),
id: "upstream",
role: "server",
onClose: handleClose,
});
let timer: ReturnType<typeof setTimeout>;
function handleClose() {
if (done) return;
clearTimeout(timer);
timer = setTimeout(() => {
const wsPeer: Peer = createWebSocketPeer({
id: "upstream",
websocket: new WebSocketConstructor(peer),
role: "server",
onClose: handleClose,
});
addPeer(wsPeer);
}, 1000);
}
return {
peer: wsPeer,
done: () => {
done = true;
clearTimeout(timer);
},
};
}

View File

@@ -1,5 +1,27 @@
# jazz-browser-media-images
## 0.13.28
### Patch Changes
- Updated dependencies [e7ccb2c]
- cojson@0.13.28
- jazz-auth-clerk@0.13.28
- jazz-browser@0.13.28
- jazz-react@0.13.28
- jazz-tools@0.13.28
## 0.13.27
### Patch Changes
- Updated dependencies [6357052]
- cojson@0.13.27
- jazz-auth-clerk@0.13.27
- jazz-browser@0.13.27
- jazz-react@0.13.27
- jazz-tools@0.13.27
## 0.13.26
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "jazz-react-auth-clerk",
"version": "0.13.26",
"version": "0.13.28",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",

View File

@@ -1,5 +1,21 @@
# jazz-react-core
## 0.13.28
### Patch Changes
- Updated dependencies [e7ccb2c]
- cojson@0.13.28
- jazz-tools@0.13.28
## 0.13.27
### Patch Changes
- Updated dependencies [6357052]
- cojson@0.13.27
- jazz-tools@0.13.27
## 0.13.26
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "jazz-react-core",
"version": "0.13.26",
"version": "0.13.28",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",

Some files were not shown because too many files have changed in this diff Show More