Compare commits

..

74 Commits

Author SHA1 Message Date
Guido D'Orsi
c1c6e31711 Merge pull request #2719 from garden-co/changeset-release/main
Version Packages
2025-08-11 14:15:38 +02:00
github-actions[bot]
0b16085f3c Version Packages 2025-08-11 12:07:42 +00:00
Guido D'Orsi
e53db2e96a chore: format 2025-08-11 14:04:22 +02:00
Guido D'Orsi
384f0e23c0 Merge pull request #2701 from garden-co/feat/better-async-storage
feat: support multiple instances of storage
2025-08-11 14:03:39 +02:00
Guido D'Orsi
daaf1789d9 Merge pull request #2721 from garden-co/fix/char-chunking-coplaintext
Fix local transactions streaming and implement chunking for CoPlainText
2025-08-11 14:02:49 +02:00
Guido D'Orsi
1f9e20e753 Merge pull request #2705 from garden-co/chore/biome-2
chore: bump biome version to 2.1.3
2025-08-11 14:01:16 +02:00
Guido D'Orsi
ce9ca54f5c feat: content chunking on CoPlainText 2025-08-11 14:00:20 +02:00
Guido D'Orsi
67e0968809 fix: fix content streaming chunking, now chunks should be splitted always respecting the MAX_RECOMMENDED_TX_SIZE 2025-08-11 14:00:17 +02:00
Giordano Ricci
96a922cceb Merge pull request #2711 from garden-co/gio/usage-metering 2025-08-11 12:22:00 +01:00
Sammii
0a98b826f1 Merge pull request #2675 from garden-co/feat/quint-add-full-button-suite
Feat/quint add full button suite
2025-08-11 10:59:10 +01:00
Sammii
62a3854c41 Update packages/quint-ui/src/components/button.tsx
Co-authored-by: Giordano Ricci <me@giordanoricci.com>
2025-08-11 10:45:57 +01:00
Guido D'Orsi
7bdb6f4279 chore: simplify drawWaveform 2025-08-11 11:33:49 +02:00
Guido D'Orsi
4b99ff1fe3 feat: support multiple storage instances 2025-08-11 11:21:22 +02:00
Guido D'Orsi
3ebf8258a0 Merge pull request #2692 from garden-co/feat/garbage-collector
feat: added a TTL-based garbage collection
2025-08-11 10:54:58 +02:00
Guido D'Orsi
4809d14f6d chore: restore CI quality check 2025-08-11 10:45:04 +02:00
Guido D'Orsi
5ae1f33127 chore: disable importOrder and format the codebase 2025-08-11 10:44:03 +02:00
Guido D'Orsi
ca5d84f6a9 Merge pull request #2720 from garden-co/fix/vitest-nested-projects
chore: removed nested projects in vitest.config
2025-08-11 10:33:59 +02:00
Guido D'Orsi
6e6acc3404 chore: revert the homepage formatting 2025-08-11 10:26:24 +02:00
Guido D'Orsi
b17b7b6481 Merge remote-tracking branch 'origin/main' into chore/biome-2 2025-08-11 10:22:41 +02:00
Guido D'Orsi
5341646301 chore: revert formatting, remove the code-quality CI check 2025-08-11 10:21:33 +02:00
Guido D'Orsi
5416165d28 Merge remote-tracking branch 'origin/main' into feat/garbage-collector 2025-08-11 10:18:37 +02:00
Guido D'Orsi
b5a9f681c5 Merge pull request #2696 from garden-co/feat/chat-pagination
feat(chat): implement lazy-loading
2025-08-11 10:17:32 +02:00
Matteo Manchi
7dffc006eb chore: removed nested projects in vitest.config 2025-08-11 00:09:19 +02:00
Guido D'Orsi
cd3cc5b0ab Merge pull request #2716 from garden-co/fix/co-record-key-deep-loading
Fix return type on deep loaded co.record() when using single keys
2025-08-10 22:54:54 +02:00
Guido D'Orsi
ceab75eb4d Merge pull request #2718 from garden-co/feat/nice-music-player
fix: fix UnknownError: Unknown transaction on IndexedDB
2025-08-10 22:40:50 +02:00
Guido D'Orsi
103d1b41f7 fix: fix unknown transaction error on IndexedDB 2025-08-10 22:32:23 +02:00
Guido D'Orsi
b87cc6973e Merge pull request #2717 from garden-co/feat/nice-music-player
feat: improve music player UI
2025-08-10 22:23:41 +02:00
Guido D'Orsi
3d541ca241 feat: improve music player controls bar 2025-08-10 22:21:03 +02:00
Matteo Manchi
e72bfec884 fixup! fix(jazz-tools/coValues): fix return type on deep loaded co.record() when using string keys 2025-08-10 20:37:37 +02:00
Matteo Manchi
19c7ad27d9 fix(jazz-tools/coValues): fix return type on deep loaded co.record() when using string keys 2025-08-10 15:22:24 +02:00
Guido D'Orsi
0bc7bfc5cc test: cover string loading and unavailable props 2025-08-09 17:31:49 +02:00
Matteo Manchi
2c8120d46f fix(jazz-tools/coValues): fix return type on deep loaded co.record() when using single keys 2025-08-09 16:58:49 +02:00
Giordano Ricci
ac5d20d159 Revert "Merge pull request #2709 from garden-co/revert-2682-gio/usage-metering"
This reverts commit b3d1ad7201, reversing
changes made to fbc29f2f17.
2025-08-08 12:35:16 +01:00
Brad Anderson
3915bbbf3c fix: update tests due to sync protocol improvements 2025-08-06 17:08:00 -04:00
Brad Anderson
0b471c4e89 fix: undo organizeImports that broke tests - jazz-tools 2025-08-06 12:34:29 -04:00
Brad Anderson
09077d37ef chore: code-quality version bump, biome to catalog for examples 2025-08-06 10:40:42 -04:00
Brad Anderson
afe06b4fa6 chore: format-and-lint:fix 2025-08-06 10:29:38 -04:00
Brad Anderson
d89b6e488a chore: bump biome version to 2.1.3 2025-08-06 10:26:33 -04:00
Sammii
a5ece15797 adding defaults to button 2025-08-05 12:04:41 +01:00
Sammii
9f8877202e creating color-highlight var in quint 2025-08-05 10:56:08 +01:00
Sammii
d190097ed9 creating tempory nav 2025-08-05 10:55:16 +01:00
Sammii
9841617c66 adding colours to homepage 2025-08-05 10:54:56 +01:00
Sammii
688a4850a4 add svg sizes to button and amend icons docs page 2025-08-04 16:09:39 +01:00
Sammii
e87fef751e remove old icon pages 2025-08-04 16:08:01 +01:00
Sammii
8f714440f8 create icons page 2025-08-04 16:07:28 +01:00
Sammii
70cd09170e updating button docs page 2025-08-04 16:02:28 +01:00
Guido D'Orsi
4c63334299 chore: add comments 2025-08-04 14:48:17 +02:00
Guido D'Orsi
4aef7cdac5 Update .changeset/ten-cobras-fetch.md
Co-authored-by: Joe Innes <joe@joeinn.es>
2025-08-04 14:39:26 +02:00
Guido D'Orsi
76adeb0d53 chore: clean up implementation 2025-08-04 14:03:51 +02:00
Guido D'Orsi
40c7336c09 chore: update lucide-react 2025-08-04 11:21:18 +02:00
Guido D'Orsi
e0d2723615 fix: router update when calling navitate 2025-08-04 11:17:49 +02:00
Guido D'Orsi
c19a25f928 feat(chat): implement lazy-loading 2025-08-03 17:20:46 +02:00
Guido D'Orsi
6d9b77195a chore: clean up code 2025-08-02 12:36:56 +02:00
Guido D'Orsi
9bf7946ee6 feat: added a TTL-based garbage collection 2025-08-01 19:58:11 +02:00
Sammii
2f24d35471 amending comments for button tv 2025-07-30 12:49:49 +01:00
Sammii
42667c81bb imrprove icon docs 2025-07-30 12:42:36 +01:00
Sammii
77e3c21cbd format globals css 2025-07-30 10:58:18 +01:00
Sammii
f5039cefc1 addig icon button to docs page and icons pagr tidy 2025-07-28 14:09:46 +01:00
Sammii
6540893caf adding default, strong and muted to css 2025-07-28 13:51:23 +01:00
Sammii
bfc85c4573 refactoring icon and icon page 2025-07-28 13:50:57 +01:00
Sammii
e9076313ab amending Button page, adding title 2025-07-28 13:50:44 +01:00
Sammii
c6afd8ae36 adding placeholder favicon 2025-07-28 13:40:52 +01:00
Sammii
370f20d13d refactoring css and button component 2025-07-28 13:40:31 +01:00
Sammii
f9b3116deb adding custom color steps to all tailwind css colours in design system 2025-07-28 13:10:42 +01:00
Sammii
352d34979f create icon component 2025-07-25 17:34:29 +01:00
Sammii
7ff736ace4 improving gradient on muted, default and strong intent buttons 2025-07-25 16:23:23 +01:00
Sammii
5bab466fd0 adding default/hover/active states for all intents 2025-07-25 16:20:55 +01:00
Sammii
329b8c3d6a switching muted and default styles 2025-07-25 15:29:01 +01:00
Sammii
c0aeb7baf9 porting over variant/intent styles 2025-07-25 11:21:17 +01:00
Sammii
8a14de10d7 fix(quint-ui): correct hover and active states for button variants 2025-07-25 11:13:39 +01:00
Sammii
b585b39a86 porting over glass styles with specular borders 2025-07-25 10:15:57 +01:00
Sammii
e9b2860e74 button tv refactor 2025-07-23 16:32:07 +01:00
Sammii
6327d74f68 mapping over button suite from old design system to quint 2025-07-23 16:24:19 +01:00
Sammii
bedbabdcb4 styling the layout of quint docs 2025-07-23 15:18:48 +01:00
142 changed files with 3463 additions and 813 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,13 @@
# passkey-svelte
## 0.0.112
### Patch Changes
- Updated dependencies [67e0968]
- Updated dependencies [2c8120d]
- jazz-tools@0.16.6
## 0.0.111
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "chat-svelte",
"version": "0.0.111",
"version": "0.0.112",
"type": "module",
"private": true,
"scripts": {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -4,8 +4,8 @@
html {
overflow: hidden;
max-width: 1200px;
position: relative;
background-color: hsl(0 0% 99%);
}
:root {

View File

@@ -15,6 +15,7 @@ export class AudioManager {
if (this.audioObjectURL) {
URL.revokeObjectURL(this.audioObjectURL);
this.audioObjectURL = null;
this.mediaElement.src = "";
}
}

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,19 @@
# 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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,17 @@
# 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

View File

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

View File

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

View File

@@ -1,5 +1,17 @@
# 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

View File

@@ -1,11 +1,12 @@
{
"name": "cojson-transport-ws",
"type": "module",
"version": "0.16.5",
"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"
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,15 @@
# 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

View File

@@ -25,7 +25,7 @@
},
"type": "module",
"license": "MIT",
"version": "0.16.5",
"version": "0.16.6",
"devDependencies": {
"@opentelemetry/sdk-metrics": "^2.0.0",
"libsql": "^0.5.13",

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

View File

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

View File

@@ -69,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;
@@ -78,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;
} = {};
@@ -201,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" });
@@ -609,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",
@@ -851,9 +870,7 @@ export class CoValueCore {
}
}
waitForSync(options?: {
timeout?: number;
}) {
waitForSync(options?: { timeout?: number }) {
return this.node.syncManager.waitForSync(this.id, options?.timeout);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import { Result, err, ok } from "neverthrow";
import { GarbageCollector } from "./GarbageCollector.js";
import type { CoID } from "./coValue.js";
import type { RawCoValue } from "./coValue.js";
import {
@@ -30,7 +31,7 @@ import {
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();
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,11 @@
# hash-slash
## 0.2.4
### Patch Changes
- e0d2723: Fix router update when calling navigate\
## 0.2.3
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "hash-slash",
"version": "0.2.3",
"version": "0.2.4",
"type": "module",
"main": "dist/index.js",
"types": "src/index.tsx",

View File

@@ -4,6 +4,14 @@ export type Routes = {
[Key: `/${string}`]: ReactNode | ((param: string) => ReactNode);
};
class NavigateEvent extends Event {
static readonly type = "navigate";
constructor() {
super(NavigateEvent.type);
}
}
export function useHashRouter(options?: { tellParentFrame?: boolean }) {
const [hash, setHash] = useState(location.hash.slice(1));
@@ -13,7 +21,7 @@ export function useHashRouter(options?: { tellParentFrame?: boolean }) {
options?.tellParentFrame &&
window.parent.postMessage(
{
type: "navigate",
type: NavigateEvent.type,
url: location.href,
},
"*",
@@ -22,16 +30,18 @@ export function useHashRouter(options?: { tellParentFrame?: boolean }) {
};
window.addEventListener("hashchange", onHashChange);
window.addEventListener(NavigateEvent.type, onHashChange);
return () => {
window.removeEventListener("hashchange", onHashChange);
window.removeEventListener(NavigateEvent.type, onHashChange);
};
});
return {
navigate: (url: string) => {
history.replaceState({}, "", url);
window.dispatchEvent(new HashChangeEvent("hashchange"));
window.dispatchEvent(new NavigateEvent());
},
route: function (routes: {
[route: `${string}` | `/${string}/:${string}`]: (

View File

@@ -1,5 +1,19 @@
# jazz-auth-betterauth
## 0.16.6
### Patch Changes
- Updated dependencies [67e0968]
- Updated dependencies [ce9ca54]
- Updated dependencies [4b99ff1]
- Updated dependencies [ac5d20d]
- Updated dependencies [2c8120d]
- Updated dependencies [9bf7946]
- jazz-tools@0.16.6
- cojson@0.16.6
- jazz-betterauth-client-plugin@0.16.6
## 0.16.5
### Patch Changes

View File

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

View File

@@ -1,5 +1,11 @@
# jazz-betterauth-client-plugin
## 0.16.6
### Patch Changes
- jazz-betterauth-server-plugin@0.16.6
## 0.16.5
### Patch Changes

View File

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

View File

@@ -1,5 +1,18 @@
# jazz-betterauth-server-plugin
## 0.16.6
### Patch Changes
- Updated dependencies [67e0968]
- Updated dependencies [ce9ca54]
- Updated dependencies [4b99ff1]
- Updated dependencies [ac5d20d]
- Updated dependencies [2c8120d]
- Updated dependencies [9bf7946]
- jazz-tools@0.16.6
- cojson@0.16.6
## 0.16.5
### Patch Changes

View File

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

View File

@@ -1,5 +1,20 @@
# jazz-react-auth-betterauth
## 0.16.6
### Patch Changes
- Updated dependencies [67e0968]
- Updated dependencies [ce9ca54]
- Updated dependencies [4b99ff1]
- Updated dependencies [ac5d20d]
- Updated dependencies [2c8120d]
- Updated dependencies [9bf7946]
- jazz-tools@0.16.6
- cojson@0.16.6
- jazz-auth-betterauth@0.16.6
- jazz-betterauth-client-plugin@0.16.6
## 0.16.5
### Patch Changes

View File

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

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