Compare commits

..

68 Commits

Author SHA1 Message Date
Guido D'Orsi
165a6170cd Merge pull request #2700 from garden-co/changeset-release/main
Version Packages
2025-08-04 21:13:54 +02:00
github-actions[bot]
5148419df9 Version Packages 2025-08-04 19:11:13 +00:00
Guido D'Orsi
fc0ecb0968 chore: changeset 2025-08-04 21:07:48 +02:00
Guido D'Orsi
802b5a3060 chore: changeset 2025-08-04 21:06:23 +02:00
Guido D'Orsi
e47af262b3 Merge pull request #2673 from garden-co/feat/storage-wal
fix: ensure that transactions are synced in the correct order
2025-08-04 20:54:53 +02:00
Guido D'Orsi
e98b610fd0 Merge pull request #2698 from garden-co/feat/comap-pick-and-partial
feat: Add `.pick()` and `.partial()` methods to CoMapSchema
2025-08-04 14:53:38 +02:00
Guido D'Orsi
b554983558 Merge pull request #2699 from garden-co/fix/extend-circular-check
fix: fixes error when extending a group without having child groups loaded
2025-08-04 14:53:15 +02:00
Guido D'Orsi
d95dcbe7db fix: align pick to the Zod API 2025-08-04 13:24:44 +02:00
Guido D'Orsi
f9d538f049 fix: fixes error when extending a group without having child groups loaded 2025-08-04 12:37:53 +02:00
Guido D'Orsi
93e68c62f5 docs: fix a missing type alias 2025-08-04 10:52:06 +02:00
Guido D'Orsi
dadee9dcc5 test: fix flaky test 2025-08-04 10:40:03 +02:00
Guido D'Orsi
6724c4bd83 feat: add docs, remove lodash-es dependency and add tests for recursive types with pick and partial 2025-08-04 10:34:43 +02:00
NicoR
1942bd5de4 Replace lodash with lodash-es 2025-08-04 01:44:27 -03:00
NicoR
16764f6365 Add changeset 2025-08-04 01:23:00 -03:00
NicoR
b56cfc2e1f Add TS docs 2025-08-04 01:21:32 -03:00
NicoR
7091bcf9c0 Add CoMapSchema.partial 2025-08-04 01:17:25 -03:00
NicoR
436cbfa095 Add CoMapSchema.pick 2025-08-04 01:00:57 -03:00
Guido D'Orsi
104e664bbb fix: fix build errors on music player 2025-08-03 17:20:15 +02:00
Guido D'Orsi
f199b451eb chore: use inline JSON when creating covalues 2025-08-03 17:09:02 +02:00
Guido D'Orsi
70bc48458e Merge pull request #2695 from garden-co/feat/music-player-refresh
docs: exclude upgrade guides from llm.txt
2025-08-02 14:37:56 +02:00
Guido D'Orsi
f28b2a6135 docs: exclude upgrade guides 2025-08-02 14:36:55 +02:00
Guido D'Orsi
55b770b7c9 Merge pull request #2694 from garden-co/feat/music-player-refresh
feat: improve the music player UI
2025-08-02 14:29:35 +02:00
Guido D'Orsi
e6838dfb98 feat: make the music-player a PWA 2025-08-02 14:22:36 +02:00
Guido D'Orsi
5e34061fdc feat: improve the music player UI 2025-08-02 14:19:24 +02:00
Guido D'Orsi
acecffaeb2 test: fix flaky tests on the created and update time 2025-08-01 19:57:33 +02:00
Nico Rainhart
0a98d6aaf2 Merge pull request #2691 from garden-co/changeset-release/main
Version Packages
2025-08-01 12:57:33 -03:00
github-actions[bot]
4ea1a63a0a Version Packages 2025-08-01 15:47:51 +00:00
Nico Rainhart
41a4c3bc95 Merge pull request #2683 from garden-co/feat/json-create-and-set
feat: create CoValues using plain JSON objects
2025-08-01 12:45:30 -03:00
Guido D'Orsi
60d0027f9d Merge pull request #2690 from garden-co/docs/useCoState-jsDoc
docs: adds jsDocs to useCoState and useAccount react hooks
2025-08-01 16:12:39 +02:00
Guido D'Orsi
f2f5b55dbf Update packages/jazz-tools/src/react-core/hooks.ts
Co-authored-by: Nico Rainhart <nmrainhart@gmail.com>
2025-08-01 15:42:24 +02:00
Guido D'Orsi
896ee3460f docs: adds jsDocs to useCoState and useAccount react hooks 2025-08-01 15:37:02 +02:00
Guido D'Orsi
5a48c9c44c chore: improve tests titles and add comments 2025-08-01 10:14:24 +02:00
NicoR
c564fbb02e test: add permission tests for creating nested CoValues from JSON 2025-07-31 15:03:25 -03:00
NicoR
fd2d247ff5 docs: improve examples 2025-07-31 13:08:50 -03:00
NicoR
9e9ea029b2 fix: move alert out of CodeGroup 2025-07-30 17:32:56 -03:00
NicoR
a0da272dcd fix: add missing import in docs 2025-07-30 16:51:23 -03:00
NicoR
72fbcc3262 chore: remove unnecessary import from form example 2025-07-30 15:52:07 -03:00
NicoR
f4c8cc858b docs: add sections for creating CoValues from JSON and permissions 2025-07-30 15:48:55 -03:00
NicoR
4cbda689c4 refactor: update form example to use new API 2025-07-30 14:54:08 -03:00
NicoR
771b0ed914 fix: cannot create empty plain text when nested 2025-07-30 14:53:38 -03:00
NicoR
79913c3136 fix: simplify CoMapInit schema 2025-07-30 13:37:10 -03:00
NicoR
43d3511d15 Add changeset 2025-07-30 12:40:25 -03:00
NicoR
928ef14086 feat: support deeply nested optional primitive fields 2025-07-30 12:33:28 -03:00
NicoR
048dd7def0 feat: support deeply nested optional CoValue fields 2025-07-30 12:33:09 -03:00
Guido D'Orsi
51fcb8a44b test: improve the client subscription test 2025-07-30 17:29:01 +02:00
Guido D'Orsi
c5888c39f5 perf: update parent before updating children to favor batching 2025-07-30 17:14:45 +02:00
Guido D'Orsi
2defcfae67 test: mark retry unavailable states as flaky 2025-07-30 17:10:50 +02:00
NicoR
873b146d15 feat: create a child group for each created CoValue 2025-07-30 11:03:38 -03:00
Guido D'Orsi
213de11c3b feat: preserve transaction order on sync 2025-07-30 15:37:58 +02:00
Guido D'Orsi
af295d816a chore: add comments and rename CoValueSyncQueue in LocalTransactionsSyncQueue 2025-07-30 12:54:46 +02:00
Guido D'Orsi
fe8d3497c0 chore: fix the peer attribution on storage corrections tests 2025-07-30 12:37:19 +02:00
Guido D'Orsi
ba9ad295b6 fix: don't consider -1 as a valid signature checkpoint 2025-07-30 12:32:07 +02:00
NicoR
ab1798c7bd feat: make CoValue creation from JSON type safe 2025-07-29 16:41:48 -03:00
NicoR
26ae69a242 refactor: reuse TypeOfZodSchema 2025-07-29 12:44:42 -03:00
NicoR
21ad3767b9 refactor: avoid InstanceOrPrimitiveOfSchemaCoValuesNullable duplication 2025-07-29 11:48:46 -03:00
NicoR
a9383516c1 refactor: avoid InstanceOrPrimitiveOfSchema duplication 2025-07-29 11:38:09 -03:00
NicoR
bffc516c68 refactor: extract TypeOfZodSchema util 2025-07-29 11:16:14 -03:00
NicoR
9e7c0d9887 chore: clean up instantiateRefEncodedWithInit's implementation 2025-07-29 10:56:32 -03:00
NicoR
99b44d5780 feat: create CoMap with JSON discriminated union fields 2025-07-29 10:36:33 -03:00
NicoR
02db5f3b1d feat: create CoMap with JSON CoFeed fields 2025-07-29 09:13:01 -03:00
NicoR
1949a5fcd9 feat: create CoMap with JSON CoList fields 2025-07-28 17:15:43 -03:00
NicoR
dcd3b022cc feat: create CoMap with JSON plain and rich text fields 2025-07-28 16:56:43 -03:00
NicoR
a7b837c7e1 feat: create CoMap with JSON CoMap fields 2025-07-28 16:47:51 -03:00
Guido D'Orsi
b173e0884a feat: improve local transactions streaming calculation 2025-07-28 19:45:31 +02:00
Guido D'Orsi
231947c97a fix(sync): start a new content message when the size exceeds the recommended value 2025-07-28 18:44:13 +02:00
Guido D'Orsi
d5b57ad1fc fix: fix priority for content 2025-07-28 17:53:33 +02:00
Guido D'Orsi
0bf5c53bec fix: disable code coverage check on CI 2025-07-28 16:59:02 +02:00
Guido D'Orsi
e7b1550003 feat: perserve insert order when storing transactions on multiple covalues 2025-07-28 16:59:02 +02:00
113 changed files with 4969 additions and 1055 deletions

View File

@@ -1,5 +1,19 @@
# passkey-svelte
## 0.0.110
### Patch Changes
- Updated dependencies [16764f6]
- jazz-tools@0.16.4
## 0.0.109
### Patch Changes
- Updated dependencies [43d3511]
- jazz-tools@0.16.3
## 0.0.108
### Patch Changes

View File

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

View File

