Compare commits

..

43 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
Guido D'Orsi
e410823087 Merge pull request #2251 from garden-co/changeset-release/main
Version Packages
2025-05-16 12:26:58 +02:00
github-actions[bot]
dcc11e5b60 Version Packages 2025-05-16 10:22:41 +00:00
Guido D'Orsi
1b05fe5b55 Merge pull request #2252 from garden-co/fix/remove-members-no-parent-access
fix(group): removing members when the admin doesn't have access to the parent group read keys
2025-05-16 12:18:14 +02:00
Guido D'Orsi
de8f896bf9 Merge pull request #2247 from garden-co/feat/skipInvalid
feat: add $onError to catch errors on resolve
2025-05-16 12:17:10 +02:00
Guido D'Orsi
d63716a827 fix(group): removing members when the admin doesn't have access to the parent group read keys 2025-05-16 12:11:03 +02:00
Guido D'Orsi
a1e7fce3b9 Merge pull request #2250 from garden-co/fix/invites-upgrades
fix(invite): restore role upgrades an inviting revoked members
2025-05-16 12:02:50 +02:00
Guido D'Orsi
d5edad7ba5 fix(invite): restore role upgrades an inviting revoked members 2025-05-16 12:02:32 +02:00
Guido D'Orsi
559a4a223b test: cover more logic for $onError 2025-05-16 11:18:27 +02:00
Guido D'Orsi
16d5553ccd docs: replace expect in the example with comments 2025-05-16 09:52:01 +02:00
Guido D'Orsi
ef76c586cc docs: improve the Requesting Invites section 2025-05-15 23:25:54 +02:00
Guido D'Orsi
8ea4b9761c docs: improve the Requesting Invites section 2025-05-15 23:24:38 +02:00
Guido D'Orsi
a81f281079 feat: move to 2025-05-15 23:11:18 +02:00
Guido D'Orsi
3ecb602459 fix: remove Jazz paper scissors from the examples list because the worker isn't deployed 2025-05-15 23:09:16 +02:00
Meg Culotta
ea69ea1f67 Merge pull request #2004 from garden-co/feat/1936-add-request-invites-to-docs
Add request invites to docs
2025-05-15 15:25:28 -05:00
Margaret Culotta
e430bd061e remove unnecessary break 2025-05-15 15:18:35 -05:00
Guido D'Orsi
a957485172 chore: docs improvements and more tests 2025-05-15 19:52:00 +02:00
Margaret Culotta
9060975dfb Merge main and handle conflicts 2025-05-15 12:41:32 -05:00
Margaret Culotta
753ec83fdd remove unnecessary comments, clean up 2025-05-15 12:34:05 -05:00
Margaret Culotta
44a9785e93 clean up, clarify and simplfy examples 2025-05-15 12:28:55 -05:00
Trisha Lim
d9b390e538 remove support for demos in examples page (#2230) 2025-05-15 17:57:12 +01:00
Guido D'Orsi
b194f7831b fix: fix return type of $skipInvalid when used on nested lists 2025-05-15 18:17:53 +02:00
Guido D'Orsi
b60be9405d docs: add $skipInvalid usage 2025-05-15 17:48:32 +02:00
Guido D'Orsi
84588e0798 feat(organization): add remove member flow and add tests 2025-05-15 17:11:56 +02:00
Guido D'Orsi
e5b170f25e feat: add $skipInvalid resolve keyword 2025-05-15 17:11:09 +02:00
Margaret Culotta
63d0b0673e run linter 2025-05-14 11:49:21 -05:00
Margaret Culotta
2b456b5e07 add approval code snippet 2025-05-14 11:45:25 -05:00
Margaret Culotta
e20809d314 fix errors in code snippets 2025-05-14 11:12:26 -05:00
Margaret Culotta
b6de11e125 Merge branch 'main' into feat/1936-add-request-invites-to-docs 2025-05-13 12:56:38 -05:00
Margaret Culotta
d23c71d511 working through twoslash errors 2025-04-30 08:11:32 -05:00
Benjamin S. Leveritt
b24071cf33 Fixes example for TwoSlash 2025-04-28 16:36:25 +01:00
Benjamin S. Leveritt
a5c88c08de Merge main into feat/1936-add-request-invites-to-docs 2025-04-28 16:14:17 +01:00
Margaret Culotta
906932db1e update imports and mocks for twoslash 2025-04-24 12:22:17 -05:00
Margaret Culotta
2033e35a41 Merge branch 'main' into feat/1936-add-request-invites-to-docs 2025-04-24 11:26:09 -05:00
Margaret Culotta
5a2a12ccbb update twoslash imports 2025-04-18 08:10:50 -05:00
Margaret Culotta
d288cb6954 update docs for request invite 2025-04-17 10:18:17 -05:00
164 changed files with 4333 additions and 1633 deletions

View File

@@ -13,7 +13,7 @@ jobs:
continue-on-error: true
strategy:
matrix:
project: ["tests/e2e", "examples/chat", "examples/clerk", "examples/betterauth", "examples/file-share-svelte", "examples/form", "examples/music-player", "examples/pets", "starters/react-passkey-auth"]
project: ["tests/e2e", "examples/chat", "examples/clerk", "examples/betterauth", "examples/file-share-svelte", "examples/form", "examples/music-player", "examples/organization", "examples/pets", "starters/react-passkey-auth"]
steps:
- uses: actions/checkout@v4

2
.gitignore vendored
View File

@@ -29,3 +29,5 @@ test-results
.cursorrules
.windsurfrules
playwright-report

View File

@@ -1,5 +1,26 @@
# 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
- Updated dependencies [e5b170f]
- jazz-tools@0.13.31
- jazz-betterauth-server-plugin@0.13.31
- jazz-inspector@0.13.31
- jazz-react@0.13.31
- jazz-react-auth-betterauth@0.13.31
- jazz-betterauth-client-plugin@0.13.31
## 0.1.2
### Patch Changes

View File

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

View File

@@ -1,5 +1,20 @@
# chat-rn-expo-clerk
## 1.0.123
### Patch Changes
- jazz-expo@0.13.32
## 1.0.122
### Patch Changes
- Updated dependencies [e5b170f]
- jazz-tools@0.13.31
- jazz-expo@0.13.31
- jazz-react-native-media-images@0.13.31
## 1.0.121
### Patch Changes

View File

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

View File

@@ -1,5 +1,19 @@
# chat-rn-expo
## 1.0.110
### Patch Changes
- jazz-expo@0.13.32
## 1.0.109
### Patch Changes
- Updated dependencies [e5b170f]
- jazz-tools@0.13.31
- jazz-expo@0.13.31
## 1.0.108
### Patch Changes

View File

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

View File

@@ -1,5 +1,23 @@
# chat-rn
## 1.0.118
### Patch Changes
- jazz-react-native@0.13.32
## 1.0.117
### Patch Changes
- Updated dependencies [e5b170f]
- Updated dependencies [d63716a]
- Updated dependencies [d5edad7]
- jazz-tools@0.13.31
- cojson@0.13.31
- jazz-react-native@0.13.31
- cojson-transport-ws@0.13.31
## 1.0.116
### Patch Changes

View File

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

View File

@@ -1,5 +1,21 @@
# chat-vue
## 0.0.101
### Patch Changes
- jazz-browser@0.13.32
- jazz-vue@0.13.32
## 0.0.100
### Patch Changes
- Updated dependencies [e5b170f]
- jazz-tools@0.13.31
- jazz-browser@0.13.31
- jazz-vue@0.13.31
## 0.0.99
### Patch Changes

View File

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

View File

@@ -1,5 +1,20 @@
# jazz-example-chat
## 0.0.199
### Patch Changes
- jazz-react@0.13.32
## 0.0.198
### Patch Changes
- Updated dependencies [e5b170f]
- jazz-tools@0.13.31
- jazz-inspector@0.13.31
- jazz-react@0.13.31
## 0.0.197
### Patch Changes

View File

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

View File

@@ -1,5 +1,21 @@
# 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
- Updated dependencies [e5b170f]
- jazz-tools@0.13.31
- jazz-react@0.13.31
- jazz-react-auth-clerk@0.13.31
## 0.0.96
### Patch Changes

View File

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

View File

@@ -1,5 +1,20 @@
# file-share-svelte
## 0.0.82
### Patch Changes
- jazz-svelte@0.13.32
## 0.0.81
### Patch Changes
- Updated dependencies [e5b170f]
- jazz-tools@0.13.31
- jazz-inspector-element@0.13.31
- jazz-svelte@0.13.31
## 0.0.80
### Patch Changes

View File

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

View File

@@ -1,5 +1,20 @@
# jazz-tailwind-demo-auth-starter
## 0.0.38
### Patch Changes
- jazz-react@0.13.32
## 0.0.37
### Patch Changes
- Updated dependencies [e5b170f]
- jazz-tools@0.13.31
- jazz-inspector@0.13.31
- jazz-react@0.13.31
## 0.0.36
### Patch Changes

View File

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

View File

@@ -1,5 +1,19 @@
# form
## 0.1.39
### Patch Changes
- jazz-react@0.13.32
## 0.1.38
### Patch Changes
- Updated dependencies [e5b170f]
- jazz-tools@0.13.31
- jazz-react@0.13.31
## 0.1.37
### Patch Changes

View File

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

View File

@@ -1,5 +1,19 @@
# image-upload
## 0.0.95
### Patch Changes
- jazz-react@0.13.32
## 0.0.94
### Patch Changes
- Updated dependencies [e5b170f]
- jazz-tools@0.13.31
- jazz-react@0.13.31
## 0.0.93
### Patch Changes

View File

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

View File

@@ -1,5 +1,15 @@
# jazz-example-inspector
## 0.0.148
### Patch Changes
- Updated dependencies [d63716a]
- Updated dependencies [d5edad7]
- cojson@0.13.31
- jazz-inspector@0.13.31
- cojson-transport-ws@0.13.31
## 0.0.147
### Patch Changes

View File

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

View File

@@ -1,5 +1,19 @@
# multi-cursors
## 0.0.91
### Patch Changes
- jazz-react@0.13.32
## 0.0.90
### Patch Changes
- Updated dependencies [e5b170f]
- jazz-tools@0.13.31
- jazz-react@0.13.31
## 0.0.89
### Patch Changes

View File

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

View File

@@ -1,5 +1,21 @@
# multiauth
## 0.0.39
### Patch Changes
- jazz-react@0.13.32
- jazz-react-auth-clerk@0.13.32
## 0.0.38
### Patch Changes
- Updated dependencies [e5b170f]
- jazz-tools@0.13.31
- jazz-react@0.13.31
- jazz-react-auth-clerk@0.13.31
## 0.0.37
### Patch Changes

View File

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

View File

@@ -1,5 +1,20 @@
# jazz-example-musicplayer
## 0.0.120
### Patch Changes
- jazz-react@0.13.32
## 0.0.119
### Patch Changes
- Updated dependencies [e5b170f]
- jazz-tools@0.13.31
- jazz-inspector@0.13.31
- jazz-react@0.13.31
## 0.0.118
### Patch Changes

View File

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

View File

@@ -22,3 +22,5 @@ dist-ssr
*.njsproj
*.sln
*.sw?
playwright-report

View File

@@ -1,5 +1,19 @@
# organization
## 0.0.91
### Patch Changes
- jazz-react@0.13.32
## 0.0.90
### Patch Changes
- Updated dependencies [e5b170f]
- jazz-tools@0.13.31
- jazz-react@0.13.31
## 0.0.89
### Patch Changes

View File

@@ -1,14 +1,16 @@
{
"name": "organization",
"private": true,
"version": "0.0.89",
"version": "0.0.91",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"format-and-lint": "biome check .",
"format-and-lint:fix": "biome check . --write"
"format-and-lint:fix": "biome check . --write",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui"
},
"dependencies": {
"jazz-react": "workspace:*",
@@ -20,6 +22,7 @@
"react-router-dom": "^6.16.0"
},
"devDependencies": {
"@playwright/test": "^1.50.1",
"@biomejs/biome": "1.9.4",
"@tailwindcss/forms": "^0.5.9",
"@types/react": "^18.3.12",

View File

@@ -0,0 +1,53 @@
import { defineConfig, devices } from "@playwright/test";
import isCI from "is-ci";
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// import dotenv from 'dotenv';
// dotenv.config({ path: path.resolve(__dirname, '.env') });
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: "./tests",
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: isCI,
/* Retry on CI only */
retries: isCI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: isCI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "html",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: "http://localhost:5173/",
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
permissions: ["clipboard-read", "clipboard-write"],
},
/* Configure projects for major browsers */
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
/* Run your local dev server before starting the tests */
webServer: [
{
command: "pnpm preview --port 5173",
url: "http://localhost:5173/",
reuseExistingServer: !isCI,
},
],
});

