Compare commits

..

8 Commits

Author SHA1 Message Date
Guido D'Orsi
31614c0a4f Merge pull request #2263 from garden-co/changeset-release/main
Version Packages
2025-05-17 08:58:51 +02:00
github-actions[bot]
57b69eb8da Version Packages 2025-05-16 19:49:36 +00:00
Guido D'Orsi
066676c243 Merge pull request #2264 from garden-co/feat/storage-streaming
feat(storage): implement content streaming
2025-05-16 21:46:46 +02:00
Guido D'Orsi
e141024656 feat(storage): implement content streaming 2025-05-16 20:36:09 +02:00
Guido D'Orsi
2c48ae0434 Merge pull request #2262 from garden-co/feat/storage-streaming
feat(storage): implement chunking for large content files
2025-05-16 19:46:36 +02:00
Guido D'Orsi
2bf974390d feat(storage): implement chunking for large content files 2025-05-16 18:41:14 +02:00
Joe Innes
e123715819 Fix missing $ in template literal (#2261)
Svelte interpolates curly braces in normal strings, but not template literals.
2025-05-16 16:47:11 +01:00
Trisha Lim
0d087f3d4c docs: all roles can remove themselves from a Group (#2255) 2025-05-16 13:44:27 +01:00
108 changed files with 1199 additions and 462 deletions

View File

@@ -1,5 +1,14 @@
# betterauth
## 0.1.4
### Patch Changes
- jazz-betterauth-server-plugin@0.13.32
- jazz-react@0.13.32
- jazz-react-auth-betterauth@0.13.32
- jazz-betterauth-client-plugin@0.13.32
## 0.1.3
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "betterauth",
"version": "0.1.3",
"version": "0.1.4",
"private": true,
"type": "module",
"scripts": {

View File

@@ -1,5 +1,11 @@
# chat-rn-expo-clerk
## 1.0.123
### Patch Changes
- jazz-expo@0.13.32
## 1.0.122
### Patch Changes

View File

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

View File

@@ -1,5 +1,11 @@
# chat-rn-expo
## 1.0.110
### Patch Changes
- jazz-expo@0.13.32
## 1.0.109
### Patch Changes

View File

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

View File

@@ -1,5 +1,11 @@
# chat-rn
## 1.0.118
### Patch Changes
- jazz-react-native@0.13.32
## 1.0.117
### Patch Changes

View File

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

View File

@@ -1,5 +1,12 @@
# chat-vue
## 0.0.101
### Patch Changes
- jazz-browser@0.13.32
- jazz-vue@0.13.32
## 0.0.100
### Patch Changes

View File

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

View File

@@ -1,5 +1,11 @@
# jazz-example-chat
## 0.0.199
### Patch Changes
- jazz-react@0.13.32
## 0.0.198
### Patch Changes

View File

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

View File

@@ -1,5 +1,12 @@
# minimal-auth-clerk
## 0.0.98
### Patch Changes
- jazz-react@0.13.32
- jazz-react-auth-clerk@0.13.32
## 0.0.97
### Patch Changes

View File

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

View File

@@ -1,5 +1,11 @@
# file-share-svelte
## 0.0.82
### Patch Changes
- jazz-svelte@0.13.32
## 0.0.81
### Patch Changes

View File

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

View File

@@ -1,5 +1,11 @@
# jazz-tailwind-demo-auth-starter
## 0.0.38
### Patch Changes
- jazz-react@0.13.32
## 0.0.37
### Patch Changes

View File

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

View File

@@ -1,5 +1,11 @@
# form
## 0.1.39
### Patch Changes
- jazz-react@0.13.32
## 0.1.38
### Patch Changes

View File

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

View File

@@ -1,5 +1,11 @@
# image-upload
## 0.0.95
### Patch Changes
- jazz-react@0.13.32
## 0.0.94
### Patch Changes

View File

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

View File

@@ -1,5 +1,11 @@
# multi-cursors
## 0.0.91
### Patch Changes
- jazz-react@0.13.32
## 0.0.90
### Patch Changes

View File

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

View File

@@ -1,5 +1,12 @@
# multiauth
## 0.0.39
### Patch Changes
- jazz-react@0.13.32
- jazz-react-auth-clerk@0.13.32
## 0.0.38
### Patch Changes

View File

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

View File

@@ -1,5 +1,11 @@
# jazz-example-musicplayer
## 0.0.120
### Patch Changes
- jazz-react@0.13.32
## 0.0.119
### Patch Changes

View File

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

View File

@@ -1,5 +1,11 @@
# organization
## 0.0.91
### Patch Changes
- jazz-react@0.13.32
## 0.0.90
### Patch Changes

View File

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

View File

@@ -1,5 +1,11 @@
# passkey-svelte
## 0.0.86
### Patch Changes
- jazz-svelte@0.13.32
## 0.0.85
### Patch Changes

View File

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

View File

@@ -7,7 +7,7 @@
<JazzProvider
sync={{
peer: `wss://cloud.jazz.tools/?key={apiKey}`,
peer: `wss://cloud.jazz.tools/?key=${apiKey}`,
}}
>
<PasskeyAuthBasicUI appName="minimal-svelte-auth-passkey">

View File

@@ -1,5 +1,11 @@
# minimal-auth-passkey
## 0.0.96
### Patch Changes
- jazz-react@0.13.32
## 0.0.95
### Patch Changes

View File

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

View File

@@ -1,5 +1,11 @@
# passphrase
## 0.0.93
### Patch Changes
- jazz-react@0.13.32
## 0.0.92
### Patch Changes

View File

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

View File

@@ -1,5 +1,11 @@
# jazz-password-manager
## 0.0.117
### Patch Changes
- jazz-react@0.13.32
## 0.0.116
### Patch Changes

View File

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

View File

@@ -1,5 +1,11 @@
# jazz-example-pets
## 0.0.215
### Patch Changes
- jazz-react@0.13.32
## 0.0.214
### Patch Changes

View File

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

View File

@@ -1,5 +1,11 @@
# reactions
## 0.0.95
### Patch Changes
- jazz-react@0.13.32
## 0.0.94
### Patch Changes

View File

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

View File

@@ -1,5 +1,12 @@
# richtext-tiptap
## 0.1.8
### Patch Changes
- jazz-react@0.13.32
- jazz-richtext-tiptap@0.1.8
## 0.1.7
### Patch Changes

View File

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

View File

@@ -1,5 +1,12 @@
# richtext
## 0.0.85
### Patch Changes
- jazz-react@0.13.32
- jazz-richtext-prosemirror@0.1.19
## 0.0.84
### Patch Changes

View File

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

View File

@@ -1,5 +1,12 @@
# todo-vue
## 0.0.99
### Patch Changes
- jazz-browser@0.13.32
- jazz-vue@0.13.32
## 0.0.98
### Patch Changes

View File

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

View File

@@ -1,5 +1,11 @@
# jazz-example-todo
## 0.0.214
### Patch Changes
- jazz-react@0.13.32
## 0.0.213
### Patch Changes

View File

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

View File

@@ -1,5 +1,11 @@
# version-history
## 0.0.93
### Patch Changes
- jazz-react@0.13.32
## 0.0.92
### Patch Changes

View File

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

View File

@@ -8,7 +8,7 @@ import { CodeGroup } from "@/components/forMdx";
Every CoValue has an owner, which can be a `Group` or an `Account`.
You can use a `Group` to grant access to a CoValue to multiple users. These users can
You can use a `Group` to grant access to a CoValue to **multiple users**. These users can
have different roles, such as "writer", "reader" or "admin".
## Creating a Group
@@ -43,7 +43,7 @@ group.addMember(bob, "writer");
```
</CodeGroup>
Note: if the account ID is of type `string`, because it comes from a URL parameter or something similar, you need to cast it to `ID<Account>` first:
**Note:** if the account ID is of type `string`, because it comes from a URL parameter or something similar, you need to cast it to `ID<Account>` first:
<CodeGroup>
```tsx
@@ -66,7 +66,7 @@ group.addMember(bob, "reader");
Bob just went from a writer to a reader.
Note: only admins can change a member's role.
**Note:** only admins can change a member's role.
## Removing a member
@@ -78,9 +78,11 @@ group.removeMember(bob);
```
</CodeGroup>
This only works if you are an admin, and Bob is not an admin.
Admins cannot remove other admins, but they can remove themselves, as long as there is another admin present.
Rules:
- All roles can remove themselves.
- Only admins can remove other users.
- An admin cannot remove other admins.
- As an admin, you cannot remove yourself if you are the only admin in the Group, because there has to be at least one admin present.
## Getting the Group of an existing CoValue

View File

@@ -1,5 +1,12 @@
# cojson-storage-indexeddb
## 0.13.32
### Patch Changes
- Updated dependencies [2bf9743]
- cojson-storage@0.13.32
## 0.13.31
### Patch Changes

View File

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

View File

@@ -67,16 +67,14 @@ export class IDBClient implements DBClientInterfaceAsync {
async getNewTransactionInSession(
sessionRowId: number,
firstNewTxIdx: number,
fromIdx: number,
toIdx: number,
): Promise<TransactionRow[]> {
return this.makeRequest<TransactionRow[]>((tx) =>
tx
.getObjectStore("transactions")
.getAll(
IDBKeyRange.bound(
[sessionRowId, firstNewTxIdx],
[sessionRowId, Number.POSITIVE_INFINITY],
),
IDBKeyRange.bound([sessionRowId, fromIdx], [sessionRowId, toIdx]),
),
);
}
@@ -161,7 +159,7 @@ export class IDBClient implements DBClientInterfaceAsync {
ses: sessionRowID,
idx,
signature,
} satisfies SignatureAfterRow),
}),
);
}

View File

@@ -4,7 +4,7 @@ import { WasmCrypto } from "cojson/crypto/WasmCrypto";
import { expect, test, vi } from "vitest";
import { IDBStorage } from "../index.js";
import { toSimplifiedMessages } from "./messagesTestUtils.js";
import { trackMessages } from "./testUtils.js";
import { trackMessages, waitFor } from "./testUtils.js";
const Crypto = await WasmCrypto.create();
@@ -96,11 +96,84 @@ test("should sync and load data from storage", async () => {
).toMatchInlineSnapshot(`
[
"client -> LOAD Map sessions: empty",
"storage -> KNOWN Group sessions: header/3",
"storage -> CONTENT Group header: true new: After: 0 New: 3",
"storage -> KNOWN Map sessions: header/1",
"storage -> CONTENT Map header: true new: After: 0 New: 1",
"client -> KNOWN Group sessions: header/3",
]
`);
node2Sync.restore();
});
test("should send an empty content message if there is no content", async () => {
const agentSecret = Crypto.newRandomAgentSecret();
const node1 = new LocalNode(
agentSecret,
Crypto.newRandomSessionID(Crypto.getAgentID(agentSecret)),
Crypto,
);
const node1Sync = trackMessages(node1);
const peer = await IDBStorage.asPeer();
node1.syncManager.addPeer(peer);
const group = node1.createGroup();
const map = group.createMap();
await new Promise((resolve) => setTimeout(resolve, 200));
expect(
toSimplifiedMessages(
{
Map: map.core,
Group: group.core,
},
node1Sync.messages,
),
).toMatchInlineSnapshot(`
[
"client -> CONTENT Group header: true new: After: 0 New: 3",
"storage -> KNOWN Group sessions: header/3",
"client -> CONTENT Map header: true new: ",
"storage -> KNOWN Map sessions: header/0",
]
`);
node1Sync.restore();
const node2 = new LocalNode(
agentSecret,
Crypto.newRandomSessionID(Crypto.getAgentID(agentSecret)),
Crypto,
);
const node2Sync = trackMessages(node2);
const peer2 = await IDBStorage.asPeer();
node2.syncManager.addPeer(peer2);
const map2 = await node2.load(map.id);
if (map2 === "unavailable") {
throw new Error("Map is unavailable");
}
expect(
toSimplifiedMessages(
{
Map: map.core,
Group: group.core,
},
node2Sync.messages,
),
).toMatchInlineSnapshot(`
[
"client -> LOAD Map sessions: empty",
"storage -> CONTENT Group header: true new: After: 0 New: 3",
"storage -> CONTENT Map header: true new: ",
]
`);
@@ -185,14 +258,9 @@ test("should load dependencies correctly (group inheritance)", async () => {
).toMatchInlineSnapshot(`
[
"client -> LOAD Map sessions: empty",
"storage -> KNOWN ParentGroup sessions: header/4",
"storage -> CONTENT ParentGroup header: true new: After: 0 New: 4",
"storage -> KNOWN Group sessions: header/5",
"storage -> CONTENT Group header: true new: After: 0 New: 5",
"client -> KNOWN ParentGroup sessions: header/4",
"storage -> KNOWN Map sessions: header/1",
"storage -> CONTENT Map header: true new: After: 0 New: 1",
"client -> KNOWN Group sessions: header/5",
]
`);
});
@@ -260,19 +328,14 @@ test("should not send the same dependency value twice", async () => {
).toMatchInlineSnapshot(`
[
"client -> LOAD Map sessions: empty",
"storage -> KNOWN ParentGroup sessions: header/4",
"storage -> CONTENT ParentGroup header: true new: After: 0 New: 4",
"storage -> KNOWN Group sessions: header/5",
"storage -> CONTENT Group header: true new: After: 0 New: 5",
"client -> KNOWN ParentGroup sessions: header/4",
"storage -> KNOWN Map sessions: header/1",
"storage -> CONTENT Map header: true new: After: 0 New: 1",
"client -> KNOWN ParentGroup sessions: header/4",
"client -> KNOWN Group sessions: header/5",
"client -> KNOWN Map sessions: header/1",
"client -> LOAD MapFromParent sessions: empty",
"storage -> KNOWN MapFromParent sessions: header/1",
"storage -> CONTENT MapFromParent header: true new: After: 0 New: 1",
"client -> KNOWN MapFromParent sessions: header/1",
]
`);
});
@@ -374,11 +437,164 @@ test("should recover from data loss", async () => {
).toMatchInlineSnapshot(`
[
"client -> LOAD Map sessions: empty",
"storage -> KNOWN Group sessions: header/3",
"storage -> CONTENT Group header: true new: After: 0 New: 3",
"storage -> KNOWN Map sessions: header/4",
"storage -> CONTENT Map header: true new: After: 0 New: 4",
"client -> KNOWN Group sessions: header/3",
]
`);
});
test("should sync multiple sessions in a single content message", async () => {
const agentSecret = Crypto.newRandomAgentSecret();
const node1 = new LocalNode(
agentSecret,
Crypto.newRandomSessionID(Crypto.getAgentID(agentSecret)),
Crypto,
);
node1.syncManager.addPeer(await IDBStorage.asPeer());
const group = node1.createGroup();
const map = group.createMap();
map.set("hello", "world");
await new Promise((resolve) => setTimeout(resolve, 200));
node1.gracefulShutdown();
const node2 = new LocalNode(
agentSecret,
Crypto.newRandomSessionID(Crypto.getAgentID(agentSecret)),
Crypto,
);
node2.syncManager.addPeer(await IDBStorage.asPeer());
const map2 = await node2.load(map.id);
if (map2 === "unavailable") {
throw new Error("Map is unavailable");
}
expect(map2.get("hello")).toBe("world");
map2.set("hello", "world2");
await map2.core.waitForSync();
node2.gracefulShutdown();
const node3 = new LocalNode(
agentSecret,
Crypto.newRandomSessionID(Crypto.getAgentID(agentSecret)),
Crypto,
);
const node3Sync = trackMessages(node3);
node3.syncManager.addPeer(await IDBStorage.asPeer());
const map3 = await node3.load(map.id);
if (map3 === "unavailable") {
throw new Error("Map is unavailable");
}
expect(map3.get("hello")).toBe("world2");
expect(
toSimplifiedMessages(
{
Map: map.core,
Group: group.core,
},
node3Sync.messages,
),
).toMatchInlineSnapshot(`
[
"client -> LOAD Map sessions: empty",
"storage -> CONTENT Group header: true new: After: 0 New: 3",
"storage -> CONTENT Map header: true new: After: 0 New: 1 | After: 0 New: 1",
]
`);
node3Sync.restore();
});
test("large coValue upload streaming", async () => {
const agentSecret = Crypto.newRandomAgentSecret();
const node1 = new LocalNode(
agentSecret,
Crypto.newRandomSessionID(Crypto.getAgentID(agentSecret)),
Crypto,
);
node1.syncManager.addPeer(await IDBStorage.asPeer());
const group = node1.createGroup();
const largeMap = group.createMap();
// Generate a large amount of data (about 100MB)
const dataSize = 1 * 1024 * 200;
const chunkSize = 1024; // 1KB chunks
const chunks = dataSize / chunkSize;
const value = "a".repeat(chunkSize);
for (let i = 0; i < chunks; i++) {
const key = `key${i}`;
largeMap.set(key, value, "trusting");
}
await largeMap.core.waitForSync();
const knownState = largeMap.core.knownState();
node1.gracefulShutdown();
const node2 = new LocalNode(
agentSecret,
Crypto.newRandomSessionID(Crypto.getAgentID(agentSecret)),
Crypto,
);
const node2Sync = trackMessages(node2);
node2.syncManager.addPeer(await IDBStorage.asPeer());
const largeMapOnNode2 = await node2.load(largeMap.id);
if (largeMapOnNode2 === "unavailable") {
throw new Error("Map is unavailable");
}
await waitFor(() => {
expect(largeMapOnNode2.core.knownState()).toEqual(knownState);
return true;
});
expect(
toSimplifiedMessages(
{
Map: largeMap.core,
Group: group.core,
},
node2Sync.messages,
),
).toMatchInlineSnapshot(`
[
"client -> LOAD Map sessions: empty",
"storage -> KNOWN Map sessions: header/200",
"storage -> CONTENT Group header: true new: After: 0 New: 3",
"storage -> CONTENT Map header: true new: After: 0 New: 97",
"storage -> CONTENT Map header: true new: After: 97 New: 97",
"storage -> CONTENT Map header: true new: After: 194 New: 6",
"client -> KNOWN Group sessions: header/3",
"client -> KNOWN Map sessions: header/97",
"client -> KNOWN Map sessions: header/194",
"client -> KNOWN Map sessions: header/200",
]
`);
});

View File

@@ -42,3 +42,33 @@ export function trackMessages(node: LocalNode) {
restore,
};
}
export function waitFor(
callback: () => boolean | undefined | Promise<boolean | undefined>,
) {
return new Promise<void>((resolve, reject) => {
const checkPassed = async () => {
try {
return { ok: await callback(), error: null };
} catch (error) {
return { ok: false, error };
}
};
let retries = 0;
const interval = setInterval(async () => {
const { ok, error } = await checkPassed();
if (ok !== false) {
clearInterval(interval);
resolve();
}
if (++retries > 10) {
clearInterval(interval);
reject(error);
}
}, 100);
});
}

View File

@@ -1,5 +1,12 @@
# cojson-storage-sqlite
## 0.13.32
### Patch Changes
- Updated dependencies [2bf9743]
- cojson-storage@0.13.32
## 0.13.31
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "cojson-storage-sqlite",
"type": "module",
"version": "0.13.31",
"version": "0.13.32",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"license": "MIT",

View File

@@ -86,13 +86,14 @@ export class SQLiteClient implements DBClientInterfaceSync {
getNewTransactionInSession(
sessionRowId: number,
firstNewTxIdx: number,
fromIdx: number,
toIdx: number,
): TransactionRow[] {
const txs = this.db
.prepare<[number, number]>(
"SELECT * FROM transactions WHERE ses = ? AND idx >= ?",
.prepare<[number, number, number]>(
"SELECT * FROM transactions WHERE ses = ? AND idx >= ? AND idx <= ?",
)
.all(sessionRowId, firstNewTxIdx) as RawTransactionRow[];
.all(sessionRowId, fromIdx, toIdx) as RawTransactionRow[];
try {
return txs.map((transactionRow) => ({

View File

@@ -8,7 +8,7 @@ import { WasmCrypto } from "cojson/crypto/WasmCrypto";
import { expect, onTestFinished, test, vi } from "vitest";
import { SQLiteNode } from "../index.js";
import { toSimplifiedMessages } from "./messagesTestUtils.js";
import { trackMessages } from "./testUtils.js";
import { trackMessages, waitFor } from "./testUtils.js";
const Crypto = await WasmCrypto.create();
@@ -117,10 +117,8 @@ test("should sync and load data from storage", async () => {
).toMatchInlineSnapshot(`
[
"client -> LOAD Map sessions: empty",
"storage -> KNOWN Group sessions: header/3",
"storage -> CONTENT Group header: true new: After: 0 New: 3",
"client -> KNOWN Group sessions: header/3",
"storage -> KNOWN Map sessions: header/1",
"storage -> CONTENT Map header: true new: After: 0 New: 1",
"client -> KNOWN Map sessions: header/1",
]
@@ -129,6 +127,84 @@ test("should sync and load data from storage", async () => {
node2Sync.restore();
});
test("should send an empty content message if there is no content", async () => {
const agentSecret = Crypto.newRandomAgentSecret();
const node1 = new LocalNode(
agentSecret,
Crypto.newRandomSessionID(Crypto.getAgentID(agentSecret)),
Crypto,
);
const node1Sync = trackMessages(node1);
const { peer, dbPath } = await createSQLiteStorage();
node1.syncManager.addPeer(peer);
const group = node1.createGroup();
const map = group.createMap();
await new Promise((resolve) => setTimeout(resolve, 200));
expect(
toSimplifiedMessages(
{
Map: map.core,
Group: group.core,
},
node1Sync.messages,
),
).toMatchInlineSnapshot(`
[
"client -> CONTENT Group header: true new: After: 0 New: 3",
"storage -> KNOWN Group sessions: header/3",
"client -> CONTENT Map header: true new: ",
"storage -> KNOWN Map sessions: header/0",
]
`);
node1Sync.restore();
const node2 = new LocalNode(
agentSecret,
Crypto.newRandomSessionID(Crypto.getAgentID(agentSecret)),
Crypto,
);
const node2Sync = trackMessages(node2);
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(
toSimplifiedMessages(
{
Map: map.core,
Group: group.core,
},
node2Sync.messages,
),
).toMatchInlineSnapshot(`
[
"client -> LOAD Map sessions: empty",
"storage -> CONTENT Group header: true new: After: 0 New: 3",
"client -> KNOWN Group sessions: header/3",
"storage -> CONTENT Map header: true new: ",
"client -> KNOWN Map sessions: header/0",
]
`);
node2Sync.restore();
});
test("should load dependencies correctly (group inheritance)", async () => {
const agentSecret = Crypto.newRandomAgentSecret();
@@ -207,13 +283,10 @@ test("should load dependencies correctly (group inheritance)", async () => {
).toMatchInlineSnapshot(`
[
"client -> LOAD Map sessions: empty",
"storage -> KNOWN ParentGroup sessions: header/4",
"storage -> CONTENT ParentGroup header: true new: After: 0 New: 4",
"client -> KNOWN ParentGroup sessions: header/4",
"storage -> KNOWN Group sessions: header/5",
"storage -> CONTENT Group header: true new: After: 0 New: 5",
"client -> KNOWN Group sessions: header/5",
"storage -> KNOWN Map sessions: header/1",
"storage -> CONTENT Map header: true new: After: 0 New: 1",
"client -> KNOWN Map sessions: header/1",
]
@@ -283,17 +356,13 @@ test("should not send the same dependency value twice", async () => {
).toMatchInlineSnapshot(`
[
"client -> LOAD Map sessions: empty",
"storage -> KNOWN ParentGroup sessions: header/4",
"storage -> CONTENT ParentGroup header: true new: After: 0 New: 4",
"client -> KNOWN ParentGroup sessions: header/4",
"storage -> KNOWN Group sessions: header/5",
"storage -> CONTENT Group header: true new: After: 0 New: 5",
"client -> KNOWN Group sessions: header/5",
"storage -> KNOWN Map sessions: header/1",
"storage -> CONTENT Map header: true new: After: 0 New: 1",
"client -> KNOWN Map sessions: header/1",
"client -> LOAD MapFromParent sessions: empty",
"storage -> KNOWN MapFromParent sessions: header/1",
"storage -> CONTENT MapFromParent header: true new: After: 0 New: 1",
"client -> KNOWN MapFromParent sessions: header/1",
]
@@ -397,10 +466,8 @@ test("should recover from data loss", async () => {
).toMatchInlineSnapshot(`
[
"client -> LOAD Map sessions: empty",
"storage -> KNOWN Group sessions: header/3",
"storage -> CONTENT Group header: true new: After: 0 New: 3",
"client -> KNOWN Group sessions: header/3",
"storage -> KNOWN Map sessions: header/4",
"storage -> CONTENT Map header: true new: After: 0 New: 4",
"client -> KNOWN Map sessions: header/4",
]
@@ -495,3 +562,166 @@ test("should recover missing dependencies from storage", async () => {
"0": 0,
});
});
test("should sync multiple sessions in a single content message", async () => {
const agentSecret = Crypto.newRandomAgentSecret();
const node1 = new LocalNode(
agentSecret,
Crypto.newRandomSessionID(Crypto.getAgentID(agentSecret)),
Crypto,
);
const { peer, dbPath } = await createSQLiteStorage();
node1.syncManager.addPeer(peer);
const group = node1.createGroup();
const map = group.createMap();
map.set("hello", "world");
await new Promise((resolve) => setTimeout(resolve, 200));
node1.gracefulShutdown();
const node2 = new LocalNode(
agentSecret,
Crypto.newRandomSessionID(Crypto.getAgentID(agentSecret)),
Crypto,
);
node2.syncManager.addPeer((await createSQLiteStorage(dbPath)).peer);
const map2 = await node2.load(map.id);
if (map2 === "unavailable") {
throw new Error("Map is unavailable");
}
expect(map2.get("hello")).toBe("world");
map2.set("hello", "world2");
await map2.core.waitForSync();
node2.gracefulShutdown();
const node3 = new LocalNode(
agentSecret,
Crypto.newRandomSessionID(Crypto.getAgentID(agentSecret)),
Crypto,
);
const node3Sync = trackMessages(node3);
node3.syncManager.addPeer((await createSQLiteStorage(dbPath)).peer);
const map3 = await node3.load(map.id);
if (map3 === "unavailable") {
throw new Error("Map is unavailable");
}
expect(map3.get("hello")).toBe("world2");
expect(
toSimplifiedMessages(
{
Map: map.core,
Group: group.core,
},
node3Sync.messages,
),
).toMatchInlineSnapshot(`
[
"client -> LOAD Map sessions: empty",
"storage -> CONTENT Group header: true new: After: 0 New: 3",
"client -> KNOWN Group sessions: header/3",
"storage -> CONTENT Map header: true new: After: 0 New: 1 | After: 0 New: 1",
"client -> KNOWN Map sessions: header/2",
]
`);
node3Sync.restore();
});
test("large coValue upload streaming", async () => {
const agentSecret = Crypto.newRandomAgentSecret();
const node1 = new LocalNode(
agentSecret,
Crypto.newRandomSessionID(Crypto.getAgentID(agentSecret)),
Crypto,
);
const { peer, dbPath } = await createSQLiteStorage();
node1.syncManager.addPeer(peer);
const group = node1.createGroup();
const largeMap = group.createMap();
const dataSize = 1 * 1024 * 200;
const chunkSize = 1024; // 1KB chunks
const chunks = dataSize / chunkSize;
const value = "a".repeat(chunkSize);
for (let i = 0; i < chunks; i++) {
const key = `key${i}`;
largeMap.set(key, value, "trusting");
}
await largeMap.core.waitForSync();
node1.gracefulShutdown();
const node2 = new LocalNode(
agentSecret,
Crypto.newRandomSessionID(Crypto.getAgentID(agentSecret)),
Crypto,
);
const node2Sync = trackMessages(node2);
const { peer: peer2 } = await createSQLiteStorage(dbPath);
node2.syncManager.addPeer(peer2);
const largeMapOnNode2 = await node2.load(largeMap.id);
if (largeMapOnNode2 === "unavailable") {
throw new Error("Map is unavailable");
}
await waitFor(() => {
expect(largeMapOnNode2.core.knownState()).toEqual(
largeMap.core.knownState(),
);
return true;
});
expect(
toSimplifiedMessages(
{
Map: largeMap.core,
Group: group.core,
},
node2Sync.messages,
),
).toMatchInlineSnapshot(`
[
"client -> LOAD Map sessions: empty",
"storage -> KNOWN Map sessions: header/200",
"storage -> CONTENT Group header: true new: After: 0 New: 3",
"client -> KNOWN Group sessions: header/3",
"storage -> CONTENT Map header: true new: After: 0 New: 97",
"client -> KNOWN Map sessions: header/97",
"storage -> CONTENT Map header: true new: After: 97 New: 97",
"client -> KNOWN Map sessions: header/194",
"storage -> CONTENT Map header: true new: After: 194 New: 6",
"client -> KNOWN Map sessions: header/200",
]
`);
});

View File

@@ -42,3 +42,32 @@ export function trackMessages(node: LocalNode) {
restore,
};
}
export function waitFor(
callback: () => boolean | undefined | Promise<boolean | undefined>,
) {
return new Promise<void>((resolve, reject) => {
const checkPassed = async () => {
try {
return { ok: await callback(), error: null };
} catch (error) {
return { ok: false, error };
}
};
let retries = 0;
const interval = setInterval(async () => {
const { ok, error } = await checkPassed();
if (ok !== false) {
clearInterval(interval);
resolve();
}
if (++retries > 10) {
clearInterval(interval);
reject(error);
}
}, 100);
});
}

View File

@@ -1,5 +1,11 @@
# cojson-storage
## 0.13.32
### Patch Changes
- 2bf9743: Implement content streaming for large CoValues on storage
## 0.13.31
### Patch Changes

View File

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

View File

@@ -9,7 +9,12 @@ import {
logger,
} from "cojson";
import { collectNewTxs, getDependedOnCoValues } from "./syncUtils.js";
import type { DBClientInterfaceAsync, StoredSessionRow } from "./types.js";
import type {
DBClientInterfaceAsync,
SignatureAfterRow,
StoredCoValueRow,
StoredSessionRow,
} from "./types.js";
import NewContentMessage = CojsonInternalTypes.NewContentMessage;
import KnownStateMessage = CojsonInternalTypes.KnownStateMessage;
import RawCoID = CojsonInternalTypes.RawCoID;
@@ -42,156 +47,156 @@ export class StorageManagerAsync {
await this.handleContent(msg);
break;
case "known":
await this.handleKnown(msg);
this.handleKnown(msg);
break;
case "done":
await this.handleDone(msg);
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 newTxsInSession = await this.dbClient.getNewTransactionInSession(
sessionRow.rowID,
firstNewTxIdx,
);
collectNewTxs({
newTxsInSession,
newContentMessages,
sessionRow,
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();
for (const { knownMessage, contentMessages } of collectedMessages) {
this.sendStateMessage(knownMessage);
if (contentMessages?.length) {
for (const msg of contentMessages) {
this.sendStateMessage(msg);
}
}
}
}
private async collectCoValueData(
peerKnownState: CojsonInternalTypes.CoValueKnownState,
messageMap: OutputMessageMap = {},
asDependencyOf?: CojsonInternalTypes.RawCoID,
) {
if (messageMap[peerKnownState.id]) {
return messageMap;
}
const coValueRow = await this.dbClient.getCoValue(peerKnownState.id);
const coValueRow = await this.dbClient.getCoValue(coValueKnownState.id);
if (!coValueRow) {
const emptyKnownMessage: KnownStateMessage = {
action: "known",
...emptyKnownState(peerKnownState.id),
...emptyKnownState(coValueKnownState.id),
};
if (asDependencyOf) {
emptyKnownMessage.asDependencyOf = asDependencyOf;
}
messageMap[peerKnownState.id] = { knownMessage: emptyKnownMessage };
return messageMap;
this.sendStateMessage(emptyKnownMessage);
return;
}
const allCoValueSessions = await this.dbClient.getCoValueSessions(
coValueRow.rowID,
);
const newCoValueKnownState: CojsonInternalTypes.CoValueKnownState = {
id: coValueRow.id,
header: true,
sessions: {},
};
const signaturesBySession = new Map<
SessionID,
Pick<SignatureAfterRow, "idx" | "signature">[]
>();
const newContentMessages: CojsonInternalTypes.NewContentMessage[] = [
{
action: "content",
let contentStreaming = false;
for (const sessionRow of allCoValueSessions) {
const signatures = await this.dbClient.getSignatures(sessionRow.rowID, 0);
if (signatures.length > 0) {
contentStreaming = true;
signaturesBySession.set(sessionRow.sessionID, signatures);
}
}
/**
* If we are going to send the content in streaming, we send before a known state message
* to let the peer know how many transactions we are going to send.
*/
if (contentStreaming) {
const newCoValueKnownState: CojsonInternalTypes.CoValueKnownState = {
id: coValueRow.id,
header: coValueRow.header,
new: {},
priority: cojsonInternals.getPriorityFromHeader(coValueRow.header),
},
];
header: true,
sessions: {},
};
await Promise.all(
allCoValueSessions.map((sessionRow) => {
for (const sessionRow of allCoValueSessions) {
newCoValueKnownState.sessions[sessionRow.sessionID] =
sessionRow.lastIdx;
// Collect new sessions data into newContentMessages
return this.handleSessionUpdate({
sessionRow,
peerKnownState,
newContentMessages,
});
}),
);
}
this.sendStateMessage({
action: "known",
...newCoValueKnownState,
});
}
this.loadedCoValues.add(coValueRow.id);
let contentMessage = {
action: "content",
id: coValueRow.id,
header: coValueRow.header,
new: {},
priority: cojsonInternals.getPriorityFromHeader(coValueRow.header),
} satisfies CojsonInternalTypes.NewContentMessage;
for (const sessionRow of allCoValueSessions) {
if (
sessionRow.lastIdx <=
(coValueKnownState.sessions[sessionRow.sessionID] || 0)
) {
continue;
}
const signatures = signaturesBySession.get(sessionRow.sessionID) || [];
let idx = 0;
signatures.push({
idx: sessionRow.lastIdx,
signature: sessionRow.lastSignature,
});
for (const signature of signatures) {
const newTxsInSession = await this.dbClient.getNewTransactionInSession(
sessionRow.rowID,
idx,
signature.idx,
);
collectNewTxs({
newTxsInSession,
contentMessage,
sessionRow,
firstNewTxIdx: idx,
signature: signature.signature,
});
idx = signature.idx + 1;
if (signatures.length > 1) {
await this.sendContentMessage(coValueRow, contentMessage);
contentMessage = {
action: "content",
id: coValueRow.id,
header: coValueRow.header,
new: {},
priority: cojsonInternals.getPriorityFromHeader(coValueRow.header),
} satisfies CojsonInternalTypes.NewContentMessage;
}
}
}
if (Object.keys(contentMessage.new).length === 0 && contentStreaming) {
return;
}
return this.sendContentMessage(coValueRow, contentMessage);
}
async sendContentMessage(
coValueRow: StoredCoValueRow,
contentMessage: CojsonInternalTypes.NewContentMessage,
) {
const dependedOnCoValuesList = getDependedOnCoValues({
coValueRow,
newContentMessages,
newContentMessages: [contentMessage],
});
const knownMessage: KnownStateMessage = {
action: "known",
...newCoValueKnownState,
};
if (asDependencyOf) {
knownMessage.asDependencyOf = asDependencyOf;
for (const dependedOnCoValue of dependedOnCoValuesList) {
if (this.loadedCoValues.has(dependedOnCoValue)) {
continue;
}
await this.sendNewContent({
id: dependedOnCoValue,
header: false,
sessions: {},
});
}
messageMap[newCoValueKnownState.id] = {
knownMessage: knownMessage,
contentMessages: newContentMessages,
};
await Promise.all(
dependedOnCoValuesList.map((dependedOnCoValue) => {
if (this.loadedCoValues.has(dependedOnCoValue)) {
return;
}
return this.collectCoValueData(
{
id: dependedOnCoValue,
header: false,
sessions: {},
},
messageMap,
asDependencyOf || coValueRow.id,
);
}),
);
return messageMap;
this.sendStateMessage(contentMessage);
}
handleLoad(msg: CojsonInternalTypes.LoadMessage) {

View File

@@ -9,7 +9,12 @@ import {
logger,
} from "cojson";
import { collectNewTxs, getDependedOnCoValues } from "./syncUtils.js";
import type { DBClientInterfaceSync, StoredSessionRow } from "./types.js";
import type {
DBClientInterfaceSync,
SignatureAfterRow,
StoredCoValueRow,
StoredSessionRow,
} from "./types.js";
import NewContentMessage = CojsonInternalTypes.NewContentMessage;
import KnownStateMessage = CojsonInternalTypes.KnownStateMessage;
import RawCoID = CojsonInternalTypes.RawCoID;
@@ -47,145 +52,161 @@ export class StorageManagerSync {
}
}
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 newTxsInSession = this.dbClient.getNewTransactionInSession(
sessionRow.rowID,
firstNewTxIdx,
);
collectNewTxs({
newTxsInSession,
newContentMessages,
sessionRow,
firstNewTxIdx,
});
}
sendNewContent(coValueKnownState: CojsonInternalTypes.CoValueKnownState) {
const outputMessages: OutputMessageMap =
this.collectCoValueData(coValueKnownState);
// reverse it to send the top level id the last in the order
const collectedMessages = Object.values(outputMessages).reverse();
for (const { knownMessage, contentMessages } of collectedMessages) {
this.sendStateMessage(knownMessage);
if (contentMessages?.length) {
for (const msg of contentMessages) {
this.sendStateMessage(msg);
}
}
}
}
private collectCoValueData(
peerKnownState: CojsonInternalTypes.CoValueKnownState,
messageMap: OutputMessageMap = {},
asDependencyOf?: CojsonInternalTypes.RawCoID,
async sendNewContent(
coValueKnownState: CojsonInternalTypes.CoValueKnownState,
) {
if (messageMap[peerKnownState.id]) {
return messageMap;
}
const coValueRow = this.dbClient.getCoValue(peerKnownState.id);
const coValueRow = this.dbClient.getCoValue(coValueKnownState.id);
if (!coValueRow) {
const emptyKnownMessage: KnownStateMessage = {
action: "known",
...emptyKnownState(peerKnownState.id),
...emptyKnownState(coValueKnownState.id),
};
if (asDependencyOf) {
emptyKnownMessage.asDependencyOf = asDependencyOf;
}
messageMap[peerKnownState.id] = { knownMessage: emptyKnownMessage };
return messageMap;
this.sendStateMessage(emptyKnownMessage);
return;
}
const allCoValueSessions = this.dbClient.getCoValueSessions(
coValueRow.rowID,
);
const newCoValueKnownState: CojsonInternalTypes.CoValueKnownState = {
id: coValueRow.id,
header: true,
sessions: {},
};
const signaturesBySession = new Map<
SessionID,
Pick<SignatureAfterRow, "idx" | "signature">[]
>();
const newContentMessages: CojsonInternalTypes.NewContentMessage[] = [
{
action: "content",
let contentStreaming = false;
for (const sessionRow of allCoValueSessions) {
const signatures = this.dbClient.getSignatures(sessionRow.rowID, 0);
if (signatures.length > 0) {
contentStreaming = true;
signaturesBySession.set(sessionRow.sessionID, signatures);
}
}
/**
* If we are going to send the content in streaming, we send before a known state message
* to let the peer know how many transactions we are going to send.
*/
if (contentStreaming) {
const newCoValueKnownState: CojsonInternalTypes.CoValueKnownState = {
id: coValueRow.id,
header: coValueRow.header,
new: {},
priority: cojsonInternals.getPriorityFromHeader(coValueRow.header),
},
];
header: true,
sessions: {},
};
allCoValueSessions.map((sessionRow) => {
newCoValueKnownState.sessions[sessionRow.sessionID] = sessionRow.lastIdx;
// Collect new sessions data into newContentMessages
this.handleSessionUpdate({
sessionRow,
peerKnownState,
newContentMessages,
for (const sessionRow of allCoValueSessions) {
newCoValueKnownState.sessions[sessionRow.sessionID] =
sessionRow.lastIdx;
}
this.sendStateMessage({
action: "known",
...newCoValueKnownState,
});
});
}
this.loadedCoValues.add(coValueRow.id);
const dependedOnCoValuesList = getDependedOnCoValues({
coValueRow,
newContentMessages,
});
let contentMessage = {
action: "content",
id: coValueRow.id,
header: coValueRow.header,
new: {},
priority: cojsonInternals.getPriorityFromHeader(coValueRow.header),
} satisfies CojsonInternalTypes.NewContentMessage;
const knownMessage: KnownStateMessage = {
action: "known",
...newCoValueKnownState,
};
if (asDependencyOf) {
knownMessage.asDependencyOf = asDependencyOf;
}
messageMap[newCoValueKnownState.id] = {
knownMessage: knownMessage,
contentMessages: newContentMessages,
};
dependedOnCoValuesList.map((dependedOnCoValue) => {
if (this.loadedCoValues.has(dependedOnCoValue)) {
return;
for (const sessionRow of allCoValueSessions) {
if (
sessionRow.lastIdx <=
(coValueKnownState.sessions[sessionRow.sessionID] || 0)
) {
continue;
}
return this.collectCoValueData(
{
id: dependedOnCoValue,
header: false,
sessions: {},
},
messageMap,
asDependencyOf || coValueRow.id,
);
const signatures = signaturesBySession.get(sessionRow.sessionID) || [];
let idx = 0;
signatures.push({
idx: sessionRow.lastIdx,
signature: sessionRow.lastSignature,
});
for (const signature of signatures) {
const newTxsInSession = this.dbClient.getNewTransactionInSession(
sessionRow.rowID,
idx,
signature.idx,
);
collectNewTxs({
newTxsInSession,
contentMessage,
sessionRow,
firstNewTxIdx: idx,
signature: signature.signature,
});
idx = signature.idx + 1;
if (signatures.length > 1) {
await this.sendContentMessage(coValueRow, contentMessage);
contentMessage = {
action: "content",
id: coValueRow.id,
header: coValueRow.header,
new: {},
priority: cojsonInternals.getPriorityFromHeader(coValueRow.header),
} satisfies CojsonInternalTypes.NewContentMessage;
// Introduce a delay to not block the main thread
// for the entire content processing
await new Promise((resolve) => setTimeout(resolve));
}
}
}
if (Object.keys(contentMessage.new).length === 0 && contentStreaming) {
return;
}
return this.sendContentMessage(coValueRow, contentMessage);
}
async sendContentMessage(
coValueRow: StoredCoValueRow,
contentMessage: CojsonInternalTypes.NewContentMessage,
) {
const dependedOnCoValuesList = getDependedOnCoValues({
coValueRow,
newContentMessages: [contentMessage],
});
return messageMap;
for (const dependedOnCoValue of dependedOnCoValuesList) {
if (this.loadedCoValues.has(dependedOnCoValue)) {
continue;
}
await this.sendNewContent({
id: dependedOnCoValue,
header: false,
sessions: {},
});
}
this.sendStateMessage(contentMessage);
}
handleLoad(msg: CojsonInternalTypes.LoadMessage) {
return this.sendNewContent(msg);
this.sendNewContent(msg).catch((e) =>
logger.error("Error sending new content", {
id: msg.id,
err: e,
}),
);
}
handleContent(msg: CojsonInternalTypes.NewContentMessage) {

View File

@@ -13,32 +13,33 @@ import type {
export function collectNewTxs({
newTxsInSession,
newContentMessages,
contentMessage,
sessionRow,
firstNewTxIdx,
signature,
}: {
newTxsInSession: TransactionRow[];
newContentMessages: CojsonInternalTypes.NewContentMessage[];
contentMessage: CojsonInternalTypes.NewContentMessage;
sessionRow: StoredSessionRow;
signature: CojsonInternalTypes.Signature;
firstNewTxIdx: number;
}) {
for (const tx of newTxsInSession) {
const lastMessage = newContentMessages[newContentMessages.length - 1];
if (!lastMessage) return;
let sessionEntry = contentMessage.new[sessionRow.sessionID];
let sessionEntry = lastMessage.new[sessionRow.sessionID];
if (!sessionEntry) {
sessionEntry = {
after: firstNewTxIdx,
lastSignature: "WILL_BE_REPLACED" as CojsonInternalTypes.Signature,
newTransactions: [],
};
lastMessage.new[sessionRow.sessionID] = sessionEntry;
}
sessionEntry.newTransactions.push(tx.tx);
sessionEntry.lastSignature = sessionRow.lastSignature;
if (!sessionEntry) {
sessionEntry = {
after: firstNewTxIdx,
lastSignature: "WILL_BE_REPLACED" as CojsonInternalTypes.Signature,
newTransactions: [],
};
contentMessage.new[sessionRow.sessionID] = sessionEntry;
}
for (const tx of newTxsInSession) {
sessionEntry.newTransactions.push(tx.tx);
}
sessionEntry.lastSignature = signature;
}
export function getDependedOnCoValues({

View File

@@ -98,13 +98,7 @@ describe("DB sync manager", () => {
await syncManager.handleSyncMessage(loadMsg);
expect(syncManager.sendStateMessage).toBeCalledTimes(2);
expect(syncManager.sendStateMessage).toBeCalledWith({
action: "known",
header: true,
id: coValueIdToLoad,
sessions: {},
});
expect(syncManager.sendStateMessage).toBeCalledTimes(1);
expect(syncManager.sendStateMessage).toBeCalledWith({
action: "content",
header: expect.objectContaining({
@@ -117,78 +111,6 @@ describe("DB sync manager", () => {
});
});
test("Sends both known and content messages when we have new sessions info for the requested coValue ", async () => {
const loadMsg = createEmptyLoadMsg(coValueIdToLoad);
DBClient.prototype.getCoValue.mockResolvedValueOnce({
id: coValueIdToLoad,
header: coValueHeader,
rowID: 3,
});
DBClient.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;
return acc;
},
{} as Record<string, number>,
),
});
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),
};
return acc;
},
{} as Record<
string,
{
after: number;
lastSignature: string;
newTransactions: Transaction[];
}
>,
),
priority: 0,
});
});
test("Sends messages for unique coValue dependencies only, leaving out circular dependencies", async () => {
const loadMsg = createEmptyLoadMsg(coValueIdToLoad);
const dependency1 = "co_zMKhQJs5rAeGjta3JX2qEdBS6hS";
@@ -220,13 +142,7 @@ describe("DB sync manager", () => {
// 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: {},
};
expect(syncManager.sendStateMessage).toBeCalledTimes(4);
const contentExpected = {
action: "content",
@@ -236,37 +152,18 @@ describe("DB sync manager", () => {
};
expect(syncManager.sendStateMessage).toHaveBeenNthCalledWith(1, {
...knownExpected,
id: dependency3,
asDependencyOf: coValueIdToLoad,
...contentExpected,
id: dependency1,
});
expect(syncManager.sendStateMessage).toHaveBeenNthCalledWith(2, {
...contentExpected,
id: dependency3,
});
expect(syncManager.sendStateMessage).toHaveBeenNthCalledWith(3, {
...knownExpected,
...contentExpected,
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,
});

View File

@@ -47,7 +47,8 @@ export interface DBClientInterfaceAsync {
getNewTransactionInSession(
sessionRowId: number,
firstNewTxIdx: number,
fromIdx: number,
toIdx: number,
): Promise<TransactionRow[]>;
getSignatures(
@@ -96,13 +97,14 @@ export interface DBClientInterfaceSync {
getNewTransactionInSession(
sessionRowId: number,
firstNewTxIdx: number,
fromIdx: number,
toIdx: number,
): TransactionRow[];
getSignatures(
sessionRowId: number,
firstNewTxIdx: number,
): SignatureAfterRow[];
): Pick<SignatureAfterRow, "idx" | "signature">[];
addCoValue(msg: CojsonInternalTypes.NewContentMessage): number;

View File

@@ -1,5 +1,12 @@
# jazz-auth-betterauth
## 0.13.32
### Patch Changes
- jazz-browser@0.13.32
- jazz-betterauth-client-plugin@0.13.32
## 0.13.31
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "jazz-auth-betterauth",
"version": "0.13.31",
"version": "0.13.32",
"type": "module",
"main": "dist/index.js",
"types": "src/index.ts",

View File

@@ -1,5 +1,11 @@
# jazz-auth-clerk
## 0.13.32
### Patch Changes
- jazz-browser@0.13.32
## 0.13.31
### Patch Changes

View File

@@ -1,13 +1,13 @@
{
"name": "jazz-auth-clerk",
"version": "0.13.31",
"version": "0.13.32",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"license": "MIT",
"dependencies": {
"cojson": "workspace:0.13.31",
"jazz-browser": "workspace:0.13.31",
"jazz-browser": "workspace:0.13.32",
"jazz-tools": "workspace:0.13.31"
},
"scripts": {

View File

@@ -1,5 +1,11 @@
# jazz-betterauth-client-plugin
## 0.13.32
### Patch Changes
- jazz-betterauth-server-plugin@0.13.32
## 0.13.31
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "jazz-betterauth-client-plugin",
"version": "0.13.31",
"version": "0.13.32",
"type": "module",
"main": "dist/index.js",
"types": "src/index.ts",

View File

@@ -1,5 +1,11 @@
# jazz-betterauth-server-plugin
## 0.13.32
### Patch Changes
- jazz-browser@0.13.32
## 0.13.31
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "jazz-betterauth-server-plugin",
"version": "0.13.31",
"version": "0.13.32",
"type": "module",
"main": "dist/index.js",
"types": "src/index.ts",

View File

@@ -1,5 +1,11 @@
# jazz-browser-media-images
## 0.13.32
### Patch Changes
- jazz-browser@0.13.32
## 0.13.31
### Patch Changes

View File

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

View File

@@ -1,5 +1,11 @@
# jazz-browser
## 0.13.32
### Patch Changes
- cojson-storage-indexeddb@0.13.32
## 0.13.31
### Patch Changes

View File

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

View File

@@ -1,5 +1,12 @@
# jazz-browser
## 0.13.32
### Patch Changes
- jazz-react-native-core@0.13.32
- jazz-auth-clerk@0.13.32
## 0.13.31
### Patch Changes

View File

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

View File

@@ -1,5 +1,14 @@
# jazz-react-auth-betterauth
## 0.13.32
### Patch Changes
- jazz-browser@0.13.32
- jazz-auth-betterauth@0.13.32
- jazz-react@0.13.32
- jazz-betterauth-client-plugin@0.13.32
## 0.13.31
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "jazz-react-auth-betterauth",
"version": "0.13.31",
"version": "0.13.32",
"type": "module",
"main": "dist/index.js",
"types": "src/index.tsx",

View File

@@ -1,5 +1,13 @@
# jazz-browser-media-images
## 0.13.32
### Patch Changes
- jazz-browser@0.13.32
- jazz-auth-clerk@0.13.32
- jazz-react@0.13.32
## 0.13.31
### Patch Changes

View File

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

View File

@@ -1,5 +1,12 @@
# jazz-browser
## 0.13.32
### Patch Changes
- Updated dependencies [2bf9743]
- cojson-storage@0.13.32
## 0.13.31
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "jazz-react-native-core",
"type": "module",
"version": "0.13.31",
"version": "0.13.32",
"license": "MIT",
"main": "./dist/index.js",
"module": "./dist/index.js",

View File

@@ -1,5 +1,13 @@
# jazz-browser
## 0.13.32
### Patch Changes
- Updated dependencies [2bf9743]
- cojson-storage@0.13.32
- jazz-react-native-core@0.13.32
## 0.13.31
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "jazz-react-native",
"version": "0.13.31",
"version": "0.13.32",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",

View File

@@ -1,5 +1,12 @@
# jazz-react
## 0.13.32
### Patch Changes
- jazz-browser@0.13.32
- jazz-browser-media-images@0.13.32
## 0.13.31
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "jazz-react",
"version": "0.13.31",
"version": "0.13.32",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
@@ -18,8 +18,8 @@
"dependencies": {
"@scure/bip39": "^1.3.0",
"cojson": "workspace:0.13.31",
"jazz-browser-media-images": "workspace:0.13.31",
"jazz-browser": "workspace:0.13.31",
"jazz-browser-media-images": "workspace:0.13.32",
"jazz-browser": "workspace:0.13.32",
"jazz-react-core": "workspace:0.13.31",
"jazz-tools": "workspace:0.13.31"
},

View File

@@ -1,5 +1,11 @@
# jazz-richtext-prosemirror
## 0.1.19
### Patch Changes
- jazz-browser@0.13.32
## 0.1.18
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "jazz-richtext-prosemirror",
"version": "0.1.18",
"version": "0.1.19",
"description": "ProseMirror integration for Jazz rich text editing",
"main": "dist/index.js",
"types": "src/index.ts",

View File

@@ -1,5 +1,12 @@
# jazz-richtext-tiptap
## 0.1.8
### Patch Changes
- jazz-browser@0.13.32
- jazz-richtext-prosemirror@0.1.19
## 0.1.7
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "jazz-richtext-tiptap",
"version": "0.1.7",
"version": "0.1.8",
"description": "Tiptap integration for Jazz rich text editing",
"main": "dist/index.js",
"types": "src/index.ts",

View File

@@ -1,5 +1,11 @@
# jazz-run
## 0.13.32
### Patch Changes
- cojson-storage-sqlite@0.13.32
## 0.13.31
### Patch Changes

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