@@ -1,5 +1,4 @@
import { useIframeHashRouter } from "hash-slash";
import { Loaded } from "jazz-tools";
import { useAccount, useCoState } from "jazz-tools/react";
import { useState } from "react";
import { Errors } from "./Errors.tsx";
@@ -9,7 +8,6 @@ import {
BubbleTeaOrder,
DraftBubbleTeaOrder,
JazzAccount,
ListOfBubbleTeaAddOns,
validateDraftOrder,
} from "./schema.ts";
@@ -22,7 +20,7 @@ export function CreateOrder() {
if (!me?.root) return;
const onSave = (draft: Loaded<typeof DraftBubbleTeaOrder>) => {
const onSave = (draft: DraftBubbleTeaOrder) => {
const validation = validateDraftOrder(draft);
setErrors(validation.errors);
if (validation.errors.length > 0) {
@@ -30,11 +28,11 @@ export function CreateOrder() {
}
// turn the draft into a real order
me.root.orders.push(draft as Loaded<typeof BubbleTeaOrder>);
me.root.orders.push(draft as BubbleTeaOrder);
// reset the draft
me.root.draft = DraftBubbleTeaOrder.create({
addOns: ListOfBubbleTeaAddOns.create([]),
addOns: [],
});
router.navigate("/");
@@ -60,7 +58,7 @@ function CreateOrderForm({
onSave,
}: {
id: string;
onSave: (draft: Loaded<typeof DraftBubbleTeaOrder>) => void;
onSave: (draft: DraftBubbleTeaOrder) => void;
}) {
const draft = useCoState(DraftBubbleTeaOrder, id, {
resolve: { addOns: true, instructions: true },

View File

@@ -1,4 +1,4 @@
import { CoPlainText, Loaded } from "jazz-tools";
import { CoPlainText } from "jazz-tools";
import {
BubbleTeaAddOnTypes,
BubbleTeaBaseTeaTypes,
@@ -10,7 +10,7 @@ export function OrderForm({
order,
onSave,
}: {
order: Loaded<typeof BubbleTeaOrder> | Loaded<typeof DraftBubbleTeaOrder>;
order: BubbleTeaOrder | DraftBubbleTeaOrder;
onSave?: (e: React.FormEvent<HTMLFormElement>) => void;
}) {
// Handles updates to the instructions field of the order.

View File

@@ -1,10 +1,9 @@
import { Loaded } from "jazz-tools";
import { BubbleTeaOrder } from "./schema.ts";
export function OrderThumbnail({
order,
}: {
order: Loaded<typeof BubbleTeaOrder>;
order: BubbleTeaOrder;
}) {
const { id, baseTea, addOns, instructions, deliveryDate, withMilk } = order;
const date = deliveryDate.toLocaleDateString();

View File

@@ -1,4 +1,4 @@
import { Loaded, co, z } from "jazz-tools";
import { co, z } from "jazz-tools";
export const BubbleTeaAddOnTypes = [
"Pearl",
@@ -18,8 +18,9 @@ export const BubbleTeaBaseTeaTypes = [
export const ListOfBubbleTeaAddOns = co.list(
z.literal([...BubbleTeaAddOnTypes]),
);
export type ListOfBubbleTeaAddOns = co.loaded<typeof ListOfBubbleTeaAddOns>;
function hasAddOnsChanges(list?: Loaded<typeof ListOfBubbleTeaAddOns> | null) {
function hasAddOnsChanges(list?: ListOfBubbleTeaAddOns | null) {
return list && Object.entries(list._raw.insertions).length > 0;
}
@@ -30,16 +31,12 @@ export const BubbleTeaOrder = co.map({
withMilk: z.boolean(),
instructions: co.optional(co.plainText()),
});
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
export const DraftBubbleTeaOrder = co.map({
baseTea: z.optional(z.literal([...BubbleTeaBaseTeaTypes])),
addOns: co.optional(ListOfBubbleTeaAddOns),
deliveryDate: z.optional(z.date()),
withMilk: z.optional(z.boolean()),
instructions: co.optional(co.plainText()),
});
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
export function validateDraftOrder(order: Loaded<typeof DraftBubbleTeaOrder>) {
export function validateDraftOrder(order: DraftBubbleTeaOrder) {
const errors: string[] = [];
if (!order.baseTea) {
@@ -52,7 +49,7 @@ export function validateDraftOrder(order: Loaded<typeof DraftBubbleTeaOrder>) {
return { errors };
}
export function hasChanges(order?: Loaded<typeof DraftBubbleTeaOrder> | null) {
export function hasChanges(order?: DraftBubbleTeaOrder | null) {
return (
!!order &&
(Object.keys(order._edits).length > 1 || hasAddOnsChanges(order.addOns))
@@ -73,15 +70,9 @@ export const JazzAccount = co
})
.withMigration((account) => {
if (!account.root) {
const orders = co.list(BubbleTeaOrder).create([], account);
const draft = DraftBubbleTeaOrder.create(
{
addOns: ListOfBubbleTeaAddOns.create([], account),
instructions: co.plainText().create("", account),
},
account.root = AccountRoot.create(
{ draft: { addOns: [], instructions: "" }, orders: [] },
account,
);
account.root = AccountRoot.create({ draft, orders }, account);
}
});

View File

@@ -38,6 +38,7 @@
"postcss": "^8.4.27",
"tailwindcss": "^4.1.10",
"typescript": "5.6.2",
"vite": "^6.3.5"
"vite": "^6.3.5",
"vite-plugin-pwa": "^1.0.2"
}
}

View File

@@ -84,15 +84,14 @@ export const MusicaAccount = co
* You can use it to set up the account root and any other initial CoValues you need.
*/
if (account.root === undefined) {
const tracks = co.list(MusicTrack).create([]);
const rootPlaylist = Playlist.create({
tracks,
tracks: [],
title: "",
});
account.root = MusicaAccountRoot.create({
rootPlaylist,
playlists: co.list(Playlist).create([]),
playlists: [],
activeTrack: undefined,
activePlaylist: rootPlaylist,
exampleDataLoaded: false,

View File

@@ -11,6 +11,7 @@ import { uploadMusicTracks } from "./4_actions";
import { MediaPlayer } from "./5_useMediaPlayer";
import { FileUploadButton } from "./components/FileUploadButton";
import { MusicTrackRow } from "./components/MusicTrackRow";
import { PlayerControls } from "./components/PlayerControls";
import { PlaylistTitleInput } from "./components/PlaylistTitleInput";
import { SidePanel } from "./components/SidePanel";
import { Button } from "./components/ui/button";
@@ -42,7 +43,11 @@ export function HomePage({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
const playlistId = params.playlistId ?? me?.root._refs.rootPlaylist.id;
const playlist = useCoState(Playlist, playlistId, {
resolve: { tracks: true },
resolve: {
tracks: {
$each: true,
},
},
});
const isRootPlaylist = !params.playlistId;
@@ -66,8 +71,8 @@ export function HomePage({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
return (
<SidebarInset className="flex flex-col h-screen text-gray-800 bg-blue-50">
<div className="flex flex-1 overflow-hidden">
<SidePanel mediaPlayer={mediaPlayer} />
<main className="flex-1 p-6 overflow-y-auto overflow-x-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">
{isRootPlaylist ? (
@@ -106,12 +111,12 @@ export function HomePage({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
onClick={() => {
mediaPlayer.setActiveTrack(track, playlist);
}}
showAddToPlaylist={isRootPlaylist}
/>
),
)}
</ul>
</main>
<PlayerControls mediaPlayer={mediaPlayer} />
</div>
</SidebarInset>
);

View File

@@ -1,11 +1,6 @@
import { getAudioFileData } from "@/lib/audio/getAudioFileData";
import { FileStream, Group, co } from "jazz-tools";
import {
MusicTrack,
MusicTrackWaveform,
MusicaAccount,
Playlist,
} from "./1_schema";
import { FileStream, Group } from "jazz-tools";
import { MusicTrack, MusicaAccount, Playlist } from "./1_schema";
/**
* Walkthrough: Actions
@@ -51,7 +46,7 @@ export async function uploadMusicTracks(
{
file: fileStream,
duration: data.duration,
waveform: MusicTrackWaveform.create({ data: data.waveform }, group),
waveform: { data: data.waveform },
title: file.name,
isExampleTrack,
},
@@ -73,18 +68,10 @@ export async function createNewPlaylist() {
},
});
// Since playlists are meant to be shared we associate them
// to a group which will contain the keys required to get
// access to the "owned" values
const playlistGroup = Group.create();
const playlist = Playlist.create(
{
title: "New Playlist",
tracks: co.list(MusicTrack).create([], playlistGroup),
},
playlistGroup,
);
const playlist = Playlist.create({
title: "New Playlist",
tracks: [],
});
// Again, we associate the new playlist to the
// user by pushing it into the playlists CoList

View File

@@ -0,0 +1,59 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
interface ConfirmDialogProps {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description: string;
confirmText?: string;
cancelText?: string;
onConfirm: () => void;
variant?: "default" | "destructive";
}
export function ConfirmDialog({
isOpen,
onOpenChange,
title,
description,
confirmText = "Confirm",
cancelText = "Cancel",
onConfirm,
variant = "destructive",
}: ConfirmDialogProps) {
function handleConfirm() {
onConfirm();
onOpenChange(false);
}
function handleCancel() {
onOpenChange(false);
}
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={handleCancel}>
{cancelText}
</Button>
<Button variant={variant} onClick={handleConfirm}>
{confirmText}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -10,27 +10,34 @@ import { cn } from "@/lib/utils";
import { Loaded } from "jazz-tools";
import { useAccount, useCoState } from "jazz-tools/react";
import { MoreHorizontal } from "lucide-react";
import { Fragment } from "react/jsx-runtime";
import { MusicTrackTitleInput } from "./MusicTrackTitleInput";
import { Fragment, useCallback, useState } from "react";
import { EditTrackDialog } from "./RenameTrackDialog";
import { Button } from "./ui/button";
function isPartOfThePlaylist(
trackId: string,
playlist: Loaded<typeof Playlist, { tracks: true }>,
) {
return Array.from(playlist.tracks._refs).some((t) => t.id === trackId);
}
export function MusicTrackRow({
trackId,
isLoading,
isPlaying,
onClick,
showAddToPlaylist,
}: {
trackId: string;
isLoading: boolean;
isPlaying: boolean;
onClick: (track: Loaded<typeof MusicTrack>) => void;
showAddToPlaylist: boolean;
}) {
const track = useCoState(MusicTrack, trackId);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const { me } = useAccount(MusicaAccount, {
resolve: { root: { playlists: { $each: true } } },
resolve: { root: { playlists: { $each: { tracks: true } } } },
});
const playlists = me?.root.playlists ?? [];
@@ -60,10 +67,18 @@ export function MusicTrackRow({
}
}
function handleEdit() {
setIsEditDialogOpen(true);
}
const handleContextMenu = useCallback((e: React.MouseEvent) => {
e.preventDefault();
setIsDropdownOpen(true);
}, []);
return (
<li
className={"flex gap-1 hover:bg-slate-200 group py-2 px-2 cursor-pointer"}
onClick={handleTrackClick}
>
<button
className={cn(
@@ -81,50 +96,57 @@ export function MusicTrackRow({
"▶️"
)}
</button>
<MusicTrackTitleInput trackId={trackId} />
<button
onContextMenu={handleContextMenu}
onClick={handleTrackClick}
className="w-full flex items-center overflow-hidden text-ellipsis whitespace-nowrap"
>
{track?.title}
</button>
<div onClick={(evt) => evt.stopPropagation()}>
{showAddToPlaylist && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0"
aria-label={`Open ${track?.title} menu`}
>
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
key={`delete`}
onSelect={async () => {
if (!track) return;
deleteTrack();
}}
>
Delete
</DropdownMenuItem>
{playlists.map((playlist, index) => (
<Fragment key={index}>
<DropdownMenuItem
key={`add-${index}`}
onSelect={() => handleAddToPlaylist(playlist)}
>
Add to {playlist.title}
</DropdownMenuItem>
<DropdownMenu open={isDropdownOpen} onOpenChange={setIsDropdownOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0"
aria-label={`Open ${track?.title} menu`}
>
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={handleEdit}>Edit</DropdownMenuItem>
{playlists.map((playlist, index) => (
<Fragment key={index}>
{isPartOfThePlaylist(trackId, playlist) ? (
<DropdownMenuItem
key={`remove-${index}`}
onSelect={() => handleRemoveFromPlaylist(playlist)}
>
Remove from {playlist.title}
</DropdownMenuItem>
</Fragment>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
) : (
<DropdownMenuItem
key={`add-${index}`}
onSelect={() => handleAddToPlaylist(playlist)}
>
Add to {playlist.title}
</DropdownMenuItem>
)}
</Fragment>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
{track && isEditDialogOpen && (
<EditTrackDialog
track={track}
isOpen={isEditDialogOpen}
onOpenChange={setIsEditDialogOpen}
onDelete={deleteTrack}
/>
)}
</li>
);
}

View File

@@ -24,25 +24,25 @@ export function PlayerControls({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
const activeTrackTitle = activeTrack.title;
return (
<footer className="flex items-center justify-between p-4 gap-4 bg-white border-t border-gray-200 fixed bottom-0 left-0 right-0 w-full">
<div className="flex justify-center items-center space-x-2">
<div className="flex items-center space-x-4">
<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
onClick={mediaPlayer.playPrevTrack}
className="text-blue-600 hover:text-blue-800"
aria-label="Previous track"
>
<SkipBack size={20} />
<SkipBack size={16} className="sm:w-5 sm:h-5" />
</button>
<button
onClick={playState.toggle}
className="w-[42px] h-[42px] flex items-center justify-center bg-blue-600 rounded-full text-white hover:bg-blue-700"
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"
aria-label={isPlaying ? "Pause active track" : "Play active track"}
>
{isPlaying ? (
<Pause size={24} fill="currentColor" />
<Pause size={16} className="sm:w-6 sm:h-6" fill="currentColor" />
) : (
<Play size={24} fill="currentColor" />
<Play size={16} className="sm:w-6 sm:h-6" fill="currentColor" />
)}
</button>
<button
@@ -50,16 +50,22 @@ export function PlayerControls({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
className="text-blue-600 hover:text-blue-800"
aria-label="Next track"
>
<SkipForward size={20} />
<SkipForward size={16} className="sm:w-5 sm:h-5" />
</button>
</div>
</div>
<div className=" sm:hidden md:flex flex-col flex-shrink-1 items-center w-[75%]">
<Waveform track={activeTrack} height={30} />
<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 w-[25%]">
<h4 className="font-medium text-blue-800">{activeTrackTitle}</h4>
<p className="text-sm text-gray-600">
<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">
{activeTrackTitle}
</h4>
<p className="text-xs sm:text-sm text-gray-600 truncate max-w-32 sm:max-w-80">
{activePlaylist?.title || "All tracks"}
</p>
</div>

View File

@@ -0,0 +1,108 @@
import { MusicTrack } from "@/1_schema";
import { updateMusicTrackTitle } from "@/4_actions";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Loaded } from "jazz-tools";
import { useState } from "react";
import { ConfirmDialog } from "./ConfirmDialog";
interface EditTrackDialogProps {
track: Loaded<typeof MusicTrack>;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
onDelete: () => void;
}
export function EditTrackDialog({
track,
isOpen,
onOpenChange,
onDelete,
}: EditTrackDialogProps) {
const [newTitle, setNewTitle] = useState(track.title);
const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = useState(false);
function handleSave() {
if (track && newTitle.trim()) {
updateMusicTrackTitle(track, newTitle.trim());
onOpenChange(false);
}
}
function handleCancel() {
setNewTitle(track?.title || "");
onOpenChange(false);
}
function handleDeleteClick() {
setIsDeleteConfirmOpen(true);
}
function handleDeleteConfirm() {
onDelete();
onOpenChange(false);
}
function handleKeyDown(event: React.KeyboardEvent) {
if (event.key === "Enter") {
handleSave();
} else if (event.key === "Escape") {
handleCancel();
}
}
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Track</DialogTitle>
<DialogDescription>Edit "{track?.title}".</DialogDescription>
</DialogHeader>
<form className="py-4" onSubmit={handleSave}>
<Input
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Enter track name..."
autoFocus
/>
</form>
<DialogFooter className="flex justify-between">
<Button
variant="destructive"
onClick={handleDeleteClick}
className="mr-auto"
>
Delete Track
</Button>
<div className="flex gap-2">
<Button variant="outline" onClick={handleCancel}>
Cancel
</Button>
<Button onClick={handleSave} disabled={!newTitle.trim()}>
Save
</Button>
</div>
</DialogFooter>
</DialogContent>
<ConfirmDialog
isOpen={isDeleteConfirmOpen}
onOpenChange={setIsDeleteConfirmOpen}
title="Delete Track"
description={`Are you sure you want to delete "${track.title}"? This action cannot be undone.`}
confirmText="Delete"
cancelText="Cancel"
onConfirm={handleDeleteConfirm}
variant="destructive"
/>
</Dialog>
);
}

View File

@@ -1,10 +1,8 @@
import { MusicTrack, MusicaAccount } from "@/1_schema";
import { MusicaAccount } from "@/1_schema";
import { createNewPlaylist, deletePlaylist } from "@/4_actions";
import { MediaPlayer } from "@/5_useMediaPlayer";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
@@ -14,22 +12,18 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { usePlayState } from "@/lib/audio/usePlayState";
import { useAccount, useCoState } from "jazz-tools/react";
import { Home, Music, Pause, Play, Plus, Trash2 } from "lucide-react";
import { useAccount } from "jazz-tools/react";
import { Home, Music, Plus, Trash2 } from "lucide-react";
import { useNavigate, useParams } from "react-router";
import { AuthButton } from "./AuthButton";
export function SidePanel({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
export function SidePanel() {
const { playlistId } = useParams();
const navigate = useNavigate();
const { me } = useAccount(MusicaAccount, {
resolve: { root: { playlists: { $each: true } } },
});
const playState = usePlayState();
const isPlaying = playState.value === "play";
function handleAllTracksClick() {
navigate(`/`);
}
@@ -50,12 +44,6 @@ export function SidePanel({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
navigate(`/playlist/${playlist.id}`);
}
const activeTrack = useCoState(MusicTrack, mediaPlayer.activeTrackId, {
resolve: { waveform: true },
});
const activeTrackTitle = activeTrack?.title;
return (
<Sidebar>
<SidebarHeader>
@@ -137,29 +125,6 @@ export function SidePanel({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
{activeTrack && (
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem className="flex justify-end">
<SidebarMenuButton
onClick={playState.toggle}
aria-label={
isPlaying ? "Pause active track" : "Play active track"
}
>
<div className="w-[28px] h-[28px] flex items-center justify-center bg-blue-600 rounded-full text-white hover:bg-blue-700">
{isPlaying ? (
<Pause size={16} fill="currentColor" />
) : (
<Play size={16} fill="currentColor" />
)}
</div>
<span>{activeTrackTitle}</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
)}
</Sidebar>
);
}

View File

@@ -7,6 +7,7 @@ import { useCoState } from "jazz-tools/react";
export function Waveform(props: {
track: Loaded<typeof MusicTrack>;
height: number;
className?: string;
}) {
const { track, height } = props;
const waveformData = useCoState(
@@ -36,7 +37,7 @@ export function Waveform(props: {
return (
<div
className="flex justify-center items-end w-full"
className={cn("flex justify-center items-end w-full", props.className)}
style={{
height,
gap: 1,

View File

@@ -2,6 +2,12 @@
@custom-variant dark (&:is(.dark *));
html {
overflow: hidden;
max-width: 1200px;
position: relative;
}
:root {
--background: hsl(0 0% 100%);
--foreground: hsl(20 14.3% 4.1%);

View File

@@ -55,10 +55,20 @@ export class HomePage {
async editTrackTitle(trackTitle: string, newTitle: string) {
await this.page
.getByRole("textbox", {
name: `Edit track title: ${trackTitle}`,
.getByRole("button", {
name: `Open ${trackTitle} menu`,
})
.fill(newTitle);
.click();
await this.page
.getByRole("menuitem", {
name: `Edit`,
})
.click();
await this.page.getByPlaceholder("Enter track name...").fill(newTitle);
await this.page.getByRole("button", { name: "Save" }).click();
}
async createPlaylist() {

View File

@@ -1,10 +1,21 @@
import path from "path";
import react from "@vitejs/plugin-react-swc";
import { defineConfig } from "vite";
import { VitePWA } from "vite-plugin-pwa";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
plugins: [
react(),
VitePWA({
registerType: "autoUpdate",
strategies: "generateSW",
workbox: {
globPatterns: ["**/*.{js,css,html,ico,png,svg}"],
maximumFileSizeToCacheInBytes: 1024 * 1024 * 5,
},
}),
],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),

View File

@@ -54,10 +54,10 @@ import { co, z, CoMap } from "jazz-tools";
export const BubbleTeaOrder = co.map({
name: z.string(),
});
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
export const DraftBubbleTeaOrder = co.map({
name: z.optional(z.string()),
});
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
```
</CodeGroup>
@@ -73,17 +73,17 @@ import { co, z } from "jazz-tools";
export const BubbleTeaOrder = co.map({
name: z.string(),
});
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
export const DraftBubbleTeaOrder = co.map({
name: z.optional(z.string()),
});
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
// ---cut---
// OrderForm.tsx
export function OrderForm({
order,
onSave,
}: {
order: co.loaded<typeof BubbleTeaOrder> | co.loaded<typeof DraftBubbleTeaOrder>;
order: BubbleTeaOrder | DraftBubbleTeaOrder;
onSave?: (e: React.FormEvent<HTMLFormElement>) => void;
}) {
return (
@@ -118,16 +118,16 @@ import * as React from "react";
export const BubbleTeaOrder = co.map({
name: z.string(),
});
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
export const DraftBubbleTeaOrder = co.map({
name: z.optional(z.string()),
});
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
export function OrderForm({
order,
onSave,
}: {
order: co.loaded<typeof BubbleTeaOrder> | co.loaded<typeof DraftBubbleTeaOrder>;
order: BubbleTeaOrder | DraftBubbleTeaOrder;
onSave?: (e: React.FormEvent<HTMLFormElement>) => void;
}) {
return (
@@ -177,10 +177,10 @@ import { useState, useEffect } from "react";
export const BubbleTeaOrder = co.map({
name: z.string(),
});
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
export const DraftBubbleTeaOrder = co.map({
name: z.optional(z.string()),
});
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
export const AccountRoot = co.map({
draft: DraftBubbleTeaOrder,
@@ -218,7 +218,7 @@ export function OrderForm({
// CreateOrder.tsx
export function CreateOrder() {
const { me } = useAccount();
const [draft, setDraft] = useState<co.loaded<typeof DraftBubbleTeaOrder>>();
const [draft, setDraft] = useState<DraftBubbleTeaOrder>();
useEffect(() => {
setDraft(DraftBubbleTeaOrder.create({}));
@@ -228,7 +228,7 @@ export function CreateOrder() {
e.preventDefault();
if (!draft || !draft.name) return;
const order = draft as co.loaded<typeof BubbleTeaOrder>; // TODO: this should narrow correctly
const order = draft as BubbleTeaOrder; // TODO: this should narrow correctly
console.log("Order created:", order);
};
@@ -251,11 +251,15 @@ Update the schema to include a `validateDraftOrder` helper.
import { co, z } from "jazz-tools";
// ---cut---
// schema.ts
export const DraftBubbleTeaOrder = co.map({
name: z.optional(z.string()),
export const BubbleTeaOrder = co.map({
name: z.string(),
});
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
export function validateDraftOrder(draft: co.loaded<typeof DraftBubbleTeaOrder>) { // [!code ++:9]
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
export function validateDraftOrder(draft: DraftBubbleTeaOrder) { // [!code ++:9]
const errors: string[] = [];
if (!draft.name) {
@@ -279,12 +283,12 @@ import { useState, useEffect } from "react";
export const BubbleTeaOrder = co.map({
name: z.string(),
});
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
export const DraftBubbleTeaOrder = co.map({
name: z.optional(z.string()),
});
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
export function validateDraftOrder(draft: co.loaded<typeof DraftBubbleTeaOrder>) {
export function validateDraftOrder(draft: DraftBubbleTeaOrder) {
const errors: string[] = [];
if (!draft.name) {
@@ -307,7 +311,7 @@ export function OrderForm({
order,
onSave,
}: {
order: co.loaded<typeof BubbleTeaOrder> | co.loaded<typeof DraftBubbleTeaOrder>;
order: BubbleTeaOrder | DraftBubbleTeaOrder;
onSave?: (e: React.FormEvent<HTMLFormElement>) => void;
}) {
return (
@@ -330,7 +334,7 @@ export function OrderForm({
// CreateOrder.tsx
export function CreateOrder() {
const { me } = useAccount();
const [draft, setDraft] = useState<co.loaded<typeof DraftBubbleTeaOrder>>();
const [draft, setDraft] = useState<DraftBubbleTeaOrder>();
useEffect(() => {
setDraft(DraftBubbleTeaOrder.create({}));
@@ -346,7 +350,7 @@ export function CreateOrder() {
return;
}
const order = draft as co.loaded<typeof BubbleTeaOrder>;
const order = draft as BubbleTeaOrder;
console.log("Order created:", order);
};
@@ -372,10 +376,10 @@ import { co, z } from "jazz-tools";
export const BubbleTeaOrder = co.map({
name: z.string(),
});
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
export const DraftBubbleTeaOrder = co.map({
name: z.optional(z.string()),
});
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
export const AccountRoot = co.map({ // [!code ++:15]
draft: DraftBubbleTeaOrder,
@@ -403,10 +407,10 @@ import { co, z } from "jazz-tools";
export const BubbleTeaOrder = co.map({
name: z.string(),
});
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
export const DraftBubbleTeaOrder = co.map({
name: z.optional(z.string()),
});
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
export const AccountRoot = co.map({
draft: DraftBubbleTeaOrder,
@@ -452,12 +456,12 @@ import { co, z } from "jazz-tools";
export const BubbleTeaOrder = co.map({
name: z.string(),
});
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
export const DraftBubbleTeaOrder = co.map({
name: z.optional(z.string()),
});
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
export function validateDraftOrder(draft: co.loaded<typeof DraftBubbleTeaOrder>) {
export function validateDraftOrder(draft: DraftBubbleTeaOrder) {
const errors: string[] = [];
if (!draft.name) {
@@ -492,7 +496,7 @@ export function OrderForm({
order,
onSave,
}: {
order: co.loaded<typeof BubbleTeaOrder> | co.loaded<typeof DraftBubbleTeaOrder>;
order: BubbleTeaOrder | DraftBubbleTeaOrder;
onSave?: (e: React.FormEvent<HTMLFormElement>) => void;
}) {
return (
@@ -533,7 +537,7 @@ export function CreateOrder() {
return;
}
const order = draft as co.loaded<typeof BubbleTeaOrder>;
const order = draft as BubbleTeaOrder;
console.log("Order created:", order);
// create a new empty draft
@@ -577,11 +581,15 @@ Simply add a `hasChanges` helper to your schema.
import { co, z } from "jazz-tools";
// ---cut---
// schema.ts
export const DraftBubbleTeaOrder = co.map({
name: z.optional(z.string()),
export const BubbleTeaOrder = co.map({
name: z.string(),
});
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
export function validateDraftOrder(draft: co.loaded<typeof DraftBubbleTeaOrder>) {
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
export function validateDraftOrder(draft: DraftBubbleTeaOrder) {
const errors: string[] = [];
if (!draft.name) {
@@ -591,7 +599,7 @@ export function validateDraftOrder(draft: co.loaded<typeof DraftBubbleTeaOrder>)
return { errors };
};
export function hasChanges(draft?: co.loaded<typeof DraftBubbleTeaOrder>) { // [!code ++:3]
export function hasChanges(draft?: DraftBubbleTeaOrder) { // [!code ++:3]
return draft ? Object.keys(draft._edits).length : false;
};
```
@@ -608,12 +616,12 @@ import * as React from "react";
export const BubbleTeaOrder = co.map({
name: z.string(),
});
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
export const DraftBubbleTeaOrder = co.map({
name: z.optional(z.string()),
});
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
export function validateDraftOrder(draft: co.loaded<typeof DraftBubbleTeaOrder>) {
export function validateDraftOrder(draft: DraftBubbleTeaOrder) {
const errors: string[] = [];
if (!draft.name) {
@@ -623,7 +631,7 @@ export function validateDraftOrder(draft: co.loaded<typeof DraftBubbleTeaOrder>)
return { errors };
};
export function hasChanges(draft?: co.loaded<typeof DraftBubbleTeaOrder>) {
export function hasChanges(draft?: DraftBubbleTeaOrder) {
return draft ? Object.keys(draft._edits).length : false;
};

View File

@@ -205,6 +205,101 @@ console.log(containingGroup.getParentGroups()); // [addedGroup]
```
</CodeGroup>
## Group hierarchy on CoValue creation
When creating CoValues that contain other CoValues using plain JSON objects, Jazz not only creates
the necessary CoValues automatically but it will also manage their group ownership.
<CodeGroup>
```ts twoslash
import { co, z } from "jazz-tools";
// ---cut---
const Task = co.plainText();
const Column = co.list(Task);
const Board = co.map({
title: z.string(),
columns: co.list(Column),
});
const board = Board.create({
title: "My board",
columns: [
["Task 1.1", "Task 1.2"],
["Task 2.1", "Task 2.2"],
],
});
```
</CodeGroup>
For each created column and task CoValue, Jazz also creates a new group as its owner and
adds the referencing CoValue's owner as a member of that group. This means permissions for nested CoValues
are inherited from the CoValue that references them, but can also be modified independently for each CoValue
if needed.
<CodeGroup>
```ts twoslash
import { co, z, Group, Account } from "jazz-tools";
const alice = {} as unknown as Account;
const bob = {} as unknown as Account;
const Task = co.plainText();
const Column = co.list(Task);
const Board = co.map({
title: z.string(),
columns: co.list(Column),
});
// ---cut---
const writeAccess = Group.create();
writeAccess.addMember(bob, "writer");
// Give Bob write access to the board, columns and tasks
const board = Board.create({
title: "My board",
columns: [
["Task 1.1", "Task 1.2"],
["Task 2.1", "Task 2.2"],
],
}, writeAccess);
// Give Alice read access to one specific task
const task = board.columns[0][0];
const taskGroup = task._owner.castAs(Group);
taskGroup.addMember(alice, "reader");
```
</CodeGroup>
If you prefer to manage permissions differently, you can always create CoValues explicitly:
<CodeGroup>
```ts twoslash
import { co, Group, z, Account } from "jazz-tools";
const bob = {} as unknown as Account;
const Task = co.plainText();
const Column = co.list(Task);
const Board = co.map({
title: z.string(),
columns: co.list(Column),
});
// ---cut---
const writeAccess = Group.create();
writeAccess.addMember(bob, "writer");
const readAccess = Group.create();
readAccess.addMember(bob, "reader");
// Give Bob read access to the board and write access to the columns and tasks
const board = Board.create({
title: "My board",
columns: co.list(Column).create([
["Task 1.1", "Task 1.2"],
["Task 2.1", "Task 2.2"],
], writeAccess),
}, readAccess);
```
</CodeGroup>
## Example: Team Hierarchy
Here's a practical example of using group inheritance for team permissions:

View File

@@ -3,6 +3,7 @@ export const metadata = {
};
import { CodeGroup, ComingSoon } from "@/components/forMdx";
import { Alert } from "@garden-co/design-system/src/components/atoms/Alert";
# Defining schemas: CoValues
@@ -80,6 +81,40 @@ const project = TodoProject.create(
</CodeGroup>
When creating CoValues that contain other CoValues, you can pass in a plain JSON object.
Jazz will automatically create the CoValues for you.
<CodeGroup>
```ts twoslash
// @filename: schema.ts
import { co, z, CoMap, CoList } from "jazz-tools";
export const ListOfTasks = co.list(z.string());
export const TodoProject = co.map({
title: z.string(),
tasks: ListOfTasks,
});
// @filename: app.ts
// ---cut---
// app.ts
import { Group } from "jazz-tools";
import { TodoProject, ListOfTasks } from "./schema";
const group = Group.create().makePublic();
const project = TodoProject.create({
title: "New Project",
tasks: [], // Permissions are inherited, so the tasks list will also be public
}, group);
```
</CodeGroup>
<Alert variant="info" className="flex gap-2 items-center my-4">
To learn more about how permissions work when creating nested CoValues with plain JSON objects,
refer to [Group hierarchy on CoValue creation](/docs/groups/inheritance#group-hierarchy-on-covalue-creation).
</Alert>
## Types of CoValues
### `CoMap` (declaration)

View File

@@ -228,6 +228,54 @@ export type Project = co.loaded<typeof Project>;
```
</CodeGroup>
### Partial
For convenience Jazz provies a dedicated API for making all the properties of a CoMap optional:
<CodeGroup>
```ts twoslash
import { co, z } from "jazz-tools";
const Project = co.map({
name: z.string(),
startDate: z.date(),
status: z.literal(["planning", "active", "completed"]),
});
const ProjectDraft = Project.partial();
// The fields are all optional now
const project = ProjectDraft.create({});
```
</CodeGroup>
### Pick
You can also pick specific fields from a CoMap:
<CodeGroup>
```ts twoslash
import { co, z } from "jazz-tools";
const Project = co.map({
name: z.string(),
startDate: z.date(),
status: z.literal(["planning", "active", "completed"]),
});
const ProjectStep1 = Project.pick({
name: true,
startDate: true,
});
// We don't provide the status field
const project = ProjectStep1.create({
name: "My project",
startDate: new Date("2025-04-01"),
});
```
</CodeGroup>
### Working with Record CoMaps
For record-type CoMaps, you can access values using bracket notation:

View File

@@ -5,6 +5,8 @@ import { readFile, readdir } from "fs/promises";
import { DOC_SECTIONS } from "./utils/config.mjs";
import { writeDocsFile } from "./utils/index.mjs";
const exclude = [/\/upgrade\//];
async function readMdxContent(url) {
try {
// Special case for the introduction
@@ -31,12 +33,17 @@ async function readMdxContent(url) {
// If it's a directory, try to read all framework variants
const fullPath = path.join(baseDir, relativePath);
if (exclude.some((pattern) => pattern.test(fullPath))) {
return null;
}
try {
const stats = await fs.stat(fullPath);
if (stats.isDirectory()) {
// Read all MDX files in the directory
const files = await fs.readdir(fullPath);
const mdxFiles = files.filter((f) => f.endsWith(".mdx"));
const mdxFiles = files.filter((f) => f.endsWith(".mdx")).filter((f) => !exclude.some((pattern) => pattern.test(f)));
if (mdxFiles.length === 0) return null;

View File

@@ -31,7 +31,7 @@
"build:packages": "turbo build --filter='./packages/*'",
"lint": "turbo lint && cd homepage/homepage && pnpm run lint",
"test": "vitest",
"test:ci": "vitest run --watch=false --coverage.enabled=true",
"test:ci": "vitest run --watch=false",
"test:coverage": "vitest --ui --coverage.enabled=true",
"format-and-lint": "biome check .",
"format-and-lint:fix": "biome check . --write",

View File

@@ -1,5 +1,19 @@
# cojson-storage-indexeddb
## 0.16.4
### Patch Changes
- Updated dependencies [f9d538f]
- Updated dependencies [802b5a3]
- cojson@0.16.4
## 0.16.3
### Patch Changes
- cojson@0.16.3
## 0.16.2
### Patch Changes

View File

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

View File

@@ -179,8 +179,9 @@ test("should load dependencies correctly (group inheritance)", async () => {
),
).toMatchInlineSnapshot(`
[
"client -> CONTENT Group header: true new: After: 0 New: 5",
"client -> CONTENT Group header: true new: After: 0 New: 3",
"client -> CONTENT ParentGroup header: true new: After: 0 New: 4",
"client -> CONTENT Group header: false new: After: 3 New: 2",
"client -> CONTENT Map header: true new: After: 0 New: 1",
]
`);
@@ -561,9 +562,10 @@ test("should sync and load accounts from storage", async () => {
),
).toMatchInlineSnapshot(`
[
"client -> CONTENT Account header: true new: After: 0 New: 4",
"client -> CONTENT Account header: true new: After: 0 New: 3",
"client -> CONTENT ProfileGroup header: true new: After: 0 New: 5",
"client -> CONTENT Profile header: true new: After: 0 New: 1",
"client -> CONTENT Account header: false new: After: 3 New: 1",
]
`);

View File

@@ -36,12 +36,11 @@ export function trackMessages() {
};
StorageApiAsync.prototype.store = async function (data, correctionCallback) {
for (const msg of data) {
messages.push({
from: "client",
msg,
});
}
messages.push({
from: "client",
msg: data,
});
return originalStore.call(this, data, (msg) => {
messages.push({
from: "storage",
@@ -51,7 +50,18 @@ export function trackMessages() {
...msg,
},
});
correctionCallback(msg);
const correctionMessages = correctionCallback(msg);
if (correctionMessages) {
for (const msg of correctionMessages) {
messages.push({
from: "client",
msg,
});
}
}
return correctionMessages;
});
};

View File

@@ -1,5 +1,19 @@
# cojson-storage-sqlite
## 0.16.4
### Patch Changes
- Updated dependencies [f9d538f]
- Updated dependencies [802b5a3]
- cojson@0.16.4
## 0.16.3
### Patch Changes
- cojson@0.16.3
## 0.16.2
### Patch Changes

View File

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

View File

@@ -211,8 +211,9 @@ test("should load dependencies correctly (group inheritance)", async () => {
),
).toMatchInlineSnapshot(`
[
"client -> CONTENT Group header: true new: After: 0 New: 5",
"client -> CONTENT Group header: true new: After: 0 New: 3",
"client -> CONTENT ParentGroup header: true new: After: 0 New: 4",
"client -> CONTENT Group header: false new: After: 3 New: 2",
"client -> CONTENT Map header: true new: After: 0 New: 1",
]
`);
@@ -374,6 +375,8 @@ test("should recover from data loss", async () => {
[
"client -> CONTENT Group header: true new: After: 0 New: 3",
"client -> CONTENT Map header: true new: After: 0 New: 1",
"client -> CONTENT Map header: false new: After: 3 New: 1",
"storage -> KNOWN CORRECTION Map sessions: header/4",
"client -> CONTENT Map header: false new: After: 1 New: 3",
]
`);
@@ -455,10 +458,7 @@ test("should recover missing dependencies from storage", async () => {
data,
correctionCallback,
) {
if (
data[0]?.id &&
[group.core.id, account.core.id as string].includes(data[0].id)
) {
if ([group.core.id, account.core.id as string].includes(data.id)) {
return false;
}

View File

@@ -36,12 +36,11 @@ export function trackMessages() {
};
StorageApiSync.prototype.store = function (data, correctionCallback) {
for (const msg of data) {
messages.push({
from: "client",
msg,
});
}
messages.push({
from: "client",
msg: data,
});
return originalStore.call(this, data, (msg) => {
messages.push({
from: "storage",
@@ -51,7 +50,19 @@ export function trackMessages() {
...msg,
},
});
correctionCallback(msg);
const correctionMessages = correctionCallback(msg);
if (correctionMessages) {
for (const msg of correctionMessages) {
messages.push({
from: "client",
msg,
});
}
}
return correctionMessages;
});
};

View File

@@ -1,5 +1,19 @@
# cojson-transport-nodejs-ws
## 0.16.4
### Patch Changes
- Updated dependencies [f9d538f]
- Updated dependencies [802b5a3]
- cojson@0.16.4
## 0.16.3
### Patch Changes
- cojson@0.16.3
## 0.16.2
### Patch Changes

View File

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

View File

@@ -1,5 +1,14 @@
# cojson
## 0.16.4
### Patch Changes
- f9d538f: Fix the error raised when extending a group without having child groups loaded
- 802b5a3: Refactor local updates sync to ensure that the changes are synced respecting the insertion order
## 0.16.3
## 0.16.2
## 0.16.1

View File

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

View File

@@ -0,0 +1,73 @@
import {
CoValueHeader,
Transaction,
VerifiedState,
} from "./coValueCore/verifiedState.js";
import { MAX_RECOMMENDED_TX_SIZE } from "./config.js";
import { Signature } from "./crypto/crypto.js";
import { RawCoID, SessionID } from "./ids.js";
import { getPriorityFromHeader } from "./priority.js";
import { NewContentMessage, emptyKnownState } from "./sync.js";
export function createContentMessage(
id: RawCoID,
header: CoValueHeader,
includeHeader = true,
): NewContentMessage {
return {
action: "content",
id,
header: includeHeader ? header : undefined,
priority: getPriorityFromHeader(header),
new: {},
};
}
export function addTransactionToContentMessage(
content: NewContentMessage,
transaction: Transaction,
sessionID: SessionID,
signature: Signature,
txIdx: number,
) {
const sessionContent = content.new[sessionID];
if (sessionContent) {
sessionContent.newTransactions.push(transaction);
sessionContent.lastSignature = signature;
} else {
content.new[sessionID] = {
after: txIdx,
newTransactions: [transaction],
lastSignature: signature,
};
}
}
export function getTransactionSize(transaction: Transaction) {
return transaction.privacy === "private"
? transaction.encryptedChanges.length
: transaction.changes.length;
}
export function exceedsRecommendedSize(
baseSize: number,
transactionSize?: number,
) {
if (transactionSize === undefined) {
return baseSize > MAX_RECOMMENDED_TX_SIZE;
}
return baseSize + transactionSize > MAX_RECOMMENDED_TX_SIZE;
}
export function knownStateFromContent(content: NewContentMessage) {
const knownState = emptyKnownState(content.id);
for (const [sessionID, session] of Object.entries(content.new)) {
knownState.sessions[sessionID as SessionID] =
session.after + session.newTransactions.length;
}
return knownState;
}

View File

@@ -2,9 +2,9 @@ import { UpDownCounter, ValueType, metrics } from "@opentelemetry/api";
import { Result, err } from "neverthrow";
import { PeerState } from "../PeerState.js";
import { RawCoValue } from "../coValue.js";
import { ControlledAccountOrAgent, RawAccountID } from "../coValues/account.js";
import { ControlledAccountOrAgent } from "../coValues/account.js";
import { RawGroup } from "../coValues/group.js";
import { CO_VALUE_LOADING_CONFIG, MAX_RECOMMENDED_TX_SIZE } from "../config.js";
import { CO_VALUE_LOADING_CONFIG } from "../config.js";
import { coreToCoValue } from "../coreToCoValue.js";
import {
CryptoProvider,
@@ -380,7 +380,7 @@ export class CoValueCore {
}
knownStateWithStreaming(): CoValueKnownState {
if (this.isAvailable()) {
if (this.verified) {
return this.verified.knownStateWithStreaming();
} else {
return emptyKnownState(this.id);
@@ -388,7 +388,7 @@ export class CoValueCore {
}
knownState(): CoValueKnownState {
if (this.isAvailable()) {
if (this.verified) {
return this.verified.knownState();
} else {
return emptyKnownState(this.id);
@@ -605,8 +605,17 @@ export class CoValueCore {
)._unsafeUnwrap({ withStackTrace: true });
if (success) {
const session = this.verified.sessions.get(sessionID);
const txIdx = session ? session.transactions.length - 1 : 0;
this.node.syncManager.recordTransactionsSize([transaction], "local");
void this.node.syncManager.requestCoValueSync(this);
this.node.syncManager.syncLocalTransaction(
this.verified,
transaction,
sessionID,
signature,
txIdx,
);
}
return success;

View File

@@ -1,6 +1,10 @@
import { Result, err, ok } from "neverthrow";
import { AnyRawCoValue } from "../coValue.js";
import { MAX_RECOMMENDED_TX_SIZE } from "../config.js";
import {
createContentMessage,
exceedsRecommendedSize,
getTransactionSize,
} from "../coValueContentMessage.js";
import {
CryptoProvider,
Encrypted,
@@ -14,7 +18,6 @@ import { RawCoID, SessionID, TransactionID } from "../ids.js";
import { Stringified } from "../jsonStringify.js";
import { JsonObject, JsonValue } from "../jsonValue.js";
import { PermissionsDef as RulesetDef } from "../permissions.js";
import { getPriorityFromHeader } from "../priority.js";
import { CoValueKnownState, NewContentMessage } from "../sync.js";
import { InvalidHashError, InvalidSignatureError } from "./coValueCore.js";
import { TryAddTransactionsError } from "./coValueCore.js";
@@ -151,6 +154,17 @@ export class VerifiedState {
return ok(true as const);
}
getLastSignatureCheckpoint(sessionID: SessionID): number {
const sessionLog = this.sessions.get(sessionID);
if (!sessionLog?.signatureAfter) return -1;
return Object.keys(sessionLog.signatureAfter).reduce(
(max, idx) => Math.max(max, parseInt(idx)),
-1,
);
}
private doAddTransactions(
sessionID: SessionID,
newTransactions: Transaction[],
@@ -165,24 +179,14 @@ export class VerifiedState {
}
const signatureAfter = sessionLog?.signatureAfter ?? {};
const lastInbetweenSignatureIdx = Object.keys(signatureAfter).reduce(
(max, idx) => (parseInt(idx) > max ? parseInt(idx) : max),
-1,
);
const lastInbetweenSignatureIdx =
this.getLastSignatureCheckpoint(sessionID);
const sizeOfTxsSinceLastInbetweenSignature = transactions
.slice(lastInbetweenSignatureIdx + 1)
.reduce(
(sum, tx) =>
sum +
(tx.privacy === "private"
? tx.encryptedChanges.length
: tx.changes.length),
0,
);
.reduce((sum, tx) => sum + getTransactionSize(tx), 0);
if (sizeOfTxsSinceLastInbetweenSignature > MAX_RECOMMENDED_TX_SIZE) {
if (exceedsRecommendedSize(sizeOfTxsSinceLastInbetweenSignature)) {
signatureAfter[transactions.length - 1] = newSignature;
}
@@ -242,13 +246,11 @@ export class VerifiedState {
return this._cachedNewContentSinceEmpty;
}
let currentPiece: NewContentMessage = {
action: "content",
id: this.id,
header: knownState?.header ? undefined : this.header,
priority: getPriorityFromHeader(this.header),
new: {},
};
let currentPiece: NewContentMessage = createContentMessage(
this.id,
this.header,
!knownState?.header,
);
const pieces = [currentPiece];
@@ -299,25 +301,16 @@ export class VerifiedState {
const oldPieceSize = pieceSize;
for (let txIdx = firstNewTxIdx; txIdx < afterLastNewTxIdx; txIdx++) {
const tx = log.transactions[txIdx]!;
pieceSize +=
tx.privacy === "private"
? tx.encryptedChanges.length
: tx.changes.length;
pieceSize += getTransactionSize(tx);
}
if (pieceSize >= MAX_RECOMMENDED_TX_SIZE) {
if (exceedsRecommendedSize(pieceSize)) {
if (!currentPiece.expectContentUntil && pieces.length === 1) {
currentPiece.expectContentUntil =
this.knownStateWithStreaming().sessions;
}
currentPiece = {
action: "content",
id: this.id,
header: undefined,
new: {},
priority: getPriorityFromHeader(this.header),
};
currentPiece = createContentMessage(this.id, this.header, false);
pieces.push(currentPiece);
pieceSize = pieceSize - oldPieceSize;
}

View File

@@ -669,19 +669,30 @@ export class RawGroup<
/** Detect circular references in group inheritance */
isSelfExtension(parent: RawGroup) {
if (parent.id === this.id) {
return true;
}
const checkedGroups = new Set<string>();
const queue = [parent];
const childGroups = this.getChildGroups();
while (true) {
const current = queue.pop();
for (const child of childGroups) {
if (child.isSelfExtension(parent)) {
if (!current) {
return false;
}
if (current.id === this.id) {
return true;
}
}
return false;
checkedGroups.add(current.id);
const parentGroups = current.getParentGroups();
for (const parent of parentGroups) {
if (!checkedGroups.has(parent.id)) {
queue.push(parent);
}
}
}
}
extend(
@@ -700,8 +711,8 @@ export class RawGroup<
const value = role === "inherit" ? "extend" : role;
this.set(`parent_${parent.id}`, value, "trusting");
parent.set(`child_${this.id}`, "extend", "trusting");
this.set(`parent_${parent.id}`, value, "trusting");
if (
parent.myRole() !== "admin" &&

View File

@@ -351,7 +351,7 @@ export class LocalNode {
new VerifiedState(id, this.crypto, header, new Map()),
);
void this.syncManager.requestCoValueSync(coValue);
this.syncManager.syncHeader(coValue.verified);
return coValue;
}
@@ -738,9 +738,14 @@ export class LocalNode {
}
}
gracefulShutdown() {
this.storage?.close();
/**
* Closes all the peer connections, drains all the queues and closes the storage.
*
* @returns Promise of the current pending store operation, if any.
*/
gracefulShutdown(): Promise<unknown> | undefined {
this.syncManager.gracefulShutdown();
return this.storage?.close();
}
}

View File

@@ -0,0 +1,96 @@
import {
addTransactionToContentMessage,
createContentMessage,
} from "../coValueContentMessage.js";
import { Transaction, VerifiedState } from "../coValueCore/verifiedState.js";
import { Signature } from "../crypto/crypto.js";
import { SessionID } from "../ids.js";
import { NewContentMessage } from "../sync.js";
import { LinkedList } from "./LinkedList.js";
/**
* This queue is used to batch the sync of local transactions while preserving the order of updates between CoValues.
*
* We need to preserve the order of updates between CoValues to keep the state always consistent in case of shutdown in the middle of a sync.
*
* Examples:
* 1. When we extend a Group we need to always ensure that the parent group is persisted before persisting the extension transaction.
* 2. If we do multiple updates on the same CoMap, the updates will be batched because it's safe to do so.
*/
export class LocalTransactionsSyncQueue {
private readonly queue = new LinkedList<NewContentMessage>();
constructor(private readonly sync: (content: NewContentMessage) => void) {}
syncHeader = (coValue: VerifiedState) => {
const lastPendingSync = this.queue.tail?.value;
if (lastPendingSync?.id === coValue.id) {
return;
}
this.enqueue(createContentMessage(coValue.id, coValue.header));
};
syncTransaction = (
coValue: VerifiedState,
transaction: Transaction,
sessionID: SessionID,
signature: Signature,
txIdx: number,
) => {
const lastPendingSync = this.queue.tail?.value;
const lastSignatureIdx = coValue.getLastSignatureCheckpoint(sessionID);
const isSignatureCheckpoint =
lastSignatureIdx > -1 && lastSignatureIdx === txIdx - 1;
if (lastPendingSync?.id === coValue.id && !isSignatureCheckpoint) {
addTransactionToContentMessage(
lastPendingSync,
transaction,
sessionID,
signature,
txIdx,
);
return;
}
const content = createContentMessage(coValue.id, coValue.header, false);
addTransactionToContentMessage(
content,
transaction,
sessionID,
signature,
txIdx,
);
this.enqueue(content);
};
enqueue(content: NewContentMessage) {
this.queue.push(content);
this.processPendingSyncs();
}
private processingSyncs = false;
processPendingSyncs() {
if (this.processingSyncs) return;
this.processingSyncs = true;
queueMicrotask(() => {
while (this.queue.head) {
const content = this.queue.head.value;
this.sync(content);
this.queue.shift();
}
this.processingSyncs = false;
});
}
}

View File

@@ -1,19 +1,22 @@
import { CorrectionCallback } from "../exports.js";
import { logger } from "../logger.js";
import { CoValueKnownState, NewContentMessage } from "../sync.js";
import { NewContentMessage } from "../sync.js";
import { LinkedList } from "./LinkedList.js";
type StoreQueueEntry = {
data: NewContentMessage[];
correctionCallback: (data: CoValueKnownState) => void;
data: NewContentMessage;
correctionCallback: CorrectionCallback;
};
export class StoreQueue {
private queue = new LinkedList<StoreQueueEntry>();
closed = false;
public push(data: NewContentMessage, correctionCallback: CorrectionCallback) {
if (this.closed) {
return;
}
public push(
data: NewContentMessage[],
correctionCallback: (data: CoValueKnownState) => void,
) {
this.queue.push({ data, correctionCallback });
}
@@ -22,12 +25,13 @@ export class StoreQueue {
}
processing = false;
lastCallback: Promise<unknown> | undefined;
async processQueue(
callback: (
data: NewContentMessage[],
correctionCallback: (data: CoValueKnownState) => void,
) => Promise<void>,
data: NewContentMessage,
correctionCallback: CorrectionCallback,
) => Promise<unknown>,
) {
if (this.processing) {
return;
@@ -41,16 +45,22 @@ export class StoreQueue {
const { data, correctionCallback } = entry;
try {
await callback(data, correctionCallback);
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;
}
drain() {
close() {
this.closed = true;
while (this.pull()) {}
return this.lastCallback;
}
}

View File

@@ -1,11 +1,15 @@
import {
createContentMessage,
exceedsRecommendedSize,
getTransactionSize,
} from "../coValueContentMessage.js";
import {
type CoValueCore,
MAX_RECOMMENDED_TX_SIZE,
type RawCoID,
type SessionID,
type StorageAPI,
logger,
} from "../exports.js";
import { getPriorityFromHeader } from "../priority.js";
import { StoreQueue } from "../queue/StoreQueue.js";
import {
CoValueKnownState,
@@ -13,8 +17,13 @@ import {
emptyKnownState,
} from "../sync.js";
import { StorageKnownState } from "./knownState.js";
import { collectNewTxs, getDependedOnCoValues } from "./syncUtils.js";
import {
collectNewTxs,
getDependedOnCoValues,
getNewTransactionsSize,
} from "./syncUtils.js";
import type {
CorrectionCallback,
DBClientInterfaceAsync,
SignatureAfterRow,
StoredCoValueRow,
@@ -82,6 +91,7 @@ export class StorageApiAsync implements StorageAPI {
);
const knownState = this.knwonStates.getKnownState(coValueRow.id);
knownState.header = true;
for (const sessionRow of allCoValueSessions) {
knownState.sessions[sessionRow.sessionID] = sessionRow.lastIdx;
@@ -89,13 +99,7 @@ export class StorageApiAsync implements StorageAPI {
this.loadedCoValues.add(coValueRow.id);
let contentMessage = {
action: "content",
id: coValueRow.id,
header: coValueRow.header,
new: {},
priority: getPriorityFromHeader(coValueRow.header),
} as NewContentMessage;
let contentMessage = createContentMessage(coValueRow.id, coValueRow.header);
if (contentStreaming) {
contentMessage.expectContentUntil = knownState["sessions"];
@@ -136,13 +140,10 @@ export class StorageApiAsync implements StorageAPI {
contentMessage,
callback,
);
contentMessage = {
action: "content",
id: coValueRow.id,
header: coValueRow.header,
new: {},
priority: getPriorityFromHeader(coValueRow.header),
} satisfies NewContentMessage;
contentMessage = createContentMessage(
coValueRow.id,
coValueRow.header,
);
}
}
}
@@ -194,33 +195,64 @@ export class StorageApiAsync implements StorageAPI {
storeQueue = new StoreQueue();
async store(
msgs: NewContentMessage[],
correctionCallback: (data: CoValueKnownState) => void,
) {
async store(msg: NewContentMessage, correctionCallback: CorrectionCallback) {
/**
* The store operations must be done one by one, because we can't start a new transaction when there
* is already a transaction open.
*/
this.storeQueue.push(msgs, correctionCallback);
this.storeQueue.push(msg, correctionCallback);
this.storeQueue.processQueue(async (data, correctionCallback) => {
for (const msg of data) {
const success = await this.storeSingle(msg, correctionCallback);
if (!success) {
// Stop processing the messages for this entry, because the data is out of sync with storage
// and the other transactions will be rejected anyway.
break;
}
}
return this.storeSingle(data, correctionCallback);
});
}
/**
* This function is called when the storage lacks the information required to store the incoming content.
*
* It triggers a `correctionCallback` to ask the syncManager to provide the missing information.
*
* The correction is applied immediately, to ensure that, when applicable, the dependent content in the queue won't require additional corrections.
*/
private async handleCorrection(
knownState: CoValueKnownState,
correctionCallback: CorrectionCallback,
) {
const correction = correctionCallback(knownState);
if (!correction) {
logger.error("Correction callback returned undefined", {
knownState,
correction: correction ?? null,
});
return false;
}
for (const msg of correction) {
const success = await this.storeSingle(msg, (knownState) => {
logger.error("Double correction requested", {
msg,
knownState,
});
return undefined;
});
if (!success) {
return false;
}
}
return true;
}
private async storeSingle(
msg: NewContentMessage,
correctionCallback: (data: CoValueKnownState) => void,
correctionCallback: CorrectionCallback,
): Promise<boolean> {
if (this.storeQueue.closed) {
return false;
}
const id = msg.id;
const coValueRow = await this.dbClient.getCoValue(id);
@@ -231,8 +263,7 @@ export class StorageApiAsync implements StorageAPI {
const knownState = emptyKnownState(id as RawCoID);
this.knwonStates.setKnownState(id, knownState);
correctionCallback(knownState);
return false;
return this.handleCorrection(knownState, correctionCallback);
}
const storedCoValueRowID: number = coValueRow
@@ -276,8 +307,7 @@ export class StorageApiAsync implements StorageAPI {
this.knwonStates.handleUpdate(id, knownState);
if (invalidAssumptions) {
correctionCallback(knownState);
return false;
return this.handleCorrection(knownState, correctionCallback);
}
return true;
@@ -290,38 +320,31 @@ export class StorageApiAsync implements StorageAPI {
storedCoValueRowID: number,
) {
const newTransactions = msg.new[sessionID]?.newTransactions || [];
const lastIdx = sessionRow?.lastIdx || 0;
const actuallyNewOffset =
(sessionRow?.lastIdx || 0) - (msg.new[sessionID]?.after || 0);
const actuallyNewOffset = lastIdx - (msg.new[sessionID]?.after || 0);
const actuallyNewTransactions = newTransactions.slice(actuallyNewOffset);
if (actuallyNewTransactions.length === 0) {
return sessionRow?.lastIdx || 0;
return lastIdx;
}
let newBytesSinceLastSignature =
(sessionRow?.bytesSinceLastSignature || 0) +
actuallyNewTransactions.reduce(
(sum, tx) =>
sum +
(tx.privacy === "private"
? tx.encryptedChanges.length
: tx.changes.length),
0,
);
let bytesSinceLastSignature = sessionRow?.bytesSinceLastSignature || 0;
const newTransactionsSize = getNewTransactionsSize(actuallyNewTransactions);
const newLastIdx =
(sessionRow?.lastIdx || 0) + actuallyNewTransactions.length;
const newLastIdx = lastIdx + actuallyNewTransactions.length;
let shouldWriteSignature = false;
if (newBytesSinceLastSignature > MAX_RECOMMENDED_TX_SIZE) {
if (exceedsRecommendedSize(bytesSinceLastSignature, newTransactionsSize)) {
shouldWriteSignature = true;
newBytesSinceLastSignature = 0;
bytesSinceLastSignature = 0;
} else {
bytesSinceLastSignature += newTransactionsSize;
}
const nextIdx = sessionRow?.lastIdx || 0;
const nextIdx = lastIdx;
if (!msg.new[sessionID]) throw new Error("Session ID not found");
@@ -330,7 +353,7 @@ export class StorageApiAsync implements StorageAPI {
sessionID,
lastIdx: newLastIdx,
lastSignature: msg.new[sessionID].lastSignature,
bytesSinceLastSignature: newBytesSinceLastSignature,
bytesSinceLastSignature,
};
const sessionRowID: number = await this.dbClient.addSessionUpdate({
@@ -360,7 +383,6 @@ export class StorageApiAsync implements StorageAPI {
}
close() {
// Drain the store queue
this.storeQueue.drain();
return this.storeQueue.close();
}
}

View File

@@ -1,20 +1,29 @@
import { UpDownCounter, metrics } from "@opentelemetry/api";
import {
createContentMessage,
exceedsRecommendedSize,
getTransactionSize,
} from "../coValueContentMessage.js";
import {
CoValueCore,
MAX_RECOMMENDED_TX_SIZE,
RawCoID,
type SessionID,
type StorageAPI,
logger,
} from "../exports.js";
import { getPriorityFromHeader } from "../priority.js";
import {
CoValueKnownState,
NewContentMessage,
emptyKnownState,
} from "../sync.js";
import { StorageKnownState } from "./knownState.js";
import { collectNewTxs, getDependedOnCoValues } from "./syncUtils.js";
import {
collectNewTxs,
getDependedOnCoValues,
getNewTransactionsSize,
} from "./syncUtils.js";
import type {
CorrectionCallback,
DBClientInterfaceSync,
SignatureAfterRow,
StoredCoValueRow,
@@ -84,6 +93,7 @@ export class StorageApiSync implements StorageAPI {
}
const knownState = this.knwonStates.getKnownState(coValueRow.id);
knownState.header = true;
for (const sessionRow of allCoValueSessions) {
knownState.sessions[sessionRow.sessionID] = sessionRow.lastIdx;
@@ -91,13 +101,7 @@ export class StorageApiSync implements StorageAPI {
this.loadedCoValues.add(coValueRow.id);
let contentMessage = {
action: "content",
id: coValueRow.id,
header: coValueRow.header,
new: {},
priority: getPriorityFromHeader(coValueRow.header),
} as NewContentMessage;
let contentMessage = createContentMessage(coValueRow.id, coValueRow.header);
if (contentStreaming) {
this.streamingCounter.add(1);
@@ -137,13 +141,10 @@ export class StorageApiSync implements StorageAPI {
contentMessage,
callback,
);
contentMessage = {
action: "content",
id: coValueRow.id,
header: coValueRow.header,
new: {},
priority: getPriorityFromHeader(coValueRow.header),
} satisfies NewContentMessage;
contentMessage = createContentMessage(
coValueRow.id,
coValueRow.header,
);
// Introduce a delay to not block the main thread
// for the entire content processing
@@ -189,22 +190,49 @@ export class StorageApiSync implements StorageAPI {
pushCallback(contentMessage);
}
store(
msgs: NewContentMessage[],
correctionCallback: (data: CoValueKnownState) => void,
store(msg: NewContentMessage, correctionCallback: CorrectionCallback) {
return this.storeSingle(msg, correctionCallback);
}
/**
* This function is called when the storage lacks the information required to store the incoming content.
*
* It triggers a `correctionCallback` to ask the syncManager to provide the missing information.
*/
private handleCorrection(
knownState: CoValueKnownState,
correctionCallback: CorrectionCallback,
) {
for (const msg of msgs) {
const success = this.storeSingle(msg, correctionCallback);
const correction = correctionCallback(knownState);
if (!correction) {
logger.error("Correction callback returned undefined", {
knownState,
correction: correction ?? null,
});
return false;
}
for (const msg of correction) {
const success = this.storeSingle(msg, (knownState) => {
logger.error("Double correction requested", {
msg,
knownState,
});
return undefined;
});
if (!success) {
return false;
}
}
return true;
}
private storeSingle(
msg: NewContentMessage,
correctionCallback: (data: CoValueKnownState) => void,
correctionCallback: CorrectionCallback,
): boolean {
const id = msg.id;
const coValueRow = this.dbClient.getCoValue(id);
@@ -214,11 +242,9 @@ export class StorageApiSync implements StorageAPI {
if (invalidAssumptionOnHeaderPresence) {
const knownState = emptyKnownState(id as RawCoID);
correctionCallback(knownState);
this.knwonStates.setKnownState(id, knownState);
return false;
return this.handleCorrection(knownState, correctionCallback);
}
const storedCoValueRowID: number = coValueRow
@@ -258,8 +284,7 @@ export class StorageApiSync implements StorageAPI {
this.knwonStates.handleUpdate(id, knownState);
if (invalidAssumptions) {
correctionCallback(knownState);
return false;
return this.handleCorrection(knownState, correctionCallback);
}
return true;
@@ -272,35 +297,29 @@ export class StorageApiSync implements StorageAPI {
storedCoValueRowID: number,
) {
const newTransactions = msg.new[sessionID]?.newTransactions || [];
const lastIdx = sessionRow?.lastIdx || 0;
const actuallyNewOffset =
(sessionRow?.lastIdx || 0) - (msg.new[sessionID]?.after || 0);
const actuallyNewOffset = lastIdx - (msg.new[sessionID]?.after || 0);
const actuallyNewTransactions = newTransactions.slice(actuallyNewOffset);
if (actuallyNewTransactions.length === 0) {
return sessionRow?.lastIdx || 0;
return lastIdx;
}
let newBytesSinceLastSignature =
(sessionRow?.bytesSinceLastSignature || 0) +
actuallyNewTransactions.reduce(
(sum, tx) =>
sum +
(tx.privacy === "private"
? tx.encryptedChanges.length
: tx.changes.length),
0,
);
let bytesSinceLastSignature = sessionRow?.bytesSinceLastSignature || 0;
const newTransactionsSize = getNewTransactionsSize(actuallyNewTransactions);
const newLastIdx =
(sessionRow?.lastIdx || 0) + actuallyNewTransactions.length;
let shouldWriteSignature = false;
if (newBytesSinceLastSignature > MAX_RECOMMENDED_TX_SIZE) {
if (exceedsRecommendedSize(bytesSinceLastSignature, newTransactionsSize)) {
shouldWriteSignature = true;
newBytesSinceLastSignature = 0;
bytesSinceLastSignature = 0;
} else {
bytesSinceLastSignature += newTransactionsSize;
}
const nextIdx = sessionRow?.lastIdx || 0;
@@ -312,7 +331,7 @@ export class StorageApiSync implements StorageAPI {
sessionID,
lastIdx: newLastIdx,
lastSignature: msg.new[sessionID].lastSignature,
bytesSinceLastSignature: newBytesSinceLastSignature,
bytesSinceLastSignature,
};
const sessionRowID: number = this.dbClient.addSessionUpdate({
@@ -339,5 +358,7 @@ export class StorageApiSync implements StorageAPI {
return this.knwonStates.waitForSync(id, coValue);
}
close() {}
close() {
return undefined;
}
}

View File

@@ -1,5 +1,9 @@
import { getTransactionSize } from "../coValueContentMessage.js";
import { getDependedOnCoValuesFromRawData } from "../coValueCore/utils.js";
import type { CoValueHeader } from "../coValueCore/verifiedState.js";
import type {
CoValueHeader,
Transaction,
} from "../coValueCore/verifiedState.js";
import type { Signature } from "../crypto/crypto.js";
import type { SessionID } from "../exports.js";
import type { NewContentMessage } from "../sync.js";
@@ -48,3 +52,7 @@ export function getDependedOnCoValues(
return getDependedOnCoValuesFromRawData(id, header, sessionIDs, transactions);
}
export function getNewTransactionsSize(newTxs: Transaction[]) {
return newTxs.reduce((sum, tx) => sum + getTransactionSize(tx), 0);
}

View File

@@ -6,6 +6,10 @@ import { Signature } from "../crypto/crypto.js";
import type { CoValueCore, RawCoID, SessionID } from "../exports.js";
import { CoValueKnownState, NewContentMessage } from "../sync.js";
export type CorrectionCallback = (
correction: CoValueKnownState,
) => NewContentMessage[] | undefined;
/**
* The StorageAPI is the interface that the StorageSync and StorageAsync classes implement.
*
@@ -18,16 +22,13 @@ export interface StorageAPI {
callback: (data: NewContentMessage) => void,
done?: (found: boolean) => void,
): void;
store(
data: NewContentMessage[] | undefined,
handleCorrection: (correction: CoValueKnownState) => void,
): void;
store(data: NewContentMessage, handleCorrection: CorrectionCallback): void;
getKnownState(id: string): CoValueKnownState;
waitForSync(id: string, coValue: CoValueCore): Promise<void>;
close(): void;
close(): Promise<unknown> | undefined;
}
export type CoValueRow = {

View File

@@ -1,15 +1,24 @@
import { Histogram, ValueType, metrics } from "@opentelemetry/api";
import { PeerState } from "./PeerState.js";
import { SyncStateManager } from "./SyncStateManager.js";
import {
getTransactionSize,
knownStateFromContent,
} from "./coValueContentMessage.js";
import { CoValueCore } from "./coValueCore/coValueCore.js";
import { getDependedOnCoValuesFromRawData } from "./coValueCore/utils.js";
import { CoValueHeader, Transaction } from "./coValueCore/verifiedState.js";
import {
CoValueHeader,
Transaction,
VerifiedState,
} from "./coValueCore/verifiedState.js";
import { Signature } from "./crypto/crypto.js";
import { RawCoID, SessionID } from "./ids.js";
import { RawCoID, SessionID, isRawCoID } from "./ids.js";
import { LocalNode } from "./localNode.js";
import { logger } from "./logger.js";
import { CoValuePriority } from "./priority.js";
import { IncomingMessagesQueue } from "./queue/IncomingMessagesQueue.js";
import { LocalTransactionsSyncQueue } from "./queue/LocalTransactionsSyncQueue.js";
import { accountOrAgentIDfromSessionID } from "./typeUtils/accountOrAgentIDfromSessionID.js";
import { isAccountID } from "./typeUtils/isAccountID.js";
@@ -57,10 +66,12 @@ export type NewContentMessage = {
};
export type SessionNewContent = {
// The index where to start appending the new transactions. The index counting starts from 1.
after: number;
newTransactions: Transaction[];
lastSignature: Signature;
};
export type DoneMessage = {
action: "done";
id: RawCoID;
@@ -162,13 +173,9 @@ export class SyncManager {
}
handleSyncMessage(msg: SyncMessage, peer: PeerState) {
if (msg.id === undefined || msg.id === null) {
logger.warn("Received sync message with undefined id", {
msg,
});
return;
} else if (!msg.id.startsWith("co_z")) {
logger.warn("Received sync message with invalid id", {
if (!isRawCoID(msg.id)) {
const errorType = msg.id ? "invalid" : "undefined";
logger.warn(`Received sync message with ${errorType} id`, {
msg,
});
return;
@@ -431,12 +438,9 @@ export class SyncManager {
recordTransactionsSize(newTransactions: Transaction[], source: string) {
for (const tx of newTransactions) {
const txLength =
tx.privacy === "private"
? tx.encryptedChanges.length
: tx.changes.length;
const size = getTransactionSize(tx);
this.transactionsSizeHistogram.record(txLength, {
this.transactionsSizeHistogram.record(size, {
source,
});
}
@@ -674,7 +678,7 @@ export class SyncManager {
const syncedPeers = [];
if (from !== "storage") {
this.storeCoValue(coValue, [msg]);
this.storeContent(msg);
}
for (const peer of this.peersInPriorityOrder()) {
@@ -736,60 +740,18 @@ export class SyncManager {
};
}
requestedSyncs = new Set<RawCoID>();
requestCoValueSync(coValue: CoValueCore) {
if (this.requestedSyncs.has(coValue.id)) {
return;
}
private syncQueue = new LocalTransactionsSyncQueue((content) =>
this.syncContent(content),
);
syncHeader = this.syncQueue.syncHeader;
syncLocalTransaction = this.syncQueue.syncTransaction;
for (const trackingSet of this.dirtyCoValuesTrackingSets) {
trackingSet.add(coValue.id);
}
syncContent(content: NewContentMessage) {
const coValue = this.local.getCoValue(content.id);
queueMicrotask(() => {
if (this.requestedSyncs.has(coValue.id)) {
this.syncCoValue(coValue);
}
});
this.storeContent(content);
this.requestedSyncs.add(coValue.id);
}
storeCoValue(coValue: CoValueCore, data: NewContentMessage[] | undefined) {
const storage = this.local.storage;
if (!storage || !data) return;
// 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(data, (correction) => {
if (!coValue.hasVerifiedContent()) return;
const newContentPieces = coValue.verified.newContentSince(correction);
if (!newContentPieces) return;
storage.store(newContentPieces, (response) => {
logger.error(
"Correction requested by storage after sending a correction content",
{
response,
knownState: coValue.knownState(),
},
);
});
});
}
syncCoValue(coValue: CoValueCore) {
this.requestedSyncs.delete(coValue.id);
if (this.local.storage && coValue.hasVerifiedContent()) {
const knownState = this.local.storage.getKnownState(coValue.id);
const newContentPieces = coValue.verified.newContentSince(knownState);
this.storeCoValue(coValue, newContentPieces);
}
const contentKnownState = knownStateFromContent(content);
for (const peer of this.peersInPriorityOrder()) {
if (peer.closed) continue;
@@ -803,7 +765,11 @@ export class SyncManager {
continue;
}
this.sendNewContentIncludingDependencies(coValue.id, peer);
// We assume that the peer already knows anything before this content
// Any eventual reconciliation will be handled through the known state messages exchange
this.trySendToPeer(peer, content);
peer.combineOptimisticWith(coValue.id, contentKnownState);
peer.trackToldKnownState(coValue.id);
}
for (const peer of this.getPeers()) {
@@ -811,6 +777,20 @@ export class SyncManager {
}
}
private storeContent(content: NewContentMessage) {
const storage = this.local.storage;
if (!storage) return;
// 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);
});
}
waitForSyncWithPeer(peerId: PeerID, id: RawCoID, timeout: number) {
const { syncState } = this;
const currentSyncState = syncState.getCurrentSyncState(peerId, id);

View File

@@ -0,0 +1,829 @@
import { randomUUID } from "node:crypto";
import { unlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, onTestFinished, test, vi } from "vitest";
import { WasmCrypto } from "../crypto/WasmCrypto.js";
import { CoID, LocalNode, RawCoID, RawCoMap, logger } from "../exports.js";
import { CoValueCore } from "../exports.js";
import {
CoValueKnownState,
NewContentMessage,
emptyKnownState,
} from "../sync.js";
import { createAsyncStorage } from "./testStorage.js";
import {
SyncMessagesLog,
loadCoValueOrFail,
randomAgentAndSessionID,
waitFor,
} from "./testUtils.js";
const crypto = await WasmCrypto.create();
/**
* Helper function that gets new content since a known state, throwing if:
* - The coValue is not verified
* - There is no new content
*/
function getNewContentSince(
coValue: CoValueCore,
knownState: CoValueKnownState,
): NewContentMessage {
if (!coValue.verified) {
throw new Error(`CoValue ${coValue.id} is not verified`);
}
const contentMessage = coValue.verified.newContentSince(knownState)?.[0];
if (!contentMessage) {
throw new Error(`No new content available for coValue ${coValue.id}`);
}
return contentMessage;
}
async function createFixturesNode(customDbPath?: string) {
const [admin, session] = randomAgentAndSessionID();
const node = new LocalNode(admin.agentSecret, session, crypto);
// Create a unique database file for each test
const dbPath = customDbPath ?? join(tmpdir(), `test-${randomUUID()}.db`);
const storage = await createAsyncStorage({
filename: dbPath,
nodeName: "test",
storageName: "test-storage",
});
onTestFinished(() => {
try {
unlinkSync(dbPath);
} catch {}
});
onTestFinished(async () => {
await node.gracefulShutdown();
});
node.setStorage(storage);
return {
fixturesNode: node,
dbPath,
};
}
async function createTestNode(dbPath?: string) {
const [admin, session] = randomAgentAndSessionID();
const node = new LocalNode(admin.agentSecret, session, crypto);
const storage = await createAsyncStorage({
filename: dbPath,
nodeName: "test",
storageName: "test-storage",
});
onTestFinished(async () => {
node.gracefulShutdown();
await storage.close();
});
return {
node,
storage,
};
}
afterEach(() => {
SyncMessagesLog.clear();
});
describe("StorageApiAsync", () => {
describe("getKnownState", () => {
test("should return known state for existing coValue ID", async () => {
const { fixturesNode } = await createFixturesNode();
const { storage } = await createTestNode();
const id = fixturesNode.createGroup().id;
const knownState = storage.getKnownState(id);
expect(knownState).toEqual(emptyKnownState(id));
expect(storage.getKnownState(id)).toBe(knownState); // Should return same instance
});
test("should return different known states for different coValue IDs", async () => {
const { storage } = await createTestNode();
const id1 = "test-id-1";
const id2 = "test-id-2";
const knownState1 = storage.getKnownState(id1);
const knownState2 = storage.getKnownState(id2);
expect(knownState1).not.toBe(knownState2);
});
});
describe("load", () => {
test("should handle non-existent coValue gracefully", async () => {
const { storage } = await createTestNode();
const id = "non-existent-id";
const callback = vi.fn();
const done = vi.fn();
// Get initial known state
const initialKnownState = storage.getKnownState(id);
expect(initialKnownState).toEqual(emptyKnownState(id as `co_z${string}`));
await storage.load(id, callback, done);
expect(done).toHaveBeenCalledWith(false);
expect(callback).not.toHaveBeenCalled();
// Verify that storage known state is NOT updated when load fails
const afterLoadKnownState = storage.getKnownState(id);
expect(afterLoadKnownState).toEqual(initialKnownState);
});
test("should load coValue with header only successfully", async () => {
const { fixturesNode, dbPath } = await createFixturesNode();
const { node, storage } = await createTestNode(dbPath);
const callback = vi.fn((content) =>
node.syncManager.handleNewContent(content, "storage"),
);
const done = vi.fn();
// Create a real group and get its content message
const group = fixturesNode.createGroup();
await group.core.waitForSync();
// Get initial known state
const initialKnownState = storage.getKnownState(group.id);
expect(initialKnownState).toEqual(emptyKnownState(group.id));
await storage.load(group.id, callback, done);
expect(callback).toHaveBeenCalledWith(
expect.objectContaining({
id: group.id,
header: group.core.verified.header,
new: expect.any(Object),
}),
);
expect(done).toHaveBeenCalledWith(true);
// Verify that storage known state is updated after load
const updatedKnownState = storage.getKnownState(group.id);
expect(updatedKnownState).toEqual(group.core.verified.knownState());
const groupOnNode = await loadCoValueOrFail(node, group.id);
expect(groupOnNode.core.verified.header).toEqual(
group.core.verified.header,
);
});
test("should load coValue with sessions and transactions successfully", async () => {
const { fixturesNode, dbPath } = await createFixturesNode();
const { node, storage } = await createTestNode(dbPath);
const callback = vi.fn((content) =>
node.syncManager.handleNewContent(content, "storage"),
);
const done = vi.fn();
// Create a real group and add a member to create transactions
const group = fixturesNode.createGroup();
group.addMember("everyone", "reader");
await group.core.waitForSync();
// Get initial known state
const initialKnownState = storage.getKnownState(group.id);
expect(initialKnownState).toEqual(emptyKnownState(group.id));
await storage.load(group.id, callback, done);
expect(callback).toHaveBeenCalledWith(
expect.objectContaining({
id: group.id,
header: group.core.verified.header,
new: expect.objectContaining({
[fixturesNode.currentSessionID]: expect.any(Object),
}),
}),
);
expect(done).toHaveBeenCalledWith(true);
// Verify that storage known state is updated after load
const updatedKnownState = storage.getKnownState(group.id);
expect(updatedKnownState).toEqual(group.core.verified.knownState());
const groupOnNode = await loadCoValueOrFail(node, group.id);
expect(groupOnNode.get("everyone")).toEqual("reader");
});
});
describe("store", () => {
test("should store new coValue with header successfully", async () => {
const { fixturesNode } = await createFixturesNode();
const { node, storage } = await createTestNode();
// Create a real group and get its content message
const group = fixturesNode.createGroup();
const contentMessage = getNewContentSince(
group.core,
emptyKnownState(group.id),
);
const correctionCallback = vi.fn();
// Get initial known state
const initialKnownState = storage.getKnownState(group.id);
expect(initialKnownState).toEqual(emptyKnownState(group.id));
await storage.store(contentMessage, correctionCallback);
await storage.waitForSync(group.id, group.core);
// Verify that storage known state is updated after store
const updatedKnownState = storage.getKnownState(group.id);
expect(updatedKnownState).toEqual(group.core.verified.knownState());
node.setStorage(storage);
const groupOnNode = await loadCoValueOrFail(node, group.id);
expect(groupOnNode.core.verified.header).toEqual(
group.core.verified.header,
);
});
test("should store coValue with transactions successfully", async () => {
const { fixturesNode } = await createFixturesNode();
const { node, storage } = await createTestNode();
// Create a real group and add a member to create transactions
const group = fixturesNode.createGroup();
const knownState = group.core.verified.knownState();
group.addMember("everyone", "reader");
const contentMessage = getNewContentSince(
group.core,
emptyKnownState(group.id),
);
const correctionCallback = vi.fn();
// Get initial known state
const initialKnownState = storage.getKnownState(group.id);
expect(initialKnownState).toEqual(emptyKnownState(group.id));
await storage.store(contentMessage, correctionCallback);
await storage.waitForSync(group.id, group.core);
// Verify that storage known state is updated after store
const updatedKnownState = storage.getKnownState(group.id);
expect(updatedKnownState).toEqual(group.core.verified.knownState());
node.setStorage(storage);
const groupOnNode = await loadCoValueOrFail(node, group.id);
expect(groupOnNode.get("everyone")).toEqual("reader");
});
test("should handle invalid assumption on header presence with correction", async () => {
const { fixturesNode } = await createFixturesNode();
const { node, storage } = await createTestNode();
const group = fixturesNode.createGroup();
const knownState = group.core.verified.knownState();
group.addMember("everyone", "reader");
const contentMessage = getNewContentSince(group.core, knownState);
const correctionCallback = vi.fn((known) => {
expect(known).toEqual(emptyKnownState(group.id));
return group.core.verified.newContentSince(known);
});
// Get initial known state
const initialKnownState = storage.getKnownState(group.id);
expect(initialKnownState).toEqual(emptyKnownState(group.id));
await storage.store(contentMessage, correctionCallback);
await storage.waitForSync(group.id, group.core);
expect(correctionCallback).toHaveBeenCalledTimes(1);
// Verify that storage known state is updated after store with correction
const updatedKnownState = storage.getKnownState(group.id);
expect(updatedKnownState).toEqual(group.core.verified.knownState());
node.setStorage(storage);
const groupOnNode = await loadCoValueOrFail(node, group.id);
expect(groupOnNode.get("everyone")).toEqual("reader");
});
test("should handle invalid assumption on new content with correction", async () => {
const { fixturesNode } = await createFixturesNode();
const { node, storage } = await createTestNode();
const group = fixturesNode.createGroup();
const initialContent = getNewContentSince(
group.core,
emptyKnownState(group.id),
);
const initialKnownState = group.core.knownState();
group.addMember("everyone", "reader");
const knownState = group.core.knownState();
group.addMember("everyone", "writer");
const contentMessage = getNewContentSince(group.core, knownState);
const correctionCallback = vi.fn((known) => {
expect(known).toEqual(initialKnownState);
return group.core.verified.newContentSince(known);
});
// Get initial storage known state
const initialStorageKnownState = storage.getKnownState(group.id);
expect(initialStorageKnownState).toEqual(emptyKnownState(group.id));
await storage.store(initialContent, correctionCallback);
await storage.store(contentMessage, correctionCallback);
await storage.waitForSync(group.id, group.core);
expect(correctionCallback).toHaveBeenCalledTimes(1);
// Verify that storage known state is updated after store with correction
const finalKnownState = storage.getKnownState(group.id);
expect(finalKnownState).toEqual(group.core.verified.knownState());
node.setStorage(storage);
const groupOnNode = await loadCoValueOrFail(node, group.id);
expect(groupOnNode.get("everyone")).toEqual("writer");
});
test("should log an error when the correction callback returns undefined", async () => {
const { fixturesNode } = await createFixturesNode();
const { storage } = await createTestNode();
const group = fixturesNode.createGroup();
const knownState = group.core.knownState();
group.addMember("everyone", "writer");
const contentMessage = getNewContentSince(group.core, knownState);
const correctionCallback = vi.fn((known) => {
return undefined;
});
// Get initial known state
const initialKnownState = storage.getKnownState(group.id);
expect(initialKnownState).toEqual(emptyKnownState(group.id));
const errorSpy = vi.spyOn(logger, "error").mockImplementation(() => {});
await storage.store(contentMessage, correctionCallback);
await waitFor(() => {
expect(correctionCallback).toHaveBeenCalledTimes(1);
});
// Verify that storage known state is NOT updated when store fails
const afterStoreKnownState = storage.getKnownState(group.id);
expect(afterStoreKnownState).toEqual(initialKnownState);
expect(errorSpy).toHaveBeenCalledWith(
"Correction callback returned undefined",
{
knownState: expect.any(Object),
correction: null,
},
);
errorSpy.mockClear();
});
test("should log an error when the correction callback returns an invalid content message", async () => {
const { fixturesNode } = await createFixturesNode();
const { storage } = await createTestNode();
const group = fixturesNode.createGroup();
const knownState = group.core.knownState();
group.addMember("everyone", "writer");
const contentMessage = getNewContentSince(group.core, knownState);
const correctionCallback = vi.fn(() => {
return [contentMessage];
});
// Get initial known state
const initialKnownState = storage.getKnownState(group.id);
expect(initialKnownState).toEqual(emptyKnownState(group.id));
const errorSpy = vi.spyOn(logger, "error").mockImplementation(() => {});
await storage.store(contentMessage, correctionCallback);
await waitFor(() => {
expect(correctionCallback).toHaveBeenCalledTimes(1);
});
// Verify that storage known state is NOT updated when store fails
const afterStoreKnownState = storage.getKnownState(group.id);
expect(afterStoreKnownState).toEqual(initialKnownState);
expect(errorSpy).toHaveBeenCalledWith(
"Correction callback returned undefined",
{
knownState: expect.any(Object),
correction: null,
},
);
expect(errorSpy).toHaveBeenCalledWith("Double correction requested", {
knownState: expect.any(Object),
msg: expect.any(Object),
});
errorSpy.mockClear();
});
test("should handle invalid assumption when pushing multiple transactions with correction", async () => {
const { node, storage } = await createTestNode();
const core = node.createCoValue({
type: "comap",
ruleset: { type: "unsafeAllowAll" },
meta: null,
...crypto.createdNowUnique(),
});
core.makeTransaction([{ count: 1 }], "trusting");
await core.waitForSync();
// Add storage later
node.setStorage(storage);
core.makeTransaction([{ count: 2 }], "trusting");
core.makeTransaction([{ count: 3 }], "trusting");
await new Promise((resolve) => setTimeout(resolve, 10));
core.makeTransaction([{ count: 4 }], "trusting");
core.makeTransaction([{ count: 5 }], "trusting");
await core.waitForSync();
expect(storage.getKnownState(core.id)).toEqual(core.knownState());
expect(
SyncMessagesLog.getMessages({
Core: core,
}),
).toMatchInlineSnapshot(`
[
"test -> test-storage | CONTENT Core header: false new: After: 1 New: 2",
"test-storage -> test | KNOWN CORRECTION Core sessions: empty",
"test -> test-storage | CONTENT Core header: true new: After: 0 New: 3",
"test -> test-storage | CONTENT Core header: false new: After: 3 New: 2",
]
`);
});
test("should handle invalid assumption when pushing multiple transactions on different coValues with correction", async () => {
const { node, storage } = await createTestNode();
const core = node.createCoValue({
type: "comap",
ruleset: { type: "unsafeAllowAll" },
meta: null,
...crypto.createdNowUnique(),
});
const core2 = node.createCoValue({
type: "comap",
ruleset: { type: "unsafeAllowAll" },
meta: null,
...crypto.createdNowUnique(),
});
core.makeTransaction([{ count: 1 }], "trusting");
core2.makeTransaction([{ count: 1 }], "trusting");
await core.waitForSync();
// Add storage later
node.setStorage(storage);
core.makeTransaction([{ count: 2 }], "trusting");
core2.makeTransaction([{ count: 2 }], "trusting");
core.makeTransaction([{ count: 3 }], "trusting");
core2.makeTransaction([{ count: 3 }], "trusting");
await new Promise((resolve) => setTimeout(resolve, 10));
core.makeTransaction([{ count: 4 }], "trusting");
core2.makeTransaction([{ count: 4 }], "trusting");
core.makeTransaction([{ count: 5 }], "trusting");
core2.makeTransaction([{ count: 5 }], "trusting");
await core.waitForSync();
expect(storage.getKnownState(core.id)).toEqual(core.knownState());
expect(
SyncMessagesLog.getMessages({
Core: core,
Core2: core2,
}),
).toMatchInlineSnapshot(`
[
"test -> test-storage | CONTENT Core header: false new: After: 1 New: 1",
"test -> test-storage | CONTENT Core2 header: false new: After: 1 New: 1",
"test -> test-storage | CONTENT Core header: false new: After: 2 New: 1",
"test -> test-storage | CONTENT Core2 header: false new: After: 2 New: 1",
"test-storage -> test | KNOWN CORRECTION Core sessions: empty",
"test -> test-storage | CONTENT Core header: true new: After: 0 New: 3",
"test-storage -> test | KNOWN CORRECTION Core2 sessions: empty",
"test -> test-storage | CONTENT Core2 header: true new: After: 0 New: 3",
"test -> test-storage | CONTENT Core header: false new: After: 3 New: 1",
"test -> test-storage | CONTENT Core2 header: false new: After: 3 New: 1",
"test -> test-storage | CONTENT Core header: false new: After: 4 New: 1",
"test -> test-storage | CONTENT Core2 header: false new: After: 4 New: 1",
]
`);
});
test("should handle close while pushing multiple transactions on different coValues with an invalid assumption", async () => {
const { node, storage } = await createTestNode();
const core = node.createCoValue({
type: "comap",
ruleset: { type: "unsafeAllowAll" },
meta: null,
...crypto.createdNowUnique(),
});
const core2 = node.createCoValue({
type: "comap",
ruleset: { type: "unsafeAllowAll" },
meta: null,
...crypto.createdNowUnique(),
});
core.makeTransaction([{ count: 1 }], "trusting");
core2.makeTransaction([{ count: 1 }], "trusting");
await core.waitForSync();
// Add storage later
node.setStorage(storage);
core.makeTransaction([{ count: 2 }], "trusting");
core2.makeTransaction([{ count: 2 }], "trusting");
core.makeTransaction([{ count: 3 }], "trusting");
core2.makeTransaction([{ count: 3 }], "trusting");
await new Promise<void>(queueMicrotask);
await storage.close();
const knownState = JSON.parse(
JSON.stringify(storage.getKnownState(core.id)),
);
core.makeTransaction([{ count: 4 }], "trusting");
core2.makeTransaction([{ count: 4 }], "trusting");
core.makeTransaction([{ count: 5 }], "trusting");
core2.makeTransaction([{ count: 5 }], "trusting");
await new Promise<void>((resolve) => setTimeout(resolve, 10));
expect(
SyncMessagesLog.getMessages({
Core: core,
Core2: core2,
}),
).toMatchInlineSnapshot(`
[
"test -> test-storage | CONTENT Core header: false new: After: 1 New: 1",
"test -> test-storage | CONTENT Core2 header: false new: After: 1 New: 1",
"test -> test-storage | CONTENT Core header: false new: After: 2 New: 1",
"test -> test-storage | CONTENT Core2 header: false new: After: 2 New: 1",
"test-storage -> test | KNOWN CORRECTION Core sessions: empty",
"test -> test-storage | CONTENT Core header: true new: After: 0 New: 3",
"test -> test-storage | CONTENT Core header: false new: After: 3 New: 1",
"test -> test-storage | CONTENT Core2 header: false new: After: 3 New: 1",
"test -> test-storage | CONTENT Core header: false new: After: 4 New: 1",
"test -> test-storage | CONTENT Core2 header: false new: After: 4 New: 1",
]
`);
expect(storage.getKnownState(core.id)).toEqual(knownState);
});
test("should handle multiple sessions correctly", async () => {
const { fixturesNode, dbPath } = await createFixturesNode();
const { fixturesNode: fixtureNode2 } = await createFixturesNode(dbPath);
const { node, storage } = await createTestNode();
const coValue = fixturesNode.createCoValue({
type: "comap",
ruleset: { type: "unsafeAllowAll" },
meta: null,
...crypto.createdNowUnique(),
});
coValue.makeTransaction(
[
{
count: 1,
},
],
"trusting",
);
await coValue.waitForSync();
const mapOnNode2 = await loadCoValueOrFail(
fixtureNode2,
coValue.id as CoID<RawCoMap>,
);
coValue.makeTransaction(
[
{
count: 2,
},
],
"trusting",
);
const knownState = mapOnNode2.core.knownState();
const contentMessage = getNewContentSince(
mapOnNode2.core,
emptyKnownState(mapOnNode2.id),
);
const correctionCallback = vi.fn();
await storage.store(contentMessage, correctionCallback);
await storage.waitForSync(mapOnNode2.id, mapOnNode2.core);
node.setStorage(storage);
const finalMap = await loadCoValueOrFail(node, mapOnNode2.id);
expect(finalMap.core.knownState()).toEqual(knownState);
});
});
describe("dependencies", () => {
test("should push dependencies before the coValue", async () => {
const { fixturesNode, dbPath } = await createFixturesNode();
const { node, storage } = await createTestNode(dbPath);
// Create a group and a map owned by that group to create dependencies
const group = fixturesNode.createGroup();
group.addMember("everyone", "reader");
const map = group.createMap({ test: "value" });
await group.core.waitForSync();
await map.core.waitForSync();
const callback = vi.fn((content) =>
node.syncManager.handleNewContent(content, "storage"),
);
const done = vi.fn();
// Get initial known states
const initialGroupKnownState = storage.getKnownState(group.id);
const initialMapKnownState = storage.getKnownState(map.id);
expect(initialGroupKnownState).toEqual(emptyKnownState(group.id));
expect(initialMapKnownState).toEqual(emptyKnownState(map.id));
// Load the map, which should also load the group dependency first
await storage.load(map.id, callback, done);
expect(callback).toHaveBeenCalledTimes(2); // Group first, then map
expect(callback).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
id: group.id,
}),
);
expect(callback).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
id: map.id,
}),
);
expect(done).toHaveBeenCalledWith(true);
// Verify that storage known states are updated after load
const updatedGroupKnownState = storage.getKnownState(group.id);
const updatedMapKnownState = storage.getKnownState(map.id);
expect(updatedGroupKnownState).toEqual(group.core.verified.knownState());
expect(updatedMapKnownState).toEqual(map.core.verified.knownState());
node.setStorage(storage);
const mapOnNode = await loadCoValueOrFail(node, map.id);
expect(mapOnNode.get("test")).toEqual("value");
});
test("should handle dependencies that are already loaded correctly", async () => {
const { fixturesNode, dbPath } = await createFixturesNode();
const { node, storage } = await createTestNode(dbPath);
// Create a group and a map owned by that group
const group = fixturesNode.createGroup();
group.addMember("everyone", "reader");
const map = group.createMap({ test: "value" });
await group.core.waitForSync();
await map.core.waitForSync();
const callback = vi.fn((content) =>
node.syncManager.handleNewContent(content, "storage"),
);
const done = vi.fn();
// Get initial known states
const initialGroupKnownState = storage.getKnownState(group.id);
const initialMapKnownState = storage.getKnownState(map.id);
expect(initialGroupKnownState).toEqual(emptyKnownState(group.id));
expect(initialMapKnownState).toEqual(emptyKnownState(map.id));
// First load the group
await storage.load(group.id, callback, done);
callback.mockClear();
done.mockClear();
// Verify group known state is updated after first load
const afterGroupLoad = storage.getKnownState(group.id);
expect(afterGroupLoad).toEqual(group.core.verified.knownState());
// Then load the map - the group dependency should already be loaded
await storage.load(map.id, callback, done);
// Should only call callback once for the map since group is already loaded
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(
expect.objectContaining({
id: map.id,
}),
);
expect(done).toHaveBeenCalledWith(true);
// Verify map known state is updated after second load
const finalMapKnownState = storage.getKnownState(map.id);
expect(finalMapKnownState).toEqual(map.core.verified.knownState());
node.setStorage(storage);
const mapOnNode = await loadCoValueOrFail(node, map.id);
expect(mapOnNode.get("test")).toEqual("value");
});
});
describe("waitForSync", () => {
test("should resolve when the coValue is already synced", async () => {
const { fixturesNode, dbPath } = await createFixturesNode();
const { node, storage } = await createTestNode(dbPath);
// Create a group and add a member
const group = fixturesNode.createGroup();
group.addMember("everyone", "reader");
await group.core.waitForSync();
// Store the group in storage
const contentMessage = getNewContentSince(
group.core,
emptyKnownState(group.id),
);
const correctionCallback = vi.fn();
await storage.store(contentMessage, correctionCallback);
node.setStorage(storage);
// Load the group on the new node
const groupOnNode = await loadCoValueOrFail(node, group.id);
// Wait for sync should resolve immediately since the coValue is already synced
await expect(
storage.waitForSync(group.id, groupOnNode.core),
).resolves.toBeUndefined();
expect(groupOnNode.get("everyone")).toEqual("reader");
});
});
describe("close", () => {
test("should close without throwing an error", async () => {
const { storage } = await createTestNode();
expect(() => storage.close()).not.toThrow();
});
});
});

View File

@@ -0,0 +1,628 @@
import { randomUUID } from "node:crypto";
import { unlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, onTestFinished, test, vi } from "vitest";
import { WasmCrypto } from "../crypto/WasmCrypto.js";
import { CoID, LocalNode, RawCoID, RawCoMap, logger } from "../exports.js";
import { CoValueCore } from "../exports.js";
import {
CoValueKnownState,
NewContentMessage,
emptyKnownState,
} from "../sync.js";
import { createSyncStorage } from "./testStorage.js";
import { loadCoValueOrFail, randomAgentAndSessionID } from "./testUtils.js";
const crypto = await WasmCrypto.create();
/**
* Helper function that gets new content since a known state, throwing if:
* - The coValue is not verified
* - There is no new content
*/
function getNewContentSince(
coValue: CoValueCore,
knownState: CoValueKnownState,
): NewContentMessage {
if (!coValue.verified) {
throw new Error(`CoValue ${coValue.id} is not verified`);
}
const contentMessage = coValue.verified.newContentSince(knownState)?.[0];
if (!contentMessage) {
throw new Error(`No new content available for coValue ${coValue.id}`);
}
return contentMessage;
}
async function createFixturesNode(customDbPath?: string) {
const [admin, session] = randomAgentAndSessionID();
const node = new LocalNode(admin.agentSecret, session, crypto);
// Create a unique database file for each test
const dbPath = customDbPath ?? join(tmpdir(), `test-${randomUUID()}.db`);
const storage = createSyncStorage({
filename: dbPath,
nodeName: "test",
storageName: "test-storage",
});
onTestFinished(() => {
try {
unlinkSync(dbPath);
} catch {}
});
node.setStorage(storage);
return {
fixturesNode: node,
dbPath,
};
}
async function createTestNode(dbPath?: string) {
const [admin, session] = randomAgentAndSessionID();
const node = new LocalNode(admin.agentSecret, session, crypto);
const storage = createSyncStorage({
filename: dbPath,
nodeName: "test",
storageName: "test-storage",
});
return {
node,
storage,
};
}
describe("StorageApiSync", () => {
describe("getKnownState", () => {
test("should return empty known state for new coValue ID and cache the result", async () => {
const { fixturesNode } = await createFixturesNode();
const { storage } = await createTestNode();
const id = fixturesNode.createGroup().id;
const knownState = storage.getKnownState(id);
expect(knownState).toEqual(emptyKnownState(id));
expect(storage.getKnownState(id)).toBe(knownState); // Should return same instance
});
test("should return separate known state instances for different coValue IDs", async () => {
const { storage } = await createTestNode();
const id1 = "test-id-1";
const id2 = "test-id-2";
const knownState1 = storage.getKnownState(id1);
const knownState2 = storage.getKnownState(id2);
expect(knownState1).not.toBe(knownState2);
});
});
describe("load", () => {
test("should fail gracefully when loading non-existent coValue and preserve known state", async () => {
const { storage } = await createTestNode();
const id = "non-existent-id";
const callback = vi.fn();
const done = vi.fn();
// Get initial known state
const initialKnownState = storage.getKnownState(id);
expect(initialKnownState).toEqual(emptyKnownState(id as `co_z${string}`));
await storage.load(id, callback, done);
expect(done).toHaveBeenCalledWith(false);
expect(callback).not.toHaveBeenCalled();
// Verify that storage known state is NOT updated when load fails
const afterLoadKnownState = storage.getKnownState(id);
expect(afterLoadKnownState).toEqual(initialKnownState);
});
test("should successfully load coValue with header and update known state", async () => {
const { fixturesNode, dbPath } = await createFixturesNode();
const { node, storage } = await createTestNode(dbPath);
const callback = vi.fn((content) =>
node.syncManager.handleNewContent(content, "storage"),
);
const done = vi.fn();
// Create a real group and get its content message
const group = fixturesNode.createGroup();
await group.core.waitForSync();
// Get initial known state
const initialKnownState = storage.getKnownState(group.id);
expect(initialKnownState).toEqual(emptyKnownState(group.id));
await storage.load(group.id, callback, done);
expect(callback).toHaveBeenCalledWith(
expect.objectContaining({
id: group.id,
header: group.core.verified.header,
new: expect.any(Object),
}),
);
expect(done).toHaveBeenCalledWith(true);
// Verify that storage known state is updated after load
const updatedKnownState = storage.getKnownState(group.id);
expect(updatedKnownState).toEqual(group.core.verified.knownState());
const groupOnNode = await loadCoValueOrFail(node, group.id);
expect(groupOnNode.core.verified.header).toEqual(
group.core.verified.header,
);
});
test("should successfully load coValue with transactions and update known state", async () => {
const { fixturesNode, dbPath } = await createFixturesNode();
const { node, storage } = await createTestNode(dbPath);
const callback = vi.fn((content) =>
node.syncManager.handleNewContent(content, "storage"),
);
const done = vi.fn();
// Create a real group and add a member to create transactions
const group = fixturesNode.createGroup();
group.addMember("everyone", "reader");
await group.core.waitForSync();
// Get initial known state
const initialKnownState = storage.getKnownState(group.id);
expect(initialKnownState).toEqual(emptyKnownState(group.id));
await storage.load(group.id, callback, done);
expect(callback).toHaveBeenCalledWith(
expect.objectContaining({
id: group.id,
header: group.core.verified.header,
new: expect.objectContaining({
[fixturesNode.currentSessionID]: expect.any(Object),
}),
}),
);
expect(done).toHaveBeenCalledWith(true);
// Verify that storage known state is updated after load
const updatedKnownState = storage.getKnownState(group.id);
expect(updatedKnownState).toEqual(group.core.verified.knownState());
const groupOnNode = await loadCoValueOrFail(node, group.id);
expect(groupOnNode.get("everyone")).toEqual("reader");
});
});
describe("store", () => {
test("should successfully store new coValue with header and update known state", async () => {
const { fixturesNode } = await createFixturesNode();
const { node, storage } = await createTestNode();
// Create a real group and get its content message
const group = fixturesNode.createGroup();
const contentMessage = getNewContentSince(
group.core,
emptyKnownState(group.id),
);
const correctionCallback = vi.fn();
// Get initial known state
const initialKnownState = storage.getKnownState(group.id);
expect(initialKnownState).toEqual(emptyKnownState(group.id));
storage.store(contentMessage, correctionCallback);
// Verify that storage known state is updated after store
const updatedKnownState = storage.getKnownState(group.id);
expect(updatedKnownState).toEqual(group.core.verified.knownState());
node.setStorage(storage);
const groupOnNode = await loadCoValueOrFail(node, group.id);
expect(groupOnNode.core.verified.header).toEqual(
group.core.verified.header,
);
});
test("should successfully store coValue with transactions and update known state", async () => {
const { fixturesNode } = await createFixturesNode();
const { node, storage } = await createTestNode();
// Create a real group and add a member to create transactions
const group = fixturesNode.createGroup();
const knownState = group.core.verified.knownState();
group.addMember("everyone", "reader");
const contentMessage = getNewContentSince(
group.core,
emptyKnownState(group.id),
);
const correctionCallback = vi.fn();
// Get initial known state
const initialKnownState = storage.getKnownState(group.id);
expect(initialKnownState).toEqual(emptyKnownState(group.id));
storage.store(contentMessage, correctionCallback);
// Verify that storage known state is updated after store
const updatedKnownState = storage.getKnownState(group.id);
expect(updatedKnownState).toEqual(group.core.verified.knownState());
node.setStorage(storage);
const groupOnNode = await loadCoValueOrFail(node, group.id);
expect(groupOnNode.get("everyone")).toEqual("reader");
});
test("should handle correction when header assumption is invalid", async () => {
const { fixturesNode } = await createFixturesNode();
const { node, storage } = await createTestNode();
const group = fixturesNode.createGroup();
const knownState = group.core.verified.knownState();
group.addMember("everyone", "reader");
const contentMessage = getNewContentSince(group.core, knownState);
const correctionCallback = vi.fn((known) => {
expect(known).toEqual(emptyKnownState(group.id));
return group.core.verified.newContentSince(known);
});
// Get initial known state
const initialKnownState = storage.getKnownState(group.id);
expect(initialKnownState).toEqual(emptyKnownState(group.id));
const result = storage.store(contentMessage, correctionCallback);
expect(correctionCallback).toHaveBeenCalledTimes(1);
expect(result).toBe(true);
// Verify that storage known state is updated after store with correction
const updatedKnownState = storage.getKnownState(group.id);
expect(updatedKnownState).toEqual(group.core.verified.knownState());
node.setStorage(storage);
const groupOnNode = await loadCoValueOrFail(node, group.id);
expect(groupOnNode.get("everyone")).toEqual("reader");
});
test("should handle correction when new content assumption is invalid", async () => {
const { fixturesNode } = await createFixturesNode();
const { node, storage } = await createTestNode();
const group = fixturesNode.createGroup();
const initialContent = getNewContentSince(
group.core,
emptyKnownState(group.id),
);
const initialKnownState = group.core.knownState();
group.addMember("everyone", "reader");
const knownState = group.core.knownState();
group.addMember("everyone", "writer");
const contentMessage = getNewContentSince(group.core, knownState);
const correctionCallback = vi.fn((known) => {
expect(known).toEqual(initialKnownState);
return group.core.verified.newContentSince(known);
});
// Get initial storage known state
const initialStorageKnownState = storage.getKnownState(group.id);
expect(initialStorageKnownState).toEqual(emptyKnownState(group.id));
storage.store(initialContent, correctionCallback);
// Verify storage known state after first store
const afterFirstStore = storage.getKnownState(group.id);
expect(afterFirstStore).toEqual(initialKnownState);
const result = storage.store(contentMessage, correctionCallback);
expect(correctionCallback).toHaveBeenCalledTimes(1);
expect(result).toBe(true);
// Verify that storage known state is updated after store with correction
const finalKnownState = storage.getKnownState(group.id);
expect(finalKnownState).toEqual(group.core.verified.knownState());
node.setStorage(storage);
const groupOnNode = await loadCoValueOrFail(node, group.id);
expect(groupOnNode.get("everyone")).toEqual("writer");
});
test("should log error and fail when correction callback returns undefined", async () => {
const { fixturesNode } = await createFixturesNode();
const { storage } = await createTestNode();
const group = fixturesNode.createGroup();
const knownState = group.core.knownState();
group.addMember("everyone", "writer");
const contentMessage = getNewContentSince(group.core, knownState);
const correctionCallback = vi.fn((known) => {
return undefined;
});
// Get initial known state
const initialKnownState = storage.getKnownState(group.id);
expect(initialKnownState).toEqual(emptyKnownState(group.id));
const errorSpy = vi.spyOn(logger, "error").mockImplementation(() => {});
const result = storage.store(contentMessage, correctionCallback);
expect(correctionCallback).toHaveBeenCalledTimes(1);
expect(result).toBe(false);
// Verify that storage known state is NOT updated when store fails
const afterStoreKnownState = storage.getKnownState(group.id);
expect(afterStoreKnownState).toEqual(initialKnownState);
expect(errorSpy).toHaveBeenCalledWith(
"Correction callback returned undefined",
{
knownState: expect.any(Object),
correction: null,
},
);
errorSpy.mockClear();
});
test("should log error and fail when correction callback returns invalid content message", async () => {
const { fixturesNode } = await createFixturesNode();
const { storage } = await createTestNode();
const group = fixturesNode.createGroup();
const knownState = group.core.knownState();
group.addMember("everyone", "writer");
const contentMessage = getNewContentSince(group.core, knownState);
const correctionCallback = vi.fn(() => {
return [contentMessage];
});
const errorSpy = vi.spyOn(logger, "error").mockImplementation(() => {});
const result = storage.store(contentMessage, correctionCallback);
expect(correctionCallback).toHaveBeenCalledTimes(1);
expect(result).toBe(false);
expect(errorSpy).toHaveBeenCalledWith(
"Correction callback returned undefined",
{
knownState: expect.any(Object),
correction: null,
},
);
expect(errorSpy).toHaveBeenCalledWith("Double correction requested", {
knownState: expect.any(Object),
msg: expect.any(Object),
});
errorSpy.mockClear();
});
test("should successfully store coValue with multiple sessions", async () => {
const { fixturesNode, dbPath } = await createFixturesNode();
const { fixturesNode: fixtureNode2 } = await createFixturesNode(dbPath);
const { node, storage } = await createTestNode();
const coValue = fixturesNode.createCoValue({
type: "comap",
ruleset: { type: "unsafeAllowAll" },
meta: null,
...crypto.createdNowUnique(),
});
coValue.makeTransaction(
[
{
count: 1,
},
],
"trusting",
);
await coValue.waitForSync();
const mapOnNode2 = await loadCoValueOrFail(
fixtureNode2,
coValue.id as CoID<RawCoMap>,
);
coValue.makeTransaction(
[
{
count: 2,
},
],
"trusting",
);
const knownState = mapOnNode2.core.knownState();
const contentMessage = getNewContentSince(
mapOnNode2.core,
emptyKnownState(mapOnNode2.id),
);
const correctionCallback = vi.fn();
storage.store(contentMessage, correctionCallback);
node.setStorage(storage);
const finalMap = await loadCoValueOrFail(node, mapOnNode2.id);
expect(finalMap.core.knownState()).toEqual(knownState);
});
});
describe("dependencies", () => {
test("should load dependencies before dependent coValues and update all known states", async () => {
const { fixturesNode, dbPath } = await createFixturesNode();
const { node, storage } = await createTestNode(dbPath);
// Create a group and a map owned by that group to create dependencies
const group = fixturesNode.createGroup();
group.addMember("everyone", "reader");
const map = group.createMap({ test: "value" });
await group.core.waitForSync();
await map.core.waitForSync();
const callback = vi.fn((content) =>
node.syncManager.handleNewContent(content, "storage"),
);
const done = vi.fn();
// Get initial known states
const initialGroupKnownState = storage.getKnownState(group.id);
const initialMapKnownState = storage.getKnownState(map.id);
expect(initialGroupKnownState).toEqual(emptyKnownState(group.id));
expect(initialMapKnownState).toEqual(emptyKnownState(map.id));
// Load the map, which should also load the group dependency first
await storage.load(map.id, callback, done);
expect(callback).toHaveBeenCalledTimes(2); // Group first, then map
expect(callback).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
id: group.id,
}),
);
expect(callback).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
id: map.id,
}),
);
expect(done).toHaveBeenCalledWith(true);
// Verify that storage known states are updated after load
const updatedGroupKnownState = storage.getKnownState(group.id);
const updatedMapKnownState = storage.getKnownState(map.id);
expect(updatedGroupKnownState).toEqual(group.core.verified.knownState());
expect(updatedMapKnownState).toEqual(map.core.verified.knownState());
node.setStorage(storage);
const mapOnNode = await loadCoValueOrFail(node, map.id);
expect(mapOnNode.get("test")).toEqual("value");
});
test("should skip loading already loaded dependencies", async () => {
const { fixturesNode, dbPath } = await createFixturesNode();
const { node, storage } = await createTestNode(dbPath);
// Create a group and a map owned by that group
const group = fixturesNode.createGroup();
group.addMember("everyone", "reader");
const map = group.createMap({ test: "value" });
await group.core.waitForSync();
await map.core.waitForSync();
const callback = vi.fn((content) =>
node.syncManager.handleNewContent(content, "storage"),
);
const done = vi.fn();
// Get initial known states
const initialGroupKnownState = storage.getKnownState(group.id);
const initialMapKnownState = storage.getKnownState(map.id);
expect(initialGroupKnownState).toEqual(emptyKnownState(group.id));
expect(initialMapKnownState).toEqual(emptyKnownState(map.id));
// First load the group
await storage.load(group.id, callback, done);
callback.mockClear();
done.mockClear();
// Verify group known state is updated after first load
const afterGroupLoad = storage.getKnownState(group.id);
expect(afterGroupLoad).toEqual(group.core.verified.knownState());
// Then load the map - the group dependency should already be loaded
await storage.load(map.id, callback, done);
// Should only call callback once for the map since group is already loaded
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(
expect.objectContaining({
id: map.id,
}),
);
expect(done).toHaveBeenCalledWith(true);
// Verify map known state is updated after second load
const finalMapKnownState = storage.getKnownState(map.id);
expect(finalMapKnownState).toEqual(map.core.verified.knownState());
node.setStorage(storage);
const mapOnNode = await loadCoValueOrFail(node, map.id);
expect(mapOnNode.get("test")).toEqual("value");
});
});
describe("waitForSync", () => {
test("should resolve immediately when coValue is already synced", async () => {
const { fixturesNode, dbPath } = await createFixturesNode();
const { node, storage } = await createTestNode(dbPath);
// Create a group and add a member
const group = fixturesNode.createGroup();
group.addMember("everyone", "reader");
await group.core.waitForSync();
// Store the group in storage
const contentMessage = getNewContentSince(
group.core,
emptyKnownState(group.id),
);
const correctionCallback = vi.fn();
storage.store(contentMessage, correctionCallback);
node.setStorage(storage);
// Load the group on the new node
const groupOnNode = await loadCoValueOrFail(node, group.id);
// Wait for sync should resolve immediately since the coValue is already synced
await expect(
storage.waitForSync(group.id, groupOnNode.core),
).resolves.toBeUndefined();
expect(groupOnNode.get("everyone")).toEqual("reader");
});
});
describe("close", () => {
test("should close storage without throwing errors", async () => {
const { storage } = await createTestNode();
expect(() => storage.close()).not.toThrow();
});
});
});

View File

@@ -2,15 +2,13 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
import { StoreQueue } from "../queue/StoreQueue.js";
import type { CoValueKnownState, NewContentMessage } from "../sync.js";
function createMockNewContentMessage(id: string): NewContentMessage[] {
return [
{
action: "content",
id: id as any,
priority: 0,
new: {},
},
];
function createMockNewContentMessage(id: string): NewContentMessage {
return {
action: "content",
id: id as any,
priority: 0,
new: {},
};
}
function setup() {
@@ -154,14 +152,14 @@ describe("StoreQueue", () => {
storeQueue.push(data1, mockCorrectionCallback);
storeQueue.push(data2, mockCorrectionCallback);
storeQueue.drain();
storeQueue.close();
expect(storeQueue.pull()).toBeUndefined();
});
test("should handle empty queue", () => {
const { storeQueue } = setup();
expect(() => storeQueue.drain()).not.toThrow();
expect(() => storeQueue.close()).not.toThrow();
expect(storeQueue.pull()).toBeUndefined();
});
});
@@ -240,23 +238,11 @@ describe("StoreQueue", () => {
});
describe("edge cases", () => {
test("should handle undefined data", () => {
const { storeQueue, mockCorrectionCallback } = setup();
const data: NewContentMessage[] = [];
storeQueue.push(data, mockCorrectionCallback);
const entry = storeQueue.pull();
expect(entry).toEqual({
data,
correctionCallback: mockCorrectionCallback,
});
});
test("should handle null correction callback", () => {
const { storeQueue } = setup();
const data = createMockNewContentMessage("co1");
const nullCallback = () => {};
const nullCallback = () => undefined;
storeQueue.push(data, nullCallback);
const entry = storeQueue.pull();

View File

@@ -38,14 +38,6 @@ describe("SyncStateManager", () => {
const updateSpy: GlobalSyncStateListenerCallback = vi.fn();
const unsubscribe = subscriptionManager.subscribeToUpdates(updateSpy);
await client.node.syncManager.syncCoValue(map.core);
expect(updateSpy).toHaveBeenCalledWith(
peerState.id,
emptyKnownState(map.core.id),
{ uploaded: false },
);
await waitFor(() => {
return subscriptionManager.getCurrentSyncState(peerState.id, map.core.id)
.uploaded;
@@ -98,13 +90,6 @@ describe("SyncStateManager", () => {
unsubscribe2();
});
await client.node.syncManager.syncCoValue(map.core);
expect(updateToJazzCloudSpy).toHaveBeenCalledWith(
emptyKnownState(map.core.id),
{ uploaded: false },
);
await waitFor(() => {
return subscriptionManager.getCurrentSyncState(peerState.id, map.core.id)
.uploaded;
@@ -117,7 +102,7 @@ describe("SyncStateManager", () => {
{ uploaded: true },
);
expect(updateToStorageSpy).toHaveBeenLastCalledWith(
expect(updateToStorageSpy).toHaveBeenCalledWith(
emptyKnownState(group.core.id),
{ uploaded: false },
);
@@ -133,8 +118,6 @@ describe("SyncStateManager", () => {
const map = group.createMap();
map.set("key1", "value1", "trusting");
await client.node.syncManager.syncCoValue(map.core);
const subscriptionManager = client.node.syncManager.syncState;
expect(
@@ -174,8 +157,6 @@ describe("SyncStateManager", () => {
unsubscribe1();
unsubscribe2();
await client.node.syncManager.syncCoValue(map.core);
anyUpdateSpy.mockClear();
await waitFor(() => {
@@ -336,6 +317,26 @@ describe("SyncStateManager", () => {
await map.core.waitForSync();
expect(client.node.getCoValue(map.id).isAvailable()).toBe(true);
expect(client.node.getCoValue(map.id).hasVerifiedContent()).toBe(true);
// Since only the map is subscribed, the dependencies are pushed after the client requests them
await waitFor(() => {
expect(client.node.getCoValue(map.id).isAvailable()).toBe(true);
});
expect(
SyncMessagesLog.getMessages({
Map: map.core,
Group: group.core,
}),
).toMatchInlineSnapshot(`
[
"server -> client | CONTENT Map header: true new: After: 0 New: 1",
"client -> server | LOAD Group sessions: empty",
"client -> server | KNOWN Map sessions: header/1",
"server -> client | CONTENT Group header: true new: After: 0 New: 3",
"client -> server | KNOWN Group sessions: header/3",
]
`);
});
});

View File

@@ -1,10 +1,19 @@
import { describe, expect, test } from "vitest";
import { beforeEach, describe, expect, test } from "vitest";
import {
SyncMessagesLog,
createThreeConnectedNodes,
createTwoConnectedNodes,
loadCoValueOrFail,
setupTestNode,
} from "./testUtils";
let jazzCloud: ReturnType<typeof setupTestNode>;
beforeEach(async () => {
SyncMessagesLog.clear();
jazzCloud = setupTestNode({ isSyncServer: true });
});
describe("extend", () => {
test("inherited writer roles should work correctly", async () => {
const { node1, node2 } = await createTwoConnectedNodes("server", "server");
@@ -143,6 +152,132 @@ describe("extend", () => {
expect(map.get("test")).toEqual("Hello!");
});
test("should not break when checking for cycles on a loaded group", async () => {
const clientSession1 = setupTestNode({
connected: true,
});
const clientSession2 = clientSession1.spawnNewSession();
const group = clientSession1.node.createGroup();
const childGroup = clientSession1.node.createGroup();
const group2 = clientSession1.node.createGroup();
const group3 = clientSession1.node.createGroup();
childGroup.extend(group);
group.extend(group2);
group2.extend(group3);
await group.core.waitForSync();
await childGroup.core.waitForSync();
await group2.core.waitForSync();
await group3.core.waitForSync();
const groupOnClientSession2 = await loadCoValueOrFail(
clientSession2.node,
group.id,
);
const group3OnClientSession2 = await loadCoValueOrFail(
clientSession2.node,
group3.id,
);
expect(group3OnClientSession2.isSelfExtension(groupOnClientSession2)).toBe(
true,
);
// Child groups are not loaded as dependencies, and we want to make sure having a missing child doesn't break the extension
expect(clientSession2.node.getCoValue(childGroup.id).isAvailable()).toEqual(
false,
);
group3OnClientSession2.extend(groupOnClientSession2);
expect(group3OnClientSession2.getParentGroups()).toEqual([]);
const map = group3OnClientSession2.createMap();
map.set("test", "Hello!");
expect(map.get("test")).toEqual("Hello!");
});
test("should extend groups when loaded from a different session", async () => {
const clientSession1 = setupTestNode({
connected: true,
});
const clientSession2 = clientSession1.spawnNewSession();
const group = clientSession1.node.createGroup();
const group2 = clientSession1.node.createGroup();
await group.core.waitForSync();
await group2.core.waitForSync();
const groupOnClientSession2 = await loadCoValueOrFail(
clientSession2.node,
group.id,
);
const group2OnClientSession2 = await loadCoValueOrFail(
clientSession2.node,
group2.id,
);
group2OnClientSession2.extend(groupOnClientSession2);
expect(group2OnClientSession2.getParentGroups()).toEqual([
groupOnClientSession2,
]);
const map = group2OnClientSession2.createMap();
map.set("test", "Hello!");
expect(map.get("test")).toEqual("Hello!");
});
test("should extend groups when there is a cycle in the parent groups", async () => {
const clientSession1 = setupTestNode({
connected: true,
});
const clientSession2 = clientSession1.spawnNewSession();
const group = clientSession1.node.createGroup();
const group2 = clientSession1.node.createGroup();
await group.core.waitForSync();
await group2.core.waitForSync();
const groupOnClientSession2 = await loadCoValueOrFail(
clientSession2.node,
group.id,
);
const group2OnClientSession2 = await loadCoValueOrFail(
clientSession2.node,
group2.id,
);
group.extend(group2);
group2OnClientSession2.extend(groupOnClientSession2);
expect(group.getParentGroups()).toEqual([group2]);
expect(group2OnClientSession2.getParentGroups()).toEqual([
groupOnClientSession2,
]);
await group.core.waitForSync();
await group2OnClientSession2.core.waitForSync();
const group3 = clientSession1.node.createGroup();
group3.extend(group2);
expect(group3.getParentGroups()).toEqual([group2]);
const map = group3.createMap();
map.set("test", "Hello!");
expect(map.get("test")).toEqual("Hello!");
});
test("a writerInvite role should not be inherited", async () => {
const { node1, node2 } = await createTwoConnectedNodes("server", "server");

View File

@@ -53,12 +53,14 @@ describe("LocalNode auth sync", () => {
}),
).toMatchInlineSnapshot(`
[
"client -> server | CONTENT Account header: true new: After: 0 New: 4",
"client -> server | CONTENT Account header: true new: After: 0 New: 3",
"client -> server | CONTENT ProfileGroup header: true new: After: 0 New: 5",
"client -> server | CONTENT Profile header: true new: After: 0 New: 1",
"server -> client | KNOWN Account sessions: header/4",
"client -> server | CONTENT Account header: false new: After: 3 New: 1",
"server -> client | KNOWN Account sessions: header/3",
"server -> client | KNOWN ProfileGroup sessions: header/5",
"server -> client | KNOWN Profile sessions: header/1",
"server -> client | KNOWN Account sessions: header/4",
]
`);
});
@@ -114,12 +116,18 @@ describe("LocalNode auth sync", () => {
}),
).toMatchInlineSnapshot(`
[
"client -> server | CONTENT Account header: true new: After: 0 New: 5",
"client -> server | CONTENT Root header: true new: After: 0 New: 1",
"client -> server | CONTENT Profile header: true new: After: 0 New: 1",
"server -> client | KNOWN Account sessions: header/5",
"client -> server | CONTENT Account header: true new: After: 0 New: 3",
"client -> server | CONTENT Root header: true new: ",
"client -> server | CONTENT Profile header: true new: ",
"client -> server | CONTENT Root header: false new: After: 0 New: 1",
"client -> server | CONTENT Profile header: false new: After: 0 New: 1",
"client -> server | CONTENT Account header: false new: After: 3 New: 2",
"server -> client | KNOWN Account sessions: header/3",
"server -> client | KNOWN Root sessions: header/0",
"server -> client | KNOWN Profile sessions: header/0",
"server -> client | KNOWN Root sessions: header/1",
"server -> client | KNOWN Profile sessions: header/1",
"server -> client | KNOWN Account sessions: header/5",
]
`);
});
@@ -168,13 +176,15 @@ describe("LocalNode auth sync", () => {
}),
).toMatchInlineSnapshot(`
[
"creation-node -> server | CONTENT Account header: true new: After: 0 New: 4",
"creation-node -> server | CONTENT Account header: true new: After: 0 New: 3",
"creation-node -> server | CONTENT ProfileGroup header: true new: After: 0 New: 5",
"creation-node -> server | CONTENT Profile header: true new: After: 0 New: 1",
"creation-node -> server | CONTENT Account header: false new: After: 3 New: 1",
"auth-node -> server | LOAD Account sessions: empty",
"server -> creation-node | KNOWN Account sessions: header/4",
"server -> creation-node | KNOWN Account sessions: header/3",
"server -> creation-node | KNOWN ProfileGroup sessions: header/5",
"server -> creation-node | KNOWN Profile sessions: header/1",
"server -> creation-node | KNOWN Account sessions: header/4",
"server -> auth-node | CONTENT Account header: true new: After: 0 New: 4",
"auth-node -> server | KNOWN Account sessions: header/4",
"auth-node -> server | LOAD Profile sessions: empty",
@@ -236,12 +246,14 @@ describe("LocalNode auth sync", () => {
}),
).toMatchInlineSnapshot(`
[
"creation-node -> server | CONTENT Account header: true new: After: 0 New: 4",
"creation-node -> server | CONTENT Account header: true new: After: 0 New: 3",
"creation-node -> server | CONTENT ProfileGroup header: true new: After: 0 New: 5",
"creation-node -> server | CONTENT Profile header: true new: After: 0 New: 1",
"server -> creation-node | KNOWN Account sessions: header/4",
"creation-node -> server | CONTENT Account header: false new: After: 3 New: 1",
"server -> creation-node | KNOWN Account sessions: header/3",
"server -> creation-node | KNOWN ProfileGroup sessions: header/5",
"server -> creation-node | KNOWN Profile sessions: header/1",
"server -> creation-node | KNOWN Account sessions: header/4",
"auth-node -> server | LOAD Account sessions: empty",
"server -> auth-node | CONTENT Account header: true new: After: 0 New: 4",
"auth-node -> server | KNOWN Account sessions: header/4",

View File

@@ -15,15 +15,15 @@ import {
waitFor,
} from "./testUtils";
// We want to simulate a real world communication that happens asynchronously
TEST_NODE_CONFIG.withAsyncPeers = true;
let jazzCloud: ReturnType<typeof setupTestNode>;
// Set a short timeout to make the tests on unavailable complete faster
setCoValueLoadingRetryDelay(100);
beforeEach(async () => {
// We want to simulate a real world communication that happens asynchronously
TEST_NODE_CONFIG.withAsyncPeers = true;
SyncMessagesLog.clear();
jazzCloud = setupTestNode({ isSyncServer: true });
});
@@ -295,10 +295,9 @@ describe("loading coValues from server", () => {
[
"client -> server | LOAD Group sessions: header/3",
"client -> server | LOAD Map sessions: header/1",
"server -> client | CONTENT Group header: true new: After: 0 New: 3",
"server -> client | CONTENT Map header: true new: After: 0 New: 2",
"server -> client | CONTENT Map header: false new: After: 1 New: 1",
"client -> server | KNOWN Group sessions: header/3",
"server -> client | KNOWN Group sessions: header/3",
"server -> client | CONTENT Map header: false new: After: 1 New: 1",
"client -> server | KNOWN Map sessions: header/2",
"client -> server | KNOWN Map sessions: header/2",
]
@@ -340,11 +339,10 @@ describe("loading coValues from server", () => {
[
"client -> server | LOAD Group sessions: header/5",
"client -> server | LOAD Map sessions: header/2",
"server -> client | CONTENT Group header: true new: After: 0 New: 5",
"server -> client | CONTENT Map header: true new: After: 0 New: 2",
"client -> server | CONTENT Map header: false new: After: 0 New: 1",
"server -> client | CONTENT Map header: false new: After: 1 New: 1",
"client -> server | KNOWN Group sessions: header/5",
"client -> server | CONTENT Map header: false new: After: 0 New: 1",
"server -> client | KNOWN Group sessions: header/5",
"server -> client | CONTENT Map header: false new: After: 1 New: 1",
"client -> server | KNOWN Map sessions: header/3",
"server -> client | KNOWN Map sessions: header/3",
"client -> server | KNOWN Map sessions: header/3",
@@ -550,6 +548,8 @@ describe("loading coValues from server", () => {
});
test("should handle reconnections in the middle of a load with a persistent peer", async () => {
TEST_NODE_CONFIG.withAsyncPeers = false; // To avoid flakiness
const client = setupTestNode();
const connection1 = client.connectToSyncServer({
persistent: true,
@@ -591,9 +591,9 @@ describe("loading coValues from server", () => {
"server -> client | CONTENT Group header: true new: After: 0 New: 5",
"client -> server | KNOWN Group sessions: header/5",
"client -> server | LOAD Map sessions: empty",
"client -> server | LOAD Group sessions: header/5",
"server -> client | KNOWN Group sessions: header/5",
"server -> client | CONTENT Map header: true new: After: 0 New: 1",
"client -> server | LOAD Group sessions: header/5",
"client -> server | KNOWN Map sessions: header/1",
]
`);
@@ -634,7 +634,7 @@ describe("loading coValues from server", () => {
"server -> client | CONTENT Map header: true new: After: 0 New: 1",
"client -> server | KNOWN ParentGroup sessions: header/6",
"client -> server | LOAD Group sessions: empty",
"client -> server | KNOWN Map sessions: empty",
"client -> server | KNOWN Map sessions: header/1",
"server -> client | CONTENT Group header: true new: After: 0 New: 5",
"client -> server | KNOWN Group sessions: header/5",
]
@@ -677,8 +677,8 @@ describe("loading coValues from server", () => {
"server -> client | CONTENT Group header: true new: After: 0 New: 5",
"server -> client | CONTENT Map header: true new: After: 0 New: 1",
"client -> server | LOAD ParentGroup sessions: empty",
"client -> server | KNOWN Group sessions: empty",
"client -> server | KNOWN Map sessions: empty",
"client -> server | KNOWN Group sessions: header/5",
"client -> server | KNOWN Map sessions: header/1",
"server -> client | CONTENT ParentGroup header: true new: After: 0 New: 6",
"client -> server | KNOWN ParentGroup sessions: header/6",
]
@@ -723,8 +723,8 @@ describe("loading coValues from server", () => {
"server -> client | CONTENT Group header: true new: After: 0 New: 5",
"server -> client | CONTENT Map header: true new: After: 0 New: 1",
"client -> server | LOAD Account sessions: empty",
"client -> server | KNOWN Group sessions: empty",
"client -> server | KNOWN Map sessions: empty",
"client -> server | KNOWN Group sessions: header/0",
"client -> server | KNOWN Map sessions: header/0",
"server -> client | CONTENT Account header: true new: After: 0 New: 4",
"client -> server | KNOWN Account sessions: header/4",
"client -> server | KNOWN Group sessions: header/5",
@@ -787,7 +787,7 @@ describe("loading coValues from server", () => {
"server -> client | CONTENT Map header: true new: After: 0 New: 1 | After: 0 New: 1",
"client -> server | KNOWN Group sessions: header/5",
"client -> server | LOAD Account sessions: empty",
"client -> server | KNOWN Map sessions: empty",
"client -> server | KNOWN Map sessions: header/1",
"server -> client | CONTENT Account header: true new: After: 0 New: 4",
"client -> server | KNOWN Account sessions: header/4",
"client -> server | KNOWN Map sessions: header/2",
@@ -851,8 +851,8 @@ describe("loading coValues from server", () => {
"server -> client | CONTENT Group header: true new: After: 0 New: 5",
"server -> client | CONTENT Map header: true new: After: 0 New: 1",
"client -> server | LOAD Account sessions: empty",
"client -> server | KNOWN Group sessions: empty",
"client -> server | KNOWN Map sessions: empty",
"client -> server | KNOWN Group sessions: header/0",
"client -> server | KNOWN Map sessions: header/0",
"server -> client | CONTENT Account header: true new: After: 0 New: 4",
"server -> client | CONTENT Account header: true new: After: 0 New: 4",
"client -> server | KNOWN Account sessions: header/4",
@@ -922,7 +922,7 @@ describe("loading coValues from server", () => {
"client -> server | LOAD Map sessions: empty",
"server -> client | CONTENT Map header: true new: After: 0 New: 1",
"client -> server | LOAD Account sessions: empty",
"client -> server | KNOWN Map sessions: empty",
"client -> server | KNOWN Map sessions: header/0",
"server -> client | CONTENT Account header: true new: After: 0 New: 4",
"server -> client | CONTENT Account header: true new: After: 0 New: 4",
"client -> server | KNOWN Account sessions: header/4",
@@ -960,14 +960,17 @@ describe("loading coValues from server", () => {
}),
).toMatchInlineSnapshot(`
[
"client -> server | CONTENT ParentGroup header: true new: After: 0 New: 8",
"client -> server | CONTENT Group header: true new: After: 0 New: 6",
"client -> server | CONTENT Group header: true new: After: 0 New: 3",
"client -> server | CONTENT ParentGroup header: true new: After: 0 New: 6",
"client -> server | CONTENT Group header: false new: After: 3 New: 3",
"client -> server | CONTENT ParentGroup header: false new: After: 6 New: 2",
"client -> server | CONTENT Map header: true new: After: 0 New: 1",
"server -> client | LOAD Group sessions: empty",
"server -> client | KNOWN ParentGroup sessions: empty",
"server -> client | KNOWN Group sessions: header/3",
"server -> client | KNOWN ParentGroup sessions: header/6",
"server -> client | KNOWN Group sessions: header/6",
"server -> client | KNOWN ParentGroup sessions: header/8",
"server -> client | KNOWN Map sessions: header/1",
"client -> server | CONTENT Group header: true new: After: 0 New: 6",
"client -> server | CONTENT ParentGroup header: true new: ",
]
`);
});

View File

@@ -142,19 +142,26 @@ describe("multiple clients syncing with the a cloud-like server mesh", () => {
}),
).toMatchInlineSnapshot(`
[
"edge-france -> storage | CONTENT Group header: true new: After: 0 New: 5",
"edge-france -> core | CONTENT ParentGroup header: true new: After: 0 New: 6",
"edge-france -> core | CONTENT Group header: true new: After: 0 New: 5",
"edge-france -> storage | CONTENT Group header: true new: After: 0 New: 3",
"edge-france -> core | CONTENT Group header: true new: After: 0 New: 3",
"edge-france -> storage | CONTENT ParentGroup header: true new: After: 0 New: 6",
"edge-france -> core | CONTENT ParentGroup header: true new: After: 0 New: 6",
"edge-france -> storage | CONTENT Group header: false new: After: 3 New: 2",
"edge-france -> core | CONTENT Group header: false new: After: 3 New: 2",
"edge-france -> storage | CONTENT Map header: true new: After: 0 New: 1",
"edge-france -> core | CONTENT Map header: true new: After: 0 New: 1",
"core -> edge-france | KNOWN Group sessions: header/3",
"core -> storage | CONTENT Group header: true new: After: 0 New: 3",
"core -> edge-france | KNOWN ParentGroup sessions: header/6",
"core -> storage | CONTENT ParentGroup header: true new: After: 0 New: 6",
"core -> edge-france | KNOWN Group sessions: header/5",
"core -> storage | CONTENT Group header: true new: After: 0 New: 5",
"core -> storage | CONTENT Group header: false new: After: 3 New: 2",
"core -> edge-france | KNOWN Map sessions: header/1",
"core -> storage | CONTENT Map header: true new: After: 0 New: 1",
"edge-france -> core | CONTENT ParentGroup header: true new: ",
"client -> edge-italy | LOAD Map sessions: empty",
"core -> edge-france | KNOWN ParentGroup sessions: header/6",
"core -> storage | CONTENT ParentGroup header: true new: ",
"edge-italy -> storage | LOAD Map sessions: empty",
"storage -> edge-italy | KNOWN Map sessions: empty",
"edge-italy -> core | LOAD Map sessions: empty",
@@ -509,8 +516,7 @@ 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: expectContentUntil: header/200",
"edge -> storage | CONTENT Map header: false new: After: 0 New: 73",
"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",
]

View File

@@ -47,6 +47,8 @@ describe("peer reconciliation", () => {
"server -> client | KNOWN Map sessions: empty",
"server -> client | KNOWN Group sessions: header/3",
"server -> client | KNOWN Map sessions: header/1",
"client -> server | CONTENT Group header: true new: ",
"client -> server | CONTENT Map header: true new: ",
]
`);
});
@@ -203,7 +205,7 @@ describe("peer reconciliation", () => {
"server -> client | KNOWN CORRECTION Map sessions: empty",
"client -> server | CONTENT Map header: true new: After: 0 New: 2",
"server -> client | LOAD Group sessions: empty",
"server -> client | KNOWN Map sessions: empty",
"server -> client | KNOWN Map sessions: header/2",
"client -> server | CONTENT Group header: true new: After: 0 New: 3",
"server -> client | KNOWN Group sessions: header/3",
]
@@ -276,8 +278,8 @@ describe("peer reconciliation", () => {
"client -> server | CONTENT Profile header: true new: After: 0 New: 1",
"client -> server | CONTENT Map header: false new: After: 1 New: 1",
"server -> client | LOAD Account sessions: empty",
"server -> client | KNOWN ProfileGroup sessions: empty",
"server -> client | KNOWN Profile sessions: empty",
"server -> client | KNOWN ProfileGroup sessions: header/0",
"server -> client | KNOWN Profile sessions: header/0",
"server -> client | KNOWN CORRECTION Map sessions: empty",
"client -> server | CONTENT Account header: true new: After: 0 New: 4",
"client -> server | CONTENT Map header: true new: After: 0 New: 2",
@@ -285,7 +287,7 @@ describe("peer reconciliation", () => {
"server -> client | KNOWN ProfileGroup sessions: header/5",
"server -> client | KNOWN Profile sessions: header/1",
"server -> client | LOAD Group sessions: empty",
"server -> client | KNOWN Map sessions: empty",
"server -> client | KNOWN Map sessions: header/2",
"client -> server | CONTENT Group header: true new: After: 0 New: 3",
"server -> client | KNOWN Group sessions: header/3",
]

View File

@@ -181,13 +181,11 @@ describe("client with storage syncs with server", () => {
[
"client -> server | LOAD Group sessions: header/3",
"client -> server | LOAD Map sessions: header/1",
"server -> client | CONTENT Group header: true new: After: 0 New: 3",
"server -> client | CONTENT Map header: true new: After: 0 New: 2",
"server -> client | CONTENT Map header: false new: After: 1 New: 1",
"client -> server | KNOWN Group sessions: header/3",
"client -> storage | CONTENT Group header: true new: After: 0 New: 3",
"server -> client | KNOWN Group sessions: header/3",
"server -> client | CONTENT Map header: false new: After: 1 New: 1",
"client -> server | KNOWN Map sessions: header/2",
"client -> storage | CONTENT Map header: true new: After: 0 New: 2",
"client -> storage | CONTENT Map header: false new: After: 1 New: 1",
"client -> server | KNOWN Map sessions: header/2",
"client -> storage | CONTENT Map header: false new: After: 1 New: 1",
]
@@ -291,20 +289,16 @@ 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: expectContentUntil: header/200",
"client -> storage | CONTENT Map header: false new: After: 0 New: 73",
"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 -> storage | CONTENT Map header: false new: After: 146 New: 54",
"client -> server | CONTENT Map header: true new: expectContentUntil: header/200",
"client -> server | CONTENT Map header: false new: After: 0 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",
"server -> client | KNOWN Group sessions: header/5",
"server -> storage | CONTENT Group header: true new: After: 0 New: 5",
"server -> client | KNOWN Map sessions: header/0",
"server -> storage | CONTENT Map header: true new: expectContentUntil: header/200",
"server -> client | KNOWN Map sessions: header/73",
"server -> storage | CONTENT Map header: false new: After: 0 New: 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",
@@ -410,7 +404,7 @@ describe("client syncs with a server with storage", () => {
const correctionSpy = vi.fn();
client.node.storage?.store(newContentChunks.slice(1, 2), correctionSpy);
client.node.storage?.store(newContentChunks[1]!, correctionSpy);
expect(correctionSpy).not.toHaveBeenCalled();

View File

@@ -16,6 +16,7 @@ describe("client with storage syncs with server", () => {
let jazzCloud: ReturnType<typeof setupTestNode>;
beforeEach(async () => {
vi.resetAllMocks();
SyncMessagesLog.clear();
jazzCloud = setupTestNode({
isSyncServer: true,
@@ -174,15 +175,43 @@ describe("client with storage syncs with server", () => {
[
"client -> server | LOAD Group sessions: header/3",
"client -> server | LOAD Map sessions: header/1",
"server -> client | CONTENT Group header: true new: After: 0 New: 3",
"server -> client | CONTENT Map header: true new: After: 0 New: 2",
"server -> client | CONTENT Map header: false new: After: 1 New: 1",
"client -> server | KNOWN Group sessions: header/3",
"client -> storage | CONTENT Group header: true new: After: 0 New: 3",
"client -> server | KNOWN Map sessions: header/2",
"client -> storage | CONTENT Map header: true new: After: 0 New: 2",
"server -> client | KNOWN Group sessions: header/3",
"server -> client | CONTENT Map header: false new: After: 1 New: 1",
"client -> server | KNOWN Map sessions: header/2",
"client -> storage | CONTENT Map header: false new: After: 1 New: 1",
"client -> server | KNOWN Map sessions: header/2",
"client -> storage | CONTENT Map header: false new: After: 1 New: 1",
]
`);
});
test("the order of updates between CoValues should be preserved to ensure consistency in case of shutdown in the middle of sync", async () => {
const client = setupTestNode();
await client.addAsyncStorage();
const group = client.node.createGroup();
const initialMap = group.createMap();
const child = group.createMap();
child.set("parent", initialMap.id);
initialMap.set("child", child.id);
await initialMap.core.waitForSync();
expect(
SyncMessagesLog.getMessages({
Group: group.core,
InitialMap: initialMap.core,
ChildMap: child.core,
}),
).toMatchInlineSnapshot(`
[
"client -> storage | CONTENT Group header: true new: After: 0 New: 3",
"client -> storage | CONTENT InitialMap header: true new: ",
"client -> storage | CONTENT ChildMap header: true new: After: 0 New: 1",
"client -> storage | CONTENT InitialMap header: false new: After: 0 New: 1",
]
`);
});
@@ -271,20 +300,16 @@ 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: expectContentUntil: header/200",
"client -> storage | CONTENT Map header: false new: After: 0 New: 73",
"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 -> storage | CONTENT Map header: false new: After: 146 New: 54",
"client -> server | CONTENT Map header: true new: expectContentUntil: header/200",
"client -> server | CONTENT Map header: false new: After: 0 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",
"server -> client | KNOWN Group sessions: header/5",
"server -> storage | CONTENT Group header: true new: After: 0 New: 5",
"server -> client | KNOWN Map sessions: header/0",
"server -> storage | CONTENT Map header: true new: expectContentUntil: header/200",
"server -> client | KNOWN Map sessions: header/73",
"server -> storage | CONTENT Map header: false new: After: 0 New: 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",
@@ -369,7 +394,7 @@ describe("client syncs with a server with storage", () => {
const correctionSpy = vi.fn();
client.node.storage?.store(newContentChunks.slice(1, 2), correctionSpy);
client.node.storage?.store(newContentChunks[1]!, correctionSpy);
// Wait for the content to be stored in the storage
// We can't use waitForSync because we are trying to store stale data

View File

@@ -100,15 +100,18 @@ test("Can sync a coValue with private transactions through a server to another c
const map = group.createMap();
map.set("hello", "world", "private");
group.addMember("everyone", "reader");
const { node: client2 } = await setupTestAccount({
connected: true,
});
const mapOnClient2 = await loadCoValueOrFail(client2, map.id);
await waitFor(async () => {
const loadedMap = await loadCoValueOrFail(client2, map.id);
expect(mapOnClient2.get("hello")).toEqual("world");
expect(loadedMap.get("hello")).toEqual("world");
});
});
test("should keep the peer state when the peer closes if persistent is true", async () => {
@@ -563,8 +566,6 @@ describe("SyncManager - knownStates vs optimisticKnownStates", () => {
const mapOnClient = group.createMap();
mapOnClient.set("key1", "value1", "trusting");
await client.syncManager.syncCoValue(mapOnClient.core);
// Wait for the full sync to complete
await mapOnClient.core.waitForSync();
@@ -594,7 +595,6 @@ describe("SyncManager - knownStates vs optimisticKnownStates", () => {
const map = group.createMap();
map.set("key1", "value1", "trusting");
await client.node.syncManager.syncCoValue(map.core);
await map.core.waitForSync();
// Block the content messages
@@ -606,7 +606,7 @@ describe("SyncManager - knownStates vs optimisticKnownStates", () => {
map.set("key2", "value2", "trusting");
await client.node.syncManager.syncCoValue(map.core);
await new Promise<void>(queueMicrotask);
expect(peerState.optimisticKnownStates.get(map.core.id)).not.toEqual(
peerState.knownStates.get(map.core.id),
@@ -638,8 +638,6 @@ describe("SyncManager.addPeer", () => {
const map = group.createMap();
map.set("key1", "value1", "trusting");
await client.node.syncManager.syncCoValue(map.core);
// Wait for initial sync
await map.core.waitForSync();
@@ -671,8 +669,6 @@ describe("SyncManager.addPeer", () => {
const map = group.createMap();
map.set("key1", "value1", "trusting");
await client.node.syncManager.syncCoValue(map.core);
// Wait for initial sync
await map.core.waitForSync();
@@ -843,8 +839,6 @@ describe("waitForSyncWithPeer", () => {
const map = group.createMap();
map.set("key1", "value1", "trusting");
await client.node.syncManager.syncCoValue(map.core);
await expect(
client.node.syncManager.waitForSyncWithPeer(
peerState.id,
@@ -868,8 +862,6 @@ describe("waitForSyncWithPeer", () => {
return Promise.resolve();
});
await client.node.syncManager.syncCoValue(map.core);
await expect(
client.node.syncManager.waitForSyncWithPeer(
peerState.id,

View File

@@ -78,12 +78,15 @@ describe("client to server upload", () => {
}),
).toMatchInlineSnapshot(`
[
"client -> server | CONTENT Group header: true new: After: 0 New: 3",
"client -> server | CONTENT ParentGroup header: true new: After: 0 New: 6",
"client -> server | CONTENT Group header: true new: After: 0 New: 5",
"client -> server | CONTENT Group header: false new: After: 3 New: 2",
"client -> server | CONTENT Map header: true new: After: 0 New: 1",
"server -> client | KNOWN Group sessions: header/3",
"server -> client | KNOWN ParentGroup sessions: header/6",
"server -> client | KNOWN Group sessions: header/5",
"server -> client | KNOWN Map sessions: header/1",
"client -> server | CONTENT ParentGroup header: true new: ",
]
`);
});
@@ -253,6 +256,40 @@ describe("client to server upload", () => {
`);
});
test("local updates batching", async () => {
const client = setupTestNode({
connected: true,
});
const group = client.node.createGroup();
const initialMap = group.createMap();
const child = group.createMap();
child.set("parent", initialMap.id);
initialMap.set("child", child.id);
await initialMap.core.waitForSync();
expect(
SyncMessagesLog.getMessages({
Group: group.core,
InitialMap: initialMap.core,
ChildMap: child.core,
}),
).toMatchInlineSnapshot(`
[
"client -> server | CONTENT Group header: true new: After: 0 New: 3",
"client -> server | CONTENT InitialMap header: true new: ",
"client -> server | CONTENT ChildMap header: true new: After: 0 New: 1",
"client -> server | CONTENT InitialMap header: false new: After: 0 New: 1",
"server -> client | KNOWN Group sessions: header/3",
"server -> client | KNOWN InitialMap sessions: header/0",
"server -> client | KNOWN ChildMap sessions: header/1",
"server -> client | KNOWN InitialMap sessions: header/1",
]
`);
});
test("large coValue upload streaming", async () => {
const client = setupTestNode({
connected: true,

View File

@@ -5,11 +5,7 @@ import { join } from "node:path";
import Database, { type Database as DatabaseT } from "libsql";
import { onTestFinished } from "vitest";
import { RawCoID, StorageAPI } from "../exports";
import {
SQLiteDatabaseDriver,
StorageApiAsync,
StorageApiSync,
} from "../storage";
import { SQLiteDatabaseDriver } from "../storage";
import { getSqliteStorage } from "../storage/sqlite";
import {
SQLiteDatabaseDriverAsync,
@@ -148,13 +144,11 @@ function trackStorageMessages(
const originalLoad = storage.load;
storage.store = function (data, correctionCallback) {
for (const msg of data ?? []) {
SyncMessagesLog.add({
from: nodeName,
to: storageName,
msg,
});
}
SyncMessagesLog.add({
from: nodeName,
to: storageName,
msg: data,
});
return originalStore.call(storage, data, (correction) => {
SyncMessagesLog.add({
@@ -167,7 +161,19 @@ function trackStorageMessages(
},
});
return correctionCallback(correction);
const correctionMessages = correctionCallback(correction);
if (correctionMessages) {
for (const msg of correctionMessages) {
SyncMessagesLog.add({
from: nodeName,
to: storageName,
msg,
});
}
}
return correctionMessages;
});
};

View File

@@ -40,6 +40,14 @@ export function randomAgentAndSessionID(): [ControlledAgent, SessionID] {
return [new ControlledAgent(agentSecret, Crypto), sessionID];
}
export function agentAndSessionIDFromSecret(
secret: AgentSecret,
): [ControlledAgent, SessionID] {
const sessionID = Crypto.newRandomSessionID(Crypto.getAgentID(secret));
return [new ControlledAgent(secret, Crypto), sessionID];
}
export function nodeWithRandomAgentAndSessionID() {
const [agent, session] = randomAgentAndSessionID();
return new LocalNode(agent.agentSecret, session, Crypto);
@@ -154,8 +162,8 @@ export function connectTwoPeers(
bRole: "client" | "server",
) {
const [aAsPeer, bAsPeer] = connectedPeers(
"peer:" + a.getCurrentAgent().id,
"peer:" + b.getCurrentAgent().id,
"peer:" + a.currentSessionID,
"peer:" + b.currentSessionID,
{
peer1role: aRole,
peer2role: bRole,
@@ -443,7 +451,7 @@ export function getSyncServerConnectedPeer(opts: {
const { peer1, peer2 } = connectedPeersWithMessagesTracking({
peer1: {
id: currentSyncServer.getCurrentAgent().id,
id: currentSyncServer.currentSessionID,
role: "server",
name: opts.syncServerName,
},
@@ -472,9 +480,13 @@ export function setupTestNode(
opts: {
isSyncServer?: boolean;
connected?: boolean;
secret?: AgentSecret;
} = {},
) {
const [admin, session] = randomAgentAndSessionID();
const [admin, session] = opts.secret
? agentAndSessionIDFromSecret(opts.secret)
: randomAgentAndSessionID();
let node = new LocalNode(admin.agentSecret, session, Crypto);
if (opts.isSyncServer) {
@@ -489,7 +501,7 @@ export function setupTestNode(
}) {
const { peer, peerStateOnServer, peerOnServer } =
getSyncServerConnectedPeer({
peerId: node.getCurrentAgent().id,
peerId: session,
syncServerName: opts?.syncServerName,
ourName: opts?.ourName,
syncServer: opts?.syncServer,
@@ -551,6 +563,13 @@ export function setupTestNode(
return node;
},
spawnNewSession: () => {
return setupTestNode({
secret: node.agentSecret,
connected: opts.connected,
isSyncServer: opts.isSyncServer,
});
},
};
return ctx;

View File

@@ -1,5 +1,25 @@
# jazz-auth-betterauth
## 0.16.4
### Patch Changes
- Updated dependencies [f9d538f]
- Updated dependencies [16764f6]
- Updated dependencies [802b5a3]
- cojson@0.16.4
- jazz-tools@0.16.4
- jazz-betterauth-client-plugin@0.16.4
## 0.16.3
### Patch Changes
- Updated dependencies [43d3511]
- jazz-tools@0.16.3
- jazz-betterauth-client-plugin@0.16.3
- cojson@0.16.3
## 0.16.2
### Patch Changes

View File

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

View File

@@ -1,5 +1,17 @@
# jazz-betterauth-client-plugin
## 0.16.4
### Patch Changes
- jazz-betterauth-server-plugin@0.16.4
## 0.16.3
### Patch Changes
- jazz-betterauth-server-plugin@0.16.3
## 0.16.2
### Patch Changes

View File

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

View File

@@ -1,5 +1,23 @@
# jazz-betterauth-server-plugin
## 0.16.4
### Patch Changes
- Updated dependencies [f9d538f]
- Updated dependencies [16764f6]
- Updated dependencies [802b5a3]
- cojson@0.16.4
- jazz-tools@0.16.4
## 0.16.3
### Patch Changes
- Updated dependencies [43d3511]
- jazz-tools@0.16.3
- cojson@0.16.3
## 0.16.2
### Patch Changes

View File

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

View File

@@ -1,5 +1,27 @@
# jazz-react-auth-betterauth
## 0.16.4
### Patch Changes
- Updated dependencies [f9d538f]
- Updated dependencies [16764f6]
- Updated dependencies [802b5a3]
- cojson@0.16.4
- jazz-tools@0.16.4
- jazz-auth-betterauth@0.16.4
- jazz-betterauth-client-plugin@0.16.4
## 0.16.3
### Patch Changes
- Updated dependencies [43d3511]
- jazz-tools@0.16.3
- jazz-auth-betterauth@0.16.3
- jazz-betterauth-client-plugin@0.16.3
- cojson@0.16.3
## 0.16.2
### Patch Changes

View File

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

View File

@@ -1,5 +1,27 @@
# jazz-run
## 0.16.4
### Patch Changes
- Updated dependencies [f9d538f]
- Updated dependencies [16764f6]
- Updated dependencies [802b5a3]
- cojson@0.16.4
- jazz-tools@0.16.4
- cojson-storage-sqlite@0.16.4
- cojson-transport-ws@0.16.4
## 0.16.3
### Patch Changes
- Updated dependencies [43d3511]
- jazz-tools@0.16.3
- cojson@0.16.3
- cojson-storage-sqlite@0.16.3
- cojson-transport-ws@0.16.3
## 0.16.2
### Patch Changes

View File

@@ -3,7 +3,7 @@
"bin": "./dist/index.js",
"type": "module",
"license": "MIT",
"version": "0.16.2",
"version": "0.16.4",
"exports": {
"./startSyncServer": {
"types": "./dist/startSyncServer.d.ts",
@@ -28,11 +28,11 @@
"@effect/printer-ansi": "^0.34.5",
"@effect/schema": "^0.71.1",
"@effect/typeclass": "^0.25.5",
"cojson": "workspace:0.16.2",
"cojson-storage-sqlite": "workspace:0.16.2",
"cojson-transport-ws": "workspace:0.16.2",
"cojson": "workspace:0.16.4",
"cojson-storage-sqlite": "workspace:0.16.4",
"cojson-transport-ws": "workspace:0.16.4",
"effect": "^3.6.5",
"jazz-tools": "workspace:0.16.2",
"jazz-tools": "workspace:0.16.4",
"ws": "^8.14.2"
},
"devDependencies": {

View File

@@ -1,5 +1,27 @@
# jazz-tools
## 0.16.4
### Patch Changes
- 16764f6: Added `pick()` and `partial()` methods to CoMapSchema
- Updated dependencies [f9d538f]
- Updated dependencies [802b5a3]
- cojson@0.16.4
- cojson-storage-indexeddb@0.16.4
- cojson-transport-ws@0.16.4
## 0.16.3
### Patch Changes
- 43d3511: Streamlined CoValue creation:
- CoValues can be created with plain JSON objects. Nested CoValues will be automatically created when necessary.
- Optional fields can be ommited (i.e. it's no longer necessary to provide an explicit `undefined` value).
- cojson@0.16.3
- cojson-storage-indexeddb@0.16.3
- cojson-transport-ws@0.16.3
## 0.16.2
### Patch Changes

View File

@@ -139,7 +139,7 @@
},
"type": "module",
"license": "MIT",
"version": "0.16.2",
"version": "0.16.4",
"dependencies": {
"@manuscripts/prosemirror-recreate-steps": "^0.1.4",
"@scure/base": "1.2.1",

View File

@@ -141,13 +141,102 @@ function useCoValueSubscription<
return subscription.subscription;
}
/**
* React hook for subscribing to CoValues and handling loading states.
*
* This hook provides a convenient way to subscribe to CoValues and automatically
* handles the subscription lifecycle (subscribe on mount, unsubscribe on unmount).
* It also supports deep loading of nested CoValues through resolve queries.
*
* @returns The loaded CoValue, or `undefined` if loading, or `null` if not found/not accessible
*
* @example
* ```tsx
* // Deep loading with resolve queries
* const Project = co.map({
* name: z.string(),
* tasks: co.list(Task),
* owner: TeamMember,
* });
*
* function ProjectView({ projectId }: { projectId: string }) {
* const project = useCoState(Project, projectId, {
* resolve: {
* tasks: { $each: true },
* owner: true,
* },
* });
*
* if (!project) {
* return project === null
* ? "Project not found or not accessible"
* : "Loading project...";
* }
*
* return (
* <div>
* <h1>{project.name}</h1>
* <p>Owner: {project.owner.name}</p>
* <ul>
* {project.tasks.map((task) => (
* <li key={task.id}>{task.title}</li>
* ))}
* </ul>
* </div>
* );
* }
* ```
*
* @example
* ```tsx
* // Using with optional references and error handling
* const Task = co.map({
* title: z.string(),
* assignee: co.optional(TeamMember),
* subtasks: co.list(Task),
* });
*
* function TaskDetail({ taskId }: { taskId: string }) {
* const task = useCoState(Task, taskId, {
* resolve: {
* assignee: true,
* subtasks: { $each: { $onError: null } },
* },
* });
*
* if (!task) {
* return task === null
* ? "Task not found or not accessible"
* : "Loading task...";
* }
*
* return (
* <div>
* <h2>{task.title}</h2>
* {task.assignee && <p>Assigned to: {task.assignee.name}</p>}
* <ul>
* {task.subtasks.map((subtask, index) => (
* subtask ? <li key={subtask.id}>{subtask.title}</li> : <li key={index}>Inaccessible subtask</li>
* ))}
* </ul>
* </div>
* );
* }
* ```
*
* For more examples, see the [subscription and deep loading](https://jazz.tools/docs/react/using-covalues/subscription-and-loading) documentation.
*/
export function useCoState<
S extends CoValueClassOrSchema,
const R extends ResolveQuery<S> = true,
>(
/** The CoValue schema or class constructor */
Schema: S,
/** The ID of the CoValue to subscribe to. If `undefined`, returns `null` */
id: string | undefined,
/** Optional configuration for the subscription */
options?: {
/** Resolve query to specify which nested CoValues to load */
resolve?: ResolveQueryStrict<S, R>;
},
): Loaded<S, R> | undefined | null {
@@ -229,12 +318,66 @@ function useAccountSubscription<
return subscription.subscription;
}
/**
* React hook for accessing the current user's account and authentication state.
*
* This hook provides access to the current user's account profile and root data,
* along with authentication utilities. It automatically handles subscription to
* the user's account data and provides a logout function.
*
* @returns An object containing:
* - `me`: The loaded account data, or `undefined` if loading, or `null` if not authenticated
* - `agent`: The current agent (anonymous or authenticated user). Can be used as `loadAs` parameter for load and subscribe methods.
* - `logOut`: Function to log out the current user
* @example
* ```tsx
* // Deep loading with resolve queries
* function ProjectListWithDetails() {
* const { me } = useAccount(MyAppAccount, {
* resolve: {
* profile: true,
* root: {
* myProjects: {
* $each: {
* tasks: true,
* },
* },
* },
* },
* });
*
* if (!me) {
* return me === null
* ? <div>Failed to load your projects</div>
* : <div>Loading...</div>;
* }
*
* return (
* <div>
* <h1>{me.profile.name}'s projects</h1>
* <ul>
* {me.root.myProjects.map((project) => (
* <li key={project.id}>
* {project.name} ({project.tasks.length} tasks)
* </li>
* ))}
* </ul>
* </div>
* );
* }
* ```
*
*/
export function useAccount<
A extends AccountClass<Account> | AnyAccountSchema,
R extends ResolveQuery<A> = true,
>(
/** The account schema to use. Defaults to the base Account schema */
AccountSchema: A = Account as unknown as A,
/** Optional configuration for the subscription */
options?: {
/** Resolve query to specify which nested CoValues to load from the account */
resolve?: ResolveQueryStrict<A, R>;
},
): {

View File

@@ -34,6 +34,7 @@ import {
coField,
ensureCoValueLoaded,
inspect,
instantiateRefEncodedWithInit,
isRefEncoded,
loadCoValueWithoutMe,
parseCoValueCreateOptions,
@@ -278,7 +279,16 @@ export class CoFeed<out Item = any> extends CoValueBase implements CoValue {
} else if ("encoded" in itemDescriptor) {
this._raw.push(itemDescriptor.encoded.encode(item));
} else if (isRefEncoded(itemDescriptor)) {
this._raw.push((item as unknown as CoValue).id);
let refId = (item as unknown as CoValue).id;
if (!refId) {
const coValue = instantiateRefEncodedWithInit(
itemDescriptor,
item,
this._owner,
);
refId = coValue.id;
}
this._raw.push(refId);
}
}
@@ -418,9 +428,7 @@ export class CoFeed<out Item = any> extends CoValueBase implements CoValue {
*
* @category Subscription & Loading
*/
waitForSync(options?: {
timeout?: number;
}) {
waitForSync(options?: { timeout?: number }) {
return this._raw.core.waitForSync(options);
}
}

View File

@@ -29,6 +29,7 @@ import {
coValuesCache,
ensureCoValueLoaded,
inspect,
instantiateRefEncodedWithInit,
isRefEncoded,
loadCoValueWithoutMe,
makeRefs,
@@ -240,7 +241,7 @@ export class CoList<out Item = any> extends Array<Item> implements CoValue {
const { owner } = parseCoValueCreateOptions(options);
const instance = new this({ init: items, owner });
const raw = owner._raw.createList(
toRawItems(items, instance._schema[ItemsSym]),
toRawItems(items, instance._schema[ItemsSym], owner),
);
Object.defineProperties(instance, {
@@ -256,7 +257,7 @@ export class CoList<out Item = any> extends Array<Item> implements CoValue {
push(...items: Item[]): number {
this._raw.appendItems(
toRawItems(items, this._schema[ItemsSym]),
toRawItems(items, this._schema[ItemsSym], this._owner),
undefined,
"private",
);
@@ -265,7 +266,11 @@ export class CoList<out Item = any> extends Array<Item> implements CoValue {
}
unshift(...items: Item[]): number {
for (const item of toRawItems(items as Item[], this._schema[ItemsSym])) {
for (const item of toRawItems(
items as Item[],
this._schema[ItemsSym],
this._owner,
)) {
this._raw.prepend(item);
}
@@ -306,7 +311,11 @@ export class CoList<out Item = any> extends Array<Item> implements CoValue {
this._raw.delete(idxToDelete);
}
const rawItems = toRawItems(items as Item[], this._schema[ItemsSym]);
const rawItems = toRawItems(
items as Item[],
this._schema[ItemsSym],
this._owner,
);
// If there are no items to insert, return the deleted items
if (rawItems.length === 0) {
@@ -551,23 +560,36 @@ export class CoList<out Item = any> extends Array<Item> implements CoValue {
* Convert an array of items to a raw array of items.
* @param items - The array of items to convert.
* @param itemDescriptor - The descriptor of the items.
* @param owner - The owner of the CoList.
* @returns The raw array of items.
*/
function toRawItems<Item>(items: Item[], itemDescriptor: Schema) {
const rawItems =
itemDescriptor === "json"
? (items as JsonValue[])
: "encoded" in itemDescriptor
? items?.map((e) => itemDescriptor.encoded.encode(e))
: isRefEncoded(itemDescriptor)
? items?.map((v) => {
if (!v) return null;
return (v as unknown as CoValue).id;
})
: (() => {
throw new Error("Invalid element descriptor");
})();
function toRawItems<Item>(
items: Item[],
itemDescriptor: Schema,
owner: Account | Group,
) {
let rawItems: JsonValue[] = [];
if (itemDescriptor === "json") {
rawItems = items as JsonValue[];
} else if ("encoded" in itemDescriptor) {
rawItems = items?.map((e) => itemDescriptor.encoded.encode(e));
} else if (isRefEncoded(itemDescriptor)) {
rawItems = items?.map((value) => {
if (value == null) return null;
let refId = (value as unknown as CoValue).id;
if (!refId) {
const coValue = instantiateRefEncodedWithInit(
itemDescriptor,
value,
owner,
);
refId = coValue.id;
}
return refId;
});
} else {
throw new Error("Invalid element descriptor");
}
return rawItems;
}

View File

@@ -37,6 +37,7 @@ import {
activeAccountContext,
ensureCoValueLoaded,
inspect,
instantiateRefEncodedWithInit,
isRefEncoded,
loadCoValueWithoutMe,
makeRefs,
@@ -424,8 +425,17 @@ export class CoMap extends CoValueBase implements CoValue {
if (descriptor === "json") {
rawInit[key] = initValue as JsonValue;
} else if (isRefEncoded(descriptor)) {
if (initValue) {
rawInit[key] = (initValue as unknown as CoValue).id;
if (initValue != null) {
let refId = (initValue as unknown as CoValue).id;
if (!refId) {
const coValue = instantiateRefEncodedWithInit(
descriptor,
initValue,
owner,
);
refId = coValue.id;
}
rawInit[key] = refId;
}
} else if ("encoded" in descriptor) {
rawInit[key] = descriptor.encoded.encode(

View File

@@ -145,7 +145,7 @@ export class Inbox {
for (const [sessionID, items] of Object.entries(stream.items) as [
SessionID,
CoStreamItem<CoID<InboxMessage<InstanceOfSchema<M>, O>>>[],
CoStreamItem<CoID<InboxMessage<NonNullable<InstanceOfSchema<M>>, O>>>[],
][]) {
const accountID = getAccountIDfromSessionID(sessionID);

View File

@@ -2,7 +2,6 @@ import {
type CoValueUniqueness,
type CojsonInternalTypes,
type RawCoValue,
emptyKnownState,
} from "cojson";
import { AvailableCoValueCore } from "cojson/dist/coValueCore/coValueCore.js";
import {
@@ -546,9 +545,9 @@ function loadContentPiecesFromCoValue(
}
}
const pieces = core.verified.newContentSince(emptyKnownState(core.id));
const pieces = core.verified.newContentSince(undefined) ?? [];
for (const piece of pieces ?? []) {
for (const piece of pieces) {
contentPieces.push(piece);
}
}

View File

@@ -1,12 +1,16 @@
import { JsonValue, RawCoMap } from "cojson";
import {
Account,
AnonymousJazzAgent,
CoMapInit,
CoValue,
CoValueBase,
CoValueClass,
CoValueFromRaw,
Group,
ID,
Resolved,
Simplify,
SubscribeListenerOptions,
SubscribeRestArgs,
loadCoValueWithoutMe,
@@ -20,6 +24,10 @@ import {
export type SchemaUnionConcreteSubclass<V extends CoValue> =
typeof SchemaUnion & CoValueClass<V>;
export type SchemaUnionDiscriminator<V extends CoValue> = (discriminable: {
get(key: string): JsonValue | undefined;
}) => CoValueClass<V> & CoValueFromRaw<V>;
/**
* SchemaUnion allows you to create union types of CoValues that can be discriminated at runtime.
*
@@ -89,28 +97,45 @@ export abstract class SchemaUnion extends CoValueBase implements CoValue {
* @category Declaration
**/
static Of<V extends CoValue>(
discriminator: (raw: V["_raw"]) => CoValueClass<V> & CoValueFromRaw<V>,
discriminator: SchemaUnionDiscriminator<V>,
): SchemaUnionConcreteSubclass<V> {
return class SchemaUnionClass extends SchemaUnion {
static override create<V extends CoValue>(
this: CoValueClass<V>,
init: Simplify<CoMapInit<V>>,
owner: Account | Group,
): V {
const ResolvedClass = discriminator(new Map(Object.entries(init)));
// @ts-expect-error - create is a static method in the CoMap class
return ResolvedClass.create(init, owner);
}
static override fromRaw<T extends CoValue>(
this: CoValueClass<T> & CoValueFromRaw<T>,
raw: T["_raw"],
): T {
const ResolvedClass = discriminator(
raw as V["_raw"],
raw as RawCoMap,
) as unknown as CoValueClass<T> & CoValueFromRaw<T>;
return ResolvedClass.fromRaw(raw);
}
} as unknown as SchemaUnionConcreteSubclass<V>;
}
static create<V extends CoValue>(
this: CoValueClass<V>,
init: Simplify<CoMapInit<V>>,
owner: Account | Group,
): V {
throw new Error("Not implemented");
}
/**
* Create an instance from raw data. This is called internally and should not be used directly.
* Use {@link SchemaUnion.Of} to create a union type instead.
*
* @internal
*/
// @ts-ignore
static fromRaw<V extends CoValue>(this: CoValueClass<V>, raw: V["_raw"]): V {
throw new Error("Not implemented");
}

View File

@@ -107,7 +107,6 @@ export {
type CoreAccountSchema as AnyAccountSchema,
type ResolveQuery,
type ResolveQueryStrict,
type InitFor,
} from "./internal.js";
export {

View File

@@ -1,11 +1,12 @@
import type { JsonValue, RawCoValue } from "cojson";
import { CojsonInternalTypes } from "cojson";
import {
Account,
type CoValue,
type CoValueClass,
CoValueFromRaw,
Group,
ItemsSym,
JazzToolsSymbol,
SchemaInit,
isCoValueClass,
} from "../internal.js";
@@ -140,7 +141,7 @@ export function isRefEncoded<V extends CoValue>(
);
}
export function instantiateRefEncoded<V extends CoValue>(
export function instantiateRefEncodedFromRaw<V extends CoValue>(
schema: RefEncoded<V>,
raw: RawCoValue,
): V {
@@ -151,6 +152,31 @@ export function instantiateRefEncoded<V extends CoValue>(
).fromRaw(raw);
}
/**
* Creates a new CoValue of the given ref type, using the provided init values.
*
* @param schema - The schema of the CoValue to create.
* @param init - The init values to use to create the CoValue.
* @param parentOwner - The owner of the referencing CoValue. Will be used
* as the parent group of the created CoValue's group
* @returns The created CoValue.
*/
export function instantiateRefEncodedWithInit<V extends CoValue>(
schema: RefEncoded<V>,
init: any,
parentOwner: Account | Group,
): V {
if (!isCoValueClass<V>(schema.ref)) {
throw Error(
`Cannot automatically create CoValue from value: ${JSON.stringify(init)}. Use the CoValue schema's create() method instead.`,
);
}
const owner = Group.create();
owner.addMember(parentOwner.castAs(Group));
// @ts-expect-error - create is a static method in all CoValue classes
return schema.ref.create(init, owner);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type Schema = JsonEncoded | RefEncoded<CoValue> | EncodedAs<any>;

View File

@@ -6,21 +6,16 @@ import {
RefsToResolve,
RefsToResolveStrict,
Resolved,
Simplify,
SubscribeListenerOptions,
coOptionalDefiner,
} from "../../../internal.js";
import { AnonymousJazzAgent } from "../../anonymousJazzAgent.js";
import { CoFieldInit } from "../typeConverters/CoFieldInit.js";
import { CoFeedInit } from "../typeConverters/CoFieldInit.js";
import { InstanceOrPrimitiveOfSchema } from "../typeConverters/InstanceOrPrimitiveOfSchema.js";
import { InstanceOrPrimitiveOfSchemaCoValuesNullable } from "../typeConverters/InstanceOrPrimitiveOfSchemaCoValuesNullable.js";
import { CoOptionalSchema } from "./CoOptionalSchema.js";
import { CoreCoValueSchema } from "./CoValueSchema.js";
type CoFeedInit<T extends AnyZodOrCoValueSchema> = Simplify<
Array<CoFieldInit<T>>
>;
export class CoFeedSchema<T extends AnyZodOrCoValueSchema>
implements CoreCoFeedSchema<T>
{
@@ -36,7 +31,7 @@ export class CoFeedSchema<T extends AnyZodOrCoValueSchema>
init: CoFeedInit<T>,
options?: { owner: Account | Group } | Account | Group,
): CoFeedInstance<T> {
return this.coValueClass.create(init, options) as CoFeedInstance<T>;
return this.coValueClass.create(init as any, options) as CoFeedInstance<T>;
}
load<const R extends RefsToResolve<CoFeedInstanceCoValuesNullable<T>> = true>(

View File

@@ -5,22 +5,17 @@ import {
RefsToResolve,
RefsToResolveStrict,
Resolved,
Simplify,
SubscribeListenerOptions,
coOptionalDefiner,
} from "../../../internal.js";
import { AnonymousJazzAgent } from "../../anonymousJazzAgent.js";
import { CoFieldInit } from "../typeConverters/CoFieldInit.js";
import { CoListInit } from "../typeConverters/CoFieldInit.js";
import { InstanceOrPrimitiveOfSchema } from "../typeConverters/InstanceOrPrimitiveOfSchema.js";
import { InstanceOrPrimitiveOfSchemaCoValuesNullable } from "../typeConverters/InstanceOrPrimitiveOfSchemaCoValuesNullable.js";
import { AnyZodOrCoValueSchema } from "../zodSchema.js";
import { CoOptionalSchema } from "./CoOptionalSchema.js";
import { CoreCoValueSchema } from "./CoValueSchema.js";
type CoListInit<T extends AnyZodOrCoValueSchema> = Simplify<
Array<CoFieldInit<T>>
>;
export class CoListSchema<T extends AnyZodOrCoValueSchema>
implements CoreCoListSchema<T>
{
@@ -36,7 +31,7 @@ export class CoListSchema<T extends AnyZodOrCoValueSchema>
items: CoListInit<T>,
options?: { owner: Account | Group } | Account | Group,
): CoListInstance<T> {
return this.coValueClass.create(items, options) as CoListInstance<T>;
return this.coValueClass.create(items as any, options) as CoListInstance<T>;
}
load<const R extends RefsToResolve<CoListInstanceCoValuesNullable<T>> = true>(

View File

@@ -5,24 +5,25 @@ import {
DiscriminableCoValueSchemaDefinition,
DiscriminableCoreCoValueSchema,
Group,
PartialOnUndefined,
RefsToResolve,
RefsToResolveStrict,
Resolved,
Simplify,
SubscribeListenerOptions,
coMapDefiner,
coOptionalDefiner,
hydrateCoreCoValueSchema,
isAnyCoValueSchema,
} from "../../../internal.js";
import { AnonymousJazzAgent } from "../../anonymousJazzAgent.js";
import { removeGetters } from "../../schemaUtils.js";
import { CoFieldInit } from "../typeConverters/CoFieldInit.js";
import { CoMapSchemaInit } from "../typeConverters/CoFieldInit.js";
import { InstanceOrPrimitiveOfSchema } from "../typeConverters/InstanceOrPrimitiveOfSchema.js";
import { InstanceOrPrimitiveOfSchemaCoValuesNullable } from "../typeConverters/InstanceOrPrimitiveOfSchemaCoValuesNullable.js";
import { z } from "../zodReExport.js";
import { AnyZodOrCoValueSchema } from "../zodSchema.js";
import { AnyZodOrCoValueSchema, AnyZodSchema } from "../zodSchema.js";
import { CoOptionalSchema } from "./CoOptionalSchema.js";
import { CoreCoValueSchema } from "./CoValueSchema.js";
export interface CoMapSchema<
Shape extends z.core.$ZodLooseShape,
@@ -30,7 +31,7 @@ export interface CoMapSchema<
Owner extends Account | Group = Account | Group,
> extends CoreCoMapSchema<Shape, CatchAll> {
create: (
init: Simplify<CoMapSchemaInit<Shape>>,
init: CoMapSchemaInit<Shape>,
options?:
| {
owner: Owner;
@@ -134,6 +135,23 @@ export interface CoMapSchema<
getCoValueClass: () => typeof CoMap;
optional(): CoOptionalSchema<this>;
/**
* Creates a new CoMap schema by picking the specified keys from the original schema.
*
* @param keys - The keys to pick from the original schema.
* @returns A new CoMap schema with the picked keys.
*/
pick<Keys extends keyof Shape>(
keys: { [key in Keys]: true },
): CoMapSchema<Simplify<Pick<Shape, Keys>>, unknown, Owner>;
/**
* Creates a new CoMap schema by making all fields optional.
*
* @returns A new CoMap schema with all fields optional.
*/
partial(): CoMapSchema<PartialShape<Shape>, CatchAll, Owner>;
}
export function createCoreCoMapSchema<
@@ -219,21 +237,44 @@ export function enrichCoMapSchema<
getCoValueClass: () => {
return coValueClass;
},
optional: () => {
return coOptionalDefiner(coValueSchema);
},
pick: <Keys extends keyof Shape>(keys: { [key in Keys]: true }) => {
const keysSet = new Set(Object.keys(keys));
const pickedShape: Record<string, AnyZodOrCoValueSchema> = {};
for (const [key, value] of Object.entries(coValueSchema.shape)) {
if (keysSet.has(key)) {
pickedShape[key] = value;
}
}
return coMapDefiner(pickedShape);
},
partial: () => {
const partialShape: Record<string, AnyZodOrCoValueSchema> = {};
for (const [key, value] of Object.entries(coValueSchema.shape)) {
if (isAnyCoValueSchema(value)) {
partialShape[key] = coOptionalDefiner(value);
} else {
partialShape[key] = z.optional(coValueSchema.shape[key]);
}
}
const partialCoMapSchema = coMapDefiner(partialShape);
if (coValueSchema.catchAll) {
return partialCoMapSchema.catchall(
coValueSchema.catchAll as unknown as AnyZodOrCoValueSchema,
);
}
return partialCoMapSchema;
},
}) as unknown as CoMapSchema<Shape, CatchAll>;
return coValueSchema;
}
// Due to a TS limitation with types that contain known properties and
// an index signature, we cannot accept catchall properties on creation
export type CoMapSchemaInit<Shape extends z.core.$ZodLooseShape> =
PartialOnUndefined<{
[key in keyof Shape]: CoFieldInit<Shape[key]>;
}>;
export interface CoMapSchemaDefinition<
Shape extends z.core.$ZodLooseShape = z.core.$ZodLooseShape,
CatchAll extends AnyZodOrCoValueSchema | unknown = unknown,
@@ -270,3 +311,11 @@ export type CoMapInstanceCoValuesNullable<Shape extends z.core.$ZodLooseShape> =
Shape[key]
>;
};
export type PartialShape<Shape extends z.core.$ZodLooseShape> = Simplify<{
-readonly [key in keyof Shape]: Shape[key] extends AnyZodSchema
? z.ZodOptional<Shape[key]>
: Shape[key] extends CoreCoValueSchema
? CoOptionalSchema<Shape[key]>
: never;
}>;

View File

@@ -1,13 +1,78 @@
import { NotNull } from "../../../internal.js";
import {
CoDiscriminatedUnionSchema,
CoValueClass,
CoreCoFeedSchema,
CoreCoListSchema,
CoreCoMapSchema,
CoreCoRecordSchema,
CorePlainTextSchema,
PartialOnUndefined,
Simplify,
} from "../../../internal.js";
import { CoreCoOptionalSchema } from "../schemaTypes/CoOptionalSchema.js";
import { CoreCoValueSchema } from "../schemaTypes/CoValueSchema.js";
import { CoreRichTextSchema } from "../schemaTypes/RichTextSchema.js";
import { z } from "../zodReExport.js";
import { AnyZodOrCoValueSchema } from "../zodSchema.js";
import { InstanceOrPrimitiveOfSchemaCoValuesNullable } from "./InstanceOrPrimitiveOfSchemaCoValuesNullable.js";
import { AnyZodOrCoValueSchema, Loaded } from "../zodSchema.js";
import { TypeOfZodSchema } from "./TypeOfZodSchema.js";
/**
* Returns the type of the value that should be used to initialize a coField
* of the given schema.
* The type of value that can be used to initialize a CoField of the given schema.
*
* For CoValue fields, this can be either a shallowly-loaded CoValue instance
* or a JSON object that will be used to create the CoValue.
*/
export type CoFieldInit<T extends AnyZodOrCoValueSchema> =
T extends z.core.$ZodNullable
? InstanceOrPrimitiveOfSchemaCoValuesNullable<T>
: NotNull<InstanceOrPrimitiveOfSchemaCoValuesNullable<T>>;
export type CoFieldInit<S extends CoValueClass | AnyZodOrCoValueSchema> =
S extends CoreCoValueSchema
?
| Loaded<S>
| (S extends CoreCoRecordSchema<infer K, infer V>
? CoMapSchemaInit<{ [key in z.output<K> & string]: V }>
: S extends CoreCoMapSchema<infer Shape>
? CoMapSchemaInit<Shape>
: S extends CoreCoListSchema<infer T>
? CoListInit<T>
: S extends CoreCoFeedSchema<infer T>
? CoFeedInit<T>
: S extends CorePlainTextSchema | CoreRichTextSchema
? string
: S extends CoreCoOptionalSchema<infer T>
? CoFieldInit<T> | undefined
: S extends CoDiscriminatedUnionSchema<infer Members>
? CoFieldInit<Members[number]>
: never)
: S extends z.core.$ZodType
? TypeOfZodSchema<S>
: S extends CoValueClass
? InstanceType<S>
: never;
// Due to a TS limitation with types that contain known properties and
// an index signature, we cannot accept catchall properties on creation
export type CoMapSchemaInit<Shape extends z.core.$ZodLooseShape> = Simplify<
{
/**
* Cannot use {@link PartialOnUndefined} because evaluating CoFieldInit<Shape[Key]>
* to know if the value can be undefined does not work with recursive types.
*/
[Key in keyof Shape as Shape[Key] extends
| CoreCoOptionalSchema
| z.core.$ZodOptional
? never
: Key]: CoFieldInit<Shape[Key]>;
} & {
[Key in keyof Shape as Shape[Key] extends
| CoreCoOptionalSchema
| z.core.$ZodOptional
? Key
: never]?: CoFieldInit<Shape[Key]>;
}
>;
export type CoListInit<T extends AnyZodOrCoValueSchema> = Simplify<
ReadonlyArray<CoFieldInit<T>>
>;
export type CoFeedInit<T extends AnyZodOrCoValueSchema> = Simplify<
ReadonlyArray<CoFieldInit<T>>
>;

View File

@@ -27,15 +27,20 @@ export type InstanceOfSchema<S extends CoValueClass | AnyZodOrCoValueSchema> =
S extends CoreCoValueSchema
? S extends CoreAccountSchema<infer Shape>
? {
[key in keyof Shape]: InstanceOrPrimitiveOfSchema<Shape[key]>;
-readonly [key in keyof Shape]: InstanceOrPrimitiveOfSchema<
Shape[key]
>;
} & Account
: S extends CoreCoRecordSchema<infer K, infer V>
? {
[key in z.output<K> & string]: InstanceOrPrimitiveOfSchema<V>;
-readonly [key in z.output<K> &
string]: InstanceOrPrimitiveOfSchema<V>;
} & CoMap
: S extends CoreCoMapSchema<infer Shape, infer CatchAll>
? {
[key in keyof Shape]: InstanceOrPrimitiveOfSchema<Shape[key]>;
-readonly [key in keyof Shape]: InstanceOrPrimitiveOfSchema<
Shape[key]
>;
} & (CatchAll extends AnyZodOrCoValueSchema
? {
[key: string]: InstanceOrPrimitiveOfSchema<CatchAll>;
@@ -53,7 +58,7 @@ export type InstanceOfSchema<S extends CoValueClass | AnyZodOrCoValueSchema> =
: S extends CoreFileStreamSchema
? FileStream
: S extends CoreCoOptionalSchema<infer T>
? InstanceOrPrimitiveOfSchema<T>
? InstanceOrPrimitiveOfSchema<T> | undefined
: S extends CoDiscriminatedUnionSchema<infer Members>
? InstanceOrPrimitiveOfSchema<Members[number]>
: never

View File

@@ -29,7 +29,7 @@ export type InstanceOfSchemaCoValuesNullable<
? S extends CoreAccountSchema<infer Shape>
?
| ({
[key in keyof Shape]: InstanceOrPrimitiveOfSchemaCoValuesNullable<
-readonly [key in keyof Shape]: InstanceOrPrimitiveOfSchemaCoValuesNullable<
Shape[key]
>;
} & Account)
@@ -37,14 +37,14 @@ export type InstanceOfSchemaCoValuesNullable<
: S extends CoreCoRecordSchema<infer K, infer V>
?
| ({
[key in z.output<K> &
-readonly [key in z.output<K> &
string]: InstanceOrPrimitiveOfSchemaCoValuesNullable<V>;
} & CoMap)
| null
: S extends CoreCoMapSchema<infer Shape, infer CatchAll>
?
| ({
[key in keyof Shape]: InstanceOrPrimitiveOfSchemaCoValuesNullable<
-readonly [key in keyof Shape]: InstanceOrPrimitiveOfSchemaCoValuesNullable<
Shape[key]
>;
} & (CatchAll extends AnyZodOrCoValueSchema

View File

@@ -1,122 +1,11 @@
import { JsonValue } from "cojson";
import {
Account,
AnyZodOrCoValueSchema,
CoDiscriminatedUnionSchema,
CoFeed,
CoList,
CoMap,
CoPlainText,
CoRichText,
CoValueClass,
CoreAccountSchema,
CoreCoRecordSchema,
FileStream,
Profile,
InstanceOfSchema,
} from "../../../internal.js";
import { CoreCoFeedSchema } from "../schemaTypes/CoFeedSchema.js";
import { CoreCoListSchema } from "../schemaTypes/CoListSchema.js";
import { CoreCoMapSchema } from "../schemaTypes/CoMapSchema.js";
import { CoreCoOptionalSchema } from "../schemaTypes/CoOptionalSchema.js";
import { CoreCoValueSchema } from "../schemaTypes/CoValueSchema.js";
import { CoreFileStreamSchema } from "../schemaTypes/FileStreamSchema.js";
import { CorePlainTextSchema } from "../schemaTypes/PlainTextSchema.js";
import { CoreRichTextSchema } from "../schemaTypes/RichTextSchema.js";
import { z } from "../zodReExport.js";
import { TypeOfZodSchema } from "./TypeOfZodSchema.js";
export type InstanceOrPrimitiveOfSchema<
S extends CoValueClass | AnyZodOrCoValueSchema,
> = S extends CoreCoValueSchema
? S extends CoreAccountSchema<infer Shape>
? {
-readonly [key in keyof Shape]: InstanceOrPrimitiveOfSchema<Shape[key]>;
} & { profile: Profile } & Account
: S extends CoreCoRecordSchema<infer K, infer V>
? {
-readonly [key in z.output<K> &
string]: InstanceOrPrimitiveOfSchema<V>;
} & CoMap
: S extends CoreCoMapSchema<infer Shape, infer CatchAll>
? {
-readonly [key in keyof Shape]: InstanceOrPrimitiveOfSchema<
Shape[key]
>;
} & (CatchAll extends AnyZodOrCoValueSchema
? {
[key: string]: InstanceOrPrimitiveOfSchema<CatchAll>;
}
: {}) &
CoMap
: S extends CoreCoListSchema<infer T>
? CoList<InstanceOrPrimitiveOfSchema<T>>
: S extends CoreCoFeedSchema<infer T>
? CoFeed<InstanceOrPrimitiveOfSchema<T>>
: S extends CorePlainTextSchema
? CoPlainText
: S extends CoreRichTextSchema
? CoRichText
: S extends CoreFileStreamSchema
? FileStream
: S extends CoreCoOptionalSchema<infer T>
? InstanceOrPrimitiveOfSchema<T> | undefined
: S extends CoDiscriminatedUnionSchema<infer Members>
? InstanceOrPrimitiveOfSchema<Members[number]>
: never
: S extends z.core.$ZodType
? S extends z.core.$ZodOptional<infer Inner extends z.core.$ZodType>
? InstanceOrPrimitiveOfSchema<Inner> | undefined
: S extends z.core.$ZodNullable<infer Inner extends z.core.$ZodType>
? InstanceOrPrimitiveOfSchema<Inner> | null
: S extends z.ZodJSONSchema
? JsonValue
: S extends z.core.$ZodUnion<infer Members extends z.core.$ZodType[]>
? InstanceOrPrimitiveOfSchema<Members[number]>
: // primitives below here - we manually traverse to ensure we only allow what we can handle
S extends z.core.$ZodObject<infer Shape>
? {
-readonly [key in keyof Shape]: InstanceOrPrimitiveOfSchema<
Shape[key]
>;
}
: S extends z.core.$ZodArray<infer Item extends z.core.$ZodType>
? InstanceOrPrimitiveOfSchema<Item>[]
: S extends z.core.$ZodTuple<
infer Items extends readonly z.core.$ZodType[]
>
? {
[key in keyof Items]: InstanceOrPrimitiveOfSchema<
Items[key]
>;
}
: S extends z.core.$ZodString
? string
: S extends z.core.$ZodNumber
? number
: S extends z.core.$ZodBoolean
? boolean
: S extends z.core.$ZodLiteral<infer Literal>
? Literal
: S extends z.core.$ZodDate
? Date
: S extends z.core.$ZodEnum<infer Enum>
? Enum[keyof Enum]
: S extends z.core.$ZodTemplateLiteral<
infer pattern
>
? pattern
: S extends z.core.$ZodReadonly<
infer Inner extends z.core.$ZodType
>
? InstanceOrPrimitiveOfSchema<Inner>
: S extends z.core.$ZodDefault<
infer Default extends z.core.$ZodType
>
? InstanceOrPrimitiveOfSchema<Default>
: S extends z.core.$ZodCatch<
infer Catch extends z.core.$ZodType
>
? InstanceOrPrimitiveOfSchema<Catch>
: never
: S extends CoValueClass
? InstanceType<S>
: never;
> = S extends z.core.$ZodType ? TypeOfZodSchema<S> : InstanceOfSchema<S>;

View File

@@ -1,137 +1,13 @@
import { JsonValue } from "cojson";
import {
Account,
AnyZodOrCoValueSchema,
CoDiscriminatedUnionSchema,
CoFeed,
CoList,
CoMap,
CoPlainText,
CoRichText,
CoValueClass,
CoreAccountSchema,
CoreCoRecordSchema,
FileStream,
InstanceOrPrimitiveOfSchema,
Profile,
InstanceOfSchemaCoValuesNullable,
} from "../../../internal.js";
import { CoreCoFeedSchema } from "../schemaTypes/CoFeedSchema.js";
import { CoreCoListSchema } from "../schemaTypes/CoListSchema.js";
import { CoreCoMapSchema } from "../schemaTypes/CoMapSchema.js";
import { CoreCoOptionalSchema } from "../schemaTypes/CoOptionalSchema.js";
import { CoreCoValueSchema } from "../schemaTypes/CoValueSchema.js";
import { CoreFileStreamSchema } from "../schemaTypes/FileStreamSchema.js";
import { CorePlainTextSchema } from "../schemaTypes/PlainTextSchema.js";
import { CoreRichTextSchema } from "../schemaTypes/RichTextSchema.js";
import { z } from "../zodReExport.js";
import { TypeOfZodSchema } from "./TypeOfZodSchema.js";
export type InstanceOrPrimitiveOfSchemaCoValuesNullable<
S extends CoValueClass | AnyZodOrCoValueSchema,
> = S extends CoreCoValueSchema
? S extends CoreAccountSchema<infer Shape>
?
| ({
-readonly [key in keyof Shape]: InstanceOrPrimitiveOfSchemaCoValuesNullable<
Shape[key]
>;
} & { profile: Profile | null } & Account)
| null
: S extends CoreCoRecordSchema<infer K, infer V>
?
| ({
-readonly [key in z.output<K> &
string]: InstanceOrPrimitiveOfSchemaCoValuesNullable<V>;
} & CoMap)
| null
: S extends CoreCoMapSchema<infer Shape, infer CatchAll>
?
| ({
-readonly [key in keyof Shape]: InstanceOrPrimitiveOfSchemaCoValuesNullable<
Shape[key]
>;
} & (CatchAll extends AnyZodOrCoValueSchema
? {
[
key: string
]: InstanceOrPrimitiveOfSchemaCoValuesNullable<CatchAll>;
}
: {}) &
CoMap)
| null
: S extends CoreCoListSchema<infer T>
? CoList<InstanceOrPrimitiveOfSchemaCoValuesNullable<T>> | null
: S extends CoreCoFeedSchema<infer T>
? CoFeed<InstanceOrPrimitiveOfSchemaCoValuesNullable<T>> | null
: S extends CorePlainTextSchema
? CoPlainText | null
: S extends CoreRichTextSchema
? CoRichText | null
: S extends CoreFileStreamSchema
? FileStream | null
: S extends CoreCoOptionalSchema<infer T>
? InstanceOrPrimitiveOfSchemaCoValuesNullable<T> | undefined
: S extends CoDiscriminatedUnionSchema<infer Members>
? InstanceOrPrimitiveOfSchemaCoValuesNullable<
Members[number]
>
: never
: S extends z.core.$ZodType
? S extends z.core.$ZodOptional<infer Inner extends z.core.$ZodType>
? InstanceOrPrimitiveOfSchemaCoValuesNullable<Inner> | undefined
: S extends z.core.$ZodNullable<infer Inner extends z.core.$ZodType>
? InstanceOrPrimitiveOfSchemaCoValuesNullable<Inner> | null
: S extends z.ZodJSONSchema
? JsonValue
: S extends z.core.$ZodUnion<
infer Members extends readonly z.core.$ZodType[]
>
? InstanceOrPrimitiveOfSchemaCoValuesNullable<Members[number]>
: // primitives below here - we manually traverse to ensure we only allow what we can handle
S extends z.core.$ZodObject<infer Shape>
? {
-readonly [key in keyof Shape]: InstanceOrPrimitiveOfSchema<
Shape[key]
>;
}
: S extends z.core.$ZodArray<infer Item extends z.core.$ZodType>
? InstanceOrPrimitiveOfSchema<Item>[]
: S extends z.core.$ZodTuple<
infer Items extends z.core.$ZodType[]
>
? {
[key in keyof Items]: InstanceOrPrimitiveOfSchema<
Items[key]
>;
}
: S extends z.core.$ZodString
? string
: S extends z.core.$ZodNumber
? number
: S extends z.core.$ZodBoolean
? boolean
: S extends z.core.$ZodLiteral<infer Literal>
? Literal
: S extends z.core.$ZodDate
? Date
: S extends z.core.$ZodEnum<infer Enum>
? Enum[keyof Enum]
: S extends z.core.$ZodTemplateLiteral<
infer pattern
>
? pattern
: S extends z.core.$ZodReadonly<
infer Inner extends z.core.$ZodType
>
? InstanceOrPrimitiveOfSchema<Inner>
: S extends z.core.$ZodDefault<
infer Default extends z.core.$ZodType
>
? InstanceOrPrimitiveOfSchema<Default>
: S extends z.core.$ZodCatch<
infer Catch extends z.core.$ZodType
>
? InstanceOrPrimitiveOfSchema<Catch>
: never
: S extends CoValueClass
? InstanceType<S> | null
: never;
> = S extends z.core.$ZodType
? TypeOfZodSchema<S>
: InstanceOfSchemaCoValuesNullable<S>;

View File

@@ -0,0 +1,79 @@
import { JsonValue } from "cojson";
import { PartialOnUndefined } from "../../../internal.js";
import { z } from "../zodReExport.js";
// Copied from https://github.com/colinhacks/zod/blob/7e7e3461aceecf3633e158df50d6bc852e7cdf45/packages/zod/src/v4/core/schemas.ts#L1591,
// since this type is not exported by Zod
type OptionalInSchema = {
_zod: {
optin: "optional";
};
};
/**
* Get type from Zod schema definition.
*
* Similar to `z.infer`, but we manually traverse Zod types to ensure we only allow what we can handle
*/
export type TypeOfZodSchema<S extends z.core.$ZodType> =
S extends z.core.$ZodOptional<infer Inner extends z.core.$ZodType>
? TypeOfZodSchema<Inner> | undefined
: S extends z.core.$ZodNullable<infer Inner extends z.core.$ZodType>
? TypeOfZodSchema<Inner> | null
: S extends z.ZodJSONSchema
? JsonValue
: S extends z.core.$ZodUnion<
infer Members extends readonly z.core.$ZodType[]
>
? TypeOfZodSchema<Members[number]>
: S extends z.core.$ZodObject<infer Shape>
? /**
* Cannot use {@link PartialOnUndefined} because evaluating TypeOfZodSchema<Shape[key]>
* to know if the value can be undefined does not work with recursive types.
*/
{
-readonly [key in keyof Shape as Shape[key] extends OptionalInSchema
? never
: key]: TypeOfZodSchema<Shape[key]>;
} & {
-readonly [key in keyof Shape as Shape[key] extends OptionalInSchema
? key
: never]?: TypeOfZodSchema<Shape[key]>;
}
: S extends z.core.$ZodArray<infer Item extends z.core.$ZodType>
? TypeOfZodSchema<Item>[]
: S extends z.core.$ZodTuple<
infer Items extends readonly z.core.$ZodType[]
>
? {
[key in keyof Items]: TypeOfZodSchema<Items[key]>;
}
: S extends z.core.$ZodString
? string
: S extends z.core.$ZodNumber
? number
: S extends z.core.$ZodBoolean
? boolean
: S extends z.core.$ZodLiteral<infer Literal>
? Literal
: S extends z.core.$ZodDate
? Date
: S extends z.core.$ZodEnum<infer Enum>
? Enum[keyof Enum]
: S extends z.core.$ZodTemplateLiteral<
infer pattern
>
? pattern
: S extends z.core.$ZodReadonly<
infer Inner extends z.core.$ZodType
>
? TypeOfZodSchema<Inner>
: S extends z.core.$ZodDefault<
infer Default extends z.core.$ZodType
>
? TypeOfZodSchema<Default>
: S extends z.core.$ZodCatch<
infer Catch extends z.core.$ZodType
>
? TypeOfZodSchema<Catch>
: never;

View File

@@ -7,6 +7,7 @@ import {
CoreCoMapSchema,
DiscriminableCoValueSchemas,
DiscriminableCoreCoValueSchema,
SchemaUnionDiscriminator,
} from "../../internal.js";
import {
hydrateCoreCoValueSchema,
@@ -56,21 +57,17 @@ export function schemaUnionDiscriminatorFor(
}
}
const determineSchema = (_raw: RawCoMap | RawAccount | RawCoList) => {
if (_raw instanceof RawCoList) {
throw new Error(
"co.discriminatedUnion() of collaborative types is not supported for CoLists",
);
}
const determineSchema: SchemaUnionDiscriminator<CoMap> = (
discriminable,
) => {
for (const option of availableOptions) {
let match = true;
for (const key of Object.keys(discriminatorMap)) {
const discriminatorDef = (option as CoreCoMapSchema).getDefinition()
.shape[key as string];
.shape[key];
const discriminatorValue = (_raw as RawCoMap).get(key as string);
const discriminatorValue = discriminable.get(key);
if (discriminatorValue && typeof discriminatorValue === "object") {
throw new Error("Discriminator must be a primitive value");

View File

@@ -23,11 +23,7 @@ import {
} from "./schemaTypes/CoDiscriminatedUnionSchema.js";
import { CoFeedSchema, CoreCoFeedSchema } from "./schemaTypes/CoFeedSchema.js";
import { CoListSchema, CoreCoListSchema } from "./schemaTypes/CoListSchema.js";
import {
CoMapSchema,
CoMapSchemaInit,
CoreCoMapSchema,
} from "./schemaTypes/CoMapSchema.js";
import { CoMapSchema, CoreCoMapSchema } from "./schemaTypes/CoMapSchema.js";
import {
CoOptionalSchema,
CoreCoOptionalSchema,
@@ -84,8 +80,8 @@ export type CoValueSchemaFromCoreSchema<S extends CoreCoValueSchema> =
export type CoValueClassFromAnySchema<S extends CoValueClassOrSchema> =
S extends CoValueClass<any>
? S
: CoValueClass<InstanceOfSchema<S>> &
CoValueFromRaw<InstanceOfSchema<S>> &
: CoValueClass<NonNullable<InstanceOfSchema<S>>> &
CoValueFromRaw<NonNullable<InstanceOfSchema<S>>> &
(S extends CoreAccountSchema ? AccountClassEssentials : {});
type AccountClassEssentials = {
@@ -105,7 +101,7 @@ export type AnyCoreCoValueSchema =
| CoreRichTextSchema
| CoreFileStreamSchema;
type AnyZodSchema = z.core.$ZodType;
export type AnyZodSchema = z.core.$ZodType;
export type AnyZodOrCoValueSchema = AnyZodSchema | CoreCoValueSchema;
@@ -122,9 +118,3 @@ export type ResolveQueryStrict<
T extends CoValueClassOrSchema,
R extends ResolveQuery<T>,
> = RefsToResolveStrict<NonNullable<InstanceOfSchemaCoValuesNullable<T>>, R>;
export type InitFor<T extends CoValueClassOrSchema> = T extends CoreCoMapSchema<
infer Shape
>
? Simplify<CoMapSchemaInit<Shape>>
: never;

View File

@@ -45,6 +45,7 @@ export * from "./implementation/zodSchema/typeConverters/InstanceOrPrimitiveOfSc
export * from "./implementation/zodSchema/typeConverters/InstanceOrPrimitiveOfSchemaCoValuesNullable.js";
export * from "./implementation/zodSchema/typeConverters/InstanceOfSchema.js";
export * from "./implementation/zodSchema/typeConverters/InstanceOfSchemaCoValuesNullable.js";
export * from "./implementation/zodSchema/typeConverters/CoFieldInit.js";
export * from "./implementation/zodSchema/runtimeConverters/coValueSchemaTransformation.js";
export * from "./implementation/zodSchema/runtimeConverters/schemaFieldToCoFieldDef.js";
export * from "./coValues/extensions/imageDef.js";

View File

@@ -7,7 +7,7 @@ import {
type ID,
type RefEncoded,
type RefsToResolve,
instantiateRefEncoded,
instantiateRefEncodedFromRaw,
isRefEncoded,
} from "../internal.js";
import { applyCoValueMigrations } from "../lib/migration.js";
@@ -75,7 +75,9 @@ export class SubscriptionScope<D extends CoValue> {
}
this.migrating = true;
applyCoValueMigrations(instantiateRefEncoded(this.schema, value));
applyCoValueMigrations(
instantiateRefEncodedFromRaw(this.schema, value),
);
this.migrated = true;
this.handleUpdate(lastUpdate);
return;

View File

@@ -4,7 +4,7 @@ import {
CoValue,
RefEncoded,
coValueClassFromCoValueClassOrSchema,
instantiateRefEncoded,
instantiateRefEncodedFromRaw,
} from "../internal.js";
import { coValuesCache } from "../lib/cache.js";
import { SubscriptionScope } from "./SubscriptionScope.js";
@@ -26,7 +26,7 @@ export function createCoValue<D extends CoValue>(
raw: RawCoValue,
subscriptionScope: SubscriptionScope<D>,
) {
const freshValueInstance = instantiateRefEncoded(ref, raw);
const freshValueInstance = instantiateRefEncodedFromRaw(ref, raw);
Object.defineProperty(freshValueInstance, "_subscriptionScope", {
value: subscriptionScope,

View File

@@ -52,6 +52,46 @@ describe("Simple CoFeed operations", async () => {
expect(stream.perSession[me.sessionID]?.value).toEqual("milk");
});
describe("Create CoFeed with a reference", () => {
let me: Account;
beforeEach(async () => {
await setupJazzTestSync();
me = await createJazzTestAccount({
isCurrentActiveAccount: true,
creationProps: { name: "Hermes Puggington" },
});
});
test("using a CoValue", () => {
const Text = co.plainText();
const TextStream = co.feed(Text);
const stream = TextStream.create([Text.create("milk")], { owner: me });
const coValue = stream.perAccount[me.id]?.value;
expect(coValue?.toString()).toEqual("milk");
});
describe("using JSON", () => {
test("automatically creates CoValues for nested objects", () => {
const Text = co.plainText();
const TextStream = co.feed(Text);
const stream = TextStream.create(["milk"], { owner: me });
const coValue = stream.perAccount[me.id]?.value;
expect(coValue?.toString()).toEqual("milk");
});
test("can create a coPlainText from an empty string", () => {
const Schema = co.feed(co.plainText());
const feed = Schema.create([""]);
expect(feed.perAccount[me.id]?.value?.toString()).toBe("");
});
});
});
test("Construction with nullable values", () => {
const NullableTestStream = co.feed(z.string().nullable());
const stream = NullableTestStream.create(["milk", null], { owner: me });

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