Compare commits
117 Commits
jazz-auth-
...
jazz-auth-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1c6e31711 | ||
|
|
0b16085f3c | ||
|
|
e53db2e96a | ||
|
|
384f0e23c0 | ||
|
|
daaf1789d9 | ||
|
|
1f9e20e753 | ||
|
|
ce9ca54f5c | ||
|
|
67e0968809 | ||
|
|
96a922cceb | ||
|
|
0a98b826f1 | ||
|
|
62a3854c41 | ||
|
|
7bdb6f4279 | ||
|
|
4b99ff1fe3 | ||
|
|
3ebf8258a0 | ||
|
|
4809d14f6d | ||
|
|
5ae1f33127 | ||
|
|
ca5d84f6a9 | ||
|
|
6e6acc3404 | ||
|
|
b17b7b6481 | ||
|
|
5341646301 | ||
|
|
5416165d28 | ||
|
|
b5a9f681c5 | ||
|
|
7dffc006eb | ||
|
|
cd3cc5b0ab | ||
|
|
ceab75eb4d | ||
|
|
103d1b41f7 | ||
|
|
b87cc6973e | ||
|
|
3d541ca241 | ||
|
|
e72bfec884 | ||
|
|
19c7ad27d9 | ||
|
|
0bc7bfc5cc | ||
|
|
2c8120d46f | ||
|
|
c936c8c611 | ||
|
|
58c6013770 | ||
|
|
3eb3291a97 | ||
|
|
6b659f2df3 | ||
|
|
dcc9c9a5ec | ||
|
|
fe9a244363 | ||
|
|
9440bbc058 | ||
|
|
1c92cc2997 | ||
|
|
33ebbf0bdd | ||
|
|
d630b5bde5 | ||
|
|
1c6ae12cd9 | ||
|
|
ac5d20d159 | ||
|
|
21bcaabd5a | ||
|
|
17b4d5b668 | ||
|
|
3cd15862d5 | ||
|
|
b3d1ad7201 | ||
|
|
d87df11795 | ||
|
|
82c2a62b2a | ||
|
|
0a9112506e | ||
|
|
fbc29f2f17 | ||
|
|
3915bbbf3c | ||
|
|
0b471c4e89 | ||
|
|
09077d37ef | ||
|
|
afe06b4fa6 | ||
|
|
d89b6e488a | ||
|
|
f6361ee43b | ||
|
|
726dbfb6df | ||
|
|
267f689f10 | ||
|
|
893ad3ae23 | ||
|
|
f5590b1be8 | ||
|
|
17a01f57e8 | ||
|
|
7318d86f52 | ||
|
|
1c8403e87a | ||
|
|
dd747c068a | ||
|
|
1f0f230fe2 | ||
|
|
da655cbff5 | ||
|
|
02f6c6220e | ||
|
|
0755cd198e | ||
|
|
c4a8227b66 | ||
|
|
86f0302233 | ||
|
|
a5ece15797 | ||
|
|
9f8877202e | ||
|
|
d190097ed9 | ||
|
|
9841617c66 | ||
|
|
688a4850a4 | ||
|
|
e87fef751e | ||
|
|
8f714440f8 | ||
|
|
70cd09170e | ||
|
|
4c63334299 | ||
|
|
4aef7cdac5 | ||
|
|
76adeb0d53 | ||
|
|
40c7336c09 | ||
|
|
e0d2723615 | ||
|
|
c19a25f928 | ||
|
|
6d9b77195a | ||
|
|
9bf7946ee6 | ||
|
|
5c98ff4e4f | ||
|
|
2f24d35471 | ||
|
|
42667c81bb | ||
|
|
1b881cc89f | ||
|
|
c2899e94ca | ||
|
|
f4be67e9b6 | ||
|
|
9ed5a96ef8 | ||
|
|
4272ea9019 | ||
|
|
9509307ed1 | ||
|
|
be08921bc5 | ||
|
|
77e3c21cbd | ||
|
|
25be055a51 | ||
|
|
f5039cefc1 | ||
|
|
6540893caf | ||
|
|
bfc85c4573 | ||
|
|
e9076313ab | ||
|
|
c6afd8ae36 | ||
|
|
370f20d13d | ||
|
|
f9b3116deb | ||
|
|
352d34979f | ||
|
|
7ff736ace4 | ||
|
|
5bab466fd0 | ||
|
|
329b8c3d6a | ||
|
|
c0aeb7baf9 | ||
|
|
8a14de10d7 | ||
|
|
b585b39a86 | ||
|
|
e9b2860e74 | ||
|
|
6327d74f68 | ||
|
|
bedbabdcb4 |
2
.github/workflows/code-quality.yml
vendored
2
.github/workflows/code-quality.yml
vendored
@@ -22,7 +22,7 @@ jobs:
|
||||
- name: Setup Biome
|
||||
uses: biomejs/setup-biome@v2
|
||||
with:
|
||||
version: 1.9.4
|
||||
version: 2.1.3
|
||||
- name: Run Biome
|
||||
run: biome ci .
|
||||
|
||||
|
||||
47
biome.json
47
biome.json
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.1.3/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
@@ -7,39 +7,35 @@
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": false,
|
||||
"ignore": [
|
||||
"jazz-tools.json",
|
||||
"**/ios/**",
|
||||
"**/android/**",
|
||||
"tests/jazz-svelte/src/**",
|
||||
"examples/*svelte*/**",
|
||||
"starters/*svelte*/**",
|
||||
"examples/server-worker-inbox/src/routeTree.gen.ts",
|
||||
"homepage/homepage/**",
|
||||
"**/package.json"
|
||||
"includes": [
|
||||
"**",
|
||||
"!**/jazz-tools.json",
|
||||
"!**/ios/**",
|
||||
"!**/android/**",
|
||||
"!**/tests/jazz-svelte/src/**",
|
||||
"!**/examples/**/*svelte*/**",
|
||||
"!**/starters/**/*svelte*/**",
|
||||
"!**/examples/server-worker-inbox/src/routeTree.gen.ts",
|
||||
"!**/homepage/homepage/**",
|
||||
"!**/package.json",
|
||||
"!**/*svelte*/**"
|
||||
]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space"
|
||||
},
|
||||
"organizeImports": {
|
||||
"enabled": true
|
||||
},
|
||||
"assist": { "actions": { "source": { "organizeImports": "off" } } },
|
||||
"linter": {
|
||||
"enabled": false,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"correctness": {
|
||||
"useExhaustiveDependencies": "off",
|
||||
"useImportExtensions": {
|
||||
"level": "error",
|
||||
"options": {
|
||||
"suggestedExtensions": {
|
||||
"ts": {
|
||||
"module": "js",
|
||||
"component": "jsx"
|
||||
}
|
||||
}
|
||||
"forceJsExtensions": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,7 +43,7 @@
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"include": ["packages/**/src/**"],
|
||||
"includes": ["**/packages/**/src/**"],
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
@@ -56,7 +52,10 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"include": ["packages/cojson/src/storage/*/**", "cojson-transport-ws/**"],
|
||||
"includes": [
|
||||
"**/packages/cojson/src/storage/**/*/**",
|
||||
"**/cojson-transport-ws/**"
|
||||
],
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
@@ -65,7 +64,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"include": ["**/tests/**"],
|
||||
"includes": ["**/tests/**"],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"correctness": {
|
||||
@@ -75,7 +74,7 @@
|
||||
"noNonNullAssertion": "off"
|
||||
},
|
||||
"suspicious": {
|
||||
"noExplicitAny": "info"
|
||||
"noExplicitAny": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +76,9 @@ export function ChatScreen({ navigation }: { navigation: any }) {
|
||||
|
||||
const renderMessageItem = ({
|
||||
item,
|
||||
}: { item: Loaded<typeof Message, { text: true }> }) => {
|
||||
}: {
|
||||
item: Loaded<typeof Message, { text: true }>;
|
||||
}) => {
|
||||
const isMe = item._edits?.text?.by?.isMe;
|
||||
return (
|
||||
<View
|
||||
|
||||
@@ -3,11 +3,7 @@ import React from "react";
|
||||
import { Text } from "react-native";
|
||||
import { Chat } from "./schema";
|
||||
|
||||
export function HandleInviteScreen({
|
||||
navigation,
|
||||
}: {
|
||||
navigation: any;
|
||||
}) {
|
||||
export function HandleInviteScreen({ navigation }: { navigation: any }) {
|
||||
useAcceptInviteNative({
|
||||
invitedObjectSchema: Chat,
|
||||
onAccept: async (chatId) => {
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# passkey-svelte
|
||||
|
||||
## 0.0.112
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [67e0968]
|
||||
- Updated dependencies [2c8120d]
|
||||
- jazz-tools@0.16.6
|
||||
|
||||
## 0.0.111
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3cd1586]
|
||||
- Updated dependencies [33ebbf0]
|
||||
- jazz-tools@0.16.5
|
||||
|
||||
## 0.0.110
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "chat-svelte",
|
||||
"version": "0.0.110",
|
||||
"version": "0.0.112",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"clsx": "^2.0.0",
|
||||
"hash-slash": "workspace:*",
|
||||
"jazz-tools": "workspace:*",
|
||||
"lucide-react": "^0.274.0",
|
||||
"lucide-react": "^0.536.0",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"zod": "3.25.76"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Account, co } from "jazz-tools";
|
||||
import { Account } from "jazz-tools";
|
||||
import { createImage, useAccount, useCoState } from "jazz-tools/react";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Chat, Message } from "./schema.ts";
|
||||
import {
|
||||
BubbleBody,
|
||||
@@ -15,14 +15,17 @@ import {
|
||||
TextInput,
|
||||
} from "./ui.tsx";
|
||||
|
||||
export function ChatScreen(props: { chatID: string }) {
|
||||
const chat = useCoState(Chat, props.chatID, {
|
||||
resolve: { $each: { text: true } },
|
||||
});
|
||||
const { me } = useAccount();
|
||||
const [showNLastMessages, setShowNLastMessages] = useState(30);
|
||||
const INITIAL_MESSAGES_TO_SHOW = 30;
|
||||
|
||||
if (!chat)
|
||||
export function ChatScreen(props: { chatID: string }) {
|
||||
const chat = useCoState(Chat, props.chatID);
|
||||
const { me } = useAccount();
|
||||
const [showNLastMessages, setShowNLastMessages] = useState(
|
||||
INITIAL_MESSAGES_TO_SHOW,
|
||||
);
|
||||
const isLoading = useMessagesPreload(props.chatID);
|
||||
|
||||
if (!chat || isLoading)
|
||||
return (
|
||||
<div className="flex-1 flex justify-center items-center">Loading...</div>
|
||||
);
|
||||
@@ -41,7 +44,7 @@ export function ChatScreen(props: { chatID: string }) {
|
||||
chat.push(
|
||||
Message.create(
|
||||
{
|
||||
text: co.plainText().create(file.name, chat._owner),
|
||||
text: file.name,
|
||||
image: image,
|
||||
},
|
||||
chat._owner,
|
||||
@@ -59,9 +62,14 @@ export function ChatScreen(props: { chatID: string }) {
|
||||
<ChatBody>
|
||||
{chat.length > 0 ? (
|
||||
chat
|
||||
// We call slice before reverse to avoid mutating the original array
|
||||
.slice(-showNLastMessages)
|
||||
.reverse() // this plus flex-col-reverse on ChatBody gives us scroll-to-bottom behavior
|
||||
.map((msg) => <ChatBubble me={me} msg={msg} key={msg.id} />)
|
||||
// Reverse plus flex-col-reverse on ChatBody gives us scroll-to-bottom behavior
|
||||
.reverse()
|
||||
.map(
|
||||
(msg) =>
|
||||
msg?.text && <ChatBubble me={me} msg={msg} key={msg.id} />,
|
||||
)
|
||||
) : (
|
||||
<EmptyChatMessage />
|
||||
)}
|
||||
@@ -80,12 +88,7 @@ export function ChatScreen(props: { chatID: string }) {
|
||||
|
||||
<TextInput
|
||||
onSubmit={(text) => {
|
||||
chat.push(
|
||||
Message.create(
|
||||
{ text: co.plainText().create(text, chat._owner) },
|
||||
chat._owner,
|
||||
),
|
||||
);
|
||||
chat.push(Message.create({ text }, chat._owner));
|
||||
}}
|
||||
/>
|
||||
</InputBar>
|
||||
@@ -93,10 +96,7 @@ export function ChatScreen(props: { chatID: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ChatBubble(props: {
|
||||
me: Account;
|
||||
msg: co.loaded<typeof Message, { text: true }>;
|
||||
}) {
|
||||
function ChatBubble(props: { me: Account; msg: Message }) {
|
||||
if (!props.me.canRead(props.msg) || !props.msg.text?.toString()) {
|
||||
return (
|
||||
<BubbleContainer fromMe={false}>
|
||||
@@ -126,3 +126,35 @@ function ChatBubble(props: {
|
||||
</BubbleContainer>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Warms the local cache with the initial messages to load only the initial messages
|
||||
* and avoid flickering
|
||||
*/
|
||||
function useMessagesPreload(chatID: string) {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
preloadChatMessages(chatID).finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, [chatID]);
|
||||
|
||||
return isLoading;
|
||||
}
|
||||
|
||||
async function preloadChatMessages(chatID: string) {
|
||||
const chat = await Chat.load(chatID);
|
||||
|
||||
if (!chat?._refs) return;
|
||||
|
||||
const promises = [];
|
||||
|
||||
for (const msg of Array.from(chat._refs)
|
||||
.reverse()
|
||||
.slice(0, INITIAL_MESSAGES_TO_SHOW)) {
|
||||
promises.push(Message.load(msg.id, { resolve: { text: true } }));
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
@@ -112,7 +112,9 @@ export function InputBar(props: { children: React.ReactNode }) {
|
||||
|
||||
export function ImageInput({
|
||||
onImageChange,
|
||||
}: { onImageChange?: (event: React.ChangeEvent<HTMLInputElement>) => void }) {
|
||||
}: {
|
||||
onImageChange?: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
}) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const onUploadClick = () => {
|
||||
|
||||
@@ -10,7 +10,9 @@ import {
|
||||
|
||||
export function SignInScreen({
|
||||
setPage,
|
||||
}: { setPage: (page: "sign-in" | "sign-up") => void }) {
|
||||
}: {
|
||||
setPage: (page: "sign-in" | "sign-up") => void;
|
||||
}) {
|
||||
const { signIn, setActive, isLoaded } = useSignIn();
|
||||
|
||||
const [emailAddress, setEmailAddress] = useState("");
|
||||
|
||||
@@ -10,7 +10,9 @@ import {
|
||||
|
||||
export function SignUpScreen({
|
||||
setPage,
|
||||
}: { setPage: (page: "sign-in" | "sign-up") => void }) {
|
||||
}: {
|
||||
setPage: (page: "sign-in" | "sign-up") => void;
|
||||
}) {
|
||||
const { isLoaded, signUp, setActive } = useSignUp();
|
||||
|
||||
const [emailAddress, setEmailAddress] = React.useState("");
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { BubbleTeaOrder } from "./schema.ts";
|
||||
|
||||
export function OrderThumbnail({
|
||||
order,
|
||||
}: {
|
||||
order: BubbleTeaOrder;
|
||||
}) {
|
||||
export function OrderThumbnail({ order }: { order: BubbleTeaOrder }) {
|
||||
const { id, baseTea, addOns, instructions, deliveryDate, withMilk } = order;
|
||||
const date = deliveryDate.toLocaleDateString();
|
||||
|
||||
|
||||
@@ -12,7 +12,11 @@ function Avatar({
|
||||
name,
|
||||
color,
|
||||
active,
|
||||
}: { name: string; color: string; active: boolean }) {
|
||||
}: {
|
||||
name: string;
|
||||
color: string;
|
||||
active: boolean;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
title={name}
|
||||
|
||||
@@ -47,7 +47,9 @@ button {
|
||||
font-family: inherit;
|
||||
background-color: transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.05s ease, border-color 0.1s ease;
|
||||
transition:
|
||||
all 0.05s ease,
|
||||
border-color 0.1s ease;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
button:hover {
|
||||
@@ -93,8 +95,9 @@ button:active {
|
||||
background-color: white;
|
||||
padding: 2rem;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px
|
||||
rgba(0, 0, 0, 0.06);
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgba(0, 0, 0, 0.1),
|
||||
0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
width: 28rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"jazz-tools": "workspace:*",
|
||||
"lucide-react": "^0.274.0",
|
||||
"lucide-react": "^0.536.0",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-router": "^6.16.0",
|
||||
|
||||
@@ -15,7 +15,7 @@ import { SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { JazzReactProvider } from "jazz-tools/react";
|
||||
import { onAnonymousAccountDiscarded } from "./4_actions";
|
||||
import { KeyboardListener } from "./components/PlayerControls";
|
||||
import { useUploadExampleData } from "./lib/useUploadExampleData";
|
||||
import { usePrepareAppState } from "./lib/usePrepareAppState";
|
||||
|
||||
/**
|
||||
* Walkthrough: The top-level provider `<JazzReactProvider/>`
|
||||
@@ -31,7 +31,7 @@ import { useUploadExampleData } from "./lib/useUploadExampleData";
|
||||
function Main() {
|
||||
const mediaPlayer = useMediaPlayer();
|
||||
|
||||
useUploadExampleData();
|
||||
const isReady = usePrepareAppState(mediaPlayer);
|
||||
|
||||
const router = createHashRouter([
|
||||
{
|
||||
@@ -48,6 +48,8 @@ function Main() {
|
||||
},
|
||||
]);
|
||||
|
||||
if (!isReady) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<RouterProvider router={router} />
|
||||
|
||||
@@ -69,16 +69,16 @@ export function HomePage({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
|
||||
const isAuthenticated = useIsAuthenticated();
|
||||
|
||||
return (
|
||||
<SidebarInset className="flex flex-col h-screen text-gray-800 bg-blue-50">
|
||||
<SidebarInset className="flex flex-col h-screen text-gray-800">
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<SidePanel />
|
||||
<main className="flex-1 p-6 overflow-y-auto overflow-x-hidden relative">
|
||||
<SidebarTrigger />
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<main className="flex-1 px-2 py-4 md:px-6 overflow-y-auto overflow-x-hidden relative sm:h-[calc(100vh-80px)] bg-white h-[calc(100vh-165px)]">
|
||||
<SidebarTrigger className="md:hidden" />
|
||||
<div className="flex flex-row items-center justify-between mb-4 pl-1 md:pl-10 pr-2 md:pr-0 mt-2 md:mt-0 w-full">
|
||||
{isRootPlaylist ? (
|
||||
<h1 className="text-2xl font-bold text-blue-800">All tracks</h1>
|
||||
) : (
|
||||
<PlaylistTitleInput playlistId={playlistId} />
|
||||
<PlaylistTitleInput className="w-full" playlistId={playlistId} />
|
||||
)}
|
||||
<div className="flex items-center space-x-4">
|
||||
{isRootPlaylist && (
|
||||
@@ -95,14 +95,14 @@ export function HomePage({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ul className="flex flex-col max-w-full">
|
||||
<ul className="flex flex-col max-w-full sm:gap-1">
|
||||
{playlist?.tracks?.map(
|
||||
(track) =>
|
||||
(track, index) =>
|
||||
track && (
|
||||
<MusicTrackRow
|
||||
trackId={track.id}
|
||||
key={track.id}
|
||||
isLoading={mediaPlayer.loading === track.id}
|
||||
index={index}
|
||||
isPlaying={
|
||||
mediaPlayer.activeTrackId === track.id &&
|
||||
isActivePlaylist &&
|
||||
|
||||
@@ -116,7 +116,7 @@ export async function removeTrackFromPlaylist(
|
||||
|
||||
if (track._owner._type === "Group" && playlist._owner._type === "Group") {
|
||||
const trackGroup = track._owner;
|
||||
await trackGroup.removeMember(playlist._owner);
|
||||
trackGroup.removeMember(playlist._owner);
|
||||
|
||||
const index =
|
||||
playlist.tracks?.findIndex(
|
||||
|
||||
@@ -5,6 +5,7 @@ import { FileStream } from "jazz-tools";
|
||||
import { useAccount } from "jazz-tools/react";
|
||||
import { useRef, useState } from "react";
|
||||
import { updateActivePlaylist, updateActiveTrack } from "./4_actions";
|
||||
import { useAudioManager } from "./lib/audio/AudioManager";
|
||||
import { getNextTrack, getPrevTrack } from "./lib/getters";
|
||||
|
||||
export function useMediaPlayer() {
|
||||
@@ -12,6 +13,7 @@ export function useMediaPlayer() {
|
||||
resolve: { root: true },
|
||||
});
|
||||
|
||||
const audioManager = useAudioManager();
|
||||
const playState = usePlayState();
|
||||
const playMedia = usePlayMedia();
|
||||
|
||||
@@ -24,8 +26,10 @@ export function useMediaPlayer() {
|
||||
|
||||
async function loadTrack(track: MusicTrack) {
|
||||
lastLoadedTrackId.current = track.id;
|
||||
audioManager.unloadCurrentAudio();
|
||||
|
||||
setLoading(track.id);
|
||||
updateActiveTrack(track);
|
||||
|
||||
const file = await FileStream.loadAsBlob(track._refs.file!.id); // TODO: see if we can avoid !
|
||||
|
||||
@@ -40,8 +44,6 @@ export function useMediaPlayer() {
|
||||
return;
|
||||
}
|
||||
|
||||
updateActiveTrack(track);
|
||||
|
||||
await playMedia(file);
|
||||
|
||||
setLoading(null);
|
||||
@@ -85,6 +87,7 @@ export function useMediaPlayer() {
|
||||
playNextTrack,
|
||||
playPrevTrack,
|
||||
loading,
|
||||
loadTrack,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -9,9 +9,10 @@ import {
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Loaded } from "jazz-tools";
|
||||
import { useAccount, useCoState } from "jazz-tools/react";
|
||||
import { MoreHorizontal } from "lucide-react";
|
||||
import { MoreHorizontal, Pause, Play } from "lucide-react";
|
||||
import { Fragment, useCallback, useState } from "react";
|
||||
import { EditTrackDialog } from "./RenameTrackDialog";
|
||||
import { Waveform } from "./Waveform";
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
function isPartOfThePlaylist(
|
||||
@@ -23,24 +24,26 @@ function isPartOfThePlaylist(
|
||||
|
||||
export function MusicTrackRow({
|
||||
trackId,
|
||||
isLoading,
|
||||
isPlaying,
|
||||
onClick,
|
||||
index,
|
||||
}: {
|
||||
trackId: string;
|
||||
isLoading: boolean;
|
||||
isPlaying: boolean;
|
||||
onClick: (track: Loaded<typeof MusicTrack>) => void;
|
||||
index: number;
|
||||
}) {
|
||||
const track = useCoState(MusicTrack, trackId);
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
const { me } = useAccount(MusicaAccount, {
|
||||
resolve: { root: { playlists: { $each: { tracks: true } } } },
|
||||
});
|
||||
|
||||
const playlists = me?.root.playlists ?? [];
|
||||
const isActiveTrack = trackId === me?.root._refs.activeTrack?.id;
|
||||
|
||||
function handleTrackClick() {
|
||||
if (!track) return;
|
||||
@@ -76,33 +79,65 @@ export function MusicTrackRow({
|
||||
setIsDropdownOpen(true);
|
||||
}, []);
|
||||
|
||||
const showWaveform = isHovered || isActiveTrack;
|
||||
|
||||
return (
|
||||
<li
|
||||
className={"flex gap-1 hover:bg-slate-200 group py-2 px-2 cursor-pointer"}
|
||||
className={cn(
|
||||
"flex gap-1 hover:bg-slate-200 group py-2 cursor-pointer rounded-lg",
|
||||
isActiveTrack && "bg-slate-200",
|
||||
)}
|
||||
onMouseEnter={() => {
|
||||
setIsHovered(true);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setIsHovered(false);
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center justify-center bg-transparent w-8 h-8 ",
|
||||
!isPlaying && "group-hover:bg-slate-300 rounded-full",
|
||||
"flex items-center justify-center bg-transparent w-8 h-8 transition-opacity cursor-pointer",
|
||||
// Show play button on hover or when active, hide otherwise
|
||||
"md:opacity-0 opacity-50 group-hover:opacity-100",
|
||||
isActiveTrack && "md:opacity-100 opacity-100",
|
||||
)}
|
||||
onClick={handleTrackClick}
|
||||
aria-label={`${isPlaying ? "Pause" : "Play"} ${track?.title}`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="animate-spin">߷</div>
|
||||
) : isPlaying ? (
|
||||
"⏸️"
|
||||
{isPlaying ? (
|
||||
<Pause height={16} width={16} fill="currentColor" />
|
||||
) : (
|
||||
"▶️"
|
||||
<Play height={16} width={16} fill="currentColor" />
|
||||
)}
|
||||
</button>
|
||||
{/* Show track index when play button is hidden - hidden on mobile */}
|
||||
<div
|
||||
className={cn(
|
||||
"hidden md:flex items-center justify-center w-8 h-8 text-sm text-gray-500 font-mono transition-opacity",
|
||||
)}
|
||||
>
|
||||
{index + 1}
|
||||
</div>
|
||||
<button
|
||||
onContextMenu={handleContextMenu}
|
||||
onClick={handleTrackClick}
|
||||
className="w-full flex items-center overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
className="flex items-center overflow-hidden text-ellipsis whitespace-nowrap cursor-pointer flex-1 min-w-0"
|
||||
>
|
||||
{track?.title}
|
||||
</button>
|
||||
|
||||
{/* Waveform that appears on hover */}
|
||||
{track && showWaveform && (
|
||||
<div className="flex-1 min-w-0 px-2 items-center hidden md:flex">
|
||||
<Waveform
|
||||
track={track}
|
||||
height={20}
|
||||
className="opacity-70 w-full"
|
||||
showProgress={isActiveTrack}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div onClick={(evt) => evt.stopPropagation()}>
|
||||
<DropdownMenu open={isDropdownOpen} onOpenChange={setIsDropdownOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -117,18 +152,18 @@ export function MusicTrackRow({
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onSelect={handleEdit}>Edit</DropdownMenuItem>
|
||||
{playlists.map((playlist, index) => (
|
||||
<Fragment key={index}>
|
||||
{playlists.map((playlist, playlistIndex) => (
|
||||
<Fragment key={playlistIndex}>
|
||||
{isPartOfThePlaylist(trackId, playlist) ? (
|
||||
<DropdownMenuItem
|
||||
key={`remove-${index}`}
|
||||
key={`remove-${playlistIndex}`}
|
||||
onSelect={() => handleRemoveFromPlaylist(playlist)}
|
||||
>
|
||||
Remove from {playlist.title}
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
key={`add-${index}`}
|
||||
key={`add-${playlistIndex}`}
|
||||
onSelect={() => handleAddToPlaylist(playlist)}
|
||||
>
|
||||
Add to {playlist.title}
|
||||
|
||||
@@ -5,7 +5,8 @@ import { usePlayState } from "@/lib/audio/usePlayState";
|
||||
import { useKeyboardListener } from "@/lib/useKeyboardListener";
|
||||
import { useAccount, useCoState } from "jazz-tools/react";
|
||||
import { Pause, Play, SkipBack, SkipForward } from "lucide-react";
|
||||
import { Waveform } from "./Waveform";
|
||||
import WaveformCanvas from "./WaveformCanvas";
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
export function PlayerControls({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
|
||||
const playState = usePlayState();
|
||||
@@ -15,57 +16,61 @@ export function PlayerControls({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
|
||||
resolve: { root: { activePlaylist: true } },
|
||||
}).me?.root.activePlaylist;
|
||||
|
||||
const activeTrack = useCoState(MusicTrack, mediaPlayer.activeTrackId, {
|
||||
resolve: { waveform: true },
|
||||
});
|
||||
const activeTrack = useCoState(MusicTrack, mediaPlayer.activeTrackId);
|
||||
|
||||
if (!activeTrack) return null;
|
||||
|
||||
const activeTrackTitle = activeTrack.title;
|
||||
|
||||
return (
|
||||
<footer className="flex items-center justify-between p-2 sm:p-4 gap-2 sm:gap-4 bg-white border-t border-gray-200 absolute bottom-0 left-0 right-0 w-full z-50">
|
||||
<div className="flex justify-center items-center space-x-1 sm:space-x-2 flex-shrink-0">
|
||||
<div className="flex items-center space-x-2 sm:space-x-4">
|
||||
<button
|
||||
<footer className="flex flex-wrap sm:flex-nowrap items-center justify-between pt-4 p-2 sm:p-4 gap-4 sm:gap-4 bg-white border-t border-gray-200 absolute bottom-0 left-0 right-0 w-full z-50">
|
||||
{/* Player Controls - Always on top */}
|
||||
<div className="flex justify-center items-center space-x-1 sm:space-x-2 flex-shrink-0 w-full sm:w-auto order-1 sm:order-none">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={mediaPlayer.playPrevTrack}
|
||||
className="text-blue-600 hover:text-blue-800"
|
||||
aria-label="Previous track"
|
||||
>
|
||||
<SkipBack size={16} className="sm:w-5 sm:h-5" />
|
||||
</button>
|
||||
<button
|
||||
<SkipBack className="h-5 w-5" fill="currentColor" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={playState.toggle}
|
||||
className="w-8 h-8 sm:w-[42px] sm:h-[42px] flex items-center justify-center bg-blue-600 rounded-full text-white hover:bg-blue-700"
|
||||
className="bg-blue-600 text-white hover:bg-blue-700"
|
||||
aria-label={isPlaying ? "Pause active track" : "Play active track"}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause size={16} className="sm:w-6 sm:h-6" fill="currentColor" />
|
||||
<Pause className="h-5 w-5" fill="currentColor" />
|
||||
) : (
|
||||
<Play size={16} className="sm:w-6 sm:h-6" fill="currentColor" />
|
||||
<Play className="h-5 w-5" fill="currentColor" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={mediaPlayer.playNextTrack}
|
||||
className="text-blue-600 hover:text-blue-800"
|
||||
aria-label="Next track"
|
||||
>
|
||||
<SkipForward size={16} className="sm:w-5 sm:h-5" />
|
||||
</button>
|
||||
<SkipForward className="h-5 w-5" fill="currentColor" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="md:hidden sm:hidden lg:flex flex-1 justify-center items-center min-w-0 px-2">
|
||||
<Waveform
|
||||
track={activeTrack}
|
||||
height={30}
|
||||
className="h-5 sm:h-6 md:h-8 lg:h-10"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1 text-right min-w-fit flex-shrink-0">
|
||||
<h4 className="font-medium text-blue-800 text-sm sm:text-base truncate max-w-32 sm:max-w-80">
|
||||
|
||||
{/* Waveform - Below controls on mobile, between controls and info on desktop */}
|
||||
<WaveformCanvas
|
||||
className="order-1 sm:order-none"
|
||||
track={activeTrack}
|
||||
height={50}
|
||||
/>
|
||||
|
||||
{/* Track Info - Below waveform on mobile, on the right on desktop */}
|
||||
<div className="flex flex-col gap-1 min-w-fit sm:flex-shrink-0 text-center w-full sm:text-right items-center sm:items-end sm:w-auto order-0 sm:order-none">
|
||||
<h4 className="font-medium text-blue-800 text-base sm:text-base truncate max-w-80 sm:max-w-80">
|
||||
{activeTrackTitle}
|
||||
</h4>
|
||||
<p className="text-xs sm:text-sm text-gray-600 truncate max-w-32 sm:max-w-80">
|
||||
<p className="hidden sm:block text-xs sm:text-sm text-gray-600 truncate sm:max-w-80">
|
||||
{activePlaylist?.title || "All tracks"}
|
||||
</p>
|
||||
</div>
|
||||
@@ -75,7 +80,9 @@ export function PlayerControls({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
|
||||
|
||||
export function KeyboardListener({
|
||||
mediaPlayer,
|
||||
}: { mediaPlayer: MediaPlayer }) {
|
||||
}: {
|
||||
mediaPlayer: MediaPlayer;
|
||||
}) {
|
||||
const playState = usePlayState();
|
||||
|
||||
useMediaEndListener(mediaPlayer.playNextTrack);
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { Playlist } from "@/1_schema";
|
||||
import { updatePlaylistTitle } from "@/4_actions";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useCoState } from "jazz-tools/react";
|
||||
import { ChangeEvent, useState } from "react";
|
||||
|
||||
export function PlaylistTitleInput({
|
||||
playlistId,
|
||||
className,
|
||||
}: {
|
||||
playlistId: string | undefined;
|
||||
className?: string;
|
||||
}) {
|
||||
const playlist = useCoState(Playlist, playlistId);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
@@ -33,7 +36,10 @@ export function PlaylistTitleInput({
|
||||
<input
|
||||
value={inputValue}
|
||||
onChange={handleTitleChange}
|
||||
className="text-2xl font-bold text-blue-800 bg-transparent"
|
||||
className={cn(
|
||||
"text-2xl font-bold text-blue-800 bg-transparent",
|
||||
className,
|
||||
)}
|
||||
onFocus={handleFoucsIn}
|
||||
onBlur={handleFocusOut}
|
||||
aria-label={`Playlist title`}
|
||||
|
||||
@@ -8,6 +8,7 @@ export function Waveform(props: {
|
||||
track: Loaded<typeof MusicTrack>;
|
||||
height: number;
|
||||
className?: string;
|
||||
showProgress?: boolean;
|
||||
}) {
|
||||
const { track, height } = props;
|
||||
const waveformData = useCoState(
|
||||
@@ -29,29 +30,24 @@ export function Waveform(props: {
|
||||
}
|
||||
|
||||
const barCount = waveformData.length;
|
||||
const activeBar = Math.ceil(barCount * (currentTime.value / duration));
|
||||
|
||||
function seek(i: number) {
|
||||
currentTime.setValue((i / barCount) * duration);
|
||||
}
|
||||
const activeBar = props.showProgress
|
||||
? Math.ceil(barCount * (currentTime.value / duration))
|
||||
: -1;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex justify-center items-end w-full", props.className)}
|
||||
style={{
|
||||
height,
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
{waveformData.map((value, i) => (
|
||||
<button
|
||||
type="button"
|
||||
key={i}
|
||||
onClick={() => seek(i)}
|
||||
className={cn(
|
||||
"w-1 transition-colors rounded-none rounded-t-lg min-h-1",
|
||||
activeBar >= i ? "bg-gray-500" : "bg-gray-300",
|
||||
"hover:bg-black hover:border hover:border-solid hover:border-black",
|
||||
activeBar >= i ? "bg-gray-800" : "bg-gray-400",
|
||||
"focus-visible:outline-black focus:outline-hidden",
|
||||
)}
|
||||
style={{
|
||||
|
||||
282
examples/music-player/src/components/WaveformCanvas.tsx
Normal file
282
examples/music-player/src/components/WaveformCanvas.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
"use client";
|
||||
|
||||
import { MusicTrack, MusicTrackWaveform } from "@/1_schema";
|
||||
import { AudioManager, useAudioManager } from "@/lib/audio/AudioManager";
|
||||
import {
|
||||
getPlayerCurrentTime,
|
||||
setPlayerCurrentTime,
|
||||
subscribeToPlayerCurrentTime,
|
||||
usePlayerCurrentTime,
|
||||
} from "@/lib/audio/usePlayerCurrentTime";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Loaded } from "jazz-tools";
|
||||
import type React from "react";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
type Props = {
|
||||
track: Loaded<typeof MusicTrack>;
|
||||
height?: number;
|
||||
barColor?: string;
|
||||
progressColor?: string;
|
||||
backgroundColor?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const DEFAULT_HEIGHT = 96;
|
||||
|
||||
// Downsample PCM into N peaks (abs max in window)
|
||||
function buildPeaks(channelData: number[], samples: number): Float32Array {
|
||||
const length = channelData.length;
|
||||
if (channelData.length < samples) {
|
||||
// Create a peaks array that interpolates the channelData
|
||||
const interpolatedPeaks = new Float32Array(samples);
|
||||
for (let i = 0; i < samples; i++) {
|
||||
const index = Math.floor(i * (length / samples));
|
||||
interpolatedPeaks[i] = channelData[index];
|
||||
}
|
||||
return interpolatedPeaks;
|
||||
}
|
||||
|
||||
const blockSize = Math.floor(length / samples);
|
||||
const peaks = new Float32Array(samples);
|
||||
|
||||
for (let i = 0; i < samples; i++) {
|
||||
const start = i * blockSize;
|
||||
let end = start + blockSize;
|
||||
if (end > length) end = length;
|
||||
let max = 0;
|
||||
for (let j = start; j < end; j++) {
|
||||
const v = Math.abs(channelData[j]);
|
||||
if (v > max) max = v;
|
||||
}
|
||||
peaks[i] = max;
|
||||
}
|
||||
return peaks;
|
||||
}
|
||||
|
||||
type DrawWaveformCanvasProps = {
|
||||
canvas: HTMLCanvasElement;
|
||||
waveformData: number[] | undefined;
|
||||
duration: number;
|
||||
currentTime: number;
|
||||
barColor?: string;
|
||||
progressColor?: string;
|
||||
backgroundColor?: string;
|
||||
isAnimating: boolean;
|
||||
animationProgress: number;
|
||||
progress: number;
|
||||
};
|
||||
|
||||
function drawWaveform(props: DrawWaveformCanvasProps) {
|
||||
const {
|
||||
canvas,
|
||||
waveformData,
|
||||
isAnimating,
|
||||
animationProgress,
|
||||
barColor = "hsl(215, 16%, 47%)",
|
||||
progressColor = "hsl(142, 71%, 45%)",
|
||||
backgroundColor = "transparent",
|
||||
progress,
|
||||
} = props;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const cssWidth = canvas.clientWidth;
|
||||
const cssHeight = canvas.clientHeight;
|
||||
canvas.width = Math.floor(cssWidth * dpr);
|
||||
canvas.height = Math.floor(cssHeight * dpr);
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
// Background
|
||||
ctx.fillStyle = backgroundColor;
|
||||
ctx.fillRect(0, 0, cssWidth, cssHeight);
|
||||
|
||||
if (!waveformData || !waveformData.length) {
|
||||
// Draw placeholder line
|
||||
ctx.strokeStyle = "hsl(215, 20%, 65%)";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, cssHeight / 2);
|
||||
ctx.lineTo(cssWidth, cssHeight / 2);
|
||||
ctx.stroke();
|
||||
return;
|
||||
}
|
||||
|
||||
const midY = cssHeight / 2;
|
||||
const barWidth = 2; // px
|
||||
const gap = 1;
|
||||
const totalBars = Math.floor(cssWidth / (barWidth + gap));
|
||||
const ds = buildPeaks(waveformData, totalBars);
|
||||
|
||||
const draw = (color: string, untilBar: number, start = 0) => {
|
||||
ctx.fillStyle = color;
|
||||
for (let i = start; i < untilBar; i++) {
|
||||
const v = ds[i] || 0;
|
||||
const h = Math.max(2, v * (cssHeight - 8)); // margin
|
||||
const x = i * (barWidth + gap);
|
||||
|
||||
// Apply staggered animation
|
||||
if (isAnimating) {
|
||||
const barProgress = Math.max(0, Math.min(1, animationProgress / 0.2));
|
||||
const animatedHeight = h * barProgress;
|
||||
|
||||
ctx.globalAlpha = barProgress;
|
||||
ctx.fillRect(x, midY - animatedHeight / 2, barWidth, animatedHeight);
|
||||
} else {
|
||||
ctx.fillRect(x, midY - h / 2, barWidth, h);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Progress overlay
|
||||
const progressBars = Math.floor(
|
||||
totalBars * Math.max(0, Math.min(1, progress || 0)),
|
||||
);
|
||||
draw(progressColor, progressBars);
|
||||
// Base waveform
|
||||
draw(barColor, totalBars, progressBars);
|
||||
}
|
||||
|
||||
type WaveformCanvasProps = {
|
||||
audioManager: AudioManager;
|
||||
canvas: HTMLCanvasElement;
|
||||
waveformId: string;
|
||||
duration: number;
|
||||
barColor?: string;
|
||||
progressColor?: string;
|
||||
backgroundColor?: string;
|
||||
};
|
||||
|
||||
async function renderWaveform(props: WaveformCanvasProps) {
|
||||
const { audioManager, canvas, waveformId, duration } = props;
|
||||
|
||||
let mounted = true;
|
||||
let currentTime = getPlayerCurrentTime(audioManager);
|
||||
let waveformData: undefined | number[] = undefined;
|
||||
let isAnimating = true;
|
||||
const startTime = performance.now();
|
||||
let animationProgress = 0;
|
||||
const animationDuration = 800;
|
||||
|
||||
function draw() {
|
||||
const progress = currentTime / duration;
|
||||
|
||||
drawWaveform({
|
||||
canvas,
|
||||
waveformData,
|
||||
duration,
|
||||
currentTime,
|
||||
isAnimating,
|
||||
animationProgress,
|
||||
progress,
|
||||
});
|
||||
}
|
||||
|
||||
const animate = (currentTime: number) => {
|
||||
if (!mounted) return;
|
||||
|
||||
const elapsed = currentTime - startTime;
|
||||
animationProgress = Math.min(elapsed / animationDuration, 1);
|
||||
|
||||
if (animationProgress < 1) {
|
||||
requestAnimationFrame(animate);
|
||||
} else {
|
||||
isAnimating = false;
|
||||
}
|
||||
|
||||
draw();
|
||||
};
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
|
||||
const unsubscribeFromCurrentTime = subscribeToPlayerCurrentTime(
|
||||
audioManager,
|
||||
(time) => {
|
||||
currentTime = time;
|
||||
draw();
|
||||
},
|
||||
);
|
||||
|
||||
const unsubscribeFromWaveform = MusicTrackWaveform.subscribe(
|
||||
waveformId,
|
||||
{},
|
||||
(newResult) => {
|
||||
waveformData = newResult.data;
|
||||
draw();
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
unsubscribeFromCurrentTime();
|
||||
unsubscribeFromWaveform();
|
||||
};
|
||||
}
|
||||
|
||||
export default function WaveformCanvas({
|
||||
track,
|
||||
height = DEFAULT_HEIGHT,
|
||||
barColor, // muted-foreground-ish
|
||||
progressColor, // green
|
||||
backgroundColor,
|
||||
className,
|
||||
}: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const audioManager = useAudioManager();
|
||||
|
||||
const duration = track.duration;
|
||||
const waveformId = track._refs.waveform?.id;
|
||||
|
||||
// Animation effect
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
if (!waveformId) return;
|
||||
|
||||
renderWaveform({
|
||||
audioManager,
|
||||
canvas,
|
||||
waveformId,
|
||||
duration,
|
||||
barColor,
|
||||
progressColor,
|
||||
backgroundColor,
|
||||
});
|
||||
}, [audioManager, canvasRef, waveformId, duration]);
|
||||
|
||||
const onPointer = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const ratio = (e.clientX - rect.left) / rect.width;
|
||||
const time = Math.max(0, Math.min(1, ratio)) * duration;
|
||||
setPlayerCurrentTime(audioManager, time);
|
||||
};
|
||||
|
||||
const currentTime = usePlayerCurrentTime();
|
||||
const progress = currentTime.value / duration;
|
||||
|
||||
return (
|
||||
<div className={cn("w-full", className)}>
|
||||
<div
|
||||
className="w-full rounded-md bg-background"
|
||||
style={{ height }}
|
||||
role="slider"
|
||||
aria-label="Waveform scrubber"
|
||||
aria-valuenow={Math.round((progress || 0) * 100)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="w-full h-full rounded-md cursor-pointer"
|
||||
onPointerDown={onPointer}
|
||||
onPointerMove={(e) => {
|
||||
if (e.buttons === 1) onPointer(e);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -532,7 +532,8 @@ const sidebarMenuButtonVariants = cva(
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
default:
|
||||
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground cursor-pointer",
|
||||
outline:
|
||||
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
|
||||
},
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
|
||||
html {
|
||||
overflow: hidden;
|
||||
max-width: 1200px;
|
||||
position: relative;
|
||||
background-color: hsl(0 0% 99%);
|
||||
}
|
||||
|
||||
:root {
|
||||
|
||||
@@ -15,6 +15,7 @@ export class AudioManager {
|
||||
if (this.audioObjectURL) {
|
||||
URL.revokeObjectURL(this.audioObjectURL);
|
||||
this.audioObjectURL = null;
|
||||
this.mediaElement.src = "";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,22 +1,14 @@
|
||||
import { useLayoutEffect, useState } from "react";
|
||||
import { useAudioManager } from "./AudioManager";
|
||||
import { AudioManager, useAudioManager } from "./AudioManager";
|
||||
|
||||
export function usePlayerCurrentTime() {
|
||||
const audioManager = useAudioManager();
|
||||
const [value, setValue] = useState<number>(0);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
setValue(audioManager.mediaElement.currentTime);
|
||||
setValue(getPlayerCurrentTime(audioManager));
|
||||
|
||||
const onTimeUpdate = () => {
|
||||
setValue(audioManager.mediaElement.currentTime);
|
||||
};
|
||||
|
||||
audioManager.mediaElement.addEventListener("timeupdate", onTimeUpdate);
|
||||
|
||||
return () => {
|
||||
audioManager.mediaElement.removeEventListener("timeupdate", onTimeUpdate);
|
||||
};
|
||||
return subscribeToPlayerCurrentTime(audioManager, setValue);
|
||||
}, [audioManager]);
|
||||
|
||||
function setCurrentTime(time: number) {
|
||||
@@ -31,3 +23,26 @@ export function usePlayerCurrentTime() {
|
||||
setValue: setCurrentTime,
|
||||
};
|
||||
}
|
||||
|
||||
export function setPlayerCurrentTime(audioManager: AudioManager, time: number) {
|
||||
audioManager.mediaElement.currentTime = time;
|
||||
}
|
||||
|
||||
export function getPlayerCurrentTime(audioManager: AudioManager): number {
|
||||
return audioManager.mediaElement.currentTime;
|
||||
}
|
||||
|
||||
export function subscribeToPlayerCurrentTime(
|
||||
audioManager: AudioManager,
|
||||
callback: (time: number) => void,
|
||||
) {
|
||||
const onTimeUpdate = () => {
|
||||
callback(audioManager.mediaElement.currentTime);
|
||||
};
|
||||
|
||||
audioManager.mediaElement.addEventListener("timeupdate", onTimeUpdate);
|
||||
|
||||
return () => {
|
||||
audioManager.mediaElement.removeEventListener("timeupdate", onTimeUpdate);
|
||||
};
|
||||
}
|
||||
|
||||
54
examples/music-player/src/lib/usePrepareAppState.ts
Normal file
54
examples/music-player/src/lib/usePrepareAppState.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { MusicaAccount, MusicaAccountRoot } from "@/1_schema";
|
||||
import { MediaPlayer } from "@/5_useMediaPlayer";
|
||||
import { co } from "jazz-tools";
|
||||
import { useAccount } from "jazz-tools/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { uploadMusicTracks } from "../4_actions";
|
||||
|
||||
export function usePrepareAppState(mediaPlayer: MediaPlayer) {
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
|
||||
const { agent } = useAccount();
|
||||
|
||||
useEffect(() => {
|
||||
loadInitialData(mediaPlayer).then(() => {
|
||||
setIsReady(true);
|
||||
});
|
||||
}, [agent]);
|
||||
|
||||
return isReady;
|
||||
}
|
||||
|
||||
async function loadInitialData(mediaPlayer: MediaPlayer) {
|
||||
const me = await MusicaAccount.getMe().ensureLoaded({
|
||||
resolve: {
|
||||
root: {
|
||||
rootPlaylist: { tracks: { $each: true } },
|
||||
activeTrack: true,
|
||||
activePlaylist: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
uploadOnboardingData(me.root);
|
||||
|
||||
// Load the active track in the AudioManager
|
||||
if (me.root.activeTrack) {
|
||||
mediaPlayer.loadTrack(me.root.activeTrack);
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadOnboardingData(root: co.loaded<typeof MusicaAccountRoot>) {
|
||||
if (root.exampleDataLoaded) return;
|
||||
|
||||
root.exampleDataLoaded = true;
|
||||
|
||||
try {
|
||||
const trackFile = await (await fetch("/example.mp3")).blob();
|
||||
|
||||
await uploadMusicTracks([new File([trackFile], "Example song")], true);
|
||||
} catch (error) {
|
||||
root.exampleDataLoaded = false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { MusicaAccount } from "@/1_schema";
|
||||
import { useAccount } from "jazz-tools/react";
|
||||
import { useEffect } from "react";
|
||||
import { uploadMusicTracks } from "../4_actions";
|
||||
|
||||
export function useUploadExampleData() {
|
||||
const { agent } = useAccount();
|
||||
|
||||
useEffect(() => {
|
||||
uploadOnboardingData();
|
||||
}, [agent]);
|
||||
}
|
||||
|
||||
async function uploadOnboardingData() {
|
||||
const me = await MusicaAccount.getMe().ensureLoaded({
|
||||
resolve: { root: true },
|
||||
});
|
||||
|
||||
if (me.root.exampleDataLoaded) return;
|
||||
|
||||
me.root.exampleDataLoaded = true;
|
||||
|
||||
try {
|
||||
const trackFile = await (await fetch("/example.mp3")).blob();
|
||||
|
||||
await uploadMusicTracks([new File([trackFile], "Example song")], true);
|
||||
} catch (error) {
|
||||
me.root.exampleDataLoaded = false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"jazz-tools": "workspace:*",
|
||||
"lucide-react": "^0.274.0",
|
||||
"lucide-react": "^0.536.0",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-router": "^6.16.0",
|
||||
|
||||
@@ -6,7 +6,9 @@ import { Organization } from "../schema.ts";
|
||||
|
||||
export function InviteLink({
|
||||
organization,
|
||||
}: { organization: Loaded<typeof Organization> }) {
|
||||
}: {
|
||||
organization: Loaded<typeof Organization>;
|
||||
}) {
|
||||
let [copyCount, setCopyCount] = useState(0);
|
||||
let copied = copyCount > 0;
|
||||
|
||||
|
||||
@@ -4,7 +4,9 @@ import { Organization } from "../schema.ts";
|
||||
|
||||
export function OrganizationMembers({
|
||||
organization,
|
||||
}: { organization: Loaded<typeof Organization> }) {
|
||||
}: {
|
||||
organization: Loaded<typeof Organization>;
|
||||
}) {
|
||||
const group = organization._owner.castAs(Group);
|
||||
|
||||
return (
|
||||
@@ -25,7 +27,11 @@ function MemberItem({
|
||||
account,
|
||||
role,
|
||||
group,
|
||||
}: { account: Account; role: string; group: Group }) {
|
||||
}: {
|
||||
account: Account;
|
||||
role: string;
|
||||
group: Group;
|
||||
}) {
|
||||
const { me } = useAccount();
|
||||
|
||||
const canRemoveMember = group.myRole() === "admin" && account.id !== me?.id;
|
||||
|
||||
@@ -79,8 +79,9 @@ main {
|
||||
background-color: white;
|
||||
padding: 2rem;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px
|
||||
rgba(0, 0, 0, 0.06);
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgba(0, 0, 0, 0.1),
|
||||
0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
width: 28rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,9 @@ export function ReactionsScreen(props: { id: string }) {
|
||||
|
||||
const ReactionButtons = ({
|
||||
reactions,
|
||||
}: { reactions: Loaded<typeof Reactions> }) => (
|
||||
}: {
|
||||
reactions: Loaded<typeof Reactions>;
|
||||
}) => (
|
||||
<div className="reaction-buttons">
|
||||
{ReactionTypes.map((reactionType) => (
|
||||
<button
|
||||
@@ -56,7 +58,9 @@ const ReactionButtons = ({
|
||||
|
||||
const ReactionOverview = ({
|
||||
reactions,
|
||||
}: { reactions: Loaded<typeof Reactions> }) => (
|
||||
}: {
|
||||
reactions: Loaded<typeof Reactions>;
|
||||
}) => (
|
||||
<>
|
||||
{Object.values(reactions.perAccount).map((reaction) => (
|
||||
<div key={reaction.by?.id} className="reaction-row">
|
||||
|
||||
@@ -17,7 +17,7 @@ createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<JazzReactProvider
|
||||
sync={{
|
||||
peer: `wss://cloud.jazz.tools/?key=${apiKey}`,
|
||||
peer: `ws://localhost:4200/?key=${apiKey}`,
|
||||
}}
|
||||
AccountSchema={JazzAccount}
|
||||
>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"jazz-tools": "workspace:*",
|
||||
"lucide-react": "^0.274.0",
|
||||
"lucide-react": "^0.536.0",
|
||||
"qrcode": "^1.5.3",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
|
||||
@@ -2,11 +2,7 @@
|
||||
|
||||
import { JazzReactProvider } from "jazz-tools/react";
|
||||
|
||||
export default function CovaluesLayout({
|
||||
children,
|
||||
}: {
|
||||
children: any;
|
||||
}) {
|
||||
export default function CovaluesLayout({ children }: { children: any }) {
|
||||
return (
|
||||
<JazzReactProvider sync={{ when: "never" }}>{children}</JazzReactProvider>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,10 @@ import { clsx } from "clsx";
|
||||
export function Card({
|
||||
children,
|
||||
className,
|
||||
}: { children: React.ReactNode; className?: string }) {
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={clsx(className, "border rounded-xl shadow-sm")}>
|
||||
{children}
|
||||
|
||||
@@ -4,7 +4,11 @@ export function Label({
|
||||
label,
|
||||
htmlFor,
|
||||
className,
|
||||
}: { label: string; htmlFor: string; className?: string }) {
|
||||
}: {
|
||||
label: string;
|
||||
htmlFor: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<LabelRadix.Root className={className} htmlFor={htmlFor}>
|
||||
{label}
|
||||
|
||||
@@ -4,7 +4,11 @@ export function IconCoFeed({
|
||||
className,
|
||||
size,
|
||||
strokeWidth,
|
||||
}: { className?: string; size?: number; strokeWidth: number }) {
|
||||
}: {
|
||||
className?: string;
|
||||
size?: number;
|
||||
strokeWidth: number;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
||||
@@ -4,7 +4,12 @@ export function IconCoRecord({
|
||||
className,
|
||||
size,
|
||||
strokeWidth,
|
||||
}: { className?: string; size?: number; color?: string; strokeWidth: number }) {
|
||||
}: {
|
||||
className?: string;
|
||||
size?: number;
|
||||
color?: string;
|
||||
strokeWidth: number;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
||||
@@ -4,7 +4,11 @@ export function JazzLogo({
|
||||
className,
|
||||
width = undefined,
|
||||
height = undefined,
|
||||
}: { className?: string; width?: number; height?: number }) {
|
||||
}: {
|
||||
className?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 386 146"
|
||||
|
||||
@@ -93,7 +93,11 @@ const TableDataContainer = ({
|
||||
children,
|
||||
className,
|
||||
isCopyable,
|
||||
}: { children: React.ReactNode; className?: string; isCopyable?: boolean }) => {
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
isCopyable?: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={clsx("flex gap-2", className, isCopyable && "cursor-pointer")}
|
||||
|
||||
@@ -247,7 +247,13 @@ data-lsp {
|
||||
}
|
||||
.tag-container .twoslash-annotation {
|
||||
position: absolute;
|
||||
font-family: "JetBrains Mono", Menlo, Monaco, Consolas, Courier New, monospace;
|
||||
font-family:
|
||||
"JetBrains Mono",
|
||||
Menlo,
|
||||
Monaco,
|
||||
Consolas,
|
||||
Courier New,
|
||||
monospace;
|
||||
right: -10px;
|
||||
/** Default annotation text to 200px */
|
||||
width: 200px;
|
||||
|
||||
@@ -6,7 +6,9 @@ import Router from "next/router";
|
||||
|
||||
export default async function TeamMemberPage({
|
||||
params,
|
||||
}: { params: Promise<{ member: string }> }) {
|
||||
}: {
|
||||
params: Promise<{ member: string }>;
|
||||
}) {
|
||||
const { member } = await params;
|
||||
|
||||
const memberInfo = team.find((m) => m.slug === member);
|
||||
|
||||
@@ -28,18 +28,19 @@ See the [schema docs](/docs/schemas/covalues) for more information.
|
||||
<CodeGroup>
|
||||
```ts
|
||||
// src/lib/schema.ts
|
||||
import { Account, Profile, coField } from "jazz-tools";
|
||||
import { co, z } from "jazz-tools"
|
||||
|
||||
export class MyProfile extends Profile {
|
||||
name = coField.string;
|
||||
counter = coField.number; // This will be publically visible
|
||||
}
|
||||
export const MyProfile = co.profile({
|
||||
name: z.string(),
|
||||
counter: z.number()
|
||||
});
|
||||
|
||||
export class MyAccount extends Account {
|
||||
profile = coField.ref(MyProfile);
|
||||
export const root = co.map({});
|
||||
|
||||
// ...
|
||||
}
|
||||
export const UserAccount = co.account({
|
||||
root,
|
||||
profile: MyProfile
|
||||
});
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
@@ -48,17 +49,17 @@ export class MyAccount extends Account {
|
||||
<CodeGroup>
|
||||
```svelte
|
||||
<!-- src/routes/+layout.svelte -->
|
||||
|
||||
<script lang="ts">
|
||||
import { JazzSvelteProvider } from 'jazz-tools/svelte';
|
||||
import { JazzSvelteProvider } from "jazz-tools/svelte";
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
// Example configuration for authentication and peer connection
|
||||
let sync = { peer: "wss://cloud.jazz.tools/?key=you@example.com" };
|
||||
let AccountSchema = MyAccount;
|
||||
</script>
|
||||
|
||||
<JazzSvelteProvider {sync} {AccountSchema}>
|
||||
<App />
|
||||
<JazzSvelteProvider {sync} AccountSchema={MyAccount}>
|
||||
{@render children?.()}
|
||||
</JazzSvelteProvider>
|
||||
```
|
||||
</CodeGroup>
|
||||
@@ -69,12 +70,11 @@ export class MyAccount extends Account {
|
||||
```svelte
|
||||
<!-- src/routes/+page.svelte -->
|
||||
<script lang="ts">
|
||||
import { useCoState, useAccount } from 'jazz-tools/svelte';
|
||||
import { MyProfile } from './schema';
|
||||
import { CoState, AccountCoState } from "jazz-tools/svelte";
|
||||
import { MyProfile, UserAccount } from "$lib/schema";
|
||||
|
||||
const { me } = useAccount();
|
||||
|
||||
const profile = $derived(useCoState(MyProfile, me._refs.profile.id));
|
||||
const me = new AccountCoState(UserAccount);
|
||||
const profile = new CoState(MyProfile, me.current?._refs.profile?.id);
|
||||
|
||||
function increment() {
|
||||
if (!profile.current) return;
|
||||
@@ -82,7 +82,7 @@ export class MyAccount extends Account {
|
||||
}
|
||||
</script>
|
||||
|
||||
<button on:click={increment}>
|
||||
<button onclick={increment}>
|
||||
Count: {profile.current?.counter}
|
||||
</button>
|
||||
```
|
||||
|
||||
@@ -1,5 +1,27 @@
|
||||
# cojson-storage-indexeddb
|
||||
|
||||
## 0.16.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 103d1b4: Fix Unknown transaction error on IndexedDB showing up sometimes when using a readwrite transaction to read values
|
||||
- 67e0968: Fix content streaming chunking, now chunks should be splitted always respecting the MAX_RECOMMENDED_TX_SIZE
|
||||
- 4b99ff1: Add a multi-storage scheduler to avoid conflicting store operations when having multiple storage instances open on the same database
|
||||
- Updated dependencies [67e0968]
|
||||
- Updated dependencies [ce9ca54]
|
||||
- Updated dependencies [4b99ff1]
|
||||
- Updated dependencies [ac5d20d]
|
||||
- Updated dependencies [9bf7946]
|
||||
- cojson@0.16.6
|
||||
|
||||
## 0.16.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3cd1586]
|
||||
- Updated dependencies [267f689]
|
||||
- cojson@0.16.5
|
||||
|
||||
## 0.16.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cojson-storage-indexeddb",
|
||||
"version": "0.16.4",
|
||||
"version": "0.16.6",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
"types": "dist/index.d.ts",
|
||||
|
||||
@@ -118,3 +118,43 @@ export class CoJsonIDBTransaction {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function queryIndexedDbStore<T>(
|
||||
db: IDBDatabase,
|
||||
storeName: StoreName,
|
||||
callback: (store: IDBObjectStore) => IDBRequest<T>,
|
||||
) {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const tx = db.transaction(storeName, "readonly");
|
||||
const request = callback(tx.objectStore(storeName));
|
||||
|
||||
request.onerror = () => {
|
||||
reject(request.error);
|
||||
};
|
||||
|
||||
request.onsuccess = () => {
|
||||
resolve(request.result as T);
|
||||
tx.commit();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function putIndexedDbStore<T, O extends IDBValidKey>(
|
||||
db: IDBDatabase,
|
||||
storeName: StoreName,
|
||||
value: T,
|
||||
) {
|
||||
return new Promise<O>((resolve, reject) => {
|
||||
const tx = db.transaction(storeName, "readwrite");
|
||||
const request = tx.objectStore(storeName).put(value);
|
||||
|
||||
request.onerror = () => {
|
||||
reject(request.error);
|
||||
};
|
||||
|
||||
request.onsuccess = () => {
|
||||
resolve(request.result as O);
|
||||
tx.commit();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,7 +8,12 @@ import type {
|
||||
StoredSessionRow,
|
||||
TransactionRow,
|
||||
} from "cojson";
|
||||
import { CoJsonIDBTransaction } from "./CoJsonIDBTransaction.js";
|
||||
import {
|
||||
CoJsonIDBTransaction,
|
||||
putIndexedDbStore,
|
||||
queryIndexedDbStore,
|
||||
} from "./CoJsonIDBTransaction.js";
|
||||
import { StoreName } from "./CoJsonIDBTransaction.js";
|
||||
|
||||
export class IDBClient implements DBClientInterfaceAsync {
|
||||
private db;
|
||||
@@ -39,17 +44,18 @@ export class IDBClient implements DBClientInterfaceAsync {
|
||||
}
|
||||
|
||||
async getCoValue(coValueId: RawCoID): Promise<StoredCoValueRow | undefined> {
|
||||
return this.makeRequest<StoredCoValueRow | undefined>((tx) =>
|
||||
tx.getObjectStore("coValues").index("coValuesById").get(coValueId),
|
||||
return queryIndexedDbStore(this.db, "coValues", (store) =>
|
||||
store.index("coValuesById").get(coValueId),
|
||||
);
|
||||
}
|
||||
|
||||
async getCoValueRowID(coValueId: RawCoID): Promise<number | undefined> {
|
||||
return this.getCoValue(coValueId).then((row) => row?.rowID);
|
||||
}
|
||||
|
||||
async getCoValueSessions(coValueRowId: number): Promise<StoredSessionRow[]> {
|
||||
return this.makeRequest<StoredSessionRow[]>((tx) =>
|
||||
tx
|
||||
.getObjectStore("sessions")
|
||||
.index("sessionsByCoValue")
|
||||
.getAll(coValueRowId),
|
||||
return queryIndexedDbStore(this.db, "sessions", (store) =>
|
||||
store.index("sessionsByCoValue").getAll(coValueRowId),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -57,11 +63,8 @@ export class IDBClient implements DBClientInterfaceAsync {
|
||||
coValueRowId: number,
|
||||
sessionID: SessionID,
|
||||
): Promise<StoredSessionRow | undefined> {
|
||||
return this.makeRequest<StoredSessionRow>((tx) =>
|
||||
tx
|
||||
.getObjectStore("sessions")
|
||||
.index("uniqueSessions")
|
||||
.get([coValueRowId, sessionID]),
|
||||
return queryIndexedDbStore(this.db, "sessions", (store) =>
|
||||
store.index("uniqueSessions").get([coValueRowId, sessionID]),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -70,12 +73,10 @@ export class IDBClient implements DBClientInterfaceAsync {
|
||||
fromIdx: number,
|
||||
toIdx: number,
|
||||
): Promise<TransactionRow[]> {
|
||||
return this.makeRequest<TransactionRow[]>((tx) =>
|
||||
tx
|
||||
.getObjectStore("transactions")
|
||||
.getAll(
|
||||
IDBKeyRange.bound([sessionRowId, fromIdx], [sessionRowId, toIdx]),
|
||||
),
|
||||
return queryIndexedDbStore(this.db, "transactions", (store) =>
|
||||
store.getAll(
|
||||
IDBKeyRange.bound([sessionRowId, fromIdx], [sessionRowId, toIdx]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -83,32 +84,28 @@ export class IDBClient implements DBClientInterfaceAsync {
|
||||
sessionRowId: number,
|
||||
firstNewTxIdx: number,
|
||||
): Promise<SignatureAfterRow[]> {
|
||||
return this.makeRequest<SignatureAfterRow[]>((tx) =>
|
||||
tx
|
||||
.getObjectStore("signatureAfter")
|
||||
.getAll(
|
||||
IDBKeyRange.bound(
|
||||
[sessionRowId, firstNewTxIdx],
|
||||
[sessionRowId, Number.POSITIVE_INFINITY],
|
||||
),
|
||||
return queryIndexedDbStore(this.db, "signatureAfter", (store) =>
|
||||
store.getAll(
|
||||
IDBKeyRange.bound(
|
||||
[sessionRowId, firstNewTxIdx],
|
||||
[sessionRowId, Number.POSITIVE_INFINITY],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async addCoValue(
|
||||
msg: CojsonInternalTypes.NewContentMessage,
|
||||
): Promise<number> {
|
||||
if (!msg.header) {
|
||||
throw new Error(`Header is required, coId: ${msg.id}`);
|
||||
async upsertCoValue(
|
||||
id: RawCoID,
|
||||
header?: CojsonInternalTypes.CoValueHeader,
|
||||
): Promise<number | undefined> {
|
||||
if (!header) {
|
||||
return this.getCoValueRowID(id);
|
||||
}
|
||||
|
||||
return (await this.makeRequest<IDBValidKey>((tx) =>
|
||||
tx.getObjectStore("coValues").put({
|
||||
id: msg.id,
|
||||
// biome-ignore lint/style/noNonNullAssertion: TODO(JAZZ-561): Review
|
||||
header: msg.header!,
|
||||
} satisfies CoValueRow),
|
||||
)) as number;
|
||||
return putIndexedDbStore<CoValueRow, number>(this.db, "coValues", {
|
||||
id,
|
||||
header,
|
||||
}).catch(() => this.getCoValueRowID(id));
|
||||
}
|
||||
|
||||
async addSessionUpdate({
|
||||
|
||||
@@ -528,9 +528,8 @@ test("large coValue upload streaming", async () => {
|
||||
[
|
||||
"client -> LOAD Map sessions: empty",
|
||||
"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",
|
||||
"storage -> CONTENT Map header: true new: After: 0 New: 193",
|
||||
"storage -> CONTENT Map header: true new: After: 193 New: 7",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
# cojson-storage-sqlite
|
||||
|
||||
## 0.16.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 67e0968: Fix content streaming chunking, now chunks should be splitted always respecting the MAX_RECOMMENDED_TX_SIZE
|
||||
- Updated dependencies [67e0968]
|
||||
- Updated dependencies [ce9ca54]
|
||||
- Updated dependencies [4b99ff1]
|
||||
- Updated dependencies [ac5d20d]
|
||||
- Updated dependencies [9bf7946]
|
||||
- cojson@0.16.6
|
||||
|
||||
## 0.16.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3cd1586]
|
||||
- Updated dependencies [267f689]
|
||||
- cojson@0.16.5
|
||||
|
||||
## 0.16.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "cojson-storage-sqlite",
|
||||
"type": "module",
|
||||
"version": "0.16.4",
|
||||
"version": "0.16.6",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -658,9 +658,8 @@ test("large coValue upload streaming", async () => {
|
||||
[
|
||||
"client -> LOAD Map sessions: empty",
|
||||
"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",
|
||||
"storage -> CONTENT Map header: true new: After: 0 New: 193",
|
||||
"storage -> CONTENT Map header: true new: After: 193 New: 7",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
# cojson-transport-nodejs-ws
|
||||
|
||||
## 0.16.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ac5d20d: Add ingress and egress metering
|
||||
- Updated dependencies [67e0968]
|
||||
- Updated dependencies [ce9ca54]
|
||||
- Updated dependencies [4b99ff1]
|
||||
- Updated dependencies [ac5d20d]
|
||||
- Updated dependencies [9bf7946]
|
||||
- cojson@0.16.6
|
||||
|
||||
## 0.16.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3cd1586]
|
||||
- Updated dependencies [267f689]
|
||||
- cojson@0.16.5
|
||||
|
||||
## 0.16.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"name": "cojson-transport-ws",
|
||||
"type": "module",
|
||||
"version": "0.16.4",
|
||||
"version": "0.16.6",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"cojson": "workspace:*"
|
||||
},
|
||||
"scripts": {
|
||||
@@ -17,8 +18,9 @@
|
||||
"build": "rm -rf ./dist && tsc --sourceMap --outDir dist"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "catalog:",
|
||||
"@opentelemetry/sdk-metrics": "^2.0.0",
|
||||
"@types/ws": "8.5.10",
|
||||
"typescript": "catalog:",
|
||||
"ws": "^8.14.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ValueType, metrics } from "@opentelemetry/api";
|
||||
import type { DisconnectedError, SyncMessage } from "cojson";
|
||||
import type { Peer } from "cojson";
|
||||
import {
|
||||
@@ -15,7 +16,7 @@ import {
|
||||
waitForWebSocketOpen,
|
||||
} from "./utils.js";
|
||||
|
||||
const { CO_VALUE_PRIORITY } = cojsonInternals;
|
||||
const { CO_VALUE_PRIORITY, getContentMessageSize } = cojsonInternals;
|
||||
|
||||
export const MAX_OUTGOING_MESSAGES_CHUNK_BYTES = 25_000;
|
||||
|
||||
@@ -26,11 +27,22 @@ export class BatchedOutgoingMessages
|
||||
private queue: PriorityBasedMessageQueue;
|
||||
private processing = false;
|
||||
private closed = false;
|
||||
private counter = metrics
|
||||
.getMeter("cojson-transport-ws")
|
||||
.createCounter("jazz.usage.egress", {
|
||||
description: "Total egress bytes",
|
||||
unit: "bytes",
|
||||
valueType: ValueType.INT,
|
||||
});
|
||||
|
||||
constructor(
|
||||
private websocket: AnyWebSocket,
|
||||
private batching: boolean,
|
||||
peerRole: Peer["role"],
|
||||
/**
|
||||
* Additional key-value pair of attributes to add to the egress metric.
|
||||
*/
|
||||
private meta?: Record<string, string | number>,
|
||||
) {
|
||||
this.queue = new PriorityBasedMessageQueue(
|
||||
CO_VALUE_PRIORITY.HIGH,
|
||||
@@ -39,6 +51,9 @@ export class BatchedOutgoingMessages
|
||||
peerRole: peerRole,
|
||||
},
|
||||
);
|
||||
|
||||
// Initialize the counter by adding 0
|
||||
this.counter.add(0, this.meta);
|
||||
}
|
||||
|
||||
push(msg: SyncMessage | DisconnectedError) {
|
||||
@@ -93,7 +108,11 @@ export class BatchedOutgoingMessages
|
||||
this.processing = false;
|
||||
}
|
||||
|
||||
processMessage(msg: SyncMessage) {
|
||||
private processMessage(msg: SyncMessage) {
|
||||
if (msg.action === "content") {
|
||||
this.counter.add(getContentMessageSize(msg), this.meta);
|
||||
}
|
||||
|
||||
if (!this.batching) {
|
||||
this.websocket.send(JSON.stringify(msg));
|
||||
return;
|
||||
@@ -116,7 +135,7 @@ export class BatchedOutgoingMessages
|
||||
}
|
||||
}
|
||||
|
||||
sendMessagesInBulk() {
|
||||
private sendMessagesInBulk() {
|
||||
if (this.backlog.length > 0 && isWebSocketOpen(this.websocket)) {
|
||||
this.websocket.send(this.backlog);
|
||||
this.backlog = "";
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { ValueType, metrics } from "@opentelemetry/api";
|
||||
import { type Peer, type SyncMessage, cojsonInternals, logger } from "cojson";
|
||||
import { BatchedOutgoingMessages } from "./BatchedOutgoingMessages.js";
|
||||
import { deserializeMessages } from "./serialization.js";
|
||||
import type { AnyWebSocket } from "./types.js";
|
||||
|
||||
const { ConnectedPeerChannel } = cojsonInternals;
|
||||
const { ConnectedPeerChannel, getContentMessageSize } = cojsonInternals;
|
||||
|
||||
export type CreateWebSocketPeerOpts = {
|
||||
id: string;
|
||||
@@ -15,6 +16,10 @@ export type CreateWebSocketPeerOpts = {
|
||||
pingTimeout?: number;
|
||||
onClose?: () => void;
|
||||
onSuccess?: () => void;
|
||||
/**
|
||||
* Additional key-value attributes to add to the ingress metric.
|
||||
*/
|
||||
meta?: Record<string, string | number>;
|
||||
};
|
||||
|
||||
function createPingTimeoutListener(
|
||||
@@ -64,7 +69,19 @@ export function createWebSocketPeer({
|
||||
pingTimeout = 10_000,
|
||||
onSuccess,
|
||||
onClose,
|
||||
meta,
|
||||
}: CreateWebSocketPeerOpts): Peer {
|
||||
const totalIngressBytesCounter = metrics
|
||||
.getMeter("cojson-transport-ws")
|
||||
.createCounter("jazz.usage.ingress", {
|
||||
description: "Total ingress bytes from peer",
|
||||
unit: "bytes",
|
||||
valueType: ValueType.INT,
|
||||
});
|
||||
|
||||
// Initialize the counter by adding 0
|
||||
totalIngressBytesCounter.add(0, meta);
|
||||
|
||||
const incoming = new ConnectedPeerChannel();
|
||||
const emitClosedEvent = createClosedEventEmitter(onClose);
|
||||
|
||||
@@ -101,6 +118,7 @@ export function createWebSocketPeer({
|
||||
websocket,
|
||||
batchingByDefault,
|
||||
role,
|
||||
meta,
|
||||
);
|
||||
let isFirstMessage = true;
|
||||
|
||||
@@ -135,6 +153,10 @@ export function createWebSocketPeer({
|
||||
for (const msg of messages) {
|
||||
if (msg && "action" in msg) {
|
||||
incoming.push(msg);
|
||||
|
||||
if (msg.action === "content") {
|
||||
totalIngressBytesCounter.add(getContentMessageSize(msg), meta);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { SyncMessage } from "cojson";
|
||||
import { type Mocked, afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { BatchedOutgoingMessages } from "../BatchedOutgoingMessages";
|
||||
import type { AnyWebSocket } from "../types";
|
||||
import { createTestMetricReader, tearDownTestMetricReader } from "./utils.js";
|
||||
|
||||
describe("BatchedOutgoingMessages", () => {
|
||||
describe("telemetry", () => {
|
||||
afterEach(() => {
|
||||
tearDownTestMetricReader();
|
||||
});
|
||||
|
||||
test("should correctly measure egress", async () => {
|
||||
const metricReader = createTestMetricReader();
|
||||
|
||||
const mockWebSocket = {
|
||||
readyState: 1,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
close: vi.fn(),
|
||||
send: vi.fn(),
|
||||
} as unknown as Mocked<AnyWebSocket>;
|
||||
|
||||
const outgoing = new BatchedOutgoingMessages(
|
||||
mockWebSocket,
|
||||
true,
|
||||
"server",
|
||||
{ test: "test" },
|
||||
);
|
||||
|
||||
const encryptedChanges = "Hello, world!";
|
||||
vi.useFakeTimers();
|
||||
outgoing.push({
|
||||
action: "content",
|
||||
new: {
|
||||
someSessionId: {
|
||||
newTransactions: [
|
||||
{
|
||||
privacy: "private",
|
||||
encryptedChanges,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as unknown as SyncMessage);
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
vi.useRealTimers();
|
||||
|
||||
expect(
|
||||
await metricReader.getMetricValue("jazz.usage.egress", {
|
||||
test: "test",
|
||||
}),
|
||||
).toBe(encryptedChanges.length);
|
||||
|
||||
const trustingChanges = "Jazz is great!";
|
||||
vi.useFakeTimers();
|
||||
outgoing.push({
|
||||
action: "content",
|
||||
new: {
|
||||
someSessionId: {
|
||||
after: 0,
|
||||
newTransactions: [
|
||||
{
|
||||
privacy: "trusting",
|
||||
changes: trustingChanges,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as unknown as SyncMessage);
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
vi.useRealTimers();
|
||||
|
||||
expect(
|
||||
await metricReader.getMetricValue("jazz.usage.egress", {
|
||||
test: "test",
|
||||
}),
|
||||
).toBe(encryptedChanges.length + trustingChanges.length);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { CojsonInternalTypes, SyncMessage } from "cojson";
|
||||
import { cojsonInternals } from "cojson";
|
||||
import { type Mocked, describe, expect, test, vi } from "vitest";
|
||||
import { type Mocked, afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { MAX_OUTGOING_MESSAGES_CHUNK_BYTES } from "../BatchedOutgoingMessages.js";
|
||||
import {
|
||||
type CreateWebSocketPeerOpts,
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "../createWebSocketPeer.js";
|
||||
import type { AnyWebSocket } from "../types.js";
|
||||
import { BUFFER_LIMIT, BUFFER_LIMIT_POLLING_INTERVAL } from "../utils.js";
|
||||
import { createTestMetricReader, tearDownTestMetricReader } from "./utils.js";
|
||||
|
||||
const { CO_VALUE_PRIORITY } = cojsonInternals;
|
||||
|
||||
@@ -520,6 +521,89 @@ describe("createWebSocketPeer", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("telemetry", () => {
|
||||
afterEach(() => {
|
||||
tearDownTestMetricReader();
|
||||
});
|
||||
|
||||
test("should initialize to 0 when creating a websocket peer", async () => {
|
||||
const metricReader = createTestMetricReader();
|
||||
setup({
|
||||
meta: { test: "test" },
|
||||
});
|
||||
|
||||
const measuredIngress = await metricReader.getMetricValue(
|
||||
"jazz.usage.ingress",
|
||||
{
|
||||
test: "test",
|
||||
},
|
||||
);
|
||||
expect(measuredIngress).toBe(0);
|
||||
});
|
||||
|
||||
test("should correctly measure incoming ingress", async () => {
|
||||
const metricReader = createTestMetricReader();
|
||||
const { listeners } = setup({
|
||||
meta: { label: "value" },
|
||||
});
|
||||
|
||||
const messageHandler = listeners.get("message");
|
||||
|
||||
const encryptedChanges = "Hello, world!";
|
||||
messageHandler?.(
|
||||
new MessageEvent("message", {
|
||||
data: JSON.stringify({
|
||||
action: "content",
|
||||
new: {
|
||||
someSessionId: {
|
||||
after: 0,
|
||||
newTransactions: [
|
||||
{
|
||||
privacy: "private" as const,
|
||||
madeAt: 0,
|
||||
keyUsed: "key_zkey" as const,
|
||||
encryptedChanges,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
await metricReader.getMetricValue("jazz.usage.ingress", {
|
||||
label: "value",
|
||||
}),
|
||||
).toBe(encryptedChanges.length);
|
||||
|
||||
const trustingChanges = "Jazz is great!";
|
||||
messageHandler?.(
|
||||
new MessageEvent("message", {
|
||||
data: JSON.stringify({
|
||||
action: "content",
|
||||
new: {
|
||||
someSessionId: {
|
||||
newTransactions: [
|
||||
{
|
||||
privacy: "trusting",
|
||||
changes: trustingChanges,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
await metricReader.getMetricValue("jazz.usage.ingress", {
|
||||
label: "value",
|
||||
}),
|
||||
).toBe(encryptedChanges.length + trustingChanges.length);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// biome-ignore lint/suspicious/noConfusingVoidType: Test helper
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
AggregationTemporality,
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
MetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
import { expect } from "vitest";
|
||||
|
||||
// biome-ignore lint/suspicious/noConfusingVoidType: Test helper
|
||||
export function waitFor(callback: () => boolean | void) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
@@ -26,3 +35,71 @@ export function waitFor(callback: () => boolean | void) {
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* This is a test metric reader that uses an in-memory metric exporter and exposes a method to get the value of a metric given its name and attributes.
|
||||
*
|
||||
* This is useful for testing the values of metrics that are collected by the SDK.
|
||||
*
|
||||
* TODO: We may want to rethink how we access metrics (see `getMetricValue` method) to make it more flexible.
|
||||
*/
|
||||
class TestMetricReader extends MetricReader {
|
||||
private _exporter = new InMemoryMetricExporter(
|
||||
AggregationTemporality.CUMULATIVE,
|
||||
);
|
||||
|
||||
protected onShutdown(): Promise<void> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
protected onForceFlush(): Promise<void> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
async getMetricValue(
|
||||
name: string,
|
||||
attributes: { [key: string]: string | number } = {},
|
||||
) {
|
||||
await this.collectAndExport();
|
||||
const metric = this._exporter
|
||||
.getMetrics()[0]
|
||||
?.scopeMetrics[0]?.metrics.find((m) => m.descriptor.name === name);
|
||||
|
||||
const dp = metric?.dataPoints.find(
|
||||
(dp) => JSON.stringify(dp.attributes) === JSON.stringify(attributes),
|
||||
);
|
||||
|
||||
this._exporter.reset();
|
||||
|
||||
return dp?.value;
|
||||
}
|
||||
|
||||
async collectAndExport(): Promise<void> {
|
||||
const result = await this.collect();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this._exporter.export(result.resourceMetrics, (result) => {
|
||||
if (result.error != null) {
|
||||
reject(result.error);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function createTestMetricReader() {
|
||||
const metricReader = new TestMetricReader();
|
||||
const success = metrics.setGlobalMeterProvider(
|
||||
new MeterProvider({
|
||||
readers: [metricReader],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(success).toBe(true);
|
||||
|
||||
return metricReader;
|
||||
}
|
||||
|
||||
export function tearDownTestMetricReader() {
|
||||
metrics.disable();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { assert } from "node:console";
|
||||
import { ControlledAgent, type CryptoProvider, LocalNode } from "cojson";
|
||||
import { type CryptoProvider, LocalNode } from "cojson";
|
||||
import { WasmCrypto } from "cojson/crypto/WasmCrypto";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import { WebSocket } from "ws";
|
||||
|
||||
@@ -1,5 +1,28 @@
|
||||
# cojson
|
||||
|
||||
## 0.16.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 67e0968: Fix content streaming chunking, now chunks should be splitted always respecting the MAX_RECOMMENDED_TX_SIZE
|
||||
- ce9ca54: Chunk CoPlainText content to avoid generating bg messages when the user past a megabytes of text
|
||||
- 4b99ff1: Add a multi-storage scheduler to avoid conflicting store operations when having multiple storage instances open on the same database
|
||||
- ac5d20d: Add ingress and egress metering
|
||||
- 9bf7946: Added a TTL based optional garbage collection for covalues
|
||||
|
||||
## 0.16.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 3cd1586: Makes the key rotation not fail when child groups are unavailable or their readkey is not accessible.
|
||||
|
||||
Also changes the Group.removeMember method to not return a Promise, because:
|
||||
|
||||
- All the locally available child groups are rotated immediately
|
||||
- All the remote child groups are rotated in background, but since they are not locally available the user won't need the new key immediately
|
||||
|
||||
- 267f689: Groups: fix the readkey not being revealed to everyone when doing a key rotation
|
||||
|
||||
## 0.16.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
},
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "0.16.4",
|
||||
"version": "0.16.6",
|
||||
"devDependencies": {
|
||||
"@opentelemetry/sdk-metrics": "^2.0.0",
|
||||
"libsql": "^0.5.13",
|
||||
|
||||
48
packages/cojson/src/GarbageCollector.ts
Normal file
48
packages/cojson/src/GarbageCollector.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { CoValueCore } from "./coValueCore/coValueCore.js";
|
||||
import { GARBAGE_COLLECTOR_CONFIG } from "./config.js";
|
||||
import { RawCoID } from "./ids.js";
|
||||
|
||||
export class GarbageCollector {
|
||||
private readonly interval: ReturnType<typeof setInterval>;
|
||||
|
||||
constructor(private readonly coValues: Map<RawCoID, CoValueCore>) {
|
||||
this.interval = setInterval(() => {
|
||||
this.collect();
|
||||
}, GARBAGE_COLLECTOR_CONFIG.INTERVAL);
|
||||
}
|
||||
|
||||
getCurrentTime() {
|
||||
return performance.now();
|
||||
}
|
||||
|
||||
trackCoValueAccess({ verified }: CoValueCore) {
|
||||
if (verified) {
|
||||
verified.lastAccessed = this.getCurrentTime();
|
||||
}
|
||||
}
|
||||
|
||||
collect() {
|
||||
const currentTime = this.getCurrentTime();
|
||||
for (const coValue of this.coValues.values()) {
|
||||
const { verified } = coValue;
|
||||
|
||||
if (!verified?.lastAccessed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const timeSinceLastAccessed = currentTime - verified.lastAccessed;
|
||||
|
||||
if (timeSinceLastAccessed > GARBAGE_COLLECTOR_CONFIG.MAX_AGE) {
|
||||
const unmounted = coValue.unmount();
|
||||
|
||||
if (unmounted) {
|
||||
this.coValues.delete(coValue.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
clearInterval(this.interval);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
AvailableCoValueCore,
|
||||
CoValueCore,
|
||||
} from "./coValueCore/coValueCore.js";
|
||||
import { AvailableCoValueCore } from "./coValueCore/coValueCore.js";
|
||||
import { RawProfile as Profile, RawAccount } from "./coValues/account.js";
|
||||
import { RawCoList } from "./coValues/coList.js";
|
||||
import { RawCoMap } from "./coValues/coMap.js";
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
Transaction,
|
||||
VerifiedState,
|
||||
} from "./coValueCore/verifiedState.js";
|
||||
import { MAX_RECOMMENDED_TX_SIZE } from "./config.js";
|
||||
import { TRANSACTION_CONFIG } from "./config.js";
|
||||
import { Signature } from "./crypto/crypto.js";
|
||||
import { RawCoID, SessionID } from "./ids.js";
|
||||
import { getPriorityFromHeader } from "./priority.js";
|
||||
@@ -55,10 +55,12 @@ export function exceedsRecommendedSize(
|
||||
transactionSize?: number,
|
||||
) {
|
||||
if (transactionSize === undefined) {
|
||||
return baseSize > MAX_RECOMMENDED_TX_SIZE;
|
||||
return baseSize > TRANSACTION_CONFIG.MAX_RECOMMENDED_TX_SIZE;
|
||||
}
|
||||
|
||||
return baseSize + transactionSize > MAX_RECOMMENDED_TX_SIZE;
|
||||
return (
|
||||
baseSize + transactionSize > TRANSACTION_CONFIG.MAX_RECOMMENDED_TX_SIZE
|
||||
);
|
||||
}
|
||||
|
||||
export function knownStateFromContent(content: NewContentMessage) {
|
||||
@@ -71,3 +73,14 @@ export function knownStateFromContent(content: NewContentMessage) {
|
||||
|
||||
return knownState;
|
||||
}
|
||||
|
||||
export function getContentMessageSize(msg: NewContentMessage) {
|
||||
return Object.values(msg.new).reduce((acc, sessionNewContent) => {
|
||||
return (
|
||||
acc +
|
||||
sessionNewContent.newTransactions.reduce((acc, tx) => {
|
||||
return acc + getTransactionSize(tx);
|
||||
}, 0)
|
||||
);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { UpDownCounter, ValueType, metrics } from "@opentelemetry/api";
|
||||
import { Result, err } from "neverthrow";
|
||||
import { PeerState } from "../PeerState.js";
|
||||
import { RawCoValue } from "../coValue.js";
|
||||
import { ControlledAccountOrAgent } from "../coValues/account.js";
|
||||
import { RawGroup } from "../coValues/group.js";
|
||||
import type { PeerState } from "../PeerState.js";
|
||||
import type { RawCoValue } from "../coValue.js";
|
||||
import type { ControlledAccountOrAgent } from "../coValues/account.js";
|
||||
import type { RawGroup } from "../coValues/group.js";
|
||||
import { CO_VALUE_LOADING_CONFIG } from "../config.js";
|
||||
import { coreToCoValue } from "../coreToCoValue.js";
|
||||
import {
|
||||
@@ -16,25 +16,15 @@ import {
|
||||
SignerID,
|
||||
StreamingHash,
|
||||
} from "../crypto/crypto.js";
|
||||
import {
|
||||
RawCoID,
|
||||
SessionID,
|
||||
TransactionID,
|
||||
getParentGroupId,
|
||||
isParentGroupReference,
|
||||
} from "../ids.js";
|
||||
import { RawCoID, SessionID, TransactionID } from "../ids.js";
|
||||
import { parseJSON, stableStringify } from "../jsonStringify.js";
|
||||
import { JsonValue } from "../jsonValue.js";
|
||||
import { LocalNode, ResolveAccountAgentError } from "../localNode.js";
|
||||
import { logger } from "../logger.js";
|
||||
import {
|
||||
determineValidTransactions,
|
||||
isKeyForKeyField,
|
||||
} from "../permissions.js";
|
||||
import { determineValidTransactions } from "../permissions.js";
|
||||
import { CoValueKnownState, PeerID, emptyKnownState } from "../sync.js";
|
||||
import { accountOrAgentIDfromSessionID } from "../typeUtils/accountOrAgentIDfromSessionID.js";
|
||||
import { expectGroup } from "../typeUtils/expectGroup.js";
|
||||
import { isAccountID } from "../typeUtils/isAccountID.js";
|
||||
import { getDependedOnCoValuesFromRawData } from "./utils.js";
|
||||
import { CoValueHeader, Transaction, VerifiedState } from "./verifiedState.js";
|
||||
|
||||
@@ -53,8 +43,6 @@ export type DecryptedTransaction = {
|
||||
trusting?: boolean;
|
||||
};
|
||||
|
||||
const readKeyCache = new WeakMap<CoValueCore, { [id: KeyID]: KeySecret }>();
|
||||
|
||||
export type AvailableCoValueCore = CoValueCore & { verified: VerifiedState };
|
||||
|
||||
export class CoValueCore {
|
||||
@@ -81,7 +69,9 @@ export class CoValueCore {
|
||||
}
|
||||
private readonly peers = new Map<
|
||||
PeerID,
|
||||
| { type: "unknown" | "pending" | "available" | "unavailable" }
|
||||
| {
|
||||
type: "unknown" | "pending" | "available" | "unavailable";
|
||||
}
|
||||
| {
|
||||
type: "errored";
|
||||
error: TryAddTransactionsError;
|
||||
@@ -90,9 +80,8 @@ export class CoValueCore {
|
||||
|
||||
// cached state and listeners
|
||||
private _cachedContent?: RawCoValue;
|
||||
private readonly listeners: Set<
|
||||
(core: CoValueCore, unsub: () => void) => void
|
||||
> = new Set();
|
||||
readonly listeners: Set<(core: CoValueCore, unsub: () => void) => void> =
|
||||
new Set();
|
||||
private readonly _decryptionCache: {
|
||||
[key: Encrypted<JsonValue[], JsonValue>]: JsonValue[] | undefined;
|
||||
} = {};
|
||||
@@ -213,6 +202,26 @@ export class CoValueCore {
|
||||
}
|
||||
}
|
||||
|
||||
unmount() {
|
||||
// For simplicity, we don't unmount groups and accounts
|
||||
if (this.verified?.header.ruleset.type === "group") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.listeners.size > 0) {
|
||||
return false; // The coValue is still in use
|
||||
}
|
||||
|
||||
this.counter.add(-1, { state: this.loadingState });
|
||||
|
||||
if (this.groupInvalidationSubscription) {
|
||||
this.groupInvalidationSubscription();
|
||||
this.groupInvalidationSubscription = undefined;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
markNotFoundInPeer(peerId: PeerID) {
|
||||
const previousState = this.loadingState;
|
||||
this.peers.set(peerId, { type: "unavailable" });
|
||||
@@ -621,9 +630,7 @@ export class CoValueCore {
|
||||
return success;
|
||||
}
|
||||
|
||||
getCurrentContent(options?: {
|
||||
ignorePrivateTransactions: true;
|
||||
}): RawCoValue {
|
||||
getCurrentContent(options?: { ignorePrivateTransactions: true }): RawCoValue {
|
||||
if (!this.verified) {
|
||||
throw new Error(
|
||||
"CoValueCore: getCurrentContent called on coValue without verified state",
|
||||
@@ -768,20 +775,7 @@ export class CoValueCore {
|
||||
}
|
||||
|
||||
if (this.verified.header.ruleset.type === "group") {
|
||||
const content = expectGroup(this.getCurrentContent());
|
||||
|
||||
const currentKeyId = content.getCurrentReadKeyId();
|
||||
|
||||
if (!currentKeyId) {
|
||||
throw new Error("No readKey set");
|
||||
}
|
||||
|
||||
const secret = this.getReadKey(currentKeyId);
|
||||
|
||||
return {
|
||||
secret: secret,
|
||||
id: currentKeyId,
|
||||
};
|
||||
return expectGroup(this.getCurrentContent()).getCurrentReadKey();
|
||||
} else if (this.verified.header.ruleset.type === "ownedByGroup") {
|
||||
return this.node
|
||||
.expectCoValueLoaded(this.verified.header.ruleset.group)
|
||||
@@ -793,154 +787,36 @@ export class CoValueCore {
|
||||
}
|
||||
}
|
||||
|
||||
readKeyCache = new Map<KeyID, KeySecret>();
|
||||
getReadKey(keyID: KeyID): KeySecret | undefined {
|
||||
let key = readKeyCache.get(this)?.[keyID];
|
||||
if (!key) {
|
||||
key = this.getUncachedReadKey(keyID);
|
||||
if (key) {
|
||||
let cache = readKeyCache.get(this);
|
||||
if (!cache) {
|
||||
cache = {};
|
||||
readKeyCache.set(this, cache);
|
||||
}
|
||||
cache[keyID] = key;
|
||||
}
|
||||
}
|
||||
return key;
|
||||
}
|
||||
// We want to check the cache here, to skip re-computing the group content
|
||||
const cachedSecret = this.readKeyCache.get(keyID);
|
||||
|
||||
if (cachedSecret) {
|
||||
return cachedSecret;
|
||||
}
|
||||
|
||||
getUncachedReadKey(keyID: KeyID): KeySecret | undefined {
|
||||
if (!this.verified) {
|
||||
throw new Error(
|
||||
"CoValueCore: getUncachedReadKey called on coValue without verified state",
|
||||
);
|
||||
}
|
||||
|
||||
// Getting the readKey from accounts
|
||||
if (this.verified.header.ruleset.type === "group") {
|
||||
const content = expectGroup(
|
||||
this.getCurrentContent({ ignorePrivateTransactions: true }), // to prevent recursion
|
||||
);
|
||||
const keyForEveryone = content.get(`${keyID}_for_everyone`);
|
||||
if (keyForEveryone) {
|
||||
return keyForEveryone;
|
||||
}
|
||||
|
||||
// Try to find key revelation for us
|
||||
const currentAgentOrAccountID = accountOrAgentIDfromSessionID(
|
||||
this.node.currentSessionID,
|
||||
// load the account without private transactions, because we are here
|
||||
// to be able to decrypt those
|
||||
this.getCurrentContent({ ignorePrivateTransactions: true }),
|
||||
);
|
||||
|
||||
// being careful here to avoid recursion
|
||||
const lookupAccountOrAgentID = isAccountID(currentAgentOrAccountID)
|
||||
? this.id === currentAgentOrAccountID
|
||||
? this.crypto.getAgentID(this.node.agentSecret) // in accounts, the read key is revealed for the primitive agent
|
||||
: currentAgentOrAccountID // current account ID
|
||||
: currentAgentOrAccountID; // current agent ID
|
||||
|
||||
const lastReadyKeyEdit = content.lastEditAt(
|
||||
`${keyID}_for_${lookupAccountOrAgentID}`,
|
||||
);
|
||||
|
||||
if (lastReadyKeyEdit?.value) {
|
||||
const revealer = lastReadyKeyEdit.by;
|
||||
const revealerAgent = this.node
|
||||
.resolveAccountAgent(revealer, "Expected to know revealer")
|
||||
._unsafeUnwrap({ withStackTrace: true });
|
||||
|
||||
const secret = this.crypto.unseal(
|
||||
lastReadyKeyEdit.value,
|
||||
this.crypto.getAgentSealerSecret(this.node.agentSecret), // being careful here to avoid recursion
|
||||
this.crypto.getAgentSealerID(revealerAgent),
|
||||
{
|
||||
in: this.id,
|
||||
tx: lastReadyKeyEdit.tx,
|
||||
},
|
||||
);
|
||||
|
||||
if (secret) {
|
||||
return secret as KeySecret;
|
||||
}
|
||||
}
|
||||
|
||||
// Try to find indirect revelation through previousKeys
|
||||
|
||||
for (const co of content.keys()) {
|
||||
if (isKeyForKeyField(co) && co.startsWith(keyID)) {
|
||||
const encryptingKeyID = co.split("_for_")[1] as KeyID;
|
||||
const encryptingKeySecret = this.getReadKey(encryptingKeyID);
|
||||
|
||||
if (!encryptingKeySecret) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const encryptedPreviousKey = content.get(co)!;
|
||||
|
||||
const secret = this.crypto.decryptKeySecret(
|
||||
{
|
||||
encryptedID: keyID,
|
||||
encryptingID: encryptingKeyID,
|
||||
encrypted: encryptedPreviousKey,
|
||||
},
|
||||
encryptingKeySecret,
|
||||
);
|
||||
|
||||
if (secret) {
|
||||
return secret as KeySecret;
|
||||
} else {
|
||||
logger.warn(
|
||||
`Encrypting ${encryptingKeyID} key didn't decrypt ${keyID}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// try to find revelation to parent group read keys
|
||||
for (const co of content.keys()) {
|
||||
if (isParentGroupReference(co)) {
|
||||
const parentGroupID = getParentGroupId(co);
|
||||
const parentGroup = this.node.expectCoValueLoaded(
|
||||
parentGroupID,
|
||||
"Expected parent group to be loaded",
|
||||
);
|
||||
|
||||
const parentKeys = this.findValidParentKeys(
|
||||
keyID,
|
||||
content,
|
||||
parentGroup,
|
||||
);
|
||||
|
||||
for (const parentKey of parentKeys) {
|
||||
const revelationForParentKey = content.get(
|
||||
`${keyID}_for_${parentKey.id}`,
|
||||
);
|
||||
|
||||
if (revelationForParentKey) {
|
||||
const secret = parentGroup.crypto.decryptKeySecret(
|
||||
{
|
||||
encryptedID: keyID,
|
||||
encryptingID: parentKey.id,
|
||||
encrypted: revelationForParentKey,
|
||||
},
|
||||
parentKey.secret,
|
||||
);
|
||||
|
||||
if (secret) {
|
||||
return secret as KeySecret;
|
||||
} else {
|
||||
logger.warn(
|
||||
`Encrypting parent ${parentKey.id} key didn't decrypt ${keyID}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
return content.getReadKey(keyID);
|
||||
} else if (this.verified.header.ruleset.type === "ownedByGroup") {
|
||||
return this.node
|
||||
.expectCoValueLoaded(this.verified.header.ruleset.group)
|
||||
.getReadKey(keyID);
|
||||
return expectGroup(
|
||||
this.node
|
||||
.expectCoValueLoaded(this.verified.header.ruleset.group)
|
||||
.getCurrentContent(),
|
||||
).getReadKey(keyID);
|
||||
} else {
|
||||
throw new Error(
|
||||
"Only groups or values owned by groups have read secrets",
|
||||
@@ -948,28 +824,6 @@ export class CoValueCore {
|
||||
}
|
||||
}
|
||||
|
||||
findValidParentKeys(keyID: KeyID, group: RawGroup, parentGroup: CoValueCore) {
|
||||
const validParentKeys: { id: KeyID; secret: KeySecret }[] = [];
|
||||
|
||||
for (const co of group.keys()) {
|
||||
if (isKeyForKeyField(co) && co.startsWith(keyID)) {
|
||||
const encryptingKeyID = co.split("_for_")[1] as KeyID;
|
||||
const encryptingKeySecret = parentGroup.getReadKey(encryptingKeyID);
|
||||
|
||||
if (!encryptingKeySecret) {
|
||||
continue;
|
||||
}
|
||||
|
||||
validParentKeys.push({
|
||||
id: encryptingKeyID,
|
||||
secret: encryptingKeySecret,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return validParentKeys;
|
||||
}
|
||||
|
||||
getGroup(): RawGroup {
|
||||
if (!this.verified) {
|
||||
throw new Error(
|
||||
@@ -1016,9 +870,7 @@ export class CoValueCore {
|
||||
}
|
||||
}
|
||||
|
||||
waitForSync(options?: {
|
||||
timeout?: number;
|
||||
}) {
|
||||
waitForSync(options?: { timeout?: number }) {
|
||||
return this.node.syncManager.waitForSync(this.id, options?.timeout);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { getGroupDependentKey } from "../ids.js";
|
||||
import { RawCoID, SessionID } from "../ids.js";
|
||||
import { Stringified, parseJSON } from "../jsonStringify.js";
|
||||
import { JsonValue } from "../jsonValue.js";
|
||||
import { NewContentMessage } from "../sync.js";
|
||||
import { accountOrAgentIDfromSessionID } from "../typeUtils/accountOrAgentIDfromSessionID.js";
|
||||
import { isAccountID } from "../typeUtils/isAccountID.js";
|
||||
import { CoValueHeader, Transaction } from "./verifiedState.js";
|
||||
|
||||
@@ -65,6 +65,7 @@ export class VerifiedState {
|
||||
private _cachedKnownState?: CoValueKnownState;
|
||||
private _cachedNewContentSinceEmpty: NewContentMessage[] | undefined;
|
||||
private streamingKnownState?: CoValueKnownState["sessions"];
|
||||
public lastAccessed: number | undefined;
|
||||
|
||||
constructor(
|
||||
id: RawCoID,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { splitGraphemes } from "unicode-segmenter/grapheme";
|
||||
import { AvailableCoValueCore } from "../coValueCore/coValueCore.js";
|
||||
import { TRANSACTION_CONFIG } from "../config.js";
|
||||
import { JsonObject } from "../jsonValue.js";
|
||||
import { DeletionOpPayload, OpID, RawCoList } from "./coList.js";
|
||||
|
||||
@@ -110,16 +111,34 @@ export class RawCoPlainText<
|
||||
text: string,
|
||||
privacy: "private" | "trusting" = "private",
|
||||
) {
|
||||
const graphemes = [...splitGraphemes(text)];
|
||||
const graphemes = Array.from(splitGraphemes(text));
|
||||
|
||||
if (idx === 0) {
|
||||
// For insertions at start, prepend each character in reverse
|
||||
for (const grapheme of graphemes.reverse()) {
|
||||
this.prepend(grapheme, 0, privacy);
|
||||
// For insertions at start, prepend the first char and append the rest
|
||||
const firstChar = graphemes[0];
|
||||
|
||||
if (firstChar) {
|
||||
this.prepend(firstChar, 0, privacy);
|
||||
}
|
||||
|
||||
if (graphemes.length > 1) {
|
||||
this.appendChars(graphemes.slice(1), 0, privacy);
|
||||
}
|
||||
} else {
|
||||
// For other insertions, append after the previous character
|
||||
this.appendItems(graphemes, idx - 1, privacy);
|
||||
this.appendChars(graphemes, idx - 1, privacy);
|
||||
}
|
||||
}
|
||||
|
||||
appendChars(
|
||||
text: string[],
|
||||
position: number,
|
||||
privacy: "private" | "trusting" = "private",
|
||||
) {
|
||||
const chunks = splitIntoChunks(text);
|
||||
for (const chunk of chunks) {
|
||||
this.appendItems(chunk, position, privacy);
|
||||
position += chunk.length;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,11 +155,12 @@ export class RawCoPlainText<
|
||||
text: string,
|
||||
privacy: "private" | "trusting" = "private",
|
||||
) {
|
||||
const graphemes = [...splitGraphemes(text)];
|
||||
const graphemes = Array.from(splitGraphemes(text));
|
||||
|
||||
if (idx >= this.entries().length) {
|
||||
this.appendItems(graphemes, idx - 1, privacy);
|
||||
this.appendChars(graphemes, idx - 1, privacy);
|
||||
} else {
|
||||
this.appendItems(graphemes, idx, privacy);
|
||||
this.appendChars(graphemes, idx, privacy);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,3 +198,15 @@ export class RawCoPlainText<
|
||||
return graphemes.join("");
|
||||
}
|
||||
}
|
||||
|
||||
function splitIntoChunks(text: string[]) {
|
||||
const chunks: string[][] = [];
|
||||
for (
|
||||
let i = 0;
|
||||
i < text.length;
|
||||
i += TRANSACTION_CONFIG.MAX_RECOMMENDED_TX_SIZE
|
||||
) {
|
||||
chunks.push(text.slice(i, i + TRANSACTION_CONFIG.MAX_RECOMMENDED_TX_SIZE));
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { base58 } from "@scure/base";
|
||||
import { CoID } from "../coValue.js";
|
||||
import { AvailableCoValueCore } from "../coValueCore/coValueCore.js";
|
||||
import { CoValueUniqueness } from "../coValueCore/verifiedState.js";
|
||||
import {
|
||||
import type { CoID } from "../coValue.js";
|
||||
import type {
|
||||
AvailableCoValueCore,
|
||||
CoValueCore,
|
||||
} from "../coValueCore/coValueCore.js";
|
||||
import type { CoValueUniqueness } from "../coValueCore/verifiedState.js";
|
||||
import type {
|
||||
CryptoProvider,
|
||||
Encrypted,
|
||||
KeyID,
|
||||
@@ -21,8 +24,10 @@ import {
|
||||
} from "../ids.js";
|
||||
import { JsonObject } from "../jsonValue.js";
|
||||
import { logger } from "../logger.js";
|
||||
import { AccountRole, Role } from "../permissions.js";
|
||||
import { AccountRole, Role, isKeyForKeyField } from "../permissions.js";
|
||||
import { accountOrAgentIDfromSessionID } from "../typeUtils/accountOrAgentIDfromSessionID.js";
|
||||
import { expectGroup } from "../typeUtils/expectGroup.js";
|
||||
import { isAccountID } from "../typeUtils/isAccountID.js";
|
||||
import {
|
||||
ControlledAccountOrAgent,
|
||||
RawAccount,
|
||||
@@ -60,6 +65,59 @@ export type GroupShape = {
|
||||
[child: ChildGroupReference]: "revoked" | "extend";
|
||||
};
|
||||
|
||||
// We had a bug on key rotation, where the new read key was not revealed to everyone
|
||||
// TODO: remove this when we hit the 0.18.0 release (either the groups are healed or they are not used often, it's a minor issue anyway)
|
||||
function healMissingKeyForEveryone(group: RawGroup) {
|
||||
const readKeyId = group.get("readKey");
|
||||
|
||||
if (
|
||||
!readKeyId ||
|
||||
!canRead(group, EVERYONE) ||
|
||||
group.get(`${readKeyId}_for_${EVERYONE}`)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasAccessToReadKey = canRead(
|
||||
group,
|
||||
group.core.node.getCurrentAgent().id,
|
||||
);
|
||||
|
||||
// If the current account has access to the read key, we can fix the group
|
||||
if (hasAccessToReadKey) {
|
||||
const secret = group.getReadKey(readKeyId);
|
||||
if (secret) {
|
||||
group.set(`${readKeyId}_for_${EVERYONE}`, secret, "trusting");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback to the latest readable key for everyone
|
||||
const keys = group
|
||||
.keys()
|
||||
.filter((key) => key.startsWith("key_") && key.endsWith("_for_everyone"));
|
||||
|
||||
let latestKey = keys[0];
|
||||
|
||||
for (const key of keys) {
|
||||
if (!latestKey) {
|
||||
latestKey = key;
|
||||
continue;
|
||||
}
|
||||
|
||||
const keyEntry = group.getRaw(key);
|
||||
const latestKeyEntry = group.getRaw(latestKey);
|
||||
|
||||
if (keyEntry && latestKeyEntry && keyEntry.madeAt > latestKeyEntry.madeAt) {
|
||||
latestKey = key;
|
||||
}
|
||||
}
|
||||
|
||||
if (latestKey) {
|
||||
group._lastReadableKeyId = latestKey.replace("_for_everyone", "") as KeyID;
|
||||
}
|
||||
}
|
||||
|
||||
/** A `Group` is a scope for permissions of its members (`"reader" | "writer" | "admin"`), applying to objects owned by that group.
|
||||
*
|
||||
* A `Group` object exposes methods for permission management and allows you to create new CoValues owned by that group.
|
||||
@@ -86,6 +144,8 @@ export class RawGroup<
|
||||
> extends RawCoMap<GroupShape, Meta> {
|
||||
protected readonly crypto: CryptoProvider;
|
||||
|
||||
_lastReadableKeyId?: KeyID;
|
||||
|
||||
constructor(
|
||||
core: AvailableCoValueCore,
|
||||
options?: {
|
||||
@@ -94,6 +154,8 @@ export class RawGroup<
|
||||
) {
|
||||
super(core, options);
|
||||
this.crypto = core.node.crypto;
|
||||
|
||||
healMissingKeyForEveryone(this);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -191,43 +253,7 @@ export class RawGroup<
|
||||
return groups;
|
||||
}
|
||||
|
||||
loadAllChildGroups() {
|
||||
const requests: Promise<unknown>[] = [];
|
||||
const peers = this.core.node.syncManager.getServerPeers();
|
||||
|
||||
for (const key of this.keys()) {
|
||||
if (!isChildGroupReference(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const id = getChildGroupId(key);
|
||||
const child = this.core.node.getCoValue(id);
|
||||
|
||||
if (
|
||||
child.loadingState === "unknown" ||
|
||||
child.loadingState === "unavailable"
|
||||
) {
|
||||
child.load(peers);
|
||||
}
|
||||
|
||||
requests.push(
|
||||
child.waitForAvailableOrUnavailable().then((coValue) => {
|
||||
if (!coValue.isAvailable()) {
|
||||
throw new Error(`Child group ${child.id} is unavailable`);
|
||||
}
|
||||
|
||||
// Recursively load child groups
|
||||
return expectGroup(coValue.getCurrentContent()).loadAllChildGroups();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.all(requests);
|
||||
}
|
||||
|
||||
getChildGroups() {
|
||||
const groups: RawGroup[] = [];
|
||||
|
||||
forEachChildGroup(callback: (child: RawGroup) => void) {
|
||||
for (const key of this.keys()) {
|
||||
if (isChildGroupReference(key)) {
|
||||
// Check if the child group reference is revoked
|
||||
@@ -235,15 +261,22 @@ export class RawGroup<
|
||||
continue;
|
||||
}
|
||||
|
||||
const child = this.core.node.expectCoValueLoaded(
|
||||
getChildGroupId(key),
|
||||
"Expected child group to be loaded",
|
||||
);
|
||||
groups.push(expectGroup(child.getCurrentContent()));
|
||||
const id = getChildGroupId(key);
|
||||
const child = this.core.node.getCoValue(id);
|
||||
|
||||
if (child.isAvailable()) {
|
||||
callback(expectGroup(child.getCurrentContent()));
|
||||
} else {
|
||||
this.core.node.load(id).then((child) => {
|
||||
if (child !== "unavailable") {
|
||||
callback(expectGroup(child));
|
||||
} else {
|
||||
logger.warn(`Unable to load child group ${id}, skipping`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -279,7 +312,7 @@ export class RawGroup<
|
||||
"Can't make everyone something other than reader, writer or writeOnly",
|
||||
);
|
||||
}
|
||||
const currentReadKey = this.core.getCurrentReadKey();
|
||||
const currentReadKey = this.getCurrentReadKey();
|
||||
|
||||
if (!currentReadKey.secret) {
|
||||
throw new Error("Can't add member without read key secret");
|
||||
@@ -306,7 +339,7 @@ export class RawGroup<
|
||||
|
||||
if (role === "writeOnly") {
|
||||
if (previousRole === "reader" || previousRole === "writer") {
|
||||
this.rotateReadKey();
|
||||
this.rotateReadKey("everyone");
|
||||
}
|
||||
|
||||
this.delete(`${currentReadKey.id}_for_${EVERYONE}`);
|
||||
@@ -349,7 +382,7 @@ export class RawGroup<
|
||||
|
||||
this.internalCreateWriteOnlyKeyForMember(memberKey, agent);
|
||||
} else {
|
||||
const currentReadKey = this.core.getCurrentReadKey();
|
||||
const currentReadKey = this.getCurrentReadKey();
|
||||
|
||||
if (!currentReadKey.secret) {
|
||||
throw new Error("Can't add member without read key secret");
|
||||
@@ -467,6 +500,10 @@ export class RawGroup<
|
||||
}
|
||||
|
||||
getCurrentReadKeyId() {
|
||||
if (this._lastReadableKeyId) {
|
||||
return this._lastReadableKeyId;
|
||||
}
|
||||
|
||||
const myRole = this.myRole();
|
||||
|
||||
if (myRole === "writeOnly") {
|
||||
@@ -518,23 +555,173 @@ export class RawGroup<
|
||||
return memberKeys;
|
||||
}
|
||||
|
||||
getReadKey(keyID: KeyID): KeySecret | undefined {
|
||||
const cache = this.core.readKeyCache;
|
||||
|
||||
let key = cache.get(keyID);
|
||||
if (!key) {
|
||||
key = this.getUncachedReadKey(keyID);
|
||||
if (key) {
|
||||
cache.set(keyID, key);
|
||||
}
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
getUncachedReadKey(keyID: KeyID) {
|
||||
const core = this.core;
|
||||
|
||||
const keyForEveryone = this.get(`${keyID}_for_everyone`);
|
||||
if (keyForEveryone) {
|
||||
return keyForEveryone;
|
||||
}
|
||||
|
||||
// Try to find key revelation for us
|
||||
const currentAgentOrAccountID = accountOrAgentIDfromSessionID(
|
||||
core.node.currentSessionID,
|
||||
);
|
||||
|
||||
// being careful here to avoid recursion
|
||||
const lookupAccountOrAgentID = isAccountID(currentAgentOrAccountID)
|
||||
? core.id === currentAgentOrAccountID
|
||||
? core.node.crypto.getAgentID(core.node.agentSecret) // in accounts, the read key is revealed for the primitive agent
|
||||
: currentAgentOrAccountID // current account ID
|
||||
: currentAgentOrAccountID; // current agent ID
|
||||
|
||||
const lastReadyKeyEdit = this.lastEditAt(
|
||||
`${keyID}_for_${lookupAccountOrAgentID}`,
|
||||
);
|
||||
|
||||
if (lastReadyKeyEdit?.value) {
|
||||
const revealer = lastReadyKeyEdit.by;
|
||||
const revealerAgent = core.node
|
||||
.resolveAccountAgent(revealer, "Expected to know revealer")
|
||||
._unsafeUnwrap({ withStackTrace: true });
|
||||
|
||||
const secret = this.crypto.unseal(
|
||||
lastReadyKeyEdit.value,
|
||||
this.crypto.getAgentSealerSecret(core.node.agentSecret), // being careful here to avoid recursion
|
||||
this.crypto.getAgentSealerID(revealerAgent),
|
||||
{
|
||||
in: this.id,
|
||||
tx: lastReadyKeyEdit.tx,
|
||||
},
|
||||
);
|
||||
|
||||
if (secret) {
|
||||
return secret as KeySecret;
|
||||
}
|
||||
}
|
||||
|
||||
// Try to find indirect revelation through previousKeys
|
||||
for (const co of this.keys()) {
|
||||
if (isKeyForKeyField(co) && co.startsWith(keyID)) {
|
||||
const encryptingKeyID = co.split("_for_")[1] as KeyID;
|
||||
const encryptingKeySecret = this.getReadKey(encryptingKeyID);
|
||||
|
||||
if (!encryptingKeySecret) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const encryptedPreviousKey = this.get(co)!;
|
||||
|
||||
const secret = this.crypto.decryptKeySecret(
|
||||
{
|
||||
encryptedID: keyID,
|
||||
encryptingID: encryptingKeyID,
|
||||
encrypted: encryptedPreviousKey,
|
||||
},
|
||||
encryptingKeySecret,
|
||||
);
|
||||
|
||||
if (secret) {
|
||||
return secret as KeySecret;
|
||||
} else {
|
||||
logger.warn(
|
||||
`Encrypting ${encryptingKeyID} key didn't decrypt ${keyID}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// try to find revelation to parent group read keys
|
||||
for (const co of this.keys()) {
|
||||
if (isParentGroupReference(co)) {
|
||||
const parentGroupID = getParentGroupId(co);
|
||||
const parentGroup = core.node.expectCoValueLoaded(
|
||||
parentGroupID,
|
||||
"Expected parent group to be loaded",
|
||||
);
|
||||
|
||||
const parentKeys = this.findValidParentKeys(keyID, parentGroup);
|
||||
|
||||
for (const parentKey of parentKeys) {
|
||||
const revelationForParentKey = this.get(
|
||||
`${keyID}_for_${parentKey.id}`,
|
||||
);
|
||||
|
||||
if (revelationForParentKey) {
|
||||
const secret = parentGroup.node.crypto.decryptKeySecret(
|
||||
{
|
||||
encryptedID: keyID,
|
||||
encryptingID: parentKey.id,
|
||||
encrypted: revelationForParentKey,
|
||||
},
|
||||
parentKey.secret,
|
||||
);
|
||||
|
||||
if (secret) {
|
||||
return secret as KeySecret;
|
||||
} else {
|
||||
logger.warn(
|
||||
`Encrypting parent ${parentKey.id} key didn't decrypt ${keyID}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
findValidParentKeys(keyID: KeyID, parentGroup: CoValueCore) {
|
||||
const validParentKeys: { id: KeyID; secret: KeySecret }[] = [];
|
||||
|
||||
for (const co of this.keys()) {
|
||||
if (isKeyForKeyField(co) && co.startsWith(keyID)) {
|
||||
const encryptingKeyID = co.split("_for_")[1] as KeyID;
|
||||
const encryptingKeySecret = parentGroup.getReadKey(encryptingKeyID);
|
||||
|
||||
if (!encryptingKeySecret) {
|
||||
continue;
|
||||
}
|
||||
|
||||
validParentKeys.push({
|
||||
id: encryptingKeyID,
|
||||
secret: encryptingKeySecret,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return validParentKeys;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
rotateReadKey(removedMemberKey?: RawAccountID | AgentID | "everyone") {
|
||||
if (removedMemberKey !== EVERYONE && canRead(this, EVERYONE)) {
|
||||
// When everyone has access to the group, rotating the key is useless
|
||||
// because it would be stored unencrypted and available to everyone
|
||||
return;
|
||||
}
|
||||
|
||||
const memberKeys = this.getMemberKeys().filter(
|
||||
(key) => key !== removedMemberKey,
|
||||
);
|
||||
|
||||
const currentlyPermittedReaders = memberKeys.filter((key) => {
|
||||
const role = this.get(key);
|
||||
return (
|
||||
role === "admin" ||
|
||||
role === "writer" ||
|
||||
role === "reader" ||
|
||||
role === "adminInvite" ||
|
||||
role === "writerInvite" ||
|
||||
role === "readerInvite"
|
||||
);
|
||||
});
|
||||
const currentlyPermittedReaders = memberKeys.filter((key) =>
|
||||
canRead(this, key),
|
||||
);
|
||||
|
||||
const writeOnlyMembers = memberKeys.filter((key) => {
|
||||
const role = this.get(key);
|
||||
@@ -543,12 +730,12 @@ export class RawGroup<
|
||||
|
||||
// Get these early, so we fail fast if they are unavailable
|
||||
const parentGroups = this.getParentGroups();
|
||||
const childGroups = this.getChildGroups();
|
||||
|
||||
const maybeCurrentReadKey = this.core.getCurrentReadKey();
|
||||
const maybeCurrentReadKey = this.getCurrentReadKey();
|
||||
|
||||
if (!maybeCurrentReadKey.secret) {
|
||||
throw new Error("Can't rotate read key secret we don't have access to");
|
||||
throw new NoReadKeyAccessError(
|
||||
"Can't rotate read key secret we don't have access to",
|
||||
);
|
||||
}
|
||||
|
||||
const currentReadKey = {
|
||||
@@ -631,7 +818,7 @@ export class RawGroup<
|
||||
*/
|
||||
for (const parent of parentGroups) {
|
||||
const { id: parentReadKeyID, secret: parentReadKeySecret } =
|
||||
parent.core.getCurrentReadKey();
|
||||
parent.getCurrentReadKey();
|
||||
|
||||
if (!parentReadKeySecret) {
|
||||
// We can't reveal the new child key to the parent group where we don't have access to the parent read key
|
||||
@@ -655,16 +842,26 @@ export class RawGroup<
|
||||
);
|
||||
}
|
||||
|
||||
for (const child of childGroups) {
|
||||
this.forEachChildGroup((child) => {
|
||||
// Since child references are mantained only for the key rotation,
|
||||
// circular references are skipped here because it's more performant
|
||||
// than always checking for circular references in childs inside the permission checks
|
||||
if (child.isSelfExtension(this)) {
|
||||
continue;
|
||||
return;
|
||||
}
|
||||
|
||||
child.rotateReadKey(removedMemberKey);
|
||||
}
|
||||
try {
|
||||
child.rotateReadKey(removedMemberKey);
|
||||
} catch (error) {
|
||||
if (error instanceof NoReadKeyAccessError) {
|
||||
logger.warn(
|
||||
`Can't rotate read key on child ${child.id} because we don't have access to the read key`,
|
||||
);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Detect circular references in group inheritance */
|
||||
@@ -695,6 +892,19 @@ export class RawGroup<
|
||||
}
|
||||
}
|
||||
|
||||
getCurrentReadKey() {
|
||||
const keyId = this.getCurrentReadKeyId();
|
||||
|
||||
if (!keyId) {
|
||||
throw new Error("No readKey set");
|
||||
}
|
||||
|
||||
return {
|
||||
secret: this.getReadKey(keyId),
|
||||
id: keyId,
|
||||
};
|
||||
}
|
||||
|
||||
extend(
|
||||
parent: RawGroup,
|
||||
role: "reader" | "writer" | "admin" | "inherit" = "inherit",
|
||||
@@ -727,14 +937,15 @@ export class RawGroup<
|
||||
);
|
||||
}
|
||||
|
||||
const { id: parentReadKeyID, secret: parentReadKeySecret } =
|
||||
parent.core.getCurrentReadKey();
|
||||
let { id: parentReadKeyID, secret: parentReadKeySecret } =
|
||||
parent.getCurrentReadKey();
|
||||
|
||||
if (!parentReadKeySecret) {
|
||||
throw new Error("Can't extend group without parent read key secret");
|
||||
}
|
||||
|
||||
const { id: childReadKeyID, secret: childReadKeySecret } =
|
||||
this.core.getCurrentReadKey();
|
||||
this.getCurrentReadKey();
|
||||
if (!childReadKeySecret) {
|
||||
throw new Error("Can't extend group without child read key secret");
|
||||
}
|
||||
@@ -755,7 +966,7 @@ export class RawGroup<
|
||||
);
|
||||
}
|
||||
|
||||
async revokeExtend(parent: RawGroup) {
|
||||
revokeExtend(parent: RawGroup) {
|
||||
if (this.myRole() !== "admin") {
|
||||
throw new Error(
|
||||
"To unextend a group, the current account must be an admin in the child group",
|
||||
@@ -786,8 +997,6 @@ export class RawGroup<
|
||||
// Set the child key on the parent group to `revoked`
|
||||
parent.set(`child_${this.id}`, "revoked", "trusting");
|
||||
|
||||
await this.loadAllChildGroups();
|
||||
|
||||
// Rotate the keys on the child group
|
||||
this.rotateReadKey();
|
||||
}
|
||||
@@ -799,19 +1008,7 @@ export class RawGroup<
|
||||
*
|
||||
* @category 2. Role changing
|
||||
*/
|
||||
async removeMember(
|
||||
account: RawAccount | ControlledAccountOrAgent | Everyone,
|
||||
) {
|
||||
// Ensure all child groups are loaded before removing a member
|
||||
await this.loadAllChildGroups();
|
||||
|
||||
this.removeMemberInternal(account);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
removeMemberInternal(
|
||||
account: RawAccount | ControlledAccountOrAgent | AgentID | Everyone,
|
||||
) {
|
||||
removeMember(account: RawAccount | ControlledAccountOrAgent | Everyone) {
|
||||
const memberKey = typeof account === "string" ? account : account.id;
|
||||
|
||||
if (this.myRole() === "admin") {
|
||||
@@ -1022,3 +1219,25 @@ export function secretSeedFromInviteSecret(inviteSecret: InviteSecret) {
|
||||
|
||||
return base58.decode(inviteSecret.slice("inviteSecret_z".length));
|
||||
}
|
||||
|
||||
const canRead = (
|
||||
group: RawGroup,
|
||||
key: RawAccountID | AgentID | "everyone",
|
||||
): boolean => {
|
||||
const role = group.get(key);
|
||||
return (
|
||||
role === "admin" ||
|
||||
role === "writer" ||
|
||||
role === "reader" ||
|
||||
role === "adminInvite" ||
|
||||
role === "writerInvite" ||
|
||||
role === "readerInvite"
|
||||
);
|
||||
};
|
||||
|
||||
class NoReadKeyAccessError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "NoReadKeyAccessError";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,13 @@
|
||||
This also means that we want to keep signatures roughly after each MAX_RECOMMENDED_TX size chunk,
|
||||
to be able to verify partially loaded CoValues or CoValues that are still being created (like a video live stream).
|
||||
**/
|
||||
export const MAX_RECOMMENDED_TX_SIZE = 100 * 1024;
|
||||
export const TRANSACTION_CONFIG = {
|
||||
MAX_RECOMMENDED_TX_SIZE: 100 * 1024,
|
||||
};
|
||||
|
||||
export function setMaxRecommendedTxSize(size: number) {
|
||||
TRANSACTION_CONFIG.MAX_RECOMMENDED_TX_SIZE = size;
|
||||
}
|
||||
|
||||
export const CO_VALUE_LOADING_CONFIG = {
|
||||
MAX_RETRIES: 1,
|
||||
@@ -32,3 +38,16 @@ export const SYNC_SCHEDULER_CONFIG = {
|
||||
export function setIncomingMessagesTimeBudget(budget: number) {
|
||||
SYNC_SCHEDULER_CONFIG.INCOMING_MESSAGES_TIME_BUDGET = budget;
|
||||
}
|
||||
|
||||
export const GARBAGE_COLLECTOR_CONFIG = {
|
||||
MAX_AGE: 1000 * 60 * 10, // 10 minutes
|
||||
INTERVAL: 1000 * 60 * 5, // 5 minutes
|
||||
};
|
||||
|
||||
export function setGarbageCollectorMaxAge(maxAge: number) {
|
||||
GARBAGE_COLLECTOR_CONFIG.MAX_AGE = maxAge;
|
||||
}
|
||||
|
||||
export function setGarbageCollectorInterval(interval: number) {
|
||||
GARBAGE_COLLECTOR_CONFIG.INTERVAL = interval;
|
||||
}
|
||||
|
||||
@@ -61,20 +61,25 @@ import { disablePermissionErrors } from "./permissions.js";
|
||||
import type { Peer, SyncMessage } from "./sync.js";
|
||||
import { DisconnectedError, SyncManager, emptyKnownState } from "./sync.js";
|
||||
|
||||
type Value = JsonValue | AnyRawCoValue;
|
||||
|
||||
export { PriorityBasedMessageQueue } from "./queue/PriorityBasedMessageQueue.js";
|
||||
import {
|
||||
getContentMessageSize,
|
||||
getTransactionSize,
|
||||
} from "./coValueContentMessage.js";
|
||||
import { getDependedOnCoValuesFromRawData } from "./coValueCore/utils.js";
|
||||
import {
|
||||
CO_VALUE_LOADING_CONFIG,
|
||||
MAX_RECOMMENDED_TX_SIZE,
|
||||
TRANSACTION_CONFIG,
|
||||
setCoValueLoadingRetryDelay,
|
||||
setIncomingMessagesTimeBudget,
|
||||
setMaxRecommendedTxSize,
|
||||
} from "./config.js";
|
||||
import { LogLevel, logger } from "./logger.js";
|
||||
import { CO_VALUE_PRIORITY, getPriorityFromHeader } from "./priority.js";
|
||||
import { getDependedOnCoValues } from "./storage/syncUtils.js";
|
||||
|
||||
type Value = JsonValue | AnyRawCoValue;
|
||||
|
||||
export { PriorityBasedMessageQueue } from "./queue/PriorityBasedMessageQueue.js";
|
||||
/** @hidden */
|
||||
export const cojsonInternals = {
|
||||
connectedPeers,
|
||||
@@ -106,6 +111,10 @@ export const cojsonInternals = {
|
||||
ConnectedPeerChannel,
|
||||
textEncoder,
|
||||
textDecoder,
|
||||
getTransactionSize,
|
||||
getContentMessageSize,
|
||||
TRANSACTION_CONFIG,
|
||||
setMaxRecommendedTxSize,
|
||||
};
|
||||
|
||||
export {
|
||||
@@ -132,7 +141,6 @@ export {
|
||||
Media,
|
||||
CoValueCore,
|
||||
ControlledAgent,
|
||||
MAX_RECOMMENDED_TX_SIZE,
|
||||
JsonObject,
|
||||
JsonValue,
|
||||
Peer,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { base58 } from "@scure/base";
|
||||
import { CoID } from "./coValue.js";
|
||||
import { RawAccountID } from "./coValues/account.js";
|
||||
import type { CoID } from "./coValue.js";
|
||||
import type { RawAccountID } from "./coValues/account.js";
|
||||
import type { RawGroup } from "./coValues/group.js";
|
||||
import { shortHashLength } from "./crypto/crypto.js";
|
||||
import { RawGroup } from "./exports.js";
|
||||
|
||||
export type RawCoID = `co_z${string}`;
|
||||
export type ParentGroupReference = `parent_${CoID<RawGroup>}`;
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { Result, err, ok } from "neverthrow";
|
||||
import { CoID } from "./coValue.js";
|
||||
import { RawCoValue } from "./coValue.js";
|
||||
import { GarbageCollector } from "./GarbageCollector.js";
|
||||
import type { CoID } from "./coValue.js";
|
||||
import type { RawCoValue } from "./coValue.js";
|
||||
import {
|
||||
AvailableCoValueCore,
|
||||
type AvailableCoValueCore,
|
||||
CoValueCore,
|
||||
idforHeader,
|
||||
} from "./coValueCore/coValueCore.js";
|
||||
import {
|
||||
CoValueHeader,
|
||||
CoValueUniqueness,
|
||||
type CoValueHeader,
|
||||
type CoValueUniqueness,
|
||||
VerifiedState,
|
||||
} from "./coValueCore/verifiedState.js";
|
||||
import {
|
||||
@@ -26,11 +27,11 @@ import {
|
||||
expectAccount,
|
||||
} from "./coValues/account.js";
|
||||
import {
|
||||
InviteSecret,
|
||||
RawGroup,
|
||||
type InviteSecret,
|
||||
type RawGroup,
|
||||
secretSeedFromInviteSecret,
|
||||
} from "./coValues/group.js";
|
||||
import { CO_VALUE_LOADING_CONFIG } from "./config.js";
|
||||
import { CO_VALUE_LOADING_CONFIG, GARBAGE_COLLECTOR_CONFIG } from "./config.js";
|
||||
import { AgentSecret, CryptoProvider } from "./crypto/crypto.js";
|
||||
import { AgentID, RawCoID, SessionID, isAgentID } from "./ids.js";
|
||||
import { logger } from "./logger.js";
|
||||
@@ -63,6 +64,7 @@ export class LocalNode {
|
||||
/** @category 3. Low-level */
|
||||
syncManager = new SyncManager(this);
|
||||
|
||||
garbageCollector: GarbageCollector | undefined = undefined;
|
||||
crashed: Error | undefined = undefined;
|
||||
|
||||
storage?: StorageAPI;
|
||||
@@ -78,6 +80,14 @@ export class LocalNode {
|
||||
this.crypto = crypto;
|
||||
}
|
||||
|
||||
enableGarbageCollector() {
|
||||
if (this.garbageCollector) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.garbageCollector = new GarbageCollector(this.coValues);
|
||||
}
|
||||
|
||||
setStorage(storage: StorageAPI) {
|
||||
this.storage = storage;
|
||||
}
|
||||
@@ -95,6 +105,8 @@ export class LocalNode {
|
||||
this.coValues.set(id, entry);
|
||||
}
|
||||
|
||||
this.garbageCollector?.trackCoValueAccess(entry);
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
@@ -351,6 +363,7 @@ export class LocalNode {
|
||||
new VerifiedState(id, this.crypto, header, new Map()),
|
||||
);
|
||||
|
||||
this.garbageCollector?.trackCoValueAccess(coValue);
|
||||
this.syncManager.syncHeader(coValue.verified);
|
||||
|
||||
return coValue;
|
||||
@@ -745,6 +758,7 @@ export class LocalNode {
|
||||
*/
|
||||
gracefulShutdown(): Promise<unknown> | undefined {
|
||||
this.syncManager.gracefulShutdown();
|
||||
this.garbageCollector?.stop();
|
||||
return this.storage?.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ export class LocalTransactionsSyncQueue {
|
||||
const lastPendingSync = this.queue.tail?.value;
|
||||
const lastSignatureIdx = coValue.getLastSignatureCheckpoint(sessionID);
|
||||
const isSignatureCheckpoint =
|
||||
lastSignatureIdx > -1 && lastSignatureIdx === txIdx - 1;
|
||||
lastSignatureIdx > -1 && lastSignatureIdx === txIdx;
|
||||
|
||||
if (lastPendingSync?.id === coValue.id && !isSignatureCheckpoint) {
|
||||
addTransactionToContentMessage(
|
||||
|
||||
@@ -8,7 +8,38 @@ type StoreQueueEntry = {
|
||||
correctionCallback: CorrectionCallback;
|
||||
};
|
||||
|
||||
class StoreQueueManager {
|
||||
private backlog = new LinkedList<{
|
||||
queue: StoreQueue;
|
||||
callback: () => Promise<unknown>;
|
||||
}>();
|
||||
|
||||
private processing = false;
|
||||
|
||||
async schedule(queue: StoreQueue, callback: () => Promise<unknown>) {
|
||||
this.backlog.push({ queue, callback });
|
||||
|
||||
if (this.processing) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.processing = true;
|
||||
|
||||
while (this.backlog.head) {
|
||||
const entry = this.backlog.head;
|
||||
|
||||
await entry.value.callback();
|
||||
|
||||
this.backlog.shift();
|
||||
}
|
||||
|
||||
this.processing = false;
|
||||
}
|
||||
}
|
||||
|
||||
export class StoreQueue {
|
||||
static manager = new StoreQueueManager();
|
||||
|
||||
private queue = new LinkedList<StoreQueueEntry>();
|
||||
closed = false;
|
||||
|
||||
@@ -27,7 +58,7 @@ export class StoreQueue {
|
||||
processing = false;
|
||||
lastCallback: Promise<unknown> | undefined;
|
||||
|
||||
async processQueue(
|
||||
processQueue(
|
||||
callback: (
|
||||
data: NewContentMessage,
|
||||
correctionCallback: CorrectionCallback,
|
||||
@@ -39,21 +70,23 @@ export class StoreQueue {
|
||||
|
||||
this.processing = true;
|
||||
|
||||
let entry: StoreQueueEntry | undefined;
|
||||
return StoreQueue.manager.schedule(this, async () => {
|
||||
let entry: StoreQueueEntry | undefined;
|
||||
|
||||
while ((entry = this.pull())) {
|
||||
const { data, correctionCallback } = entry;
|
||||
while ((entry = this.pull())) {
|
||||
const { data, correctionCallback } = entry;
|
||||
|
||||
try {
|
||||
this.lastCallback = callback(data, correctionCallback);
|
||||
await this.lastCallback;
|
||||
} catch (err) {
|
||||
logger.error("Error processing message in store queue", { err });
|
||||
try {
|
||||
this.lastCallback = callback(data, correctionCallback);
|
||||
await this.lastCallback;
|
||||
} catch (err) {
|
||||
logger.error("Error processing message in store queue", { err });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.lastCallback = undefined;
|
||||
this.processing = false;
|
||||
this.lastCallback = undefined;
|
||||
this.processing = false;
|
||||
});
|
||||
}
|
||||
|
||||
close() {
|
||||
|
||||
@@ -112,24 +112,34 @@ export class SQLiteClient implements DBClientInterfaceSync {
|
||||
) as SignatureAfterRow[];
|
||||
}
|
||||
|
||||
addCoValue(msg: NewContentMessage): number {
|
||||
getCoValueRowID(id: RawCoID): number | undefined {
|
||||
const row = this.db.get<{ rowID: number }>(
|
||||
"SELECT rowID FROM coValues WHERE id = ?",
|
||||
[id],
|
||||
);
|
||||
return row?.rowID;
|
||||
}
|
||||
|
||||
upsertCoValue(id: RawCoID, header?: CoValueHeader): number | undefined {
|
||||
if (!header) {
|
||||
return this.getCoValueRowID(id);
|
||||
}
|
||||
|
||||
const result = this.db.get<{ rowID: number }>(
|
||||
"INSERT INTO coValues (id, header) VALUES (?, ?) RETURNING rowID",
|
||||
[msg.id, JSON.stringify(msg.header)],
|
||||
`INSERT INTO coValues (id, header) VALUES (?, ?)
|
||||
ON CONFLICT(id) DO NOTHING
|
||||
RETURNING rowID`,
|
||||
[id, JSON.stringify(header)],
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
throw new Error("Failed to add coValue");
|
||||
return this.getCoValueRowID(id);
|
||||
}
|
||||
|
||||
return result.rowID;
|
||||
}
|
||||
|
||||
addSessionUpdate({
|
||||
sessionUpdate,
|
||||
}: {
|
||||
sessionUpdate: SessionRow;
|
||||
}): number {
|
||||
addSessionUpdate({ sessionUpdate }: { sessionUpdate: SessionRow }): number {
|
||||
const result = this.db.get<{ rowID: number }>(
|
||||
`INSERT INTO sessions (coValue, sessionID, lastIdx, lastSignature, bytesSinceLastSignature) VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(coValue, sessionID) DO UPDATE SET lastIdx=excluded.lastIdx, lastSignature=excluded.lastSignature, bytesSinceLastSignature=excluded.bytesSinceLastSignature
|
||||
@@ -166,7 +176,11 @@ export class SQLiteClient implements DBClientInterfaceSync {
|
||||
sessionRowID,
|
||||
idx,
|
||||
signature,
|
||||
}: { sessionRowID: number; idx: number; signature: Signature }) {
|
||||
}: {
|
||||
sessionRowID: number;
|
||||
idx: number;
|
||||
signature: Signature;
|
||||
}) {
|
||||
this.db.run(
|
||||
"INSERT INTO signatureAfter (ses, idx, signature) VALUES (?, ?, ?)",
|
||||
[sessionRowID, idx, signature],
|
||||
|
||||
@@ -112,14 +112,31 @@ export class SQLiteClientAsync implements DBClientInterfaceAsync {
|
||||
);
|
||||
}
|
||||
|
||||
async addCoValue(msg: NewContentMessage): Promise<number> {
|
||||
async getCoValueRowID(id: RawCoID): Promise<number | undefined> {
|
||||
const row = await this.db.get<{ rowID: number }>(
|
||||
"SELECT rowID FROM coValues WHERE id = ?",
|
||||
[id],
|
||||
);
|
||||
return row?.rowID;
|
||||
}
|
||||
|
||||
async upsertCoValue(
|
||||
id: RawCoID,
|
||||
header?: CoValueHeader,
|
||||
): Promise<number | undefined> {
|
||||
if (!header) {
|
||||
return this.getCoValueRowID(id);
|
||||
}
|
||||
|
||||
const result = await this.db.get<{ rowID: number }>(
|
||||
"INSERT INTO coValues (id, header) VALUES (?, ?) RETURNING rowID",
|
||||
[msg.id, JSON.stringify(msg.header)],
|
||||
`INSERT INTO coValues (id, header) VALUES (?, ?)
|
||||
ON CONFLICT(id) DO NOTHING
|
||||
RETURNING rowID`,
|
||||
[id, JSON.stringify(header)],
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
throw new Error("Failed to add coValue");
|
||||
return this.getCoValueRowID(id);
|
||||
}
|
||||
|
||||
return result.rowID;
|
||||
@@ -166,7 +183,11 @@ export class SQLiteClientAsync implements DBClientInterfaceAsync {
|
||||
sessionRowID,
|
||||
idx,
|
||||
signature,
|
||||
}: { sessionRowID: number; idx: number; signature: Signature }) {
|
||||
}: {
|
||||
sessionRowID: number;
|
||||
idx: number;
|
||||
signature: Signature;
|
||||
}) {
|
||||
this.db.run(
|
||||
"INSERT INTO signatureAfter (ses, idx, signature) VALUES (?, ?, ?)",
|
||||
[sessionRowID, idx, signature],
|
||||
|
||||
@@ -254,22 +254,18 @@ export class StorageApiAsync implements StorageAPI {
|
||||
}
|
||||
|
||||
const id = msg.id;
|
||||
const coValueRow = await this.dbClient.getCoValue(id);
|
||||
const storedCoValueRowID = await this.dbClient.upsertCoValue(
|
||||
id,
|
||||
msg.header,
|
||||
);
|
||||
|
||||
// We have no info about coValue header
|
||||
const invalidAssumptionOnHeaderPresence = !msg.header && !coValueRow;
|
||||
|
||||
if (invalidAssumptionOnHeaderPresence) {
|
||||
if (!storedCoValueRowID) {
|
||||
const knownState = emptyKnownState(id as RawCoID);
|
||||
this.knwonStates.setKnownState(id, knownState);
|
||||
|
||||
return this.handleCorrection(knownState, correctionCallback);
|
||||
}
|
||||
|
||||
const storedCoValueRowID: number = coValueRow
|
||||
? coValueRow.rowID
|
||||
: await this.dbClient.addCoValue(msg);
|
||||
|
||||
const knownState = this.knwonStates.getKnownState(id);
|
||||
knownState.header = true;
|
||||
|
||||
|
||||
@@ -235,22 +235,15 @@ export class StorageApiSync implements StorageAPI {
|
||||
correctionCallback: CorrectionCallback,
|
||||
): boolean {
|
||||
const id = msg.id;
|
||||
const coValueRow = this.dbClient.getCoValue(id);
|
||||
const storedCoValueRowID = this.dbClient.upsertCoValue(id, msg.header);
|
||||
|
||||
// We have no info about coValue header
|
||||
const invalidAssumptionOnHeaderPresence = !msg.header && !coValueRow;
|
||||
|
||||
if (invalidAssumptionOnHeaderPresence) {
|
||||
if (!storedCoValueRowID) {
|
||||
const knownState = emptyKnownState(id as RawCoID);
|
||||
this.knwonStates.setKnownState(id, knownState);
|
||||
|
||||
return this.handleCorrection(knownState, correctionCallback);
|
||||
}
|
||||
|
||||
const storedCoValueRowID: number = coValueRow
|
||||
? coValueRow.rowID
|
||||
: this.dbClient.addCoValue(msg);
|
||||
|
||||
const knownState = this.knwonStates.getKnownState(id);
|
||||
knownState.header = true;
|
||||
|
||||
|
||||
@@ -65,6 +65,11 @@ export interface DBClientInterfaceAsync {
|
||||
coValueId: string,
|
||||
): Promise<StoredCoValueRow | undefined> | undefined;
|
||||
|
||||
upsertCoValue(
|
||||
id: string,
|
||||
header?: CoValueHeader,
|
||||
): Promise<number | undefined>;
|
||||
|
||||
getCoValueSessions(coValueRowId: number): Promise<StoredSessionRow[]>;
|
||||
|
||||
getSingleCoValueSession(
|
||||
@@ -83,8 +88,6 @@ export interface DBClientInterfaceAsync {
|
||||
firstNewTxIdx: number,
|
||||
): Promise<SignatureAfterRow[]>;
|
||||
|
||||
addCoValue(msg: NewContentMessage): Promise<number>;
|
||||
|
||||
addSessionUpdate({
|
||||
sessionUpdate,
|
||||
sessionRow,
|
||||
@@ -115,6 +118,8 @@ export interface DBClientInterfaceAsync {
|
||||
export interface DBClientInterfaceSync {
|
||||
getCoValue(coValueId: string): StoredCoValueRow | undefined;
|
||||
|
||||
upsertCoValue(id: string, header?: CoValueHeader): number | undefined;
|
||||
|
||||
getCoValueSessions(coValueRowId: number): StoredSessionRow[];
|
||||
|
||||
getSingleCoValueSession(
|
||||
@@ -133,8 +138,6 @@ export interface DBClientInterfaceSync {
|
||||
firstNewTxIdx: number,
|
||||
): Pick<SignatureAfterRow, "idx" | "signature">[];
|
||||
|
||||
addCoValue(msg: NewContentMessage): number;
|
||||
|
||||
addSessionUpdate({
|
||||
sessionUpdate,
|
||||
sessionRow,
|
||||
|
||||
@@ -461,6 +461,22 @@ export class SyncManager {
|
||||
|
||||
if (!coValue.hasVerifiedContent()) {
|
||||
if (!msg.header) {
|
||||
const storageKnownState = this.local.storage?.getKnownState(msg.id);
|
||||
|
||||
if (storageKnownState?.header) {
|
||||
// If the CoValue has been garbage collected, we load it from the storage before handling the new content
|
||||
coValue.loadFromStorage((found) => {
|
||||
if (found) {
|
||||
this.handleNewContent(msg, from);
|
||||
} else {
|
||||
logger.error("Known CoValue not found in storage", {
|
||||
id: msg.id,
|
||||
});
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (peer) {
|
||||
this.trySendToPeer(peer, {
|
||||
action: "known",
|
||||
@@ -782,12 +798,12 @@ export class SyncManager {
|
||||
|
||||
if (!storage) return;
|
||||
|
||||
const value = this.local.getCoValue(content.id);
|
||||
|
||||
// Try to store the content as-is for performance
|
||||
// In case that some transactions are missing, a correction will be requested, but it's an edge case
|
||||
storage.store(content, (correction) => {
|
||||
return this.local
|
||||
.getCoValue(content.id)
|
||||
.verified?.newContentSince(correction);
|
||||
return value.verified?.newContentSince(correction);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
127
packages/cojson/src/tests/GarbageCollector.test.ts
Normal file
127
packages/cojson/src/tests/GarbageCollector.test.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { assert, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { setGarbageCollectorMaxAge } from "../config";
|
||||
import { TEST_NODE_CONFIG, setupTestAccount, setupTestNode } from "./testUtils";
|
||||
|
||||
// We want to simulate a real world communication that happens asynchronously
|
||||
TEST_NODE_CONFIG.withAsyncPeers = true;
|
||||
|
||||
beforeEach(() => {
|
||||
// We want to test what happens when the garbage collector kicks in and removes a coValue
|
||||
// We set the max age to -1 to make it remove everything
|
||||
setGarbageCollectorMaxAge(-1);
|
||||
});
|
||||
|
||||
describe("garbage collector", () => {
|
||||
test("coValues are garbage collected when maxAge is reached", async () => {
|
||||
const client = setupTestNode();
|
||||
|
||||
client.addStorage({
|
||||
ourName: "client",
|
||||
});
|
||||
client.node.enableGarbageCollector();
|
||||
|
||||
const group = client.node.createGroup();
|
||||
const map = group.createMap();
|
||||
map.set("hello", "world", "trusting");
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
client.node.garbageCollector?.collect();
|
||||
|
||||
const coValue = client.node.getCoValue(map.id);
|
||||
|
||||
expect(coValue.isAvailable()).toBe(false);
|
||||
});
|
||||
|
||||
test("coValues are not garbage collected if they have listeners", async () => {
|
||||
const client = setupTestNode();
|
||||
|
||||
client.addStorage({
|
||||
ourName: "client",
|
||||
});
|
||||
client.node.enableGarbageCollector();
|
||||
|
||||
const group = client.node.createGroup();
|
||||
const map = group.createMap();
|
||||
map.set("hello", "world", "trusting");
|
||||
|
||||
// Add a listener to the map
|
||||
const unsubscribe = map.subscribe(() => {
|
||||
// This listener keeps the coValue alive
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
client.node.garbageCollector?.collect();
|
||||
|
||||
expect(client.node.getCoValue(map.id).isAvailable()).toBe(true);
|
||||
|
||||
// Clean up the listener
|
||||
unsubscribe();
|
||||
|
||||
// The coValue should be collected after the listener is removed
|
||||
client.node.garbageCollector?.collect();
|
||||
|
||||
expect(client.node.getCoValue(map.id).isAvailable()).toBe(false);
|
||||
});
|
||||
|
||||
test("coValues are not garbage collected if they are a group or account", async () => {
|
||||
const client = await setupTestAccount();
|
||||
|
||||
client.addStorage({
|
||||
ourName: "client",
|
||||
});
|
||||
client.node.enableGarbageCollector();
|
||||
|
||||
const group = client.node.createGroup();
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
client.node.garbageCollector?.collect();
|
||||
|
||||
expect(client.node.getCoValue(group.id).isAvailable()).toBe(true);
|
||||
expect(client.node.getCoValue(client.accountID).isAvailable()).toBe(true);
|
||||
});
|
||||
|
||||
test("coValues are not garbage collected if the maxAge is not reached", async () => {
|
||||
setGarbageCollectorMaxAge(1000);
|
||||
|
||||
const client = setupTestNode();
|
||||
|
||||
client.addStorage({
|
||||
ourName: "client",
|
||||
});
|
||||
client.node.enableGarbageCollector();
|
||||
|
||||
const garbageCollector = client.node.garbageCollector;
|
||||
|
||||
assert(garbageCollector);
|
||||
|
||||
const getCurrentTime = vi.spyOn(garbageCollector, "getCurrentTime");
|
||||
|
||||
getCurrentTime.mockReturnValue(1);
|
||||
|
||||
const group = client.node.createGroup();
|
||||
const map1 = group.createMap();
|
||||
const map2 = group.createMap();
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
map1.set("hello", "world", "trusting");
|
||||
|
||||
getCurrentTime.mockReturnValue(2000);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
garbageCollector.collect();
|
||||
|
||||
const coValue = client.node.getCoValue(map1.id);
|
||||
|
||||
expect(coValue.isAvailable()).toBe(true);
|
||||
|
||||
const coValue2 = client.node.getCoValue(map2.id);
|
||||
|
||||
expect(coValue2.isAvailable()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,21 @@
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
import { expectPlainText } from "../coValue.js";
|
||||
import { setMaxRecommendedTxSize } from "../config.js";
|
||||
import { WasmCrypto } from "../crypto/WasmCrypto.js";
|
||||
import { nodeWithRandomAgentAndSessionID } from "./testUtils.js";
|
||||
import {
|
||||
SyncMessagesLog,
|
||||
loadCoValueOrFail,
|
||||
nodeWithRandomAgentAndSessionID,
|
||||
setupTestNode,
|
||||
} from "./testUtils.js";
|
||||
|
||||
const Crypto = await WasmCrypto.create();
|
||||
|
||||
beforeEach(() => {
|
||||
setMaxRecommendedTxSize(100 * 1024);
|
||||
SyncMessagesLog.clear();
|
||||
});
|
||||
|
||||
afterEach(() => void vi.unstubAllGlobals());
|
||||
|
||||
test("Empty CoPlainText works", () => {
|
||||
@@ -185,8 +196,8 @@ test("insertBefore and insertAfter work as expected", () => {
|
||||
expect(content.toString()).toEqual("hey");
|
||||
|
||||
// Insert '!' at start
|
||||
content.insertBefore(0, "!", "trusting"); // "!hey"
|
||||
expect(content.toString()).toEqual("!hey");
|
||||
content.insertBefore(0, "!?", "trusting"); // "!?hey"
|
||||
expect(content.toString()).toEqual("!?hey");
|
||||
});
|
||||
|
||||
test("Can delete a single grapheme", () => {
|
||||
@@ -286,3 +297,164 @@ test("Splits into and from grapheme string arrays", () => {
|
||||
const text = content.fromGraphemes(graphemes);
|
||||
expect(text).toEqual("👋 안녕!");
|
||||
});
|
||||
|
||||
test("chunks transactions when when the chars are longer than MAX_RECOMMENDED_TX_SIZE", async () => {
|
||||
setMaxRecommendedTxSize(5);
|
||||
|
||||
const client = setupTestNode();
|
||||
const { storage } = client.addStorage();
|
||||
|
||||
const coValue = client.node.createCoValue({
|
||||
type: "coplaintext",
|
||||
ruleset: { type: "unsafeAllowAll" },
|
||||
meta: null,
|
||||
...Crypto.createdNowUnique(),
|
||||
});
|
||||
|
||||
const content = expectPlainText(coValue.getCurrentContent());
|
||||
|
||||
content.insertAfter(
|
||||
content.entries().length,
|
||||
"I'm writing you to test that coplaintext",
|
||||
"trusting",
|
||||
);
|
||||
content.insertAfter(
|
||||
content.entries().length,
|
||||
" chunks transactions when when the chars are longer than MAX_RECOMMENDED_TX_SIZE.",
|
||||
"trusting",
|
||||
);
|
||||
content.insertAfter(
|
||||
content.entries().length,
|
||||
"This is required because when a user paste 1Mb of text, we can split it in multiple websocket messages.",
|
||||
"trusting",
|
||||
);
|
||||
|
||||
content.insertBefore(0, "Dear reader,\n", "trusting");
|
||||
|
||||
expect(content.toString()).toMatchInlineSnapshot(
|
||||
`"Dear reader,\nI'm writing you to test that coplaintext chunks transactions when when the chars are longer than MAX_RECOMMENDED_TX_SIZE.This is required because when a user paste 1Mb of text, we can split it in multiple websocket messages."`,
|
||||
);
|
||||
|
||||
await coValue.waitForSync();
|
||||
|
||||
client.restart();
|
||||
client.addStorage({
|
||||
storage,
|
||||
});
|
||||
|
||||
const loaded = await loadCoValueOrFail(client.node, content.id);
|
||||
await loaded.core.waitForSync();
|
||||
|
||||
expect(loaded.toString()).toEqual(content.toString());
|
||||
|
||||
expect(
|
||||
SyncMessagesLog.getMessages({
|
||||
CoPlainText: coValue,
|
||||
}),
|
||||
).toMatchInlineSnapshot(`
|
||||
[
|
||||
"client -> storage | CONTENT CoPlainText header: true new: ",
|
||||
"client -> storage | CONTENT CoPlainText header: false new: After: 0 New: 1",
|
||||
"client -> storage | CONTENT CoPlainText header: false new: After: 1 New: 1",
|
||||
"client -> storage | CONTENT CoPlainText header: false new: After: 2 New: 1",
|
||||
"client -> storage | CONTENT CoPlainText header: false new: After: 3 New: 1",
|
||||
"client -> storage | CONTENT CoPlainText header: false new: After: 4 New: 1",
|
||||
"client -> storage | CONTENT CoPlainText header: false new: After: 5 New: 1",
|
||||
"client -> storage | CONTENT CoPlainText header: false new: After: 6 New: 1",
|
||||
"client -> storage | CONTENT CoPlainText header: false new: After: 7 New: 1",
|
||||
"client -> storage | CONTENT CoPlainText header: false new: After: 8 New: 1",
|
||||
"client -> storage | CONTENT CoPlainText header: false new: After: 9 New: 1",
|
||||
"client -> storage | CONTENT CoPlainText header: false new: After: 10 New: 1",
|
||||
"client -> storage | CONTENT CoPlainText header: false new: After: 11 New: 1",
|
||||
"client -> storage | CONTENT CoPlainText header: false new: After: 12 New: 1",
|
||||
"client -> storage | CONTENT CoPlainText header: false new: After: 13 New: 1",
|
||||
"client -> storage | CONTENT CoPlainText header: false new: After: 14 New: 1",
|
||||
"client -> storage | CONTENT CoPlainText header: false new: After: 15 New: 1",
|
||||
"client -> storage | CONTENT CoPlainText header: false new: After: 16 New: 1",
|
||||
"client -> storage | CONTENT CoPlainText header: false new: After: 17 New: 1",
|
||||
"client -> storage | CONTENT CoPlainText header: false new: After: 18 New: 1",
|
||||
"client -> storage | CONTENT CoPlainText header: false new: After: 19 New: 1",
|
||||
"client -> storage | CONTENT CoPlainText header: false new: After: 20 New: 1",
|
||||
"client -> storage | CONTENT CoPlainText header: false new: After: 21 New: 1",
|
||||
"client -> storage | CONTENT CoPlainText header: false new: After: 22 New: 1",
|
||||
"client -> storage | CONTENT CoPlainText header: false new: After: 23 New: 1",
|
||||
"client -> storage | CONTENT CoPlainText header: false new: After: 24 New: 1",
|
||||
"client -> storage | CONTENT CoPlainText header: false new: After: 25 New: 1",
|
||||
"client -> storage | CONTENT CoPlainText header: false new: After: 26 New: 1",
|
||||
"client -> storage | CONTENT CoPlainText header: false new: After: 27 New: 1",
|
||||
"client -> storage | CONTENT CoPlainText header: false new: After: 28 New: 1",
|
||||
"client -> storage | CONTENT CoPlainText header: false new: After: 29 New: 1",
|
||||
"client -> storage | CONTENT CoPlainText header: false new: After: 30 New: 1",
|
||||
"client -> storage | CONTENT CoPlainText header: false new: After: 31 New: 1",
|
||||
"client -> storage | CONTENT CoPlainText header: false new: After: 32 New: 1",
|
||||
"client -> storage | CONTENT CoPlainText header: false new: After: 33 New: 1",
|
||||
"client -> storage | CONTENT CoPlainText header: false new: After: 34 New: 1",
|
||||
"client -> storage | CONTENT CoPlainText header: false new: After: 35 New: 1",
|
||||
"client -> storage | CONTENT CoPlainText header: false new: After: 36 New: 1",
|
||||
"client -> storage | CONTENT CoPlainText header: false new: After: 37 New: 1",
|
||||
"client -> storage | CONTENT CoPlainText header: false new: After: 38 New: 1",
|
||||
"client -> storage | CONTENT CoPlainText header: false new: After: 39 New: 1",
|
||||
"client -> storage | CONTENT CoPlainText header: false new: After: 40 New: 1",
|
||||
"client -> storage | CONTENT CoPlainText header: false new: After: 41 New: 1",
|
||||
"client -> storage | CONTENT CoPlainText header: false new: After: 42 New: 1",
|
||||
"client -> storage | CONTENT CoPlainText header: false new: After: 43 New: 1",
|
||||
"client -> storage | CONTENT CoPlainText header: false new: After: 44 New: 1",
|
||||
"client -> storage | CONTENT CoPlainText header: false new: After: 45 New: 1",
|
||||
"client -> storage | CONTENT CoPlainText header: false new: After: 46 New: 1",
|
||||
"client -> storage | CONTENT CoPlainText header: false new: After: 47 New: 1",
|
||||
"client -> storage | CONTENT CoPlainText header: false new: After: 48 New: 1",
|
||||
"client -> storage | CONTENT CoPlainText header: false new: After: 49 New: 1",
|
||||
"client -> storage | LOAD CoPlainText sessions: empty",
|
||||
"storage -> client | CONTENT CoPlainText header: true new: After: 0 New: 1 expectContentUntil: header/50",
|
||||
"storage -> client | CONTENT CoPlainText header: true new: After: 1 New: 1",
|
||||
"storage -> client | CONTENT CoPlainText header: true new: After: 2 New: 1",
|
||||
"storage -> client | CONTENT CoPlainText header: true new: After: 3 New: 1",
|
||||
"storage -> client | CONTENT CoPlainText header: true new: After: 4 New: 1",
|
||||
"storage -> client | CONTENT CoPlainText header: true new: After: 5 New: 1",
|
||||
"storage -> client | CONTENT CoPlainText header: true new: After: 6 New: 1",
|
||||
"storage -> client | CONTENT CoPlainText header: true new: After: 7 New: 1",
|
||||
"storage -> client | CONTENT CoPlainText header: true new: After: 8 New: 1",
|
||||
"storage -> client | CONTENT CoPlainText header: true new: After: 9 New: 1",
|
||||
"storage -> client | CONTENT CoPlainText header: true new: After: 10 New: 1",
|
||||
"storage -> client | CONTENT CoPlainText header: true new: After: 11 New: 1",
|
||||
"storage -> client | CONTENT CoPlainText header: true new: After: 12 New: 1",
|
||||
"storage -> client | CONTENT CoPlainText header: true new: After: 13 New: 1",
|
||||
"storage -> client | CONTENT CoPlainText header: true new: After: 14 New: 1",
|
||||
"storage -> client | CONTENT CoPlainText header: true new: After: 15 New: 1",
|
||||
"storage -> client | CONTENT CoPlainText header: true new: After: 16 New: 1",
|
||||
"storage -> client | CONTENT CoPlainText header: true new: After: 17 New: 1",
|
||||
"storage -> client | CONTENT CoPlainText header: true new: After: 18 New: 1",
|
||||
"storage -> client | CONTENT CoPlainText header: true new: After: 19 New: 1",
|
||||
"storage -> client | CONTENT CoPlainText header: true new: After: 20 New: 1",
|
||||
"storage -> client | CONTENT CoPlainText header: true new: After: 21 New: 1",
|
||||
"storage -> client | CONTENT CoPlainText header: true new: After: 22 New: 1",
|
||||
"storage -> client | CONTENT CoPlainText header: true new: After: 23 New: 1",
|
||||
"storage -> client | CONTENT CoPlainText header: true new: After: 24 New: 1",
|
||||
"storage -> client | CONTENT CoPlainText header: true new: After: 25 New: 1",
|
||||
"storage -> client | CONTENT CoPlainText header: true new: After: 26 New: 1",
|
||||
"storage -> client | CONTENT CoPlainText header: true new: After: 27 New: 1",
|
||||
"storage -> client | CONTENT CoPlainText header: true new: After: 28 New: 1",
|
||||
"storage -> client | CONTENT CoPlainText header: true new: After: 29 New: 1",
|
||||
"storage -> client | CONTENT CoPlainText header: true new: After: 30 New: 1",
|
||||
"storage -> client | CONTENT CoPlainText header: true new: After: 31 New: 1",
|
||||
"storage -> client | CONTENT CoPlainText header: true new: After: 32 New: 1",
|
||||
"storage -> client | CONTENT CoPlainText header: true new: After: 33 New: 1",
|
||||
"storage -> client | CONTENT CoPlainText header: true new: After: 34 New: 1",
|
||||
"storage -> client | CONTENT CoPlainText header: true new: After: 35 New: 1",
|
||||
"storage -> client | CONTENT CoPlainText header: true new: After: 36 New: 1",
|
||||
"storage -> client | CONTENT CoPlainText header: true new: After: 37 New: 1",
|
||||
"storage -> client | CONTENT CoPlainText header: true new: After: 38 New: 1",
|
||||
"storage -> client | CONTENT CoPlainText header: true new: After: 39 New: 1",
|
||||
"storage -> client | CONTENT CoPlainText header: true new: After: 40 New: 1",
|
||||
"storage -> client | CONTENT CoPlainText header: true new: After: 41 New: 1",
|
||||
"storage -> client | CONTENT CoPlainText header: true new: After: 42 New: 1",
|
||||
"storage -> client | CONTENT CoPlainText header: true new: After: 43 New: 1",
|
||||
"storage -> client | CONTENT CoPlainText header: true new: After: 44 New: 1",
|
||||
"storage -> client | CONTENT CoPlainText header: true new: After: 45 New: 1",
|
||||
"storage -> client | CONTENT CoPlainText header: true new: After: 46 New: 1",
|
||||
"storage -> client | CONTENT CoPlainText header: true new: After: 47 New: 1",
|
||||
"storage -> client | CONTENT CoPlainText header: true new: After: 48 New: 1",
|
||||
"storage -> client | CONTENT CoPlainText header: true new: After: 49 New: 1",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
RawBinaryCoStream,
|
||||
RawCoStreamView,
|
||||
} from "../coValues/coStream.js";
|
||||
import { MAX_RECOMMENDED_TX_SIZE } from "../config.js";
|
||||
import { TRANSACTION_CONFIG } from "../config.js";
|
||||
import { WasmCrypto } from "../crypto/WasmCrypto.js";
|
||||
import { SessionID } from "../ids.js";
|
||||
import {
|
||||
@@ -153,7 +153,9 @@ test("When adding large transactions (small fraction of MAX_RECOMMENDED_TX_SIZE)
|
||||
);
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const chunk = new Uint8Array(MAX_RECOMMENDED_TX_SIZE / 3 + 100);
|
||||
const chunk = new Uint8Array(
|
||||
TRANSACTION_CONFIG.MAX_RECOMMENDED_TX_SIZE / 3 + 100,
|
||||
);
|
||||
|
||||
content.pushBinaryStreamChunk(chunk, "trusting");
|
||||
}
|
||||
@@ -226,7 +228,9 @@ test("When adding large transactions (bigger than MAX_RECOMMENDED_TX_SIZE), we s
|
||||
"trusting",
|
||||
);
|
||||
|
||||
const chunk = new Uint8Array(MAX_RECOMMENDED_TX_SIZE + 100);
|
||||
const chunk = new Uint8Array(
|
||||
TRANSACTION_CONFIG.MAX_RECOMMENDED_TX_SIZE + 100,
|
||||
);
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
content.pushBinaryStreamChunk(chunk, "trusting");
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { beforeEach, describe, expect, test } from "vitest";
|
||||
import type { CoID, RawGroup } from "../exports";
|
||||
import { NewContentMessage } from "../sync";
|
||||
import {
|
||||
SyncMessagesLog,
|
||||
createThreeConnectedNodes,
|
||||
@@ -96,6 +98,32 @@ describe("extend", () => {
|
||||
expect(mapOnNode2.get("test")).toEqual("Written from node2");
|
||||
});
|
||||
|
||||
test("inherited everyone roles should work correctly", async () => {
|
||||
const { node1, node2 } = await createTwoConnectedNodes("server", "server");
|
||||
|
||||
const group = node1.node.createGroup();
|
||||
group.addMember("everyone", "writer");
|
||||
|
||||
const childGroup = node1.node.createGroup();
|
||||
childGroup.extend(group);
|
||||
|
||||
expect(childGroup.roleOf("everyone")).toEqual("writer");
|
||||
|
||||
const map = childGroup.createMap();
|
||||
map.set("test", "Written from the admin");
|
||||
|
||||
await map.core.waitForSync();
|
||||
|
||||
const mapOnNode2 = await loadCoValueOrFail(node2.node, map.id);
|
||||
|
||||
// The writer role should be able to see the edits from the admin
|
||||
expect(mapOnNode2.get("test")).toEqual("Written from the admin");
|
||||
|
||||
mapOnNode2.set("hello", "from node 2");
|
||||
|
||||
expect(mapOnNode2.get("hello")).toEqual("from node 2");
|
||||
});
|
||||
|
||||
test("a user should be able to extend a group when his role on the parent group is writeOnly", async () => {
|
||||
const { node1, node2 } = await createTwoConnectedNodes("server", "server");
|
||||
|
||||
@@ -315,6 +343,257 @@ describe("extend", () => {
|
||||
|
||||
expect(childGroup.roleOf(alice.id)).toBe("writer");
|
||||
});
|
||||
|
||||
test("should be possible to extend a group after getting revoked from the parent group", async () => {
|
||||
const { node1, node2, node3 } = await createThreeConnectedNodes(
|
||||
"server",
|
||||
"server",
|
||||
"server",
|
||||
);
|
||||
|
||||
const parentGroup = node1.node.createGroup();
|
||||
|
||||
const alice = await loadCoValueOrFail(node1.node, node3.accountID);
|
||||
const bob = await loadCoValueOrFail(node1.node, node2.accountID);
|
||||
parentGroup.addMember(alice, "writer");
|
||||
parentGroup.addMember(bob, "reader");
|
||||
parentGroup.removeMember(bob);
|
||||
|
||||
const parentGroupOnNode2 = await loadCoValueOrFail(
|
||||
node2.node,
|
||||
parentGroup.id,
|
||||
);
|
||||
|
||||
const childGroup = node2.node.createGroup();
|
||||
childGroup.extend(parentGroupOnNode2);
|
||||
|
||||
expect(childGroup.roleOf(alice.id)).toBe("writer");
|
||||
});
|
||||
|
||||
test("should be possible to extend when access is everyone reader and the account is revoked from the parent group", async () => {
|
||||
const { node1, node2, node3 } = await createThreeConnectedNodes(
|
||||
"server",
|
||||
"server",
|
||||
"server",
|
||||
);
|
||||
|
||||
const parentGroup = node1.node.createGroup();
|
||||
parentGroup.addMember("everyone", "reader");
|
||||
const alice = await loadCoValueOrFail(node1.node, node3.accountID);
|
||||
const bob = await loadCoValueOrFail(node1.node, node2.accountID);
|
||||
parentGroup.addMember(alice, "writer");
|
||||
parentGroup.addMember(bob, "reader");
|
||||
parentGroup.removeMember(bob);
|
||||
|
||||
const parentGroupOnNode2 = await loadCoValueOrFail(
|
||||
node2.node,
|
||||
parentGroup.id,
|
||||
);
|
||||
|
||||
const childGroup = node2.node.createGroup();
|
||||
childGroup.extend(parentGroupOnNode2);
|
||||
|
||||
expect(childGroup.roleOf(alice.id)).toBe("writer");
|
||||
});
|
||||
|
||||
test("should be able to extend when the last read key is healed", async () => {
|
||||
const clientWithAccess = setupTestNode({
|
||||
secret:
|
||||
"sealerSecret_zBTPp7U58Fzq9o7EvJpu4KEziepi8QVf2Xaxuy5xmmXFx/signerSecret_z62DuviZdXCjz4EZWofvr9vaLYFXDeTaC9KWhoQiQjzKk",
|
||||
connected: true,
|
||||
});
|
||||
const clientWithoutAccess = setupTestNode({
|
||||
connected: true,
|
||||
});
|
||||
|
||||
const brokenGroupContent = {
|
||||
action: "content",
|
||||
id: "co_zW7F36Nnop9A7Er4gUzBcUXnZCK",
|
||||
header: {
|
||||
type: "comap",
|
||||
ruleset: {
|
||||
type: "group",
|
||||
initialAdmin:
|
||||
"sealer_z12QDazYB3ygPZtBV7sMm7iYKMRnNZ6Aaj1dfLXR7LSBm/signer_z2AskZQbc82qxo7iA3oiXoNExHLsAEXC2pHbwJzRnATWv",
|
||||
},
|
||||
meta: null,
|
||||
createdAt: "2025-08-06T10:14:39.617Z",
|
||||
uniqueness: "z3LJjnuPiPJaf5Qb9A",
|
||||
},
|
||||
priority: 0,
|
||||
new: {
|
||||
"sealer_z12QDazYB3ygPZtBV7sMm7iYKMRnNZ6Aaj1dfLXR7LSBm/signer_z2AskZQbc82qxo7iA3oiXoNExHLsAEXC2pHbwJzRnATWv_session_zYLsz2CiW9pW":
|
||||
{
|
||||
after: 0,
|
||||
newTransactions: [
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279619,
|
||||
changes:
|
||||
'[{"key":"sealer_z12QDazYB3ygPZtBV7sMm7iYKMRnNZ6Aaj1dfLXR7LSBm/signer_z2AskZQbc82qxo7iA3oiXoNExHLsAEXC2pHbwJzRnATWv","op":"set","value":"admin"}]',
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279621,
|
||||
changes:
|
||||
'[{"key":"key_z5CVahfMkEWPj1B3zH_for_sealer_z12QDazYB3ygPZtBV7sMm7iYKMRnNZ6Aaj1dfLXR7LSBm/signer_z2AskZQbc82qxo7iA3oiXoNExHLsAEXC2pHbwJzRnATWv","op":"set","value":"sealed_UCg4UkytXF-W8PaIvaDffO3pZ3d9hdXUuNkQQEikPTAuOD9us92Pqb5Vgu7lx1Fpb0X8V5BJ2yxz6_D5WOzK3qjWBSsc7J1xDJA=="}]',
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279621,
|
||||
changes:
|
||||
'[{"key":"readKey","op":"set","value":"key_z5CVahfMkEWPj1B3zH"}]',
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279622,
|
||||
changes: '[{"key":"everyone","op":"set","value":"reader"}]',
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279623,
|
||||
changes:
|
||||
'[{"key":"key_z5CVahfMkEWPj1B3zH_for_everyone","op":"set","value":"keySecret_z9U9gzkahQXCxDoSw7isiUnbobXwuLdcSkL9Ci6ZEEkaL"}]',
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279623,
|
||||
changes:
|
||||
'[{"key":"key_z4Fi7hZNBx7XoVAKkP_for_sealer_z12QDazYB3ygPZtBV7sMm7iYKMRnNZ6Aaj1dfLXR7LSBm/signer_z2AskZQbc82qxo7iA3oiXoNExHLsAEXC2pHbwJzRnATWv","op":"set","value":"sealed_UuCBBfZkTnRTrGraqWWlzm9JE-VFduhsfu7WaZjpCbJYOTXpPhSNOnzGeS8qVuIsG6dORbME22lc5ltLxPjRqofQdDCNGQehCeQ=="}]',
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279624,
|
||||
changes:
|
||||
'[{"key":"key_z5CVahfMkEWPj1B3zH_for_key_z4Fi7hZNBx7XoVAKkP","op":"set","value":"encrypted_USTrBuobwTCORwy5yHxy4sFZ7swfrafP6k5ZwcTf76f0MBu9Ie-JmsX3mNXad4mluI47gvGXzi8I_"}]',
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279624,
|
||||
changes:
|
||||
'[{"key":"readKey","op":"set","value":"key_z4Fi7hZNBx7XoVAKkP"}]',
|
||||
},
|
||||
],
|
||||
lastSignature:
|
||||
"signature_z3tsE7U1JaeNeUmZ4EY3Xq5uQ9jq9jDi6Rkhdt7T7b7z4NCnpMgB4bo8TwLXYVCrRdBm6PoyyPdK8fYFzHJUh5EzA",
|
||||
},
|
||||
},
|
||||
} as unknown as NewContentMessage;
|
||||
|
||||
clientWithAccess.node.syncManager.handleNewContent(
|
||||
brokenGroupContent,
|
||||
"import",
|
||||
);
|
||||
|
||||
// Load the CoValue to recover the key_for_everyone
|
||||
await loadCoValueOrFail(
|
||||
clientWithAccess.node,
|
||||
brokenGroupContent.id as CoID<RawGroup>,
|
||||
);
|
||||
|
||||
const group = await loadCoValueOrFail(
|
||||
clientWithoutAccess.node,
|
||||
brokenGroupContent.id as CoID<RawGroup>,
|
||||
);
|
||||
const childGroup = clientWithoutAccess.node.createGroup();
|
||||
childGroup.extend(group);
|
||||
|
||||
expect(childGroup.getParentGroups()).toEqual([group]);
|
||||
});
|
||||
|
||||
test("should be able to extend when the last read key is missing", async () => {
|
||||
const clientWithoutAccess = setupTestNode({
|
||||
connected: true,
|
||||
});
|
||||
|
||||
const brokenGroupContent = {
|
||||
action: "content",
|
||||
id: "co_zW7F36Nnop9A7Er4gUzBcUXnZCK",
|
||||
header: {
|
||||
type: "comap",
|
||||
ruleset: {
|
||||
type: "group",
|
||||
initialAdmin:
|
||||
"sealer_z12QDazYB3ygPZtBV7sMm7iYKMRnNZ6Aaj1dfLXR7LSBm/signer_z2AskZQbc82qxo7iA3oiXoNExHLsAEXC2pHbwJzRnATWv",
|
||||
},
|
||||
meta: null,
|
||||
createdAt: "2025-08-06T10:14:39.617Z",
|
||||
uniqueness: "z3LJjnuPiPJaf5Qb9A",
|
||||
},
|
||||
priority: 0,
|
||||
new: {
|
||||
"sealer_z12QDazYB3ygPZtBV7sMm7iYKMRnNZ6Aaj1dfLXR7LSBm/signer_z2AskZQbc82qxo7iA3oiXoNExHLsAEXC2pHbwJzRnATWv_session_zYLsz2CiW9pW":
|
||||
{
|
||||
after: 0,
|
||||
newTransactions: [
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279619,
|
||||
changes:
|
||||
'[{"key":"sealer_z12QDazYB3ygPZtBV7sMm7iYKMRnNZ6Aaj1dfLXR7LSBm/signer_z2AskZQbc82qxo7iA3oiXoNExHLsAEXC2pHbwJzRnATWv","op":"set","value":"admin"}]',
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279621,
|
||||
changes:
|
||||
'[{"key":"key_z5CVahfMkEWPj1B3zH_for_sealer_z12QDazYB3ygPZtBV7sMm7iYKMRnNZ6Aaj1dfLXR7LSBm/signer_z2AskZQbc82qxo7iA3oiXoNExHLsAEXC2pHbwJzRnATWv","op":"set","value":"sealed_UCg4UkytXF-W8PaIvaDffO3pZ3d9hdXUuNkQQEikPTAuOD9us92Pqb5Vgu7lx1Fpb0X8V5BJ2yxz6_D5WOzK3qjWBSsc7J1xDJA=="}]',
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279621,
|
||||
changes:
|
||||
'[{"key":"readKey","op":"set","value":"key_z5CVahfMkEWPj1B3zH"}]',
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279622,
|
||||
changes: '[{"key":"everyone","op":"set","value":"reader"}]',
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279623,
|
||||
changes:
|
||||
'[{"key":"key_z5CVahfMkEWPj1B3zH_for_everyone","op":"set","value":"keySecret_z9U9gzkahQXCxDoSw7isiUnbobXwuLdcSkL9Ci6ZEEkaL"}]',
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279623,
|
||||
changes:
|
||||
'[{"key":"key_z4Fi7hZNBx7XoVAKkP_for_sealer_z12QDazYB3ygPZtBV7sMm7iYKMRnNZ6Aaj1dfLXR7LSBm/signer_z2AskZQbc82qxo7iA3oiXoNExHLsAEXC2pHbwJzRnATWv","op":"set","value":"sealed_UuCBBfZkTnRTrGraqWWlzm9JE-VFduhsfu7WaZjpCbJYOTXpPhSNOnzGeS8qVuIsG6dORbME22lc5ltLxPjRqofQdDCNGQehCeQ=="}]',
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279624,
|
||||
changes:
|
||||
'[{"key":"key_z5CVahfMkEWPj1B3zH_for_key_z4Fi7hZNBx7XoVAKkP","op":"set","value":"encrypted_USTrBuobwTCORwy5yHxy4sFZ7swfrafP6k5ZwcTf76f0MBu9Ie-JmsX3mNXad4mluI47gvGXzi8I_"}]',
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279624,
|
||||
changes:
|
||||
'[{"key":"readKey","op":"set","value":"key_z4Fi7hZNBx7XoVAKkP"}]',
|
||||
},
|
||||
],
|
||||
lastSignature:
|
||||
"signature_z3tsE7U1JaeNeUmZ4EY3Xq5uQ9jq9jDi6Rkhdt7T7b7z4NCnpMgB4bo8TwLXYVCrRdBm6PoyyPdK8fYFzHJUh5EzA",
|
||||
},
|
||||
},
|
||||
} as unknown as NewContentMessage;
|
||||
|
||||
clientWithoutAccess.node.syncManager.handleNewContent(
|
||||
brokenGroupContent,
|
||||
"import",
|
||||
);
|
||||
|
||||
const group = await loadCoValueOrFail(
|
||||
clientWithoutAccess.node,
|
||||
brokenGroupContent.id as CoID<RawGroup>,
|
||||
);
|
||||
const childGroup = clientWithoutAccess.node.createGroup();
|
||||
childGroup.extend(group);
|
||||
|
||||
expect(childGroup.getParentGroups()).toEqual([group]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unextend", () => {
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { beforeEach, describe, expect, test } from "vitest";
|
||||
import { setCoValueLoadingRetryDelay } from "../config.js";
|
||||
import {
|
||||
SyncMessagesLog,
|
||||
TEST_NODE_CONFIG,
|
||||
blockMessageTypeOnOutgoingPeer,
|
||||
loadCoValueOrFail,
|
||||
setupTestAccount,
|
||||
setupTestNode,
|
||||
} from "./testUtils.js";
|
||||
|
||||
setCoValueLoadingRetryDelay(10);
|
||||
|
||||
let jazzCloud: ReturnType<typeof setupTestNode>;
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -15,6 +18,65 @@ beforeEach(async () => {
|
||||
});
|
||||
|
||||
describe("Group.removeMember", () => {
|
||||
test("revoking a member access should not affect everyone access", async () => {
|
||||
const admin = await setupTestAccount({
|
||||
connected: true,
|
||||
});
|
||||
|
||||
const alice = await setupTestAccount({
|
||||
connected: true,
|
||||
});
|
||||
|
||||
const group = admin.node.createGroup();
|
||||
group.addMember("everyone", "writer");
|
||||
|
||||
const aliceOnAdminNode = await loadCoValueOrFail(
|
||||
admin.node,
|
||||
alice.accountID,
|
||||
);
|
||||
group.addMember(aliceOnAdminNode, "writer");
|
||||
group.removeMember(aliceOnAdminNode);
|
||||
|
||||
const groupOnAliceNode = await loadCoValueOrFail(alice.node, group.id);
|
||||
expect(groupOnAliceNode.myRole()).toEqual("writer");
|
||||
|
||||
const map = groupOnAliceNode.createMap();
|
||||
|
||||
map.set("test", "test");
|
||||
expect(map.get("test")).toEqual("test");
|
||||
});
|
||||
|
||||
test("revoking a member access should not affect everyone access when everyone access is gained through a group extension", async () => {
|
||||
const admin = await setupTestAccount({
|
||||
connected: true,
|
||||
});
|
||||
|
||||
const alice = await setupTestAccount({
|
||||
connected: true,
|
||||
});
|
||||
|
||||
const parentGroup = admin.node.createGroup();
|
||||
const group = admin.node.createGroup();
|
||||
parentGroup.addMember("everyone", "reader");
|
||||
group.extend(parentGroup);
|
||||
|
||||
const aliceOnAdminNode = await loadCoValueOrFail(
|
||||
admin.node,
|
||||
alice.accountID,
|
||||
);
|
||||
group.addMember(aliceOnAdminNode, "writer");
|
||||
group.removeMember(aliceOnAdminNode);
|
||||
|
||||
const map = group.createMap();
|
||||
map.set("test", "test");
|
||||
|
||||
const groupOnAliceNode = await loadCoValueOrFail(alice.node, group.id);
|
||||
expect(groupOnAliceNode.myRole()).toEqual("reader");
|
||||
|
||||
const mapOnAliceNode = await loadCoValueOrFail(alice.node, map.id);
|
||||
expect(mapOnAliceNode.get("test")).toEqual("test");
|
||||
});
|
||||
|
||||
test("a reader member should be able to revoke themselves", async () => {
|
||||
const admin = await setupTestAccount({
|
||||
connected: true,
|
||||
@@ -294,4 +356,185 @@ describe("Group.removeMember", () => {
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test("removing a member should rotate the readKey on available child groups", async () => {
|
||||
const admin = await setupTestAccount({
|
||||
connected: true,
|
||||
});
|
||||
|
||||
const alice = await setupTestAccount({
|
||||
connected: true,
|
||||
});
|
||||
|
||||
const aliceOnAdminNode = await loadCoValueOrFail(
|
||||
admin.node,
|
||||
alice.accountID,
|
||||
);
|
||||
|
||||
const group = admin.node.createGroup();
|
||||
const childGroup = admin.node.createGroup();
|
||||
group.addMember(aliceOnAdminNode, "reader");
|
||||
|
||||
childGroup.extend(group);
|
||||
|
||||
group.removeMember(aliceOnAdminNode);
|
||||
|
||||
const map = childGroup.createMap();
|
||||
map.set("test", "Not readable by alice");
|
||||
|
||||
await map.core.waitForSync();
|
||||
|
||||
const mapOnAliceNode = await loadCoValueOrFail(alice.node, map.id);
|
||||
expect(mapOnAliceNode.get("test")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("removing a member should rotate the readKey on unloaded child groups", async () => {
|
||||
const admin = await setupTestAccount({
|
||||
connected: true,
|
||||
});
|
||||
|
||||
const bob = await setupTestAccount({
|
||||
connected: true,
|
||||
});
|
||||
|
||||
const alice = await setupTestAccount({
|
||||
connected: true,
|
||||
});
|
||||
|
||||
const bobOnAdminNode = await loadCoValueOrFail(admin.node, bob.accountID);
|
||||
|
||||
const aliceOnAdminNode = await loadCoValueOrFail(
|
||||
admin.node,
|
||||
alice.accountID,
|
||||
);
|
||||
|
||||
const group = admin.node.createGroup();
|
||||
|
||||
const childGroup = bob.node.createGroup();
|
||||
group.addMember(bobOnAdminNode, "reader");
|
||||
group.addMember(aliceOnAdminNode, "reader");
|
||||
|
||||
const groupOnBobNode = await loadCoValueOrFail(bob.node, group.id);
|
||||
|
||||
childGroup.extend(groupOnBobNode);
|
||||
|
||||
await childGroup.core.waitForSync();
|
||||
|
||||
group.removeMember(aliceOnAdminNode);
|
||||
|
||||
// Rotating the child group keys is async when the child group is not loaded
|
||||
await admin.node.getCoValue(childGroup.id).waitForAvailableOrUnavailable();
|
||||
await admin.node.syncManager.waitForAllCoValuesSync();
|
||||
|
||||
const map = childGroup.createMap();
|
||||
map.set("test", "Not readable by alice");
|
||||
|
||||
await map.core.waitForSync();
|
||||
|
||||
const mapOnAliceNode = await loadCoValueOrFail(alice.node, map.id);
|
||||
expect(mapOnAliceNode.get("test")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("removing a member should work even if there are partially available child groups", async () => {
|
||||
const admin = await setupTestAccount({
|
||||
connected: true,
|
||||
});
|
||||
|
||||
const bob = await setupTestAccount();
|
||||
const { peer } = bob.connectToSyncServer();
|
||||
|
||||
const alice = await setupTestAccount({
|
||||
connected: true,
|
||||
});
|
||||
|
||||
const bobOnAdminNode = await loadCoValueOrFail(admin.node, bob.accountID);
|
||||
|
||||
const aliceOnAdminNode = await loadCoValueOrFail(
|
||||
admin.node,
|
||||
alice.accountID,
|
||||
);
|
||||
|
||||
const group = admin.node.createGroup();
|
||||
const childGroup = bob.node.createGroup();
|
||||
|
||||
group.addMember(bobOnAdminNode, "reader");
|
||||
group.addMember(aliceOnAdminNode, "reader");
|
||||
|
||||
await group.core.waitForSync();
|
||||
|
||||
blockMessageTypeOnOutgoingPeer(peer, "content", {
|
||||
id: childGroup.id,
|
||||
});
|
||||
|
||||
const groupOnBobNode = await loadCoValueOrFail(bob.node, group.id);
|
||||
|
||||
childGroup.extend(groupOnBobNode);
|
||||
|
||||
await groupOnBobNode.core.waitForSync();
|
||||
|
||||
group.removeMember(aliceOnAdminNode);
|
||||
|
||||
await admin.node.syncManager.waitForAllCoValuesSync();
|
||||
|
||||
const map = group.createMap();
|
||||
map.set("test", "Not readable by alice");
|
||||
|
||||
await map.core.waitForSync();
|
||||
|
||||
const mapOnAliceNode = await loadCoValueOrFail(alice.node, map.id);
|
||||
expect(mapOnAliceNode.get("test")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("removing a member should work even if there are unavailable child groups", async () => {
|
||||
const admin = await setupTestAccount({
|
||||
connected: true,
|
||||
});
|
||||
|
||||
const { peerOnServer } = admin.connectToSyncServer();
|
||||
|
||||
const bob = await setupTestAccount({
|
||||
connected: true,
|
||||
});
|
||||
|
||||
const alice = await setupTestAccount({
|
||||
connected: true,
|
||||
});
|
||||
|
||||
const bobOnAdminNode = await loadCoValueOrFail(admin.node, bob.accountID);
|
||||
|
||||
const aliceOnAdminNode = await loadCoValueOrFail(
|
||||
admin.node,
|
||||
alice.accountID,
|
||||
);
|
||||
|
||||
const group = admin.node.createGroup();
|
||||
const childGroup = bob.node.createGroup();
|
||||
|
||||
blockMessageTypeOnOutgoingPeer(peerOnServer, "content", {
|
||||
id: childGroup.id,
|
||||
});
|
||||
|
||||
group.addMember(bobOnAdminNode, "reader");
|
||||
group.addMember(aliceOnAdminNode, "reader");
|
||||
|
||||
await group.core.waitForSync();
|
||||
|
||||
const groupOnBobNode = await loadCoValueOrFail(bob.node, group.id);
|
||||
|
||||
childGroup.extend(groupOnBobNode);
|
||||
|
||||
await groupOnBobNode.core.waitForSync();
|
||||
|
||||
group.removeMember(aliceOnAdminNode);
|
||||
|
||||
await group.core.waitForSync();
|
||||
|
||||
const map = group.createMap();
|
||||
map.set("test", "Not readable by alice");
|
||||
|
||||
await map.core.waitForSync();
|
||||
|
||||
const mapOnAliceNode = await loadCoValueOrFail(alice.node, map.id);
|
||||
expect(mapOnAliceNode.get("test")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,7 +33,7 @@ describe("roleOf", () => {
|
||||
const [agent2] = randomAgentAndSessionID();
|
||||
|
||||
group.addMember(agent2, "writer");
|
||||
group.removeMemberInternal(agent2);
|
||||
group.removeMember(agent2);
|
||||
expect(group.roleOfInternal(agent2.id)).toEqual(undefined);
|
||||
});
|
||||
|
||||
@@ -63,7 +63,7 @@ describe("roleOf", () => {
|
||||
|
||||
group.addMemberInternal("everyone", "reader");
|
||||
group.addMember(agent2, "writer");
|
||||
group.removeMemberInternal("everyone");
|
||||
group.removeMember("everyone");
|
||||
expect(group.roleOfInternal(agent2.id)).toEqual("writer");
|
||||
expect(group.roleOfInternal("123" as RawAccountID)).toEqual(undefined);
|
||||
});
|
||||
|
||||
@@ -3,18 +3,27 @@ import { RawCoList } from "../coValues/coList.js";
|
||||
import { RawCoMap } from "../coValues/coMap.js";
|
||||
import { RawCoStream } from "../coValues/coStream.js";
|
||||
import { RawBinaryCoStream } from "../coValues/coStream.js";
|
||||
import { WasmCrypto } from "../crypto/WasmCrypto.js";
|
||||
import { RawAccountID } from "../exports.js";
|
||||
import type { RawCoValue, RawGroup } from "../exports.js";
|
||||
import type { NewContentMessage } from "../sync.js";
|
||||
import {
|
||||
createThreeConnectedNodes,
|
||||
createTwoConnectedNodes,
|
||||
loadCoValueOrFail,
|
||||
nodeWithRandomAgentAndSessionID,
|
||||
randomAgentAndSessionID,
|
||||
waitFor,
|
||||
setupTestNode,
|
||||
} from "./testUtils.js";
|
||||
|
||||
const Crypto = await WasmCrypto.create();
|
||||
function expectGroup(content: RawCoValue): RawGroup {
|
||||
if (content.type !== "comap") {
|
||||
throw new Error("Expected group");
|
||||
}
|
||||
|
||||
if (content.core.verified.header.ruleset.type !== "group") {
|
||||
throw new Error("Expected group ruleset in group");
|
||||
}
|
||||
|
||||
return content as RawGroup;
|
||||
}
|
||||
|
||||
test("Can create a RawCoMap in a group", () => {
|
||||
const node = nodeWithRandomAgentAndSessionID();
|
||||
@@ -307,6 +316,97 @@ test("Invites should have access to the new keys", async () => {
|
||||
expect(mapOnNode2.get("test")).toEqual("Written from node1");
|
||||
});
|
||||
|
||||
test("Should heal the missing key_for_everyone", async () => {
|
||||
const client = setupTestNode({
|
||||
secret:
|
||||
"sealerSecret_zBTPp7U58Fzq9o7EvJpu4KEziepi8QVf2Xaxuy5xmmXFx/signerSecret_z62DuviZdXCjz4EZWofvr9vaLYFXDeTaC9KWhoQiQjzKk",
|
||||
});
|
||||
|
||||
const brokenGroupContent = {
|
||||
action: "content",
|
||||
id: "co_zW7F36Nnop9A7Er4gUzBcUXnZCK",
|
||||
header: {
|
||||
type: "comap",
|
||||
ruleset: {
|
||||
type: "group",
|
||||
initialAdmin:
|
||||
"sealer_z12QDazYB3ygPZtBV7sMm7iYKMRnNZ6Aaj1dfLXR7LSBm/signer_z2AskZQbc82qxo7iA3oiXoNExHLsAEXC2pHbwJzRnATWv",
|
||||
},
|
||||
meta: null,
|
||||
createdAt: "2025-08-06T10:14:39.617Z",
|
||||
uniqueness: "z3LJjnuPiPJaf5Qb9A",
|
||||
},
|
||||
priority: 0,
|
||||
new: {
|
||||
"sealer_z12QDazYB3ygPZtBV7sMm7iYKMRnNZ6Aaj1dfLXR7LSBm/signer_z2AskZQbc82qxo7iA3oiXoNExHLsAEXC2pHbwJzRnATWv_session_zYLsz2CiW9pW":
|
||||
{
|
||||
after: 0,
|
||||
newTransactions: [
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279619,
|
||||
changes:
|
||||
'[{"key":"sealer_z12QDazYB3ygPZtBV7sMm7iYKMRnNZ6Aaj1dfLXR7LSBm/signer_z2AskZQbc82qxo7iA3oiXoNExHLsAEXC2pHbwJzRnATWv","op":"set","value":"admin"}]',
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279621,
|
||||
changes:
|
||||
'[{"key":"key_z5CVahfMkEWPj1B3zH_for_sealer_z12QDazYB3ygPZtBV7sMm7iYKMRnNZ6Aaj1dfLXR7LSBm/signer_z2AskZQbc82qxo7iA3oiXoNExHLsAEXC2pHbwJzRnATWv","op":"set","value":"sealed_UCg4UkytXF-W8PaIvaDffO3pZ3d9hdXUuNkQQEikPTAuOD9us92Pqb5Vgu7lx1Fpb0X8V5BJ2yxz6_D5WOzK3qjWBSsc7J1xDJA=="}]',
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279621,
|
||||
changes:
|
||||
'[{"key":"readKey","op":"set","value":"key_z5CVahfMkEWPj1B3zH"}]',
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279622,
|
||||
changes: '[{"key":"everyone","op":"set","value":"reader"}]',
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279623,
|
||||
changes:
|
||||
'[{"key":"key_z5CVahfMkEWPj1B3zH_for_everyone","op":"set","value":"keySecret_z9U9gzkahQXCxDoSw7isiUnbobXwuLdcSkL9Ci6ZEEkaL"}]',
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279623,
|
||||
changes:
|
||||
'[{"key":"key_z4Fi7hZNBx7XoVAKkP_for_sealer_z12QDazYB3ygPZtBV7sMm7iYKMRnNZ6Aaj1dfLXR7LSBm/signer_z2AskZQbc82qxo7iA3oiXoNExHLsAEXC2pHbwJzRnATWv","op":"set","value":"sealed_UuCBBfZkTnRTrGraqWWlzm9JE-VFduhsfu7WaZjpCbJYOTXpPhSNOnzGeS8qVuIsG6dORbME22lc5ltLxPjRqofQdDCNGQehCeQ=="}]',
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279624,
|
||||
changes:
|
||||
'[{"key":"key_z5CVahfMkEWPj1B3zH_for_key_z4Fi7hZNBx7XoVAKkP","op":"set","value":"encrypted_USTrBuobwTCORwy5yHxy4sFZ7swfrafP6k5ZwcTf76f0MBu9Ie-JmsX3mNXad4mluI47gvGXzi8I_"}]',
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279624,
|
||||
changes:
|
||||
'[{"key":"readKey","op":"set","value":"key_z4Fi7hZNBx7XoVAKkP"}]',
|
||||
},
|
||||
],
|
||||
lastSignature:
|
||||
"signature_z3tsE7U1JaeNeUmZ4EY3Xq5uQ9jq9jDi6Rkhdt7T7b7z4NCnpMgB4bo8TwLXYVCrRdBm6PoyyPdK8fYFzHJUh5EzA",
|
||||
},
|
||||
},
|
||||
} as unknown as NewContentMessage;
|
||||
|
||||
client.node.syncManager.handleNewContent(brokenGroupContent, "import");
|
||||
|
||||
const group = expectGroup(
|
||||
client.node.getCoValue(brokenGroupContent.id).getCurrentContent(),
|
||||
);
|
||||
|
||||
expect(group.get(`${group.get("readKey")!}_for_everyone`)).toBe(
|
||||
group.core.getCurrentReadKey()?.secret,
|
||||
);
|
||||
});
|
||||
|
||||
describe("writeOnly", () => {
|
||||
test("Admins can invite writeOnly members", async () => {
|
||||
const { node1, node2 } = await createTwoConnectedNodes("server", "server");
|
||||
|
||||
178
packages/cojson/src/tests/sync.garbageCollection.test.ts
Normal file
178
packages/cojson/src/tests/sync.garbageCollection.test.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { assert, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { setGarbageCollectorMaxAge } from "../config";
|
||||
import { emptyKnownState } from "../exports";
|
||||
import {
|
||||
SyncMessagesLog,
|
||||
TEST_NODE_CONFIG,
|
||||
loadCoValueOrFail,
|
||||
setupTestNode,
|
||||
waitFor,
|
||||
} from "./testUtils";
|
||||
|
||||
// We want to simulate a real world communication that happens asynchronously
|
||||
TEST_NODE_CONFIG.withAsyncPeers = true;
|
||||
|
||||
beforeEach(() => {
|
||||
// We want to test what happens when the garbage collector kicks in and removes a coValue
|
||||
// We set the max age to -1 to make it remove everything
|
||||
setGarbageCollectorMaxAge(-1);
|
||||
});
|
||||
|
||||
describe("sync after the garbage collector has run", () => {
|
||||
let jazzCloud: ReturnType<typeof setupTestNode>;
|
||||
|
||||
beforeEach(async () => {
|
||||
SyncMessagesLog.clear();
|
||||
jazzCloud = setupTestNode({
|
||||
isSyncServer: true,
|
||||
});
|
||||
jazzCloud.addStorage({
|
||||
ourName: "server",
|
||||
});
|
||||
jazzCloud.node.enableGarbageCollector();
|
||||
});
|
||||
|
||||
test("loading a coValue from the sync server that was removed by the garbage collector", async () => {
|
||||
const client = setupTestNode();
|
||||
|
||||
client.connectToSyncServer();
|
||||
|
||||
const group = jazzCloud.node.createGroup();
|
||||
const map = group.createMap();
|
||||
map.set("hello", "world", "trusting");
|
||||
|
||||
await map.core.waitForSync();
|
||||
|
||||
// force the garbage collector to run
|
||||
jazzCloud.node.garbageCollector?.collect();
|
||||
|
||||
SyncMessagesLog.clear();
|
||||
|
||||
const mapOnClient = await loadCoValueOrFail(client.node, map.id);
|
||||
expect(mapOnClient.get("hello")).toEqual("world");
|
||||
|
||||
expect(
|
||||
SyncMessagesLog.getMessages({
|
||||
Group: group.core,
|
||||
Map: map.core,
|
||||
}),
|
||||
).toMatchInlineSnapshot(`
|
||||
[
|
||||
"client -> server | LOAD Map sessions: empty",
|
||||
"server -> storage | LOAD Map sessions: empty",
|
||||
"storage -> server | CONTENT Group header: true new: After: 0 New: 3",
|
||||
"storage -> server | CONTENT Map header: true new: After: 0 New: 1",
|
||||
"server -> client | CONTENT Group header: true new: After: 0 New: 3",
|
||||
"server -> client | CONTENT Map header: true new: After: 0 New: 1",
|
||||
"client -> server | KNOWN Group sessions: header/3",
|
||||
"client -> server | KNOWN Map sessions: header/1",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
test("updating a coValue that was removed by the garbage collector", async () => {
|
||||
const client = setupTestNode();
|
||||
|
||||
client.connectToSyncServer();
|
||||
|
||||
const group = jazzCloud.node.createGroup();
|
||||
group.addMember("everyone", "writer");
|
||||
const map = group.createMap();
|
||||
map.set("hello", "world", "trusting");
|
||||
|
||||
const mapOnClient = await loadCoValueOrFail(client.node, map.id);
|
||||
expect(mapOnClient.get("hello")).toEqual("world");
|
||||
|
||||
// force the garbage collector to run
|
||||
jazzCloud.node.garbageCollector?.collect();
|
||||
SyncMessagesLog.clear();
|
||||
|
||||
mapOnClient.set("hello", "updated", "trusting");
|
||||
|
||||
await mapOnClient.core.waitForSync();
|
||||
|
||||
const mapOnServer = await loadCoValueOrFail(jazzCloud.node, map.id);
|
||||
|
||||
expect(mapOnServer.get("hello")).toEqual("updated");
|
||||
|
||||
expect(
|
||||
SyncMessagesLog.getMessages({
|
||||
Group: group.core,
|
||||
Map: map.core,
|
||||
}),
|
||||
).toMatchInlineSnapshot(`
|
||||
[
|
||||
"client -> server | CONTENT Map header: false new: After: 0 New: 1",
|
||||
"server -> storage | LOAD Map sessions: empty",
|
||||
"storage -> server | CONTENT Group header: true new: After: 0 New: 5",
|
||||
"storage -> server | CONTENT Map header: true new: After: 0 New: 1",
|
||||
"server -> client | KNOWN Map sessions: header/2",
|
||||
"server -> storage | CONTENT Map header: false new: After: 0 New: 1",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
test("syncing a coValue that was removed by the garbage collector", async () => {
|
||||
const edge = setupTestNode();
|
||||
edge.addStorage({
|
||||
ourName: "edge",
|
||||
});
|
||||
edge.connectToSyncServer({
|
||||
syncServer: jazzCloud.node,
|
||||
syncServerName: "server",
|
||||
ourName: "edge",
|
||||
});
|
||||
edge.node.enableGarbageCollector();
|
||||
const client = setupTestNode();
|
||||
|
||||
client.connectToSyncServer({
|
||||
syncServer: edge.node,
|
||||
syncServerName: "edge",
|
||||
});
|
||||
|
||||
const group = edge.node.createGroup();
|
||||
group.addMember("everyone", "writer");
|
||||
|
||||
await group.core.waitForSync();
|
||||
|
||||
const map = group.createMap();
|
||||
|
||||
map.set("hello", "updated", "trusting");
|
||||
|
||||
// force the garbage collector to run before the transaction is synced
|
||||
edge.node.garbageCollector?.collect();
|
||||
expect(edge.node.getCoValue(map.id).isAvailable()).toBe(false);
|
||||
|
||||
SyncMessagesLog.clear();
|
||||
|
||||
// The storage should work even after the coValue is unmounted, so the load should be successful
|
||||
const mapOnClient = await loadCoValueOrFail(client.node, map.id);
|
||||
expect(mapOnClient.get("hello")).toEqual("updated");
|
||||
|
||||
expect(
|
||||
SyncMessagesLog.getMessages({
|
||||
Group: group.core,
|
||||
Map: map.core,
|
||||
}),
|
||||
).toMatchInlineSnapshot(`
|
||||
[
|
||||
"client -> edge | LOAD Map sessions: empty",
|
||||
"edge -> storage | CONTENT Map header: true new: After: 0 New: 1",
|
||||
"edge -> server | CONTENT Map header: true new: After: 0 New: 1",
|
||||
"edge -> storage | LOAD Map sessions: empty",
|
||||
"storage -> edge | CONTENT Group header: true new: After: 0 New: 5",
|
||||
"storage -> edge | CONTENT Map header: true new: After: 0 New: 1",
|
||||
"edge -> server | CONTENT Map header: true new: ",
|
||||
"edge -> client | CONTENT Group header: true new: After: 0 New: 5",
|
||||
"edge -> client | CONTENT Map header: true new: After: 0 New: 1",
|
||||
"server -> edge | KNOWN Map sessions: header/1",
|
||||
"server -> storage | CONTENT Map header: true new: After: 0 New: 1",
|
||||
"server -> edge | KNOWN Map sessions: header/1",
|
||||
"server -> storage | CONTENT Map header: true new: ",
|
||||
"client -> edge | KNOWN Group sessions: header/5",
|
||||
"client -> edge | KNOWN Map sessions: header/1",
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
@@ -590,11 +590,14 @@ describe("loading coValues from server", () => {
|
||||
"client -> server | LOAD Map sessions: empty",
|
||||
"server -> client | CONTENT Group header: true new: After: 0 New: 5",
|
||||
"client -> server | KNOWN Group sessions: header/5",
|
||||
"client -> server | LOAD Map sessions: empty",
|
||||
"server -> client | KNOWN Group sessions: header/5",
|
||||
"server -> client | CONTENT Group header: true new: After: 0 New: 5",
|
||||
"client -> server | KNOWN Group sessions: header/5",
|
||||
"server -> client | CONTENT Map header: true new: After: 0 New: 1",
|
||||
"client -> server | LOAD Group sessions: header/5",
|
||||
"client -> server | KNOWN Map sessions: header/1",
|
||||
"client -> server | LOAD Group sessions: header/5",
|
||||
"server -> client | KNOWN Group sessions: header/5",
|
||||
"client -> server | LOAD Map sessions: header/1",
|
||||
"server -> client | KNOWN Map sessions: header/1",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { expectMap } from "../coValue";
|
||||
import { setMaxRecommendedTxSize } from "../config";
|
||||
import {
|
||||
SyncMessagesLog,
|
||||
TEST_NODE_CONFIG,
|
||||
@@ -14,6 +15,10 @@ import {
|
||||
// We want to simulate a real world communication that happens asynchronously
|
||||
TEST_NODE_CONFIG.withAsyncPeers = true;
|
||||
|
||||
beforeEach(() => {
|
||||
setMaxRecommendedTxSize(100 * 1024);
|
||||
});
|
||||
|
||||
function setupMesh() {
|
||||
const coreServer = setupTestNode();
|
||||
|
||||
@@ -254,6 +259,9 @@ describe("multiple clients syncing with the a cloud-like server mesh", () => {
|
||||
|
||||
// Forcefully delete the coValue from the edge (simulating some data loss)
|
||||
mesh.edgeItaly.node.internalDeleteCoValue(map.id);
|
||||
mesh.edgeItaly.addStorage({
|
||||
ourName: "edge-italy",
|
||||
});
|
||||
|
||||
mapOnClient.set("fromClient", "updated", "trusting");
|
||||
mapOnCoreServer.set("fromServer", "updated", "trusting");
|
||||
@@ -483,6 +491,7 @@ describe("multiple clients syncing with the a cloud-like server mesh", () => {
|
||||
});
|
||||
|
||||
test("large coValue streaming from an edge to the core server and a client at the same time", async () => {
|
||||
setMaxRecommendedTxSize(1000);
|
||||
const edge = setupTestNode();
|
||||
|
||||
const { storage } = edge.addStorage({
|
||||
@@ -494,12 +503,9 @@ describe("multiple clients syncing with the a cloud-like server mesh", () => {
|
||||
|
||||
const largeMap = group.createMap();
|
||||
|
||||
// Generate a large amount of data (about 100MB)
|
||||
const dataSize = 1 * 200 * 1024;
|
||||
const chunkSize = 1024; // 1KB chunks
|
||||
const chunks = dataSize / chunkSize;
|
||||
const chunks = 100;
|
||||
|
||||
const value = Buffer.alloc(chunkSize, `value$`).toString("base64");
|
||||
const value = "1".repeat(10);
|
||||
|
||||
for (let i = 0; i < chunks; i++) {
|
||||
const key = `key${i}`;
|
||||
@@ -516,9 +522,11 @@ describe("multiple clients syncing with the a cloud-like server mesh", () => {
|
||||
).toMatchInlineSnapshot(`
|
||||
[
|
||||
"edge -> storage | CONTENT Group header: true new: After: 0 New: 5",
|
||||
"edge -> storage | CONTENT Map header: true new: After: 0 New: 73",
|
||||
"edge -> storage | CONTENT Map header: false new: After: 73 New: 73",
|
||||
"edge -> storage | CONTENT Map header: false new: After: 146 New: 54",
|
||||
"edge -> storage | CONTENT Map header: true new: After: 0 New: 20",
|
||||
"edge -> storage | CONTENT Map header: false new: After: 20 New: 21",
|
||||
"edge -> storage | CONTENT Map header: false new: After: 41 New: 21",
|
||||
"edge -> storage | CONTENT Map header: false new: After: 62 New: 21",
|
||||
"edge -> storage | CONTENT Map header: false new: After: 83 New: 17",
|
||||
]
|
||||
`);
|
||||
|
||||
@@ -565,11 +573,11 @@ describe("multiple clients syncing with the a cloud-like server mesh", () => {
|
||||
"edge -> storage | LOAD Map sessions: empty",
|
||||
"storage -> edge | CONTENT Group header: true new: After: 0 New: 5",
|
||||
"edge -> core | LOAD Group sessions: header/5",
|
||||
"storage -> edge | CONTENT Map header: true new: After: 0 New: 73 expectContentUntil: header/200",
|
||||
"edge -> core | LOAD Map sessions: header/200",
|
||||
"storage -> edge | CONTENT Map header: true new: After: 0 New: 41 expectContentUntil: header/100",
|
||||
"edge -> core | LOAD Map sessions: header/100",
|
||||
"edge -> client | CONTENT Group header: true new: After: 0 New: 5",
|
||||
"edge -> client | CONTENT Map header: true new: expectContentUntil: header/200",
|
||||
"edge -> client | CONTENT Map header: false new: After: 0 New: 73",
|
||||
"edge -> client | CONTENT Map header: true new: expectContentUntil: header/100",
|
||||
"edge -> client | CONTENT Map header: false new: After: 0 New: 41",
|
||||
"core -> storage | LOAD Group sessions: empty",
|
||||
"storage -> core | KNOWN Group sessions: empty",
|
||||
"core -> edge | KNOWN Group sessions: empty",
|
||||
@@ -579,32 +587,39 @@ describe("multiple clients syncing with the a cloud-like server mesh", () => {
|
||||
"client -> edge | KNOWN Group sessions: header/5",
|
||||
"client -> storage | CONTENT Group header: true new: After: 0 New: 5",
|
||||
"client -> edge | KNOWN Map sessions: header/0",
|
||||
"client -> storage | CONTENT Map header: true new: expectContentUntil: header/200",
|
||||
"client -> edge | KNOWN Map sessions: header/73",
|
||||
"client -> storage | CONTENT Map header: false new: After: 0 New: 73",
|
||||
"storage -> edge | CONTENT Map header: true new: After: 73 New: 73",
|
||||
"edge -> client | CONTENT Map header: false new: After: 73 New: 73",
|
||||
"client -> storage | CONTENT Map header: true new: expectContentUntil: header/100",
|
||||
"client -> edge | KNOWN Map sessions: header/41",
|
||||
"client -> storage | CONTENT Map header: false new: After: 0 New: 41",
|
||||
"storage -> edge | CONTENT Map header: true new: After: 41 New: 21",
|
||||
"edge -> client | CONTENT Map header: false new: After: 41 New: 21",
|
||||
"edge -> core | CONTENT Group header: true new: After: 0 New: 5",
|
||||
"edge -> core | CONTENT Map header: true new: expectContentUntil: header/200",
|
||||
"edge -> core | CONTENT Map header: false new: After: 0 New: 73",
|
||||
"edge -> core | CONTENT Map header: false new: After: 73 New: 73",
|
||||
"client -> edge | KNOWN Map sessions: header/146",
|
||||
"client -> storage | CONTENT Map header: false new: After: 73 New: 73",
|
||||
"storage -> edge | CONTENT Map header: true new: After: 146 New: 54",
|
||||
"edge -> core | CONTENT Map header: false new: After: 146 New: 54",
|
||||
"edge -> client | CONTENT Map header: false new: After: 146 New: 54",
|
||||
"edge -> core | CONTENT Map header: true new: expectContentUntil: header/100",
|
||||
"edge -> core | CONTENT Map header: false new: After: 0 New: 41",
|
||||
"edge -> core | CONTENT Map header: false new: After: 41 New: 21",
|
||||
"client -> edge | KNOWN Map sessions: header/62",
|
||||
"client -> storage | CONTENT Map header: false new: After: 41 New: 21",
|
||||
"storage -> edge | CONTENT Map header: true new: After: 62 New: 21",
|
||||
"edge -> core | CONTENT Map header: false new: After: 62 New: 21",
|
||||
"edge -> client | CONTENT Map header: false new: After: 62 New: 21",
|
||||
"core -> edge | KNOWN Group sessions: header/5",
|
||||
"core -> storage | CONTENT Group header: true new: After: 0 New: 5",
|
||||
"core -> edge | KNOWN Map sessions: header/0",
|
||||
"core -> storage | CONTENT Map header: true new: expectContentUntil: header/200",
|
||||
"core -> edge | KNOWN Map sessions: header/73",
|
||||
"core -> storage | CONTENT Map header: false new: After: 0 New: 73",
|
||||
"core -> edge | KNOWN Map sessions: header/146",
|
||||
"core -> storage | CONTENT Map header: false new: After: 73 New: 73",
|
||||
"core -> edge | KNOWN Map sessions: header/200",
|
||||
"core -> storage | CONTENT Map header: false new: After: 146 New: 54",
|
||||
"client -> edge | KNOWN Map sessions: header/200",
|
||||
"client -> storage | CONTENT Map header: false new: After: 146 New: 54",
|
||||
"core -> storage | CONTENT Map header: true new: expectContentUntil: header/100",
|
||||
"core -> edge | KNOWN Map sessions: header/41",
|
||||
"core -> storage | CONTENT Map header: false new: After: 0 New: 41",
|
||||
"core -> edge | KNOWN Map sessions: header/62",
|
||||
"core -> storage | CONTENT Map header: false new: After: 41 New: 21",
|
||||
"core -> edge | KNOWN Map sessions: header/83",
|
||||
"core -> storage | CONTENT Map header: false new: After: 62 New: 21",
|
||||
"client -> edge | KNOWN Map sessions: header/83",
|
||||
"client -> storage | CONTENT Map header: false new: After: 62 New: 21",
|
||||
"storage -> edge | CONTENT Map header: true new: After: 83 New: 17",
|
||||
"edge -> core | CONTENT Map header: false new: After: 83 New: 17",
|
||||
"edge -> client | CONTENT Map header: false new: After: 83 New: 17",
|
||||
"core -> edge | KNOWN Map sessions: header/100",
|
||||
"core -> storage | CONTENT Map header: false new: After: 83 New: 17",
|
||||
"client -> edge | KNOWN Map sessions: header/100",
|
||||
"client -> storage | CONTENT Map header: false new: After: 83 New: 17",
|
||||
]
|
||||
`);
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
vi,
|
||||
} from "vitest";
|
||||
|
||||
import { setMaxRecommendedTxSize } from "../config";
|
||||
import { emptyKnownState } from "../exports";
|
||||
import {
|
||||
SyncMessagesLog,
|
||||
@@ -27,6 +28,7 @@ describe("client with storage syncs with server", () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
SyncMessagesLog.clear();
|
||||
setMaxRecommendedTxSize(100 * 1024);
|
||||
jazzCloud = setupTestNode({
|
||||
isSyncServer: true,
|
||||
});
|
||||
@@ -244,6 +246,7 @@ describe("client syncs with a server with storage", () => {
|
||||
});
|
||||
|
||||
test("loading a large coValue from storage", async () => {
|
||||
setMaxRecommendedTxSize(1000);
|
||||
const client = setupTestNode();
|
||||
|
||||
client.connectToSyncServer({
|
||||
@@ -260,11 +263,9 @@ describe("client syncs with a server with storage", () => {
|
||||
const largeMap = group.createMap();
|
||||
|
||||
// Generate a large amount of data (about 100MB)
|
||||
const dataSize = 1 * 200 * 1024;
|
||||
const chunkSize = 1024; // 1KB chunks
|
||||
const chunks = dataSize / chunkSize;
|
||||
const chunks = 100;
|
||||
|
||||
const value = Buffer.alloc(chunkSize, `value$`).toString("base64");
|
||||
const value = "1".repeat(10);
|
||||
|
||||
for (let i = 0; i < chunks; i++) {
|
||||
const key = `key${i}`;
|
||||
@@ -289,20 +290,28 @@ describe("client syncs with a server with storage", () => {
|
||||
[
|
||||
"client -> storage | CONTENT Group header: true new: After: 0 New: 5",
|
||||
"client -> server | CONTENT Group header: true new: After: 0 New: 5",
|
||||
"client -> storage | CONTENT Map header: true new: After: 0 New: 73",
|
||||
"client -> server | CONTENT Map header: true new: After: 0 New: 73",
|
||||
"client -> storage | CONTENT Map header: false new: After: 73 New: 73",
|
||||
"client -> server | CONTENT Map header: false new: After: 73 New: 73",
|
||||
"client -> storage | CONTENT Map header: false new: After: 146 New: 54",
|
||||
"client -> server | CONTENT Map header: false new: After: 146 New: 54",
|
||||
"client -> storage | CONTENT Map header: true new: After: 0 New: 20",
|
||||
"client -> server | CONTENT Map header: true new: After: 0 New: 20",
|
||||
"client -> storage | CONTENT Map header: false new: After: 20 New: 21",
|
||||
"client -> server | CONTENT Map header: false new: After: 20 New: 21",
|
||||
"client -> storage | CONTENT Map header: false new: After: 41 New: 21",
|
||||
"client -> server | CONTENT Map header: false new: After: 41 New: 21",
|
||||
"client -> storage | CONTENT Map header: false new: After: 62 New: 21",
|
||||
"client -> server | CONTENT Map header: false new: After: 62 New: 21",
|
||||
"client -> storage | CONTENT Map header: false new: After: 83 New: 17",
|
||||
"client -> server | CONTENT Map header: false new: After: 83 New: 17",
|
||||
"server -> client | KNOWN Group sessions: header/5",
|
||||
"server -> storage | CONTENT Group header: true new: After: 0 New: 5",
|
||||
"server -> client | KNOWN Map sessions: header/73",
|
||||
"server -> storage | CONTENT Map header: true new: After: 0 New: 73",
|
||||
"server -> client | KNOWN Map sessions: header/146",
|
||||
"server -> storage | CONTENT Map header: false new: After: 73 New: 73",
|
||||
"server -> client | KNOWN Map sessions: header/200",
|
||||
"server -> storage | CONTENT Map header: false new: After: 146 New: 54",
|
||||
"server -> client | KNOWN Map sessions: header/20",
|
||||
"server -> storage | CONTENT Map header: true new: After: 0 New: 20",
|
||||
"server -> client | KNOWN Map sessions: header/41",
|
||||
"server -> storage | CONTENT Map header: false new: After: 20 New: 21",
|
||||
"server -> client | KNOWN Map sessions: header/62",
|
||||
"server -> storage | CONTENT Map header: false new: After: 41 New: 21",
|
||||
"server -> client | KNOWN Map sessions: header/83",
|
||||
"server -> storage | CONTENT Map header: false new: After: 62 New: 21",
|
||||
"server -> client | KNOWN Map sessions: header/100",
|
||||
"server -> storage | CONTENT Map header: false new: After: 83 New: 17",
|
||||
]
|
||||
`);
|
||||
|
||||
@@ -355,12 +364,13 @@ describe("client syncs with a server with storage", () => {
|
||||
"client -> storage | LOAD Map sessions: empty",
|
||||
"storage -> client | CONTENT Group header: true new: After: 0 New: 5",
|
||||
"client -> server | LOAD Group sessions: header/5",
|
||||
"storage -> client | CONTENT Map header: true new: After: 0 New: 73 expectContentUntil: header/200",
|
||||
"client -> server | LOAD Map sessions: header/200",
|
||||
"storage -> client | CONTENT Map header: true new: After: 0 New: 41 expectContentUntil: header/100",
|
||||
"client -> server | LOAD Map sessions: header/100",
|
||||
"server -> client | KNOWN Group sessions: header/5",
|
||||
"server -> client | KNOWN Map sessions: header/200",
|
||||
"storage -> client | CONTENT Map header: true new: After: 73 New: 73",
|
||||
"storage -> client | CONTENT Map header: true new: After: 146 New: 54",
|
||||
"server -> client | KNOWN Map sessions: header/100",
|
||||
"storage -> client | CONTENT Map header: true new: After: 41 New: 21",
|
||||
"storage -> client | CONTENT Map header: true new: After: 62 New: 21",
|
||||
"storage -> client | CONTENT Map header: true new: After: 83 New: 17",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { assert, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { setMaxRecommendedTxSize } from "../config";
|
||||
import { emptyKnownState } from "../exports";
|
||||
import {
|
||||
SyncMessagesLog,
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
setupTestNode,
|
||||
waitFor,
|
||||
} from "./testUtils";
|
||||
import { getDbPath } from "./testStorage";
|
||||
|
||||
// We want to simulate a real world communication that happens asynchronously
|
||||
TEST_NODE_CONFIG.withAsyncPeers = true;
|
||||
@@ -17,6 +19,7 @@ describe("client with storage syncs with server", () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetAllMocks();
|
||||
setMaxRecommendedTxSize(100 * 1024);
|
||||
SyncMessagesLog.clear();
|
||||
jazzCloud = setupTestNode({
|
||||
isSyncServer: true,
|
||||
@@ -262,6 +265,7 @@ describe("client syncs with a server with storage", () => {
|
||||
});
|
||||
|
||||
test("large coValue streaming", async () => {
|
||||
setMaxRecommendedTxSize(1000);
|
||||
const client = setupTestNode();
|
||||
|
||||
client.connectToSyncServer({
|
||||
@@ -278,11 +282,9 @@ describe("client syncs with a server with storage", () => {
|
||||
const largeMap = group.createMap();
|
||||
|
||||
// Generate a large amount of data (about 100MB)
|
||||
const dataSize = 1 * 200 * 1024;
|
||||
const chunkSize = 1024; // 1KB chunks
|
||||
const chunks = dataSize / chunkSize;
|
||||
const chunks = 100;
|
||||
|
||||
const value = Buffer.alloc(chunkSize, `value$`).toString("base64");
|
||||
const value = "1".repeat(10);
|
||||
|
||||
for (let i = 0; i < chunks; i++) {
|
||||
const key = `key${i}`;
|
||||
@@ -300,20 +302,28 @@ describe("client syncs with a server with storage", () => {
|
||||
[
|
||||
"client -> storage | CONTENT Group header: true new: After: 0 New: 5",
|
||||
"client -> server | CONTENT Group header: true new: After: 0 New: 5",
|
||||
"client -> storage | CONTENT Map header: true new: After: 0 New: 73",
|
||||
"client -> server | CONTENT Map header: true new: After: 0 New: 73",
|
||||
"client -> storage | CONTENT Map header: false new: After: 73 New: 73",
|
||||
"client -> server | CONTENT Map header: false new: After: 73 New: 73",
|
||||
"client -> storage | CONTENT Map header: false new: After: 146 New: 54",
|
||||
"client -> server | CONTENT Map header: false new: After: 146 New: 54",
|
||||
"client -> storage | CONTENT Map header: true new: After: 0 New: 20",
|
||||
"client -> server | CONTENT Map header: true new: After: 0 New: 20",
|
||||
"client -> storage | CONTENT Map header: false new: After: 20 New: 21",
|
||||
"client -> server | CONTENT Map header: false new: After: 20 New: 21",
|
||||
"client -> storage | CONTENT Map header: false new: After: 41 New: 21",
|
||||
"client -> server | CONTENT Map header: false new: After: 41 New: 21",
|
||||
"client -> storage | CONTENT Map header: false new: After: 62 New: 21",
|
||||
"client -> server | CONTENT Map header: false new: After: 62 New: 21",
|
||||
"client -> storage | CONTENT Map header: false new: After: 83 New: 17",
|
||||
"client -> server | CONTENT Map header: false new: After: 83 New: 17",
|
||||
"server -> client | KNOWN Group sessions: header/5",
|
||||
"server -> storage | CONTENT Group header: true new: After: 0 New: 5",
|
||||
"server -> client | KNOWN Map sessions: header/73",
|
||||
"server -> storage | CONTENT Map header: true new: After: 0 New: 73",
|
||||
"server -> client | KNOWN Map sessions: header/146",
|
||||
"server -> storage | CONTENT Map header: false new: After: 73 New: 73",
|
||||
"server -> client | KNOWN Map sessions: header/200",
|
||||
"server -> storage | CONTENT Map header: false new: After: 146 New: 54",
|
||||
"server -> client | KNOWN Map sessions: header/20",
|
||||
"server -> storage | CONTENT Map header: true new: After: 0 New: 20",
|
||||
"server -> client | KNOWN Map sessions: header/41",
|
||||
"server -> storage | CONTENT Map header: false new: After: 20 New: 21",
|
||||
"server -> client | KNOWN Map sessions: header/62",
|
||||
"server -> storage | CONTENT Map header: false new: After: 41 New: 21",
|
||||
"server -> client | KNOWN Map sessions: header/83",
|
||||
"server -> storage | CONTENT Map header: false new: After: 62 New: 21",
|
||||
"server -> client | KNOWN Map sessions: header/100",
|
||||
"server -> storage | CONTENT Map header: false new: After: 83 New: 17",
|
||||
]
|
||||
`);
|
||||
|
||||
@@ -345,12 +355,13 @@ describe("client syncs with a server with storage", () => {
|
||||
"client -> storage | LOAD Map sessions: empty",
|
||||
"storage -> client | CONTENT Group header: true new: After: 0 New: 5",
|
||||
"client -> server | LOAD Group sessions: header/5",
|
||||
"storage -> client | CONTENT Map header: true new: After: 0 New: 73 expectContentUntil: header/200",
|
||||
"client -> server | LOAD Map sessions: header/200",
|
||||
"storage -> client | CONTENT Map header: true new: After: 73 New: 73",
|
||||
"storage -> client | CONTENT Map header: true new: After: 146 New: 54",
|
||||
"storage -> client | CONTENT Map header: true new: After: 0 New: 41 expectContentUntil: header/100",
|
||||
"client -> server | LOAD Map sessions: header/100",
|
||||
"storage -> client | CONTENT Map header: true new: After: 41 New: 21",
|
||||
"storage -> client | CONTENT Map header: true new: After: 62 New: 21",
|
||||
"storage -> client | CONTENT Map header: true new: After: 83 New: 17",
|
||||
"server -> client | KNOWN Group sessions: header/5",
|
||||
"server -> client | KNOWN Map sessions: header/200",
|
||||
"server -> client | KNOWN Map sessions: header/100",
|
||||
]
|
||||
`);
|
||||
});
|
||||
@@ -446,8 +457,8 @@ describe("client syncs with a server with storage", () => {
|
||||
|
||||
const largeMap = group.createMap();
|
||||
|
||||
// Generate a large amount of data (about 100MB)
|
||||
const dataSize = 1 * 200 * 1024;
|
||||
// Generate a large amount of data
|
||||
const dataSize = 1 * 10 * 1024;
|
||||
const chunkSize = 1024; // 1KB chunks
|
||||
const chunks = dataSize / chunkSize;
|
||||
|
||||
@@ -495,17 +506,58 @@ describe("client syncs with a server with storage", () => {
|
||||
"client -> storage | LOAD Map sessions: empty",
|
||||
"storage -> client | CONTENT Group header: true new: After: 0 New: 5",
|
||||
"client -> server | LOAD Group sessions: header/5",
|
||||
"storage -> client | CONTENT Map header: true new: After: 0 New: 73 expectContentUntil: header/200",
|
||||
"client -> server | LOAD Map sessions: header/200",
|
||||
"storage -> client | CONTENT Map header: true new: After: 73 New: 73",
|
||||
"storage -> client | CONTENT Map header: true new: After: 146 New: 54",
|
||||
"storage -> client | CONTENT Map header: true new: After: 0 New: 1 expectContentUntil: header/10",
|
||||
"client -> server | LOAD Map sessions: header/10",
|
||||
"storage -> client | CONTENT Map header: true new: After: 1 New: 1",
|
||||
"storage -> client | CONTENT Map header: true new: After: 2 New: 1",
|
||||
"storage -> client | CONTENT Map header: true new: After: 3 New: 1",
|
||||
"storage -> client | CONTENT Map header: true new: After: 4 New: 1",
|
||||
"storage -> client | CONTENT Map header: true new: After: 5 New: 1",
|
||||
"storage -> client | CONTENT Map header: true new: After: 6 New: 1",
|
||||
"storage -> client | CONTENT Map header: true new: After: 7 New: 1",
|
||||
"storage -> client | CONTENT Map header: true new: After: 8 New: 1",
|
||||
"storage -> client | CONTENT Map header: true new: After: 9 New: 1",
|
||||
"storage -> client | CONTENT Map header: true new: After: 10 New: 0",
|
||||
"server -> storage | LOAD Group sessions: empty",
|
||||
"storage -> server | CONTENT Group header: true new: After: 0 New: 5",
|
||||
"server -> client | KNOWN Group sessions: header/5",
|
||||
"server -> storage | LOAD Map sessions: empty",
|
||||
"storage -> server | CONTENT Map header: true new: After: 0 New: 73 expectContentUntil: header/200",
|
||||
"server -> client | KNOWN Map sessions: header/200",
|
||||
"storage -> server | CONTENT Map header: true new: After: 0 New: 1 expectContentUntil: header/10",
|
||||
"server -> client | KNOWN Map sessions: header/10",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
test("two storage instances open on the same file should not conflict with each other", async () => {
|
||||
const client = setupTestNode();
|
||||
|
||||
client.connectToSyncServer({
|
||||
syncServer: jazzCloud.node,
|
||||
});
|
||||
const dbPath = getDbPath();
|
||||
await client.addAsyncStorage({
|
||||
ourName: "client",
|
||||
filename: dbPath,
|
||||
});
|
||||
|
||||
const client2 = setupTestNode();
|
||||
client2.connectToSyncServer({
|
||||
syncServer: jazzCloud.node,
|
||||
});
|
||||
await client2.addAsyncStorage({
|
||||
ourName: "client2",
|
||||
filename: dbPath,
|
||||
});
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
for (const node of [client.node, client2.node]) {
|
||||
const group = node.createGroup();
|
||||
const map = group.createMap();
|
||||
map.set("hello", "world", "trusting");
|
||||
}
|
||||
}
|
||||
|
||||
await client.node.syncManager.waitForAllCoValuesSync();
|
||||
await client2.node.syncManager.waitForAllCoValuesSync();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -95,7 +95,11 @@ export async function createAsyncStorage({
|
||||
filename,
|
||||
nodeName = "client",
|
||||
storageName = "storage",
|
||||
}: { filename?: string; nodeName: string; storageName: string }) {
|
||||
}: {
|
||||
filename?: string;
|
||||
nodeName: string;
|
||||
storageName: string;
|
||||
}) {
|
||||
const storage = await getSqliteStorageAsync(
|
||||
new LibSQLSqliteAsyncDriver(getDbPath(filename)),
|
||||
);
|
||||
@@ -113,7 +117,11 @@ export function createSyncStorage({
|
||||
filename,
|
||||
nodeName = "client",
|
||||
storageName = "storage",
|
||||
}: { filename?: string; nodeName: string; storageName: string }) {
|
||||
}: {
|
||||
filename?: string;
|
||||
nodeName: string;
|
||||
storageName: string;
|
||||
}) {
|
||||
const storage = getSqliteStorage(
|
||||
new LibSQLSqliteSyncDriver(getDbPath(filename)),
|
||||
);
|
||||
@@ -123,7 +131,7 @@ export function createSyncStorage({
|
||||
return storage;
|
||||
}
|
||||
|
||||
function getDbPath(defaultDbPath?: string) {
|
||||
export function getDbPath(defaultDbPath?: string) {
|
||||
const dbPath = defaultDbPath ?? join(tmpdir(), `test-${randomUUID()}.db`);
|
||||
|
||||
if (!defaultDbPath) {
|
||||
|
||||
@@ -530,10 +530,13 @@ export function setupTestNode(
|
||||
return { storage };
|
||||
}
|
||||
|
||||
async function addAsyncStorage(opts: { ourName?: string } = {}) {
|
||||
async function addAsyncStorage(
|
||||
opts: { ourName?: string; filename?: string } = {},
|
||||
) {
|
||||
const storage = await createAsyncStorage({
|
||||
nodeName: opts.ourName ?? "client",
|
||||
storageName: "storage",
|
||||
filename: opts.filename,
|
||||
});
|
||||
node.setStorage(storage);
|
||||
|
||||
@@ -657,6 +660,11 @@ export async function setupTestAccount(
|
||||
connectToSyncServer,
|
||||
addStorage,
|
||||
addAsyncStorage,
|
||||
disconnect: () => {
|
||||
ctx.node.syncManager.getPeers().forEach((peer) => {
|
||||
peer.gracefulShutdown();
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { RawAccountID } from "../coValues/account.js";
|
||||
import { AgentID, SessionID } from "../ids.js";
|
||||
import type { RawAccountID } from "../coValues/account.js";
|
||||
import type { AgentID, SessionID } from "../ids.js";
|
||||
|
||||
export function accountOrAgentIDfromSessionID(
|
||||
sessionID: SessionID,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user