View File

@@ -5,7 +5,13 @@ import { Heading } from "./components/Heading.tsx";
export function HomePage() {
const { me } = useAccount({
resolve: { root: { organizations: true } },
resolve: {
root: {
organizations: {
$each: { $onError: null },
},
},
},
});
if (!me?.root.organizations) return;

View File

@@ -3,7 +3,7 @@ import { UserIcon } from "lucide-react";
export function Layout({ children }: { children: React.ReactNode }) {
const { me, logOut } = useAccount({
resolve: { root: { draftOrganization: true } },
resolve: { profile: true },
});
return (
@@ -16,7 +16,20 @@ export function Layout({ children }: { children: React.ReactNode }) {
<span className="bg-stone-500 pt-1 size-6 flex items-center justify-center rounded-full">
<UserIcon size={20} className="stroke-white" />
</span>
{me?.profile?.name}
<label htmlFor="profile-name" className="sr-only">
Profile name
</label>
<input
id="profile-name"
type="text"
value={me?.profile.name ?? ""}
className="rounded-md shadow-sm dark:bg-transparent text-sm py-1.5 px-3"
onChange={(e) => {
if (me) {
me.profile.name = e.target.value;
}
}}
/>
</span>
<button

View File

@@ -16,7 +16,21 @@ export function OrganizationPage() {
resolve: { projects: true },
});
if (!organization) return <p>Loading organization...</p>;
if (organization === undefined) return <p>Loading organization...</p>;
if (organization === null) {
return (
<div className="h-full flex items-center justify-center">
<div className="text-center">
<h1 className="text-2xl font-bold">
You don't have access to this organization
</h1>
<a href="/#" className="text-blue-500">
Go back to home
</a>
</div>
</div>
);
}
return (
<Layout>

View File

@@ -27,7 +27,7 @@ export function OrganizationForm({
type="submit"
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
>
Submit
Create
</button>
)}
</form>

View File

@@ -1,4 +1,5 @@
import { Group } from "jazz-tools";
import { useAccount, useCoState } from "jazz-react";
import { Account, Group, ID } from "jazz-tools";
import { Organization } from "../schema.ts";
export function OrganizationMembers({
@@ -9,13 +10,51 @@ export function OrganizationMembers({
return (
<>
{group.members.map((member) => (
<div key={member.id} className="px-4 py-5 sm:px-6">
<strong className="font-medium">
{member.account.profile?.name}
</strong>{" "}
({member.role})
</div>
<MemberItem
key={member.id}
accountId={member.account.id}
role={member.role}
group={group}
/>
))}
</>
);
}
function MemberItem({
accountId,
role,
group,
}: { accountId: ID<Account>; role: string; group: Group }) {
const account = useCoState(Account, accountId, {
resolve: {
profile: true,
},
});
const { me } = useAccount();
const canRemoveMember = group.myRole() === "admin" && accountId !== me?.id;
function handleRemoveMember() {
if (canRemoveMember && account) {
group.removeMember(account);
}
}
return (
<div className="px-4 py-5 sm:px-6 flex justify-between items-center">
<div>
<strong className="font-medium">{account?.profile.name}</strong> ({role}
)
</div>
{canRemoveMember && (
<button
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
onClick={handleRemoveMember}
>
Remove member
</button>
)}
</div>
);
}

View File

@@ -7,7 +7,7 @@ import { Organization } from "../schema.ts";
export function OrganizationSelector({ className }: { className?: string }) {
const { me } = useAccount({
resolve: { root: { organizations: { $each: true } } },
resolve: { root: { organizations: { $each: { $onError: null } } } },
});
const navigate = useNavigate();
@@ -48,6 +48,10 @@ export function OrganizationSelector({ className }: { className?: string }) {
className="rounded-md shadow-sm dark:bg-transparent w-full"
>
{me?.root.organizations.map((organization) => {
if (!organization) {
return null;
}
return (
<option key={organization.id} value={organization.id}>
{organization.name}

View File

@@ -1,4 +1,5 @@
import { Account, CoList, CoMap, Group, co } from "jazz-tools";
import { Account, CoList, CoMap, Group, Profile, co } from "jazz-tools";
import { getRandomUsername } from "./util";
export class Project extends CoMap {
name = co.string;
@@ -38,43 +39,49 @@ export class JazzAccountRoot extends CoMap {
export class JazzAccount extends Account {
root = co.ref(JazzAccountRoot);
async migrate() {
if (!this._refs.root) {
const draftOrganizationOwnership = {
owner: Group.create({ owner: this }),
};
async migrate(this: JazzAccount) {
if (this.profile === undefined) {
const group = Group.create();
this.profile = Profile.create(
{
name: getRandomUsername(),
},
group,
);
group.addMember("everyone", "reader");
}
if (this.root === undefined) {
const draftOrgGroup = Group.create();
const draftOrganization = DraftOrganization.create(
{
projects: ListOfProjects.create([], draftOrganizationOwnership),
projects: ListOfProjects.create([], draftOrgGroup),
},
draftOrganizationOwnership,
draftOrgGroup,
);
const initialOrganizationOwnership = {
owner: Group.create({ owner: this }),
};
const organizations = ListOfOrganizations.create(
[
Organization.create(
{
name: this.profile?.name
? `${this.profile.name}'s projects`
: "Your projects",
projects: ListOfProjects.create([], initialOrganizationOwnership),
},
initialOrganizationOwnership,
),
],
{ owner: this },
);
const defaultOrgGroup = Group.create();
this.root = JazzAccountRoot.create(
{
draftOrganization,
organizations,
const { profile } = await this.ensureLoaded({
resolve: {
profile: true,
},
{ owner: this },
);
});
const organizations = ListOfOrganizations.create([
Organization.create(
{
name: profile.name ? `${profile.name}'s projects` : "Your projects",
projects: ListOfProjects.create([], defaultOrgGroup),
},
defaultOrgGroup,
),
]);
this.root = JazzAccountRoot.create({
draftOrganization,
organizations,
});
}
}
}

View File

@@ -0,0 +1,16 @@
const animals = [
"elephant",
"penguin",
"giraffe",
"octopus",
"kangaroo",
"dolphin",
"cheetah",
"koala",
"platypus",
"pangolin",
];
export function getRandomUsername() {
return `Anonymous ${animals[Math.floor(Math.random() * animals.length)]}`;
}

View File

@@ -0,0 +1,65 @@
import { BrowserContext, expect, test } from "@playwright/test";
test("create a new organization and share", async ({
page: marioPage,
browser,
}) => {
await marioPage.goto("/");
const luigiContext = await browser.newContext();
const luigiPage = await luigiContext.newPage();
await luigiPage.goto("/");
await test.step("Set the profile names", async () => {
await marioPage
.getByRole("textbox", { name: "Profile name" })
.fill("Mario");
await luigiPage
.getByRole("textbox", { name: "Profile name" })
.fill("Luigi");
});
await test.step("Create a new organization", async () => {
await marioPage
.getByRole("textbox", { name: "Organization name" })
.fill("Mario's organization");
await marioPage.getByRole("button", { name: "Create" }).click();
await expect(
marioPage.getByRole("heading", { name: "Mario's organization" }),
).toBeVisible();
});
await test.step("Invite Luigi to the organization", async () => {
await marioPage.getByRole("button", { name: "Copy invite link" }).click();
const inviteUrl = await marioPage.evaluate(() =>
navigator.clipboard.readText(),
);
await luigiPage.goto(inviteUrl);
await expect(
luigiPage.getByRole("heading", { name: "Mario's organization" }),
).toBeVisible();
await expect(marioPage.getByText("Luigi")).toBeVisible();
});
await test.step("Kick out Luigi from the organization", async () => {
await marioPage.getByRole("button", { name: "Remove" }).click();
await expect(marioPage.getByText("Luigi")).not.toBeVisible();
await expect(
luigiPage.getByRole("heading", {
name: "You don't have access to this organization",
}),
).toBeVisible();
await luigiPage.getByRole("link", { name: "Go back to home" }).click();
await expect(
luigiPage.getByRole("heading", { name: "Organizations example app" }),
).toBeVisible();
});
});

View File

@@ -1,5 +1,19 @@
# passkey-svelte
## 0.0.86
### Patch Changes
- jazz-svelte@0.13.32
## 0.0.85
### Patch Changes
- Updated dependencies [e5b170f]
- jazz-tools@0.13.31
- jazz-svelte@0.13.31
## 0.0.84
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "passkey-svelte",
"version": "0.0.84",
"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,19 @@
# minimal-auth-passkey
## 0.0.96
### Patch Changes
- jazz-react@0.13.32
## 0.0.95
### Patch Changes
- Updated dependencies [e5b170f]
- jazz-tools@0.13.31
- jazz-react@0.13.31
## 0.0.94
### Patch Changes

View File

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

View File

@@ -1,5 +1,19 @@
# passphrase
## 0.0.93
### Patch Changes
- jazz-react@0.13.32
## 0.0.92
### Patch Changes
- Updated dependencies [e5b170f]
- jazz-tools@0.13.31
- jazz-react@0.13.31
## 0.0.91
### Patch Changes

View File

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

View File

@@ -1,5 +1,19 @@
# jazz-password-manager
## 0.0.117
### Patch Changes
- jazz-react@0.13.32
## 0.0.116
### Patch Changes
- Updated dependencies [e5b170f]
- jazz-tools@0.13.31
- jazz-react@0.13.31
## 0.0.115
### Patch Changes

View File

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

View File

@@ -1,5 +1,19 @@
# jazz-example-pets
## 0.0.215
### Patch Changes
- jazz-react@0.13.32
## 0.0.214
### Patch Changes
- Updated dependencies [e5b170f]
- jazz-tools@0.13.31
- jazz-react@0.13.31
## 0.0.213
### Patch Changes

View File

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

View File

@@ -1,5 +1,19 @@
# reactions
## 0.0.95
### Patch Changes
- jazz-react@0.13.32
## 0.0.94
### Patch Changes
- Updated dependencies [e5b170f]
- jazz-tools@0.13.31
- jazz-react@0.13.31
## 0.0.93
### Patch Changes

View File

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

View File

@@ -1,5 +1,21 @@
# richtext-tiptap
## 0.1.8
### Patch Changes
- jazz-react@0.13.32
- jazz-richtext-tiptap@0.1.8
## 0.1.7
### Patch Changes
- Updated dependencies [e5b170f]
- jazz-tools@0.13.31
- jazz-react@0.13.31
- jazz-richtext-tiptap@0.1.7
## 0.1.6
### Patch Changes

View File

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

View File

@@ -1,5 +1,21 @@
# richtext
## 0.0.85
### Patch Changes
- jazz-react@0.13.32
- jazz-richtext-prosemirror@0.1.19
## 0.0.84
### Patch Changes
- Updated dependencies [e5b170f]
- jazz-tools@0.13.31
- jazz-react@0.13.31
- jazz-richtext-prosemirror@0.1.18
## 0.0.83
### Patch Changes

View File

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

View File

@@ -1,5 +1,21 @@
# todo-vue
## 0.0.99
### Patch Changes
- jazz-browser@0.13.32
- jazz-vue@0.13.32
## 0.0.98
### Patch Changes
- Updated dependencies [e5b170f]
- jazz-tools@0.13.31
- jazz-browser@0.13.31
- jazz-vue@0.13.31
## 0.0.97
### Patch Changes

View File

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

View File

@@ -1,5 +1,19 @@
# jazz-example-todo
## 0.0.214
### Patch Changes
- jazz-react@0.13.32
## 0.0.213
### Patch Changes
- Updated dependencies [e5b170f]
- jazz-tools@0.13.31
- jazz-react@0.13.31
## 0.0.212
### Patch Changes

View File

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

View File

@@ -1,5 +1,20 @@
# version-history
## 0.0.93
### Patch Changes
- jazz-react@0.13.32
## 0.0.92
### Patch Changes
- Updated dependencies [e5b170f]
- jazz-tools@0.13.31
- jazz-inspector@0.13.31
- jazz-react@0.13.31
## 0.0.91
### Patch Changes

View File

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

View File

@@ -4,8 +4,6 @@ import { clsx } from "clsx";
import { useEffect, useRef, useState } from "react";
import { Icon } from "../atoms/Icon";
// TODO: add tabs feature, and remove CodeExampleTabs
export function CopyButton({
code,
size,

View File

@@ -1,5 +1,4 @@
import { ExampleCard } from "@/components/examples/ExampleCard";
import { ExampleDemo } from "@/components/examples/ExampleDemo";
import { ClerkFullLogo } from "@/components/icons/ClerkFullLogo";
import { ReactLogo } from "@/components/icons/ReactLogo";
import { ReactNativeLogo } from "@/components/icons/ReactNativeLogo";
@@ -508,16 +507,16 @@ const reactExamples: Example[] = [
demoUrl: "https://music-demo.jazz.tools",
illustration: <MusicIllustration />,
},
{
name: "Jazz paper scissors",
slug: "jazz-paper-scissors",
description:
"A game that shows how to communicate with other accounts through the experimental Inbox API.",
tech: [tech.react],
features: [features.serverWorker, features.inbox],
illustration: <JazzPaperScissorsIllustration />,
demoUrl: "https://jazz-paper-scissors.vercel.app",
},
// {
// name: "Jazz paper scissors",
// slug: "jazz-paper-scissors",
// description:
// "A game that shows how to communicate with other accounts through the experimental Inbox API.",
// tech: [tech.react],
// features: [features.serverWorker, features.inbox],
// illustration: <JazzPaperScissorsIllustration />,
// demoUrl: "https://jazz-paper-scissors.vercel.app",
// },
{
name: "Clerk",
slug: "clerk",
@@ -681,15 +680,11 @@ export default function Page() {
<GappedGrid>
{category.examples.map((example) =>
example.showDemo ? (
<ExampleDemo key={example.slug} example={example} />
) : (
<ExampleCard
className="border bg-stone-50 shadow-sm p-3 rounded-lg dark:bg-stone-950"
key={example.slug}
example={example}
/>
),
<ExampleCard
className="border bg-stone-50 shadow-sm p-3 rounded-lg dark:bg-stone-950"
key={example.slug}
example={example}
/>
)}
</GappedGrid>
</div>

View File

@@ -1,5 +1,3 @@
"use client";
import { HelpLinks } from "@/components/docs/HelpLinks";
import { TableOfContents } from "@/components/docs/TableOfContents";
import { JazzMobileNav } from "@/components/nav";

View File

@@ -1,53 +0,0 @@
"use client";
import { clsx } from "clsx";
import { useState } from "react";
interface CodeExampleTab {
name: string;
content: React.ReactNode;
}
export interface CodeExampleTabsProps {
tabs: Array<CodeExampleTab>;
className?: string;
}
// TODO: handle tabs in CodeGroup component
export function CodeExampleTabs({
tabs,
className = "",
}: CodeExampleTabsProps) {
const [activeTab, setActiveTab] = useState(0);
return (
<div
className={clsx(
"bg-white h-full flex flex-col",
"dark:bg-stone-925",
className,
)}
>
<div className="flex border-b overflow-x-auto overflow-y-hidden dark:bg-stone-950">
{tabs.map((tab, index) => (
<div key={index}>
<button
key={index}
className={clsx(
activeTab === index
? "border-blue-700 bg-white text-stone-700 dark:bg-stone-925 dark:text-blue-500 dark:border-blue-500"
: "border-transparent text-stone-500 hover:text-stone-700 dark:hover:text-blue-500",
"flex items-center -mb-px transition-colors px-3 pb-2 pt-2.5 block text-xs border-b-2 ",
)}
onClick={() => setActiveTab(index)}
>
{tab.name}
</button>
</div>
))}
</div>
<div className="flex-1 overflow-y-auto">{tabs[activeTab].content}</div>
</div>
);
}

View File

@@ -1,41 +0,0 @@
import { CodeExampleTabs } from "@/components/examples/CodeExampleTabs";
import { ExampleLinks } from "@/components/examples/ExampleLinks";
import { ExampleTags } from "@/components/examples/ExampleTags";
import { Example } from "@/content/example";
import { GappedGrid } from "@garden-co/design-system/src/components/molecules/GappedGrid";
export function ExampleDemo({ example }: { example: Example }) {
const { name, demoUrl, illustration } = example;
return (
<GappedGrid
gap="none"
className="border bg-stone-50 shadow-sm rounded-lg dark:bg-stone-950 overflow-hidden"
>
<div className="p-3 col-span-full flex flex-col gap-2 justify-between items-baseline border-b sm:flex-row">
<div className="flex flex-col gap-2 items-baseline sm:flex-row">
<h2 className="font-medium text-stone-900 dark:text-white leading-none">
{name}
</h2>
<ExampleTags example={example} />
</div>
<ExampleLinks example={example} />
</div>
<div className="h-[25rem] lg:h-[30rem] border-t overflow-auto col-span-full md:col-span-2 lg:col-span-3 order-last md:order-none md:border-r md:border-t-0">
{example.codeSamples && (
<CodeExampleTabs tabs={example.codeSamples}></CodeExampleTabs>
)}
</div>
<div className="col-span-full md:p-8 md:col-span-2 lg:col-span-3 h-[25rem] lg:h-[30rem] lg:p-12">
<iframe
width="100%"
height="100%"
className="md:rounded-lg md:shadow-lg"
src={demoUrl}
title={name}
/>
</div>
</GappedGrid>
);
}

View File

@@ -1,8 +1,3 @@
import {
CodeExampleTabs as CodeExampleTabsClient,
CodeExampleTabsProps,
} from "@/components/examples/CodeExampleTabs";
import {
ContentByFramework as ContentByFrameworkClient,
ContentByFrameworkProps,
@@ -14,10 +9,6 @@ import { FileDownloadLink as FileDownloadLinkClient } from "./FileDownloadLink";
import { Framework as FrameworkClient } from "./docs/Framework";
import { IssueTrackerPreview as IssueTrackerPreviewClient } from "./docs/IssueTrackerPreview";
export function CodeExampleTabs(props: CodeExampleTabsProps) {
return <CodeExampleTabsClient {...props} />;
}
export function CodeGroup(props: { children: React.ReactNode }) {
return <CodeGroupClient {...props}></CodeGroupClient>;
}

View File

@@ -150,10 +150,12 @@ declare module "jazz-react" {
}
// ---cut---
export function AcceptInvitePage() {
const { me } = useAccount({ resolve: { root: { organizations: true } } });
const { me } = useAccount({
resolve: { root: { organizations: { $each: { $onError: null } } } },
});
const onAccept = (organizationId: ID<Organization>) => {
if (me?.root?.organizations) {
if (me) {
Organization.load(organizationId).then((organization) => {
if (organization) {
// avoid duplicates

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

@@ -6,8 +6,6 @@ import { ContentByFramework, CodeGroup } from '@/components/forMdx'
# Public sharing and invites
...more docs coming soon
## Public sharing
You can share CoValues publicly by setting the `owner` to a `Group`, and granting
@@ -94,3 +92,104 @@ useAcceptInvite({
});
```
</CodeGroup>
### Requesting Invites
To allow a non-group member to request an invitation to a group you can use the `writeOnly` role.
This means that users only have write access to a specific requests list (they can't read other requests).
However, Administrators can review and approve these requests.
Create the data models.
<CodeGroup>
```ts twoslash
import { CoMap, co, CoValue, Account, CoList } from "jazz-tools";
// ---cut-before---
class JoinRequest extends CoMap {
account = co.ref(Account);
status = co.literal("pending", "approved", "rejected");
}
class RequestsList extends CoList.Of(co.ref(JoinRequest)) {};
```
</CodeGroup>
Set up the request system with appropriate access controls.
<CodeGroup>
```ts twoslash
import { Group, co, CoList, CoMap, Account } from "jazz-tools";
import { createInviteLink } from "jazz-react";
export class JoinRequest extends CoMap {
account = co.ref(Account);
status = co.literal("pending", "approved", "rejected");
}
export class RequestsList extends CoList.Of(co.ref(JoinRequest)) {};
// ---cut-before---
function createRequestsToJoin() {
const requestsGroup = Group.create();
requestsGroup.addMember("everyone", "writeOnly");
return RequestsList.create([], requestsGroup);
}
async function sendJoinRequest(
requestsList: RequestsList,
account: Account,
) {
const request = JoinRequest.create(
{
account,
status: "pending",
},
requestsList._owner // Inherit the access controls of the requestsList
);
requestsList.push(request);
return request;
}
```
</CodeGroup>
Using the write-only access users can submit requests that only administrators can review and approve.
<CodeGroup>
```ts twoslash
import { co, CoMap, CoList, ID, Account, Group, Resolved } from "jazz-tools";
class JoinRequest extends CoMap {
account = co.ref(Account);
status = co.string; // Can be "pending", "approved", "rejected"
}
export class RequestsList extends CoList.Of(co.ref(JoinRequest)) {};
export class RequestsToJoin extends CoMap {
writeOnlyInvite = co.string;
requests = co.ref(RequestsList);
}
// ---cut-before---
async function approveJoinRequest(
joinRequest: JoinRequest,
targetGroup: Group,
) {
const account = await Account.load(joinRequest._refs.account.id);
if (account) {
targetGroup.addMember(account, "reader");
joinRequest.status = "approved";
return true;
} else {
return false;
}
}
```
</CodeGroup>

View File

@@ -465,6 +465,191 @@ project?.owner // => always null
The load operation will succeed and return the object, but the inaccessible reference will always be `null`.
#### Deep loading lists with shared items
When loading a list with shared items, you can use the `$onError` option to safely load the list skipping any inaccessible items.
This is especially useful when in your app access to these items might be revoked.
This way the inaccessible items are replaced with `null` in the returned list.
<CodeGroup>
```typescript twoslash
import { ID, CoMap, CoList, co, Group, Account } from "jazz-tools";
import { assert } from "vitest";
class Person extends CoMap {
name = co.string;
}
class Friends extends CoList.Of(co.ref(Person)) {}
const privateGroup = Group.create();
const publicGroup = Group.create();
const me = {} as unknown as Account;
// ---cut-before---
const source = Friends.create(
[
Person.create(
{
name: "Jane",
},
privateGroup, // We don't have access to Jane
),
Person.create(
{
name: "Alice",
},
publicGroup, // We have access to Alice
),
],
publicGroup,
);
const friends = await Friends.load(source.id, {
resolve: {
$each: { $onError: null }
},
loadAs: me,
});
// Thanks to $onError catching the errors, the list is loaded
// because we have access to friends
friends // => Friends
assert(friends);
// Jane is null because we lack access rights
// and we have used $onError to catch the error on the list items
friends[0] // => null
// Alice is not null because we have access
// the type is nullable because we have used $onError
friends[1] // => Person
```
</CodeGroup>
The `$onError` works as a "catch" clause option to block any error in the resolved childs.
<CodeGroup>
```typescript twoslash
import { ID, CoMap, CoList, co, Group, Account } from "jazz-tools";
import { assert } from "vitest";
class Person extends CoMap {
name = co.string;
dog = co.ref(Dog);
}
class Dog extends CoMap {
name = co.string;
}
class Friends extends CoList.Of(co.ref(Person)) {}
class User extends CoMap {
name = co.string;
friends = co.ref(Friends);
}
const privateGroup = Group.create();
const publicGroup = Group.create();
const me = {} as unknown as Account;
// ---cut-before---
const source = Friends.create(
[
Person.create(
{
name: "Jane",
dog: Dog.create(
{ name: "Rex" },
privateGroup,
), // We don't have access to Rex
},
publicGroup,
),
],
publicGroup,
);
const friends = await Friends.load(source.id, {
resolve: {
$each: { dog: true, $onError: null }
},
loadAs: me,
});
assert(friends);
// Jane is null because we don't have access to Rex
// and we have used $onError to catch the error on the list items
friends[0] // => null
```
</CodeGroup>
We can actually use `$onError` everywhere in the resolve query, so we can use it to catch the error on dog:
<CodeGroup>
```typescript twoslash
import { ID, CoMap, CoList, co, Group, Account } from "jazz-tools";
import { assert } from "vitest";
class Person extends CoMap {
name = co.string;
dog = co.ref(Dog);
}
class Dog extends CoMap {
name = co.string;
}
class Friends extends CoList.Of(co.ref(Person)) {}
class User extends CoMap {
name = co.string;
friends = co.ref(Friends);
}
const privateGroup = Group.create();
const publicGroup = Group.create();
const me = {} as unknown as Account;
const source = Friends.create(
[
Person.create(
{
name: "Jane",
dog: Dog.create(
{ name: "Rex" },
privateGroup,
), // We don't have access to Rex
},
publicGroup,
),
],
publicGroup,
);
// ---cut-before---
const friends = await Friends.load(source.id, {
resolve: {
$each: { dog: { $onError: null } }
},
loadAs: me,
});
assert(friends);
// Jane now is not-nullable at type level because
// we have moved $onError down to the dog field
//
// This also means that if we don't have access to Jane
// the entire friends list will be null
friends[0] // => Person
// Jane's dog is null because we don't have access to Rex
// and we have used $onError to catch the error
friends[0].dog // => null
```
</CodeGroup>
## Type Safety with Resolved Type
Jazz provides the `Resolved` type to help you define and enforce the structure of deeply loaded data in your application. This makes it easier to ensure that components receive the data they expect with proper TypeScript validation.

View File

@@ -6,7 +6,6 @@ export type Example = {
tech?: string[];
features?: string[];
demoUrl?: string;
showDemo?: boolean;
imageUrl?: string;
codeSamples?: { name: string; content: React.ReactNode }[];
};

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "cojson-storage-indexeddb",
"version": "0.13.30",
"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,21 @@
# cojson-storage-sqlite
## 0.13.32
### Patch Changes
- Updated dependencies [2bf9743]
- cojson-storage@0.13.32
## 0.13.31
### Patch Changes
- Updated dependencies [d63716a]
- Updated dependencies [d5edad7]
- cojson@0.13.31
- cojson-storage@0.13.31
## 0.13.30
### Patch Changes

View File

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

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,19 @@
# cojson-storage
## 0.13.32
### Patch Changes
- 2bf9743: Implement content streaming for large CoValues on storage
## 0.13.31
### Patch Changes
- Updated dependencies [d63716a]
- Updated dependencies [d5edad7]
- cojson@0.13.31
## 0.13.30
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "cojson-storage",
"version": "0.13.30",
"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,13 @@
# cojson-transport-nodejs-ws
## 0.13.31
### Patch Changes
- Updated dependencies [d63716a]
- Updated dependencies [d5edad7]
- cojson@0.13.31
## 0.13.30
### Patch Changes

View File

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

View File

@@ -1,5 +1,12 @@
# cojson
## 0.13.31
### Patch Changes
- d63716a: Fix removing members when the admin doesn't have access to the parent group readkeys
- d5edad7: Group invites: restore support for role upgrades and inviting revoked members
## 0.13.30
### Patch Changes

View File

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

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