Compare commits
241 Commits
cojson@0.1
...
cojson@0.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c936c8c611 | ||
|
|
58c6013770 | ||
|
|
3eb3291a97 | ||
|
|
6b659f2df3 | ||
|
|
dcc9c9a5ec | ||
|
|
fe9a244363 | ||
|
|
9440bbc058 | ||
|
|
1c92cc2997 | ||
|
|
33ebbf0bdd | ||
|
|
d630b5bde5 | ||
|
|
1c6ae12cd9 | ||
|
|
21bcaabd5a | ||
|
|
17b4d5b668 | ||
|
|
3cd15862d5 | ||
|
|
b3d1ad7201 | ||
|
|
d87df11795 | ||
|
|
82c2a62b2a | ||
|
|
0a9112506e | ||
|
|
fbc29f2f17 | ||
|
|
f6361ee43b | ||
|
|
726dbfb6df | ||
|
|
267f689f10 | ||
|
|
893ad3ae23 | ||
|
|
f5590b1be8 | ||
|
|
17a01f57e8 | ||
|
|
7318d86f52 | ||
|
|
1c8403e87a | ||
|
|
dd747c068a | ||
|
|
1f0f230fe2 | ||
|
|
da655cbff5 | ||
|
|
02f6c6220e | ||
|
|
0755cd198e | ||
|
|
c4a8227b66 | ||
|
|
86f0302233 | ||
|
|
165a6170cd | ||
|
|
5148419df9 | ||
|
|
fc0ecb0968 | ||
|
|
802b5a3060 | ||
|
|
e47af262b3 | ||
|
|
e98b610fd0 | ||
|
|
b554983558 | ||
|
|
d95dcbe7db | ||
|
|
f9d538f049 | ||
|
|
93e68c62f5 | ||
|
|
dadee9dcc5 | ||
|
|
6724c4bd83 | ||
|
|
1942bd5de4 | ||
|
|
16764f6365 | ||
|
|
b56cfc2e1f | ||
|
|
7091bcf9c0 | ||
|
|
436cbfa095 | ||
|
|
104e664bbb | ||
|
|
f199b451eb | ||
|
|
70bc48458e | ||
|
|
f28b2a6135 | ||
|
|
55b770b7c9 | ||
|
|
e6838dfb98 | ||
|
|
5e34061fdc | ||
|
|
acecffaeb2 | ||
|
|
0a98d6aaf2 | ||
|
|
4ea1a63a0a | ||
|
|
41a4c3bc95 | ||
|
|
60d0027f9d | ||
|
|
748c2ff751 | ||
|
|
70938b0ab3 | ||
|
|
f2f5b55dbf | ||
|
|
3c3acae803 | ||
|
|
896ee3460f | ||
|
|
9b9bf44e2b | ||
|
|
392aa88d95 | ||
|
|
7ce82cd934 | ||
|
|
0c8158b91c | ||
|
|
5a48c9c44c | ||
|
|
25c56146f5 | ||
|
|
c564fbb02e | ||
|
|
12481e14c2 | ||
|
|
fd2d247ff5 | ||
|
|
9e9ea029b2 | ||
|
|
a0da272dcd | ||
|
|
72fbcc3262 | ||
|
|
f4c8cc858b | ||
|
|
0ab4d7a20d | ||
|
|
5c98ff4e4f | ||
|
|
4cbda689c4 | ||
|
|
771b0ed914 | ||
|
|
79913c3136 | ||
|
|
43d3511d15 | ||
|
|
928ef14086 | ||
|
|
048dd7def0 | ||
|
|
51fcb8a44b | ||
|
|
c5888c39f5 | ||
|
|
2defcfae67 | ||
|
|
873b146d15 | ||
|
|
213de11c3b | ||
|
|
1b881cc89f | ||
|
|
af295d816a | ||
|
|
fe8d3497c0 | ||
|
|
c2899e94ca | ||
|
|
f4be67e9b6 | ||
|
|
ba9ad295b6 | ||
|
|
9ed5a96ef8 | ||
|
|
4272ea9019 | ||
|
|
9509307ed1 | ||
|
|
be08921bc5 | ||
|
|
ab1798c7bd | ||
|
|
26ae69a242 | ||
|
|
25be055a51 | ||
|
|
21ad3767b9 | ||
|
|
a9383516c1 | ||
|
|
bffc516c68 | ||
|
|
9e7c0d9887 | ||
|
|
99b44d5780 | ||
|
|
02db5f3b1d | ||
|
|
1949a5fcd9 | ||
|
|
dcd3b022cc | ||
|
|
a7b837c7e1 | ||
|
|
88ebcf58ab | ||
|
|
b173e0884a | ||
|
|
f379a168be | ||
|
|
bde6ac7d45 | ||
|
|
231947c97a | ||
|
|
d1609cdd55 | ||
|
|
d5b57ad1fc | ||
|
|
b71ab3168a | ||
|
|
0c8f6e5039 | ||
|
|
0bf5c53bec | ||
|
|
e7b1550003 | ||
|
|
6a93a1b8a3 | ||
|
|
9f654a2603 | ||
|
|
dbf735d9e1 | ||
|
|
c62abefb66 | ||
|
|
1453869a46 | ||
|
|
239da90c9f | ||
|
|
972791e7a8 | ||
|
|
0c0178764e | ||
|
|
928350b821 | ||
|
|
be3fd9c696 | ||
|
|
269c028df0 | ||
|
|
e4df837138 | ||
|
|
54fe6d93ba | ||
|
|
979689c6d8 | ||
|
|
859a37868f | ||
|
|
57bd32d77e | ||
|
|
e21cbccd4b | ||
|
|
a66ab7d174 | ||
|
|
78e91f4030 | ||
|
|
7a915c198e | ||
|
|
c9b0420746 | ||
|
|
2303f3e70a | ||
|
|
a7bc9569a3 | ||
|
|
f351ba0fcd | ||
|
|
d3e554f491 | ||
|
|
b5e31456ad | ||
|
|
42d07ba7b4 | ||
|
|
b81b6ba69b | ||
|
|
1bc1759bb4 | ||
|
|
512aacdbc2 | ||
|
|
7ad843aa3e | ||
|
|
fc027a56db | ||
|
|
959a7a3927 | ||
|
|
2548085b59 | ||
|
|
b27bb3e65b | ||
|
|
d1efde468f | ||
|
|
2b61e853a7 | ||
|
|
6f79b45544 | ||
|
|
2e1ff99579 | ||
|
|
ac782674de | ||
|
|
5eb406d54d | ||
|
|
a3be832414 | ||
|
|
7ca8dd960e | ||
|
|
62c8aff73f | ||
|
|
7731109a28 | ||
|
|
dfc4286694 | ||
|
|
970ff0d813 | ||
|
|
eee221f563 | ||
|
|
2283d375ef | ||
|
|
202e763380 | ||
|
|
52bbdb37a9 | ||
|
|
f5c47feeb6 | ||
|
|
b8b0851433 | ||
|
|
2bbb07b0bf | ||
|
|
d3053955d8 | ||
|
|
f40484eca9 | ||
|
|
d581a59aa1 | ||
|
|
0ca09f75c1 | ||
|
|
e8fcd101f2 | ||
|
|
cf43fa7529 | ||
|
|
df1cdda4e8 | ||
|
|
7a60d7bb76 | ||
|
|
f8263a8358 | ||
|
|
f6da966922 | ||
|
|
8a2ab51543 | ||
|
|
466e6c44ee | ||
|
|
5bd8277161 | ||
|
|
0ec917e453 | ||
|
|
6326d0fc45 | ||
|
|
d746b1279a | ||
|
|
c09dcdfc76 | ||
|
|
4402c553b6 | ||
|
|
e76fe343da | ||
|
|
a2626a0f38 | ||
|
|
ec579bcaf7 | ||
|
|
6b662b0efe | ||
|
|
e9af90c841 | ||
|
|
2b7c6f5aa7 | ||
|
|
d73a3d9d46 | ||
|
|
8af39077a3 | ||
|
|
54bd487818 | ||
|
|
a8b3ec7bb0 | ||
|
|
a420b43029 | ||
|
|
a57268de32 | ||
|
|
6b2c4ed280 | ||
|
|
8d4e0027be | ||
|
|
a4141da1b7 | ||
|
|
c9ca5202f9 | ||
|
|
7b50a2e06d | ||
|
|
43dabccb57 | ||
|
|
b6d04f56ef | ||
|
|
628195b678 | ||
|
|
9a5d769717 | ||
|
|
e30a3f66bf | ||
|
|
6327fce933 | ||
|
|
a650da4184 | ||
|
|
6e4a94f6ce | ||
|
|
b73bec64bc | ||
|
|
50ae2f47c2 | ||
|
|
724d8e7f30 | ||
|
|
7b285ab110 | ||
|
|
01ac9b8c4c | ||
|
|
4e2e1ac73e | ||
|
|
94960c1f65 | ||
|
|
b5af58347b | ||
|
|
46a84558c5 | ||
|
|
f93566c045 | ||
|
|
d97ed603a3 | ||
|
|
8d33103182 | ||
|
|
aaa1ff978b | ||
|
|
82655ea7a7 | ||
|
|
8afe3a2e02 | ||
|
|
ae2adcbd15 | ||
|
|
eb0460d330 |
@@ -1,5 +1,57 @@
|
||||
# passkey-svelte
|
||||
|
||||
## 0.0.111
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3cd1586]
|
||||
- Updated dependencies [33ebbf0]
|
||||
- jazz-tools@0.16.5
|
||||
|
||||
## 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
|
||||
|
||||
- jazz-tools@0.16.2
|
||||
|
||||
## 0.0.107
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [c62abef]
|
||||
- jazz-tools@0.16.1
|
||||
|
||||
## 0.0.106
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 2bbb07b: Introduce a cleaner separation between Zod and CoValue schemas:
|
||||
- Zod schemas and CoValue schemas are fully separated. Zod schemas can only be composed with other Zod schemas. CoValue schemas can be composed with either Zod or other CoValue schemas.
|
||||
- `z.optional()` and `z.discriminatedUnion()` no longer work with CoValue schemas. Use `co.optional()` and `co.discriminatedUnion()` instead.
|
||||
- Internal schema access is now simpler. You no longer need to use Zod’s `.def` to access internals. Use properties like `CoMapSchema.shape`, `CoListSchema.element`, and `CoOptionalSchema.innerType` directly.
|
||||
- CoValue schema types are now namespaced under `co.`. Non-namespaced exports have been removed
|
||||
- CoMap schemas no longer incorrectly inherit from Zod. Previously, methods like `.extend()` and `.partial()` appeared available but could cause unexpected behavior. These methods are now disabled. In their place, `.optional()` has been added, and more Zod-like methods will be introduced in future releases.
|
||||
- Upgraded Zod from `3.25.28` to `3.25.76`.
|
||||
- Removed deprecated `withHelpers` method from CoValue schemas
|
||||
- Removed deprecated `createCoValueObservable` function
|
||||
- Updated dependencies [c09dcdf]
|
||||
- Updated dependencies [2bbb07b]
|
||||
- jazz-tools@0.16.0
|
||||
|
||||
## 0.0.105
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "chat-svelte",
|
||||
"version": "0.0.105",
|
||||
"version": "0.0.111",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { co, z } from 'jazz-tools';
|
||||
import { co } from 'jazz-tools';
|
||||
|
||||
export const Message = co.map({
|
||||
text: co.plainText(),
|
||||
image: z.optional(co.image())
|
||||
image: co.optional(co.image())
|
||||
});
|
||||
|
||||
export const Chat = co.list(Message);
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"lucide-react": "^0.274.0",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"zod": "3.25.28"
|
||||
"zod": "3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.50.1",
|
||||
|
||||
@@ -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,7 @@ import {
|
||||
BubbleTeaOrder,
|
||||
DraftBubbleTeaOrder,
|
||||
JazzAccount,
|
||||
ListOfBubbleTeaAddOns,
|
||||
validateDraftOrder,
|
||||
} from "./schema.ts";
|
||||
|
||||
export function CreateOrder() {
|
||||
@@ -21,20 +20,19 @@ export function CreateOrder() {
|
||||
|
||||
if (!me?.root) return;
|
||||
|
||||
const onSave = (draft: Loaded<typeof DraftBubbleTeaOrder>) => {
|
||||
// validate if the draft is a valid order
|
||||
const validation = DraftBubbleTeaOrder.validate(draft);
|
||||
const onSave = (draft: DraftBubbleTeaOrder) => {
|
||||
const validation = validateDraftOrder(draft);
|
||||
setErrors(validation.errors);
|
||||
if (validation.errors.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 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 },
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useAccount } from "jazz-tools/react";
|
||||
import { DraftBubbleTeaOrder, JazzAccount } from "./schema";
|
||||
import { JazzAccount, hasChanges } from "./schema";
|
||||
export function DraftIndicator() {
|
||||
const { me } = useAccount(JazzAccount, {
|
||||
resolve: { root: { draft: true } },
|
||||
});
|
||||
|
||||
if (DraftBubbleTeaOrder.hasChanges(me?.root.draft)) {
|
||||
if (hasChanges(me?.root.draft)) {
|
||||
return (
|
||||
<div className="absolute -top-1 -right-1 bg-blue-500 border-2 border-white w-3 h-3 rounded-full dark:border-stone-925">
|
||||
<span className="sr-only">You have a draft</span>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Loaded, co, z } from "jazz-tools";
|
||||
import { co, z } from "jazz-tools";
|
||||
|
||||
export const BubbleTeaAddOnTypes = [
|
||||
"Pearl",
|
||||
@@ -15,13 +15,14 @@ export const BubbleTeaBaseTeaTypes = [
|
||||
"Thai",
|
||||
] as const;
|
||||
|
||||
export const ListOfBubbleTeaAddOns = co
|
||||
.list(z.literal([...BubbleTeaAddOnTypes]))
|
||||
.withHelpers((Self) => ({
|
||||
hasChanges(list?: Loaded<typeof Self> | null) {
|
||||
return list && Object.entries(list._raw.insertions).length > 0;
|
||||
},
|
||||
}));
|
||||
export const ListOfBubbleTeaAddOns = co.list(
|
||||
z.literal([...BubbleTeaAddOnTypes]),
|
||||
);
|
||||
export type ListOfBubbleTeaAddOns = co.loaded<typeof ListOfBubbleTeaAddOns>;
|
||||
|
||||
function hasAddOnsChanges(list?: ListOfBubbleTeaAddOns | null) {
|
||||
return list && Object.entries(list._raw.insertions).length > 0;
|
||||
}
|
||||
|
||||
export const BubbleTeaOrder = co.map({
|
||||
baseTea: z.literal([...BubbleTeaBaseTeaTypes]),
|
||||
@@ -30,37 +31,30 @@ 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()),
|
||||
})
|
||||
.withHelpers((Self) => ({
|
||||
hasChanges(order: Loaded<typeof Self> | undefined) {
|
||||
return (
|
||||
!!order &&
|
||||
(Object.keys(order._edits).length > 1 ||
|
||||
ListOfBubbleTeaAddOns.hasChanges(order.addOns))
|
||||
);
|
||||
},
|
||||
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
|
||||
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
|
||||
|
||||
validate(order: Loaded<typeof Self>) {
|
||||
const errors: string[] = [];
|
||||
export function validateDraftOrder(order: DraftBubbleTeaOrder) {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!order.baseTea) {
|
||||
errors.push("Please select your preferred base tea.");
|
||||
}
|
||||
if (!order.deliveryDate) {
|
||||
errors.push("Plese select a delivery date.");
|
||||
}
|
||||
if (!order.baseTea) {
|
||||
errors.push("Please select your preferred base tea.");
|
||||
}
|
||||
if (!order.deliveryDate) {
|
||||
errors.push("Plese select a delivery date.");
|
||||
}
|
||||
|
||||
return { errors };
|
||||
},
|
||||
}));
|
||||
return { errors };
|
||||
}
|
||||
|
||||
export function hasChanges(order?: DraftBubbleTeaOrder | null) {
|
||||
return (
|
||||
!!order &&
|
||||
(Object.keys(order._edits).length > 1 || hasAddOnsChanges(order.addOns))
|
||||
);
|
||||
}
|
||||
|
||||
/** The root is an app-specific per-user private `CoMap`
|
||||
* where you can store top-level objects for that user */
|
||||
@@ -76,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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -11,9 +11,9 @@ export const Issue = co.map({
|
||||
status: z.enum(["open", "closed"]),
|
||||
labels: co.list(z.string()),
|
||||
reactions: ReactionsList,
|
||||
file: z.optional(co.fileStream()),
|
||||
image: z.optional(co.image()),
|
||||
lead: z.optional(co.account()),
|
||||
file: co.optional(co.fileStream()),
|
||||
image: co.optional(co.image()),
|
||||
lead: co.optional(co.account()),
|
||||
});
|
||||
|
||||
export const Project = co.map({
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"jazz-tools": "workspace:*",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"zod": "3.25.28"
|
||||
"zod": "3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.9.4",
|
||||
@@ -31,4 +31,4 @@
|
||||
"vite": "^6.3.5",
|
||||
"vitest": "3.1.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
@@ -129,7 +116,7 @@ export async function removeTrackFromPlaylist(
|
||||
|
||||
if (track._owner._type === "Group" && playlist._owner._type === "Group") {
|
||||
const trackGroup = track._owner;
|
||||
await trackGroup.removeMember(playlist._owner);
|
||||
trackGroup.removeMember(playlist._owner);
|
||||
|
||||
const index =
|
||||
playlist.tracks?.findIndex(
|
||||
|
||||
59
examples/music-player/src/components/ConfirmDialog.tsx
Normal file
59
examples/music-player/src/components/ConfirmDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
108
examples/music-player/src/components/RenameTrackDialog.tsx
Normal file
108
examples/music-player/src/components/RenameTrackDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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%);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
JazzAccount,
|
||||
Organization,
|
||||
Project,
|
||||
validateDraftOrganization,
|
||||
} from "../schema.ts";
|
||||
import { Errors } from "./Errors.tsx";
|
||||
import { OrganizationForm } from "./OrganizationForm.tsx";
|
||||
@@ -21,8 +22,7 @@ export function CreateOrganization() {
|
||||
if (!me?.root?.organizations) return;
|
||||
|
||||
const onSave = (draft: Loaded<typeof DraftOrganization>) => {
|
||||
// validate if the draft is a valid organization
|
||||
const validation = DraftOrganization.validate(draft);
|
||||
const validation = validateDraftOrganization(draft);
|
||||
setErrors(validation.errors);
|
||||
if (validation.errors.length > 0) {
|
||||
return;
|
||||
|
||||
@@ -10,24 +10,24 @@ export const Organization = co.map({
|
||||
projects: co.list(Project),
|
||||
});
|
||||
|
||||
export const DraftOrganization = co
|
||||
.map({
|
||||
name: z.optional(z.string()),
|
||||
projects: co.list(Project),
|
||||
})
|
||||
.withHelpers((Self) => ({
|
||||
validate(org: Loaded<typeof Self>) {
|
||||
const errors: string[] = [];
|
||||
export const DraftOrganization = co.map({
|
||||
name: z.optional(z.string()),
|
||||
projects: co.list(Project),
|
||||
});
|
||||
|
||||
if (!org.name) {
|
||||
errors.push("Please enter a name.");
|
||||
}
|
||||
export function validateDraftOrganization(
|
||||
org: Loaded<typeof DraftOrganization>,
|
||||
) {
|
||||
const errors: string[] = [];
|
||||
|
||||
return {
|
||||
errors,
|
||||
};
|
||||
},
|
||||
}));
|
||||
if (!org.name) {
|
||||
errors.push("Please enter a name.");
|
||||
}
|
||||
|
||||
return {
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
export const JazzAccountRoot = co.map({
|
||||
organizations: co.list(Organization),
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
const nextConfig: NextConfig = {};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { jazzServerAccount } from "@/jazzServerAccount";
|
||||
import { Game, Player, PlayerState, createGameState } from "@/schema";
|
||||
import { Game, createGameState } from "@/schema";
|
||||
import { serverApi } from "@/serverApi";
|
||||
import { Account, Group, JazzRequestError } from "jazz-tools";
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ export type Game = co.loaded<typeof Game>;
|
||||
|
||||
export const WaitingRoom = co.map({
|
||||
creator: co.account(),
|
||||
game: z.optional(Game),
|
||||
game: co.optional(Game),
|
||||
});
|
||||
export type WaitingRoom = co.loaded<typeof WaitingRoom>;
|
||||
|
||||
|
||||
@@ -41,7 +41,9 @@ export function Footer({
|
||||
</Link>
|
||||
</div>
|
||||
<p className="col-span-full sm:col-span-6 md:col-span-4 text-sm sm:text-base">
|
||||
Playful software for serious problems.
|
||||
Computers are magic.
|
||||
<br />
|
||||
Time to make them less complex.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-y-8 grid-cols-12">
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ChatDemoSection } from "@/components/home/ChatDemoSection";
|
||||
import { CollaborationFeaturesSection } from "@/components/home/CollaborationFeaturesSection";
|
||||
import { ComingSoonSection } from "@/components/home/ComingSoonSection";
|
||||
import { EarlyAdopterSection } from "@/components/home/EarlyAdopterSection";
|
||||
import { EncryptionSection } from "@/components/home/EncryptionSection";
|
||||
import { FeaturesSection } from "@/components/home/FeaturesSection";
|
||||
@@ -15,9 +14,9 @@ import { Testimonial } from "@garden-co/design-system/src/components/molecules/T
|
||||
export default function Home() {
|
||||
return (
|
||||
<>
|
||||
<HeroSection />
|
||||
|
||||
<HeroSection />
|
||||
<div className="container flex flex-col gap-12 lg:gap-20">
|
||||
|
||||
<GetStartedSnippetSelect />
|
||||
<SupportedEnvironmentsSection />
|
||||
<HowJazzWorksSection />
|
||||
@@ -54,8 +53,6 @@ export default function Home() {
|
||||
|
||||
<FeaturesSection />
|
||||
|
||||
<ComingSoonSection />
|
||||
|
||||
<EarlyAdopterSection />
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import CoPlainTextDescription from "@/app/(others)/(home)/coValueDescriptions/coPlainTextDescription.mdx";
|
||||
import CursorsAndCaretsDescription from "@/app/(others)/(home)/toolkit/cursorsAndCarets.mdx";
|
||||
import TwoWaySyncDescription from "@/app/(others)/(home)/toolkit/twoWaySync.mdx";
|
||||
import VideoPresenceCallsDescription from "@/app/(others)/(home)/toolkit/videoPresenceCalls.mdx";
|
||||
import { CodeRef } from "@garden-co/design-system/src/components/atoms/CodeRef";
|
||||
import { P } from "@garden-co/design-system/src/components/atoms/Paragraph";
|
||||
import { FeatureCard } from "@garden-co/design-system/src/components/molecules/FeatureCard";
|
||||
import { GappedGrid } from "@garden-co/design-system/src/components/molecules/GappedGrid";
|
||||
import { Prose } from "@garden-co/design-system/src/components/molecules/Prose";
|
||||
import { SectionHeader } from "@garden-co/design-system/src/components/molecules/SectionHeader";
|
||||
|
||||
export function ComingSoonSection() {
|
||||
return (
|
||||
<div>
|
||||
<SectionHeader title="More features coming soon" />
|
||||
|
||||
<GappedGrid cols={4}>
|
||||
<FeatureCard className="p-4" label={<h3>Cursors & carets</h3>}>
|
||||
<P>Ready-made spatial presence.</P>
|
||||
<Prose size="sm">
|
||||
<CursorsAndCaretsDescription />
|
||||
</Prose>
|
||||
</FeatureCard>
|
||||
|
||||
<FeatureCard className="p-4" label={<h3>Two-way sync to your DB</h3>}>
|
||||
<P>Add Jazz to an existing app.</P>
|
||||
<Prose size="sm">
|
||||
<TwoWaySyncDescription />
|
||||
</Prose>
|
||||
</FeatureCard>
|
||||
|
||||
<FeatureCard className="p-4" label={<h3>Video presence & calls</h3>}>
|
||||
<P>Stream and record audio & video.</P>
|
||||
<Prose size="sm">
|
||||
<VideoPresenceCallsDescription />
|
||||
</Prose>
|
||||
</FeatureCard>
|
||||
|
||||
<FeatureCard
|
||||
className="p-4"
|
||||
label={
|
||||
<h3>
|
||||
<CodeRef>CoPlainText</CodeRef> & <CodeRef>CoRichText</CodeRef>
|
||||
</h3>
|
||||
}
|
||||
>
|
||||
<Prose size="sm">
|
||||
<CoPlainTextDescription />
|
||||
</Prose>
|
||||
</FeatureCard>
|
||||
</GappedGrid>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,81 +16,70 @@ const features: Array<{
|
||||
title: string;
|
||||
icon: IconName;
|
||||
}> = [
|
||||
{
|
||||
title: "Instant updates",
|
||||
icon: "instant",
|
||||
},
|
||||
{
|
||||
title: "Real-time sync",
|
||||
icon: "devices",
|
||||
},
|
||||
{
|
||||
title: "Multiplayer",
|
||||
icon: "spatialPresence",
|
||||
},
|
||||
{
|
||||
title: "File uploads",
|
||||
icon: "upload",
|
||||
},
|
||||
{
|
||||
title: "Social features",
|
||||
icon: "social",
|
||||
},
|
||||
{
|
||||
title: "Permissions",
|
||||
icon: "permissions",
|
||||
},
|
||||
{
|
||||
title: "E2E encryption",
|
||||
icon: "encryption",
|
||||
},
|
||||
{
|
||||
title: "Authentication",
|
||||
icon: "auth",
|
||||
},
|
||||
];
|
||||
{
|
||||
title: "Instant updates",
|
||||
icon: "instant",
|
||||
},
|
||||
{
|
||||
title: "Real-time sync",
|
||||
icon: "devices",
|
||||
},
|
||||
{
|
||||
title: "Multiplayer",
|
||||
icon: "spatialPresence",
|
||||
},
|
||||
{
|
||||
title: "File uploads",
|
||||
icon: "upload",
|
||||
},
|
||||
{
|
||||
title: "Social features",
|
||||
icon: "social",
|
||||
},
|
||||
{
|
||||
title: "Permissions",
|
||||
icon: "permissions",
|
||||
},
|
||||
{
|
||||
title: "E2E encryption",
|
||||
icon: "encryption",
|
||||
},
|
||||
{
|
||||
title: "Authentication",
|
||||
icon: "auth",
|
||||
},
|
||||
];
|
||||
|
||||
export function HeroSection() {
|
||||
return (
|
||||
<div className="container grid items-center gap-x-8 gap-y-12 my-12 md:my-16 lg:my-24 lg:gap-x-10 lg:grid-cols-12">
|
||||
<div className="container grid items-center gap-x-8 gap-y-12 mt-12 md:mt-16 lg:mt-24 mb-12 lg:gap-x-10 lg:grid-cols-12">
|
||||
<div className="flex flex-col justify-center gap-5 lg:col-span-11 lg:gap-8">
|
||||
<Kicker>Toolkit for backendless apps</Kicker>
|
||||
<Kicker>Reactive, distributed, secure</Kicker>
|
||||
<H1>
|
||||
<span className="inline-block text-highlight">
|
||||
{marketingCopy.headline}
|
||||
</span>
|
||||
</H1>
|
||||
|
||||
<Prose size="lg" className="text-pretty max-w-2xl dark:text-stone-200">
|
||||
<Prose size="lg" className="text-pretty max-w-2xl dark:text-stone-200 prose-p:leading-normal">
|
||||
<p>
|
||||
Jazz gives you data without needing a database — plus auth,
|
||||
permissions, files and multiplayer without needing a backend.
|
||||
Jazz is a new kind of database that's distributed across your frontend, containers, serverless functions and its own storage cloud.
|
||||
</p>
|
||||
<p>It syncs structured data, files and LLM streams instantly.<br/>It looks like local reactive JSON state.</p>
|
||||
<p>And you get auth, orgs & teams, real-time multiplayer, edit histories, permissions, E2E encryption and offline-support out of the box.</p>
|
||||
<p>
|
||||
Do everything right from the frontend and ship better apps, faster.
|
||||
This lets you get rid of 90% of the traditional backend, and most of your frontend state juggling.
|
||||
You'll ship better apps, faster.
|
||||
</p>
|
||||
<p>
|
||||
Open source. Self-host or use{" "}
|
||||
<p className="text-base">
|
||||
Self-host or use{" "}
|
||||
<Link className="text-reset" href="/cloud">
|
||||
Jazz Cloud
|
||||
</Link>{" "}
|
||||
for zero-config magic.
|
||||
for a zero-deploy globally-scaled DB.
|
||||
<br/>Open source (MIT)
|
||||
</p>
|
||||
</Prose>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 max-w-3xl sm:grid-cols-4 sm:gap-4">
|
||||
{features.map(({ title, icon }) => (
|
||||
<div
|
||||
key={title}
|
||||
className="flex text-xs sm:text-sm gap-2 items-center"
|
||||
>
|
||||
<span className="p-1.5 rounded-lg bg-primary-transparent">
|
||||
<Icon size="xs" name={icon} intent="primary" />
|
||||
</span>
|
||||
<p>{title}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -83,7 +83,7 @@ export function HowJazzWorksSection() {
|
||||
<div className="grid gap-3">
|
||||
<Kicker>How it works</Kicker>
|
||||
|
||||
<H2>Build entire apps using only client-side code</H2>
|
||||
<H2>Build entire apps with collaborative state</H2>
|
||||
</div>
|
||||
<GappedGrid>
|
||||
<Step
|
||||
|
||||
@@ -53,7 +53,7 @@ export function LocalFirstFeaturesSection() {
|
||||
return (
|
||||
<div>
|
||||
<SectionHeader
|
||||
title="The best of all worlds"
|
||||
title="Local-first state with global sync"
|
||||
slogan={
|
||||
<>
|
||||
<p>
|
||||
|
||||
@@ -9,7 +9,7 @@ export default function ProblemStatementSection() {
|
||||
<div className="grid gap-4 lg:gap-8">
|
||||
<SectionHeader
|
||||
className="sm:text-center sm:mx-auto"
|
||||
title={"Powered by the first “flat stack”"}
|
||||
title={"A database that does what's actually needed"}
|
||||
slogan="A perspective shift worth 10,000 hours"
|
||||
/>
|
||||
|
||||
@@ -41,8 +41,7 @@ export default function ProblemStatementSection() {
|
||||
<Prose>
|
||||
<p>
|
||||
For each new app you tackle a{" "}
|
||||
<strong>mess of moving parts and infra worries.</strong> Or, you
|
||||
haven't even tried because "you're not full-stack".
|
||||
<strong>mess of moving parts and infra worries.</strong> Your backend is responsible for shuffling data around in a myriad of ways.
|
||||
</p>
|
||||
<p>
|
||||
Want to build a <strong>modern app</strong> with multiplayer or
|
||||
@@ -68,7 +67,7 @@ export default function ProblemStatementSection() {
|
||||
<strong>With users & permissions built-in.</strong>
|
||||
</p>
|
||||
<p>
|
||||
With completely <strong>app-independent infra,</strong> you get to
|
||||
With a <strong>DB and infra made for modern apps</strong> you get to
|
||||
focus on <strong>building the app your users want.</strong> You'll
|
||||
notice that <strong>90% of the work is now the UI.</strong>
|
||||
</p>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BunLogo } from "@/components/icons/BunLogo";
|
||||
import { CloudflareWorkerLogo } from "@/components/icons/CloudflareWorkerLogo";
|
||||
import { VercelLogo } from "@/components/icons/VercelLogo";
|
||||
import { ExpoLogo } from "@/components/icons/ExpoLogo";
|
||||
import { JavascriptLogo } from "@/components/icons/JavascriptLogo";
|
||||
import { NodejsLogo } from "@/components/icons/NodejsLogo";
|
||||
@@ -44,14 +45,18 @@ const serverWorkers = [
|
||||
icon: NodejsLogo,
|
||||
href: "/docs/react/server-workers",
|
||||
},
|
||||
{
|
||||
name: "Cloudflare Workers",
|
||||
icon: CloudflareWorkerLogo,
|
||||
},
|
||||
{
|
||||
name: "Bun",
|
||||
icon: BunLogo,
|
||||
},
|
||||
{
|
||||
name: "Vercel",
|
||||
icon: VercelLogo,
|
||||
},
|
||||
{
|
||||
name: "CF Workers",
|
||||
icon: CloudflareWorkerLogo,
|
||||
}
|
||||
];
|
||||
|
||||
export function SupportedEnvironmentsSection() {
|
||||
|
||||
16
homepage/homepage/components/icons/VercelLogo.tsx
Normal file
16
homepage/homepage/components/icons/VercelLogo.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import React from "react";
|
||||
import type { SVGProps } from "react";
|
||||
|
||||
export function VercelLogo(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
width="1.5em"
|
||||
height="1.5em"
|
||||
viewBox="0 0 76 65"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M37.5274 0L75.0548 65H0L37.5274 0Z" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -54,7 +54,7 @@ function AuthStateIndicator() {
|
||||
const isGuest = agent._type !== "Account"
|
||||
|
||||
// Anonymous authentication: has an account but not fully authenticated
|
||||
const isAnonymous = agent._type === "Account" && !isAuthenticated;
|
||||
const isAnonymous = agent._type === "Account" && !isAuthenticated;
|
||||
return (
|
||||
<div>
|
||||
{isGuest && <span>Guest Mode</span>}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -244,26 +244,30 @@ export function CreateOrder() {
|
||||
|
||||
In a `BubbleTeaOrder`, the `name` field is required, so it would be a good idea to validate this before turning the draft into a real order.
|
||||
|
||||
Update the schema to include a `validate` helper.
|
||||
Update the schema to include a `validateDraftOrder` helper.
|
||||
|
||||
<CodeGroup>
|
||||
```ts twoslash
|
||||
import { co, z } from "jazz-tools";
|
||||
// ---cut---
|
||||
// schema.ts
|
||||
export const DraftBubbleTeaOrder = co.map({
|
||||
name: z.optional(z.string()),
|
||||
}).withHelpers((Self) => ({ // [!code ++:11]
|
||||
validate(draft: co.loaded<typeof Self>) {
|
||||
const errors: string[] = [];
|
||||
export const BubbleTeaOrder = co.map({
|
||||
name: z.string(),
|
||||
});
|
||||
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
|
||||
|
||||
if (!draft.name) {
|
||||
errors.push("Please enter a name.");
|
||||
}
|
||||
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
|
||||
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
|
||||
|
||||
return { errors };
|
||||
},
|
||||
}));
|
||||
export function validateDraftOrder(draft: DraftBubbleTeaOrder) { // [!code ++:9]
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!draft.name) {
|
||||
errors.push("Please enter a name.");
|
||||
}
|
||||
|
||||
return { errors };
|
||||
};
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
@@ -279,20 +283,20 @@ 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()),
|
||||
}).withHelpers((Self) => ({
|
||||
validate(draft: co.loaded<typeof Self>) {
|
||||
const errors: string[] = [];
|
||||
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
|
||||
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
|
||||
|
||||
if (!draft.name) {
|
||||
errors.push("Please enter a name.");
|
||||
}
|
||||
export function validateDraftOrder(draft: DraftBubbleTeaOrder) {
|
||||
const errors: string[] = [];
|
||||
|
||||
return { errors };
|
||||
},
|
||||
}));
|
||||
if (!draft.name) {
|
||||
errors.push("Please enter a name.");
|
||||
}
|
||||
|
||||
return { errors };
|
||||
};
|
||||
|
||||
export const AccountRoot = co.map({
|
||||
draft: DraftBubbleTeaOrder,
|
||||
@@ -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({}));
|
||||
@@ -340,13 +344,13 @@ export function CreateOrder() {
|
||||
e.preventDefault();
|
||||
if (!draft) return;
|
||||
|
||||
const validation = DraftBubbleTeaOrder.validate(draft); // [!code ++:5]
|
||||
const validation = validateDraftOrder(draft); // [!code ++:5]
|
||||
if (validation.errors.length > 0) {
|
||||
console.log(validation.errors);
|
||||
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,20 +456,20 @@ 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()),
|
||||
}).withHelpers((Self) => ({
|
||||
validate(draft: co.loaded<typeof Self>) {
|
||||
const errors: string[] = [];
|
||||
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
|
||||
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
|
||||
|
||||
if (!draft.name) {
|
||||
errors.push("Please enter a name.");
|
||||
}
|
||||
export function validateDraftOrder(draft: DraftBubbleTeaOrder) {
|
||||
const errors: string[] = [];
|
||||
|
||||
return { errors };
|
||||
},
|
||||
}));
|
||||
if (!draft.name) {
|
||||
errors.push("Please enter a name.");
|
||||
}
|
||||
|
||||
return { errors };
|
||||
};
|
||||
|
||||
export const AccountRoot = co.map({
|
||||
draft: DraftBubbleTeaOrder,
|
||||
@@ -485,14 +489,14 @@ export const JazzAccount = co.account({
|
||||
// @filename: CreateOrder.tsx
|
||||
import * as React from "react";
|
||||
import { useCoState, useAccount } from "jazz-tools/react";
|
||||
import { BubbleTeaOrder, DraftBubbleTeaOrder, JazzAccount } from "schema";
|
||||
import { BubbleTeaOrder, DraftBubbleTeaOrder, JazzAccount, validateDraftOrder } from "schema";
|
||||
import { co } from "jazz-tools";
|
||||
|
||||
export function OrderForm({
|
||||
order,
|
||||
onSave,
|
||||
}: {
|
||||
order: co.loaded<typeof BubbleTeaOrder> | co.loaded<typeof DraftBubbleTeaOrder>;
|
||||
order: BubbleTeaOrder | DraftBubbleTeaOrder;
|
||||
onSave?: (e: React.FormEvent<HTMLFormElement>) => void;
|
||||
}) {
|
||||
return (
|
||||
@@ -527,13 +531,13 @@ export function CreateOrder() {
|
||||
const draft = me.root.draft; // [!code ++:2]
|
||||
if (!draft) return;
|
||||
|
||||
const validation = DraftBubbleTeaOrder.validate(draft);
|
||||
const validation = validateDraftOrder(draft);
|
||||
if (validation.errors.length > 0) {
|
||||
console.log(validation.errors);
|
||||
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,23 +581,27 @@ 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()),
|
||||
}).withHelpers((Self) => ({
|
||||
validate(draft: co.loaded<typeof Self>) {
|
||||
const errors: string[] = [];
|
||||
export const BubbleTeaOrder = co.map({
|
||||
name: z.string(),
|
||||
});
|
||||
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
|
||||
|
||||
if (!draft.name) {
|
||||
errors.push("Plese enter a name.");
|
||||
}
|
||||
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
|
||||
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
|
||||
|
||||
return { errors };
|
||||
},
|
||||
export function validateDraftOrder(draft: DraftBubbleTeaOrder) {
|
||||
const errors: string[] = [];
|
||||
|
||||
hasChanges(draft?: co.loaded<typeof Self>) { // [!code ++:3]
|
||||
return draft ? Object.keys(draft._edits).length : false;
|
||||
},
|
||||
}));
|
||||
if (!draft.name) {
|
||||
errors.push("Please enter a name.");
|
||||
}
|
||||
|
||||
return { errors };
|
||||
};
|
||||
|
||||
export function hasChanges(draft?: DraftBubbleTeaOrder) { // [!code ++:3]
|
||||
return draft ? Object.keys(draft._edits).length : false;
|
||||
};
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
@@ -608,24 +616,24 @@ 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()),
|
||||
}).withHelpers((Self) => ({
|
||||
validate(draft: co.loaded<typeof Self>) {
|
||||
const errors: string[] = [];
|
||||
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
|
||||
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
|
||||
|
||||
if (!draft.name) {
|
||||
errors.push("Plese enter a name.");
|
||||
}
|
||||
export function validateDraftOrder(draft: DraftBubbleTeaOrder) {
|
||||
const errors: string[] = [];
|
||||
|
||||
return { errors };
|
||||
},
|
||||
if (!draft.name) {
|
||||
errors.push("Please enter a name.");
|
||||
}
|
||||
|
||||
hasChanges(draft?: co.loaded<typeof Self>) { // [!code ++:3]
|
||||
return draft ? Object.keys(draft._edits).length : false;
|
||||
},
|
||||
}));
|
||||
return { errors };
|
||||
};
|
||||
|
||||
export function hasChanges(draft?: DraftBubbleTeaOrder) {
|
||||
return draft ? Object.keys(draft._edits).length : false;
|
||||
};
|
||||
|
||||
export const AccountRoot = co.map({
|
||||
draft: DraftBubbleTeaOrder,
|
||||
@@ -649,7 +657,7 @@ export function DraftIndicator() {
|
||||
resolve: { root: { draft: true } },
|
||||
});
|
||||
|
||||
if (DraftBubbleTeaOrder.hasChanges(me?.root.draft)) {
|
||||
if (hasChanges(me?.root.draft)) {
|
||||
return (
|
||||
<p>You have a draft</p>
|
||||
);
|
||||
|
||||
@@ -31,7 +31,7 @@ export const Organization = co.map({
|
||||
name: z.string(),
|
||||
|
||||
// shared data between users of each organization
|
||||
projects: co.list(Project),
|
||||
projects: co.list(Project),
|
||||
});
|
||||
|
||||
export const ListOfOrganizations = co.list(Organization);
|
||||
@@ -115,7 +115,7 @@ import * as React from "react";
|
||||
import { useAcceptInvite, useAccount } from "jazz-tools/react";
|
||||
import { co, z } from "jazz-tools";
|
||||
|
||||
const Project = z.object({
|
||||
const Project = co.map({
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
|
||||
@@ -109,6 +109,11 @@ export const docNavigationItems = [
|
||||
// collapse: true,
|
||||
prefix: "/docs/upgrade",
|
||||
items: [
|
||||
{
|
||||
name: "0.16.0 - Cleaner separation between Zod and CoValue schemas",
|
||||
href: "/docs/upgrade/0-16-0",
|
||||
done: 100,
|
||||
},
|
||||
{
|
||||
name: "0.15.0 - Everything inside `jazz-tools`",
|
||||
href: "/docs/upgrade/0-15-0",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -7,9 +7,11 @@ export const metadata = {
|
||||
|
||||
# Learn some <span className="sr-only">Jazz</span> <JazzLogo className="h-[41px] -ml-0.5 -mt-[3px] inline" />
|
||||
|
||||
**Jazz is a toolkit for building backendless apps**. You get data without needing a database — plus auth, permissions, files and multiplayer without needing a backend. Jazz lets you do everything right from the frontend and you'll ship better apps, faster.
|
||||
**Jazz is a new kind of database** that's **distributed** across your frontend, containers, serverless functions and its own storage cloud.
|
||||
|
||||
Instead of wrestling with databases, APIs, and server infrastructure, you work with **CoValues** ("collaborative values") — your new cloud-synced building blocks that feel like local state but automatically sync across all devices and users in real-time.
|
||||
It syncs structured data, files and LLM streams instantly, and looks like local reactive JSON state.
|
||||
|
||||
It also provides auth, orgs & teams, real-time multiplayer, edit histories, permissions, E2E encryption and offline-support out of the box.
|
||||
|
||||
---
|
||||
|
||||
@@ -19,7 +21,7 @@ You can use [`create-jazz-app`](/docs/tools/create-jazz-app) to create a new Jaz
|
||||
|
||||
<CodeGroup>
|
||||
```sh
|
||||
npx create-jazz-app@latest --api-key you@example.com
|
||||
npx create-jazz-app@latest --api-key you@example.com
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
@@ -30,21 +32,10 @@ Or you can follow this [React step-by-step guide](/docs/react/guide) where we wa
|
||||
|
||||
</ContentByFramework> */}
|
||||
|
||||
## Why Jazz is different
|
||||
|
||||
Most apps rebuild the same thing: shared state that syncs between users and devices. Jazz starts from that shared state, giving you:
|
||||
|
||||
- **No backend required** — Focus on building features, not infrastructure
|
||||
- **Real-time sync** — Changes appear everywhere immediately
|
||||
- **Multiplayer by default** — Collaboration just works
|
||||
- **Local-first** — Your app works offline and feels instant
|
||||
|
||||
Think Figma, Notion, or Linear — but you don't need years to build a custom stack.
|
||||
|
||||
## How it works
|
||||
|
||||
1. **Define your data** with CoValues schemas
|
||||
2. **Connect to sync infrastructure** (Jazz Cloud or self-hosted)
|
||||
2. **Connect to storage infrastructure** (Jazz Cloud or self-hosted)
|
||||
3. **Create and edit CoValues** like normal objects
|
||||
4. **Get automatic sync and persistence** across all devices and users
|
||||
|
||||
|
||||
@@ -28,18 +28,19 @@ See the [schema docs](/docs/schemas/covalues) for more information.
|
||||
<CodeGroup>
|
||||
```ts
|
||||
// src/lib/schema.ts
|
||||
import { Account, Profile, coField } from "jazz-tools";
|
||||
import { co, z } from "jazz-tools"
|
||||
|
||||
export class MyProfile extends Profile {
|
||||
name = coField.string;
|
||||
counter = coField.number; // This will be publically visible
|
||||
}
|
||||
export const MyProfile = co.profile({
|
||||
name: z.string(),
|
||||
counter: z.number()
|
||||
});
|
||||
|
||||
export class MyAccount extends Account {
|
||||
profile = coField.ref(MyProfile);
|
||||
export const root = co.map({});
|
||||
|
||||
// ...
|
||||
}
|
||||
export const UserAccount = co.account({
|
||||
root,
|
||||
profile: MyProfile
|
||||
});
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
@@ -48,17 +49,17 @@ export class MyAccount extends Account {
|
||||
<CodeGroup>
|
||||
```svelte
|
||||
<!-- src/routes/+layout.svelte -->
|
||||
|
||||
<script lang="ts">
|
||||
import { JazzSvelteProvider } from 'jazz-tools/svelte';
|
||||
import { JazzSvelteProvider } from "jazz-tools/svelte";
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
// Example configuration for authentication and peer connection
|
||||
let sync = { peer: "wss://cloud.jazz.tools/?key=you@example.com" };
|
||||
let AccountSchema = MyAccount;
|
||||
</script>
|
||||
|
||||
<JazzSvelteProvider {sync} {AccountSchema}>
|
||||
<App />
|
||||
<JazzSvelteProvider {sync} AccountSchema={MyAccount}>
|
||||
{@render children?.()}
|
||||
</JazzSvelteProvider>
|
||||
```
|
||||
</CodeGroup>
|
||||
@@ -69,12 +70,11 @@ export class MyAccount extends Account {
|
||||
```svelte
|
||||
<!-- src/routes/+page.svelte -->
|
||||
<script lang="ts">
|
||||
import { useCoState, useAccount } from 'jazz-tools/svelte';
|
||||
import { MyProfile } from './schema';
|
||||
import { CoState, AccountCoState } from "jazz-tools/svelte";
|
||||
import { MyProfile, UserAccount } from "$lib/schema";
|
||||
|
||||
const { me } = useAccount();
|
||||
|
||||
const profile = $derived(useCoState(MyProfile, me._refs.profile.id));
|
||||
const me = new AccountCoState(UserAccount);
|
||||
const profile = new CoState(MyProfile, me.current?._refs.profile?.id);
|
||||
|
||||
function increment() {
|
||||
if (!profile.current) return;
|
||||
@@ -82,7 +82,7 @@ export class MyAccount extends Account {
|
||||
}
|
||||
</script>
|
||||
|
||||
<button on:click={increment}>
|
||||
<button onclick={increment}>
|
||||
Count: {profile.current?.counter}
|
||||
</button>
|
||||
```
|
||||
|
||||
@@ -89,7 +89,7 @@ export const MyAppRoot = co.map({
|
||||
|
||||
export const MyAppProfile = co.profile({ // [!code ++:4]
|
||||
name: z.string(), // compatible with default Profile schema
|
||||
avatar: z.optional(co.image()),
|
||||
avatar: co.optional(co.image()),
|
||||
});
|
||||
|
||||
export const MyAppAccount = co.account({
|
||||
@@ -241,7 +241,7 @@ const MyAppProfile = co.profile({
|
||||
// ---cut---
|
||||
const MyAppRoot = co.map({
|
||||
myChats: co.list(Chat),
|
||||
myBookmarks: z.optional(co.list(Bookmark)), // [!code ++:1]
|
||||
myBookmarks: co.optional(co.list(Bookmark)), // [!code ++:1]
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -320,6 +355,10 @@ const Company = co.map({
|
||||
</CodeGroup>
|
||||
|
||||
#### Optional References
|
||||
You can make schema fields optional using either `z.optional()` or `co.optional()`, depending on the type of value:
|
||||
|
||||
- Use `z.optional()` for primitive Zod values like `z.string()`, `z.number()`, or `z.boolean()`
|
||||
- Use `co.optional()` for CoValues like `co.map()`, `co.list()`, or `co.record()`
|
||||
|
||||
You can make references optional with `co.optional()`:
|
||||
|
||||
@@ -331,7 +370,8 @@ const Pet = co.map({
|
||||
});
|
||||
// ---cut---
|
||||
const Person = co.map({
|
||||
pet: co.optional(Pet),
|
||||
age: z.optional(z.number()), // primitive
|
||||
pet: co.optional(Pet), // CoValue
|
||||
});
|
||||
```
|
||||
</CodeGroup>
|
||||
@@ -353,16 +393,16 @@ const Person = co.map({
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
You can use the same technique for mutually recursive references, but you'll need to help TypeScript along:
|
||||
You can use the same technique for mutually recursive references:
|
||||
|
||||
<CodeGroup>
|
||||
```ts twoslash
|
||||
// ---cut---
|
||||
import { co, z, CoListSchema } from "jazz-tools";
|
||||
import { co, z } from "jazz-tools";
|
||||
|
||||
const Person = co.map({
|
||||
name: z.string(),
|
||||
get friends(): CoListSchema<typeof Person> {
|
||||
get friends() {
|
||||
return ListOfPeople;
|
||||
}
|
||||
});
|
||||
@@ -372,22 +412,6 @@ const ListOfPeople = co.list(Person);
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
Note: similarly, if you use modifiers like `co.optional()` you'll need to help TypeScript along:
|
||||
|
||||
<CodeGroup>
|
||||
```ts twoslash
|
||||
import { co, z } from "jazz-tools";
|
||||
// ---cut---
|
||||
const Person = co.map({
|
||||
name: z.string(),
|
||||
get bestFriend(): z.ZodOptional<typeof Person> {
|
||||
return co.optional(Person);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
### Helper methods
|
||||
|
||||
If you find yourself repeating the same logic to access computed CoValues properties,
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Alert } from "@garden-co/design-system/src/components/atoms/Alert";
|
||||
|
||||
# Inbox API with Server Workers
|
||||
|
||||
The Inbox API provides a message-based communication system for Server Workers in Jazz.
|
||||
The Inbox API provides a message-based communication system for Server Workers in Jazz.
|
||||
|
||||
It works on top of the Jazz APIs and uses sync to transfer messages between the client and the server.
|
||||
|
||||
@@ -154,8 +154,8 @@ function EventComponent({ event }: { event: Event }) {
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
The `sendInboxMessage` API returns a Promise that waits for the message to be handled by a Worker.
|
||||
A message is considered to be handled when the Promise returned by `inbox.subscribe` resolves.
|
||||
The `sendInboxMessage` API returns a Promise that waits for the message to be handled by a Worker.
|
||||
A message is considered to be handled when the Promise returned by `inbox.subscribe` resolves.
|
||||
The value returned will be the id of the CoValue returned in the `inbox.subscribe` resolved promise.
|
||||
|
||||
|
||||
@@ -163,4 +163,4 @@ The value returned will be the id of the CoValue returned in the `inbox.subscrib
|
||||
|
||||
Multi-region deployments are not supported when using the Inbox API.
|
||||
|
||||
If you need to split the workload across multiple regions, you can use the [HTTP API](./http-requests.mdx) instead.
|
||||
If you need to split the workload across multiple regions, you can use the [HTTP API](./http-requests) instead.
|
||||
|
||||
158
homepage/homepage/content/docs/upgrade/0-16-0.mdx
Normal file
158
homepage/homepage/content/docs/upgrade/0-16-0.mdx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { CodeGroup } from '@/components/forMdx'
|
||||
|
||||
# Jazz 0.16.0 - Cleaner separation between Zod and CoValue schemas
|
||||
|
||||
This release introduces a cleaner separation between Zod and CoValue schemas, improves type inference with circular references, and simplifies how you access internal schemas.
|
||||
While most applications won't require extensive refactors, some breaking changes will require action.
|
||||
|
||||
## Motivation
|
||||
|
||||
Before 0.16.0, CoValue schemas were a thin wrapper around Zod schemas. This made it easy to use Zod methods on CoValue schemas,
|
||||
but it also prevented the type checker from detecting issues when combining Zod and CoValue schemas.
|
||||
|
||||
For example, the following code would previously compile without errors, but would have severe limitations:
|
||||
|
||||
<CodeGroup>
|
||||
```tsx
|
||||
import { co, z } from "jazz-tools";
|
||||
|
||||
const Dog = co.map({
|
||||
breed: z.string(),
|
||||
});
|
||||
const Person = co.map({
|
||||
pets: z.array(Dog),
|
||||
});
|
||||
|
||||
// You can create a CoMap with a z.array field that contains another CoMap
|
||||
const map = Person.create({
|
||||
pets: [Dog.create({ breed: "Labrador" })],
|
||||
});
|
||||
|
||||
// But then you cannot eagerly load the nested CoMap, because
|
||||
// there's a plain JS object in between. So this would fail:
|
||||
Person.load(map.id, { resolve: { pets: { $each: true } } });
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
Schema composition rules are now stricter: Zod schemas can only be composed with other Zod schemas.
|
||||
CoValue schemas can be composed with either Zod or other CoValue schemas. These rules are enforced at the type level, to make it easier
|
||||
to spot errors in schema definitions and avoid possible footguns when mixing Zod and CoValue schemas.
|
||||
|
||||
Having a stricter separation between Zod and CoValue schemas also allowed us to improve type inference with circular references.
|
||||
Previously, the type checker would not be able to infer types for even simple circular references, but now it can!
|
||||
<CodeGroup>
|
||||
```tsx
|
||||
import { co, z } from "jazz-tools";
|
||||
|
||||
const Person = co.map({
|
||||
name: z.string(),
|
||||
get friends(): CoListSchema<typeof Person> { // [!code --]
|
||||
get friends() { // [!code ++]
|
||||
return co.list(Person);
|
||||
},
|
||||
});
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
There are some scenarios where recursive type inference can still fail due to TypeScript limitations, but these should be rare.
|
||||
|
||||
## Breaking changes
|
||||
|
||||
### The Account root id is now discoverable
|
||||
|
||||
In prior Jazz releases, the Account root id was stored encrypted and accessible only by the account owner.
|
||||
|
||||
This made it impossible to load the account root this way:
|
||||
|
||||
<CodeGroup>
|
||||
```tsx
|
||||
const bob = MyAppAccount.load(bobId, { resolve: { root: true }, loadAs: me });
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
So we changed Account root id to be discoverable by everyone.
|
||||
**This doesn't affect the visibility of the account root**, which still follows the permissions defined in its group.
|
||||
|
||||
For existing accounts, the change is applied the next time the user loads their account.
|
||||
|
||||
No action is required on your side, but we preferred to mark this as a breaking change because it
|
||||
minimally affects access to the account root. (e.g., if in your app the root is public, now users can access other users' root by knowing their account ID)
|
||||
|
||||
### `z.optional()` and `z.discriminatedUnion()` no longer work with CoValue schemas
|
||||
|
||||
You'll now need to use the `co.optional()` and `co.discriminatedUnion()` equivalents.
|
||||
This change may require you to update any explicitly typed cyclic references.
|
||||
|
||||
<CodeGroup>
|
||||
```tsx
|
||||
import { co, z } from "jazz-tools";
|
||||
|
||||
const Person = co.map({
|
||||
name: z.string(),
|
||||
get bestFriend(): z.ZodOptional<typeof Person> { // [!code --]
|
||||
return z.optional(Person); // [!code --]
|
||||
get bestFriend(): co.Optional<typeof Person> { // [!code ++]
|
||||
return co.optional(Person); // [!code ++]
|
||||
}
|
||||
});
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
### CoValue schema types are now under the `co.` namespace
|
||||
|
||||
All CoValue schema types are now accessed via the `co.` namespace. If you're using explicit types (especially in recursive schemas), you'll need to update them accordingly.
|
||||
|
||||
<CodeGroup>
|
||||
```tsx
|
||||
import { co, z } from "jazz-tools";
|
||||
|
||||
const Person = co.map({
|
||||
name: z.string(),
|
||||
get friends(): CoListSchema<typeof Person> { // [!code --]
|
||||
get friends(): co.List<typeof Person> { // [!code ++]
|
||||
return co.list(Person);
|
||||
}
|
||||
});
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
### Unsupported Zod methods have been removed from CoMap schemas
|
||||
|
||||
CoMap schemas no longer incorrectly inherit Zod methods like `.extend()` and `.partial()`. These methods previously appeared to work but could behave unpredictably. They have now been disabled.
|
||||
|
||||
We're keeping `.optional()` and plan to introduce more Zod-like methods in future releases.
|
||||
|
||||
### Internal schema access is now simpler
|
||||
|
||||
You no longer need to use Zod's `.def` to access schema internals. Instead, you can directly use methods like `CoMapSchema.shape`, `CoListSchema.element`, and `CoOptionalSchema.innerType`.
|
||||
|
||||
<CodeGroup>
|
||||
```tsx
|
||||
import { co, z } from "jazz-tools";
|
||||
|
||||
const Message = co.map({
|
||||
content: co.richText(),
|
||||
});
|
||||
|
||||
const Thread = co.map({
|
||||
messages: co.list(Message),
|
||||
});
|
||||
|
||||
const thread = Thread.create({
|
||||
messages: Thread.def.shape.messages.create([ // [!code --]
|
||||
messages: Thread.shape.messages.create([ // [!code ++]
|
||||
Message.create({
|
||||
content: co.richText().create("Hi!"),
|
||||
}),
|
||||
Message.create({
|
||||
content: co.richText().create("What's up?"),
|
||||
}),
|
||||
]),
|
||||
});
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
### Removed the deprecated `withHelpers` method from CoValue schemas
|
||||
|
||||
The deprecated `withHelpers()` method has been removed from CoValue schemas. You can define helper functions manually to encapsulate CoValue-related logic.
|
||||
[Learn how to define helper methods](https://jazz.tools/docs/vanilla/schemas/covalues#helper-methods).
|
||||
@@ -343,14 +343,14 @@ CoLists can be used to create one-to-many relationships:
|
||||
|
||||
<CodeGroup>
|
||||
```ts twoslash
|
||||
import { co, z, CoListSchema } from "jazz-tools";
|
||||
import { co, z } from "jazz-tools";
|
||||
|
||||
const Task = co.map({
|
||||
title: z.string(),
|
||||
status: z.literal(["todo", "in-progress", "complete"]),
|
||||
|
||||
get project(): z.ZodOptional<typeof Project> {
|
||||
return z.optional(Project);
|
||||
get project(): co.Optional<typeof Project> {
|
||||
return co.optional(Project);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -359,7 +359,7 @@ const ListOfTasks = co.list(Task);
|
||||
const Project = co.map({
|
||||
name: z.string(),
|
||||
|
||||
get tasks(): CoListSchema<typeof Task> {
|
||||
get tasks(): co.List<typeof Task> {
|
||||
return ListOfTasks;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -213,14 +213,14 @@ const Member = co.map({
|
||||
name: z.string(),
|
||||
});
|
||||
// ---cut---
|
||||
import { co, z, CoListSchema } from "jazz-tools";
|
||||
import { co, z } from "jazz-tools";
|
||||
|
||||
const Project = co.map({
|
||||
name: z.string(),
|
||||
startDate: z.date(),
|
||||
status: z.literal(["planning", "active", "completed"]),
|
||||
coordinator: co.optional(Member),
|
||||
get subProjects(): z.ZodOptional<CoListSchema<typeof Project>> {
|
||||
get subProjects(): co.Optional<co.List<typeof Project>> {
|
||||
return co.optional(co.list(Project));
|
||||
}
|
||||
});
|
||||
@@ -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:
|
||||
|
||||
@@ -50,51 +50,3 @@ export type User = co.loaded<typeof User>;
|
||||
This direct linking approach offers a single source of truth. When you update a referenced CoValue, all other CoValues that point to it are automatically updated, ensuring data consistency across your application.
|
||||
|
||||
By connecting CoValues through these direct references, you can build robust and collaborative applications where data is consistent, efficient to manage, and relationships are clearly defined. The ability to link different CoValue types to the same underlying data is fundamental to building complex applications with Jazz.
|
||||
|
||||
|
||||
## Recursive references with DiscriminatedUnion
|
||||
In advanced schemas, you may want a CoValue that recursively references itself. For example, a `ReferenceItem` that contains a list of other items like `NoteItem` or `AttachmentItem`. This is common in tree-like structures such as threaded comments or nested project outlines.
|
||||
|
||||
You can model this with a Zod `z.discriminatedUnion`, but TypeScript’s type inference doesn't handle recursive unions well without a workaround.
|
||||
|
||||
Here’s how to structure your schema to avoid circular reference errors.
|
||||
|
||||
### Use this pattern for recursive discriminated unions
|
||||
<CodeGroup>
|
||||
```ts twoslash
|
||||
import { CoListSchema, co, z } from "jazz-tools";
|
||||
|
||||
// Recursive item modeling pattern using discriminated unions
|
||||
// First, define the non-recursive types
|
||||
export const NoteItem = co.map({
|
||||
type: z.literal("note"),
|
||||
internal: z.boolean(),
|
||||
content: co.plainText(),
|
||||
});
|
||||
|
||||
export const AttachmentItem = co.map({
|
||||
type: z.literal("attachment"),
|
||||
internal: z.boolean(),
|
||||
content: co.fileStream(),
|
||||
});
|
||||
|
||||
export const ReferenceItem = co.map({
|
||||
type: z.literal("reference"),
|
||||
internal: z.boolean(),
|
||||
content: z.string(),
|
||||
|
||||
// Workaround: declare the field type using CoListSchema and ZodDiscriminatedUnion so TS can safely recurse
|
||||
get children(): CoListSchema<z.ZodDiscriminatedUnion<[typeof NoteItem, typeof AttachmentItem, typeof ReferenceItem]>> {
|
||||
return ProjectContextItemList;
|
||||
},
|
||||
});
|
||||
|
||||
// Create the recursive union
|
||||
export const ProjectContextItem = z.discriminatedUnion("type", [NoteItem, AttachmentItem, ReferenceItem]);
|
||||
|
||||
// Final list of recursive types
|
||||
export const ProjectContextItemList = co.list(ProjectContextItem);
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
Even though this seems like a shortcut, TypeScript and Zod can't resolve the circular reference this way. Always define the discriminated union before introducing recursive links.
|
||||
|
||||
@@ -24,7 +24,7 @@ import { Group, co, z } from "jazz-tools";
|
||||
|
||||
const MyProfile = co.profile({
|
||||
name: z.string(),
|
||||
image: z.optional(co.image()),
|
||||
image: co.optional(co.image()),
|
||||
});
|
||||
|
||||
const MyAccount = co.account({
|
||||
|
||||
@@ -25,7 +25,7 @@ import { Group, co, z } from "jazz-tools";
|
||||
|
||||
const MyProfile = co.profile({
|
||||
name: z.string(),
|
||||
image: z.optional(co.image()),
|
||||
image: co.optional(co.image()),
|
||||
});
|
||||
|
||||
const MyAccount = co.account({
|
||||
|
||||
@@ -249,7 +249,7 @@ Resolve queries let you declare exactly which references to load and how deep to
|
||||
|
||||
<CodeGroup>
|
||||
```ts twoslash
|
||||
import { co, z, CoListSchema } from "jazz-tools";
|
||||
import { co, z } from "jazz-tools";
|
||||
const projectId = "co_123";
|
||||
|
||||
// ---cut-before---
|
||||
@@ -259,8 +259,8 @@ const TeamMember = co.map({
|
||||
|
||||
const Task = co.map({
|
||||
title: z.string(),
|
||||
assignee: z.optional(TeamMember),
|
||||
get subtasks(): CoListSchema<typeof Task> { return co.list(Task) },
|
||||
assignee: co.optional(TeamMember),
|
||||
get subtasks(): co.List<typeof Task> { return co.list(Task) },
|
||||
});
|
||||
|
||||
const Project = co.map({
|
||||
@@ -349,7 +349,7 @@ When a user tries to load a reference they don't have access to:
|
||||
|
||||
<CodeGroup>
|
||||
```ts twoslash
|
||||
import { co, z, CoListSchema } from "jazz-tools";
|
||||
import { co, z } from "jazz-tools";
|
||||
|
||||
const TeamMember = co.map({
|
||||
name: z.string(),
|
||||
@@ -357,8 +357,8 @@ const TeamMember = co.map({
|
||||
|
||||
const Task = co.map({
|
||||
title: z.string(),
|
||||
assignee: z.optional(TeamMember),
|
||||
get subtasks(): CoListSchema<typeof Task> { return co.list(Task) },
|
||||
assignee: co.optional(TeamMember),
|
||||
get subtasks(): co.List<typeof Task> { return co.list(Task) },
|
||||
});
|
||||
|
||||
const Project = co.map({
|
||||
@@ -388,7 +388,7 @@ When a list contains references to items the user can't access:
|
||||
|
||||
<CodeGroup>
|
||||
```ts twoslash
|
||||
import { co, z, CoListSchema } from "jazz-tools";
|
||||
import { co, z } from "jazz-tools";
|
||||
|
||||
const TeamMember = co.map({
|
||||
name: z.string(),
|
||||
@@ -396,8 +396,8 @@ const TeamMember = co.map({
|
||||
|
||||
const Task = co.map({
|
||||
title: z.string(),
|
||||
assignee: z.optional(TeamMember),
|
||||
get subtasks(): CoListSchema<typeof Task> { return co.list(Task) },
|
||||
assignee: co.optional(TeamMember),
|
||||
get subtasks(): co.List<typeof Task> { return co.list(Task) },
|
||||
});
|
||||
|
||||
const Project = co.map({
|
||||
@@ -424,7 +424,7 @@ When trying to load an object with an inaccessible reference without directly re
|
||||
|
||||
<CodeGroup>
|
||||
```ts twoslash
|
||||
import { co, z, CoListSchema } from "jazz-tools";
|
||||
import { co, z } from "jazz-tools";
|
||||
|
||||
const TeamMember = co.map({
|
||||
name: z.string(),
|
||||
@@ -432,8 +432,8 @@ const TeamMember = co.map({
|
||||
|
||||
const Task = co.map({
|
||||
title: z.string(),
|
||||
assignee: z.optional(TeamMember),
|
||||
get subtasks(): CoListSchema<typeof Task> { return co.list(Task) },
|
||||
assignee: co.optional(TeamMember),
|
||||
get subtasks(): co.List<typeof Task> { return co.list(Task) },
|
||||
});
|
||||
|
||||
const Project = co.map({
|
||||
@@ -468,7 +468,7 @@ This way the inaccessible items are replaced with `null` in the returned list.
|
||||
|
||||
<CodeGroup>
|
||||
```ts twoslash
|
||||
import { co, z, CoListSchema, Group } from "jazz-tools";
|
||||
import { co, z, Group } from "jazz-tools";
|
||||
import { createJazzTestAccount } from "jazz-tools/testing";
|
||||
|
||||
const me = await createJazzTestAccount();
|
||||
@@ -653,7 +653,7 @@ The `co.loaded` type is especially useful when passing data between components,
|
||||
<ContentByFramework framework="react">
|
||||
<CodeGroup>
|
||||
```tsx twoslash
|
||||
import { CoListSchema, co, z } from "jazz-tools";
|
||||
import { co, z } from "jazz-tools";
|
||||
import React from "react";
|
||||
|
||||
const TeamMember = co.map({
|
||||
@@ -662,8 +662,8 @@ const TeamMember = co.map({
|
||||
|
||||
const Task = co.map({
|
||||
title: z.string(),
|
||||
assignee: z.optional(TeamMember),
|
||||
get subtasks(): CoListSchema<typeof Task> {
|
||||
assignee: co.optional(TeamMember),
|
||||
get subtasks(): co.List<typeof Task> {
|
||||
return co.list(Task);
|
||||
},
|
||||
});
|
||||
@@ -727,7 +727,7 @@ function processProject(project: FullyLoadedProject) {
|
||||
<ContentByFramework framework="vanilla">
|
||||
<CodeGroup>
|
||||
```ts twoslash
|
||||
import { CoListSchema, co, z } from "jazz-tools";
|
||||
import { co, z } from "jazz-tools";
|
||||
|
||||
const TeamMember = co.map({
|
||||
name: z.string(),
|
||||
@@ -735,8 +735,8 @@ const TeamMember = co.map({
|
||||
|
||||
const Task = co.map({
|
||||
title: z.string(),
|
||||
assignee: z.optional(TeamMember),
|
||||
get subtasks(): CoListSchema<typeof Task> {
|
||||
assignee: co.optional(TeamMember),
|
||||
get subtasks(): co.List<typeof Task> {
|
||||
return co.list(Task);
|
||||
},
|
||||
});
|
||||
@@ -799,7 +799,7 @@ Sometimes you need to make sure data is loaded before proceeding with an operati
|
||||
|
||||
<CodeGroup>
|
||||
```ts twoslash
|
||||
import { CoListSchema, co, z } from "jazz-tools";
|
||||
import { co, z } from "jazz-tools";
|
||||
|
||||
const TeamMember = co.map({
|
||||
name: z.string(),
|
||||
@@ -809,7 +809,7 @@ const Task = co.map({
|
||||
title: z.string(),
|
||||
status: z.literal(["todo", "in-progress", "completed"]),
|
||||
assignee: z.string().optional(),
|
||||
get subtasks(): CoListSchema<typeof Task> {
|
||||
get subtasks(): co.List<typeof Task> {
|
||||
return co.list(Task);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const marketingCopy = {
|
||||
headline: "Whip up an app",
|
||||
headline: "Smooth database.",
|
||||
description:
|
||||
"Jazz gives you data without needing a database — plus auth, permissions, files and multiplayer without needing a backend. Do everything right from the frontend and ship better apps, faster.",
|
||||
"Jazz is a database that's distributed across your frontend, containers and functions. It syncs structured data, files and LLM streams instantly and looks like local reactive JSON state.",
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,5 +1,46 @@
|
||||
# cojson-storage-indexeddb
|
||||
|
||||
## 0.16.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3cd1586]
|
||||
- Updated dependencies [267f689]
|
||||
- cojson@0.16.5
|
||||
|
||||
## 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
|
||||
|
||||
- cojson@0.16.2
|
||||
|
||||
## 0.16.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- cojson@0.16.1
|
||||
|
||||
## 0.16.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [c09dcdf]
|
||||
- cojson@0.16.0
|
||||
|
||||
## 0.15.16
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cojson-storage-indexeddb",
|
||||
"version": "0.15.16",
|
||||
"version": "0.16.5",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
"types": "dist/index.d.ts",
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
`);
|
||||
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,46 @@
|
||||
# cojson-storage-sqlite
|
||||
|
||||
## 0.16.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3cd1586]
|
||||
- Updated dependencies [267f689]
|
||||
- cojson@0.16.5
|
||||
|
||||
## 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
|
||||
|
||||
- cojson@0.16.2
|
||||
|
||||
## 0.16.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- cojson@0.16.1
|
||||
|
||||
## 0.16.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [c09dcdf]
|
||||
- cojson@0.16.0
|
||||
|
||||
## 0.15.16
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "cojson-storage-sqlite",
|
||||
"type": "module",
|
||||
"version": "0.15.16",
|
||||
"version": "0.16.5",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,46 @@
|
||||
# cojson-transport-nodejs-ws
|
||||
|
||||
## 0.16.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3cd1586]
|
||||
- Updated dependencies [267f689]
|
||||
- cojson@0.16.5
|
||||
|
||||
## 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
|
||||
|
||||
- cojson@0.16.2
|
||||
|
||||
## 0.16.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- cojson@0.16.1
|
||||
|
||||
## 0.16.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [c09dcdf]
|
||||
- cojson@0.16.0
|
||||
|
||||
## 0.15.16
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "cojson-transport-ws",
|
||||
"type": "module",
|
||||
"version": "0.15.16",
|
||||
"version": "0.16.5",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,5 +1,39 @@
|
||||
# cojson
|
||||
|
||||
## 0.16.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 3cd1586: Makes the key rotation not fail when child groups are unavailable or their readkey is not accessible.
|
||||
|
||||
Also changes the Group.removeMember method to not return a Promise, because:
|
||||
|
||||
- All the locally available child groups are rotated immediately
|
||||
- All the remote child groups are rotated in background, but since they are not locally available the user won't need the new key immediately
|
||||
|
||||
- 267f689: Groups: fix the readkey not being revealed to everyone when doing a key rotation
|
||||
|
||||
## 0.16.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 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
|
||||
|
||||
## 0.16.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- c09dcdf: Change the root attribute to be public on Account. The root content will still follow the visiblity rules specified in their group.
|
||||
|
||||
Existing accounts will be gradually migrated as they are loaded.
|
||||
|
||||
## 0.15.16
|
||||
|
||||
## 0.15.15
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
},
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "0.15.16",
|
||||
"version": "0.16.5",
|
||||
"devDependencies": {
|
||||
"@opentelemetry/sdk-metrics": "^2.0.0",
|
||||
"libsql": "^0.5.13",
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
AvailableCoValueCore,
|
||||
CoValueCore,
|
||||
} from "./coValueCore/coValueCore.js";
|
||||
import { AvailableCoValueCore } from "./coValueCore/coValueCore.js";
|
||||
import { RawProfile as Profile, RawAccount } from "./coValues/account.js";
|
||||
import { RawCoList } from "./coValues/coList.js";
|
||||
import { RawCoMap } from "./coValues/coMap.js";
|
||||
|
||||
73
packages/cojson/src/coValueContentMessage.ts
Normal file
73
packages/cojson/src/coValueContentMessage.ts
Normal 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;
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
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 { RawGroup } from "../coValues/group.js";
|
||||
import { CO_VALUE_LOADING_CONFIG, MAX_RECOMMENDED_TX_SIZE } from "../config.js";
|
||||
import type { PeerState } from "../PeerState.js";
|
||||
import type { RawCoValue } from "../coValue.js";
|
||||
import type { ControlledAccountOrAgent } from "../coValues/account.js";
|
||||
import type { RawGroup } from "../coValues/group.js";
|
||||
import { CO_VALUE_LOADING_CONFIG } from "../config.js";
|
||||
import { coreToCoValue } from "../coreToCoValue.js";
|
||||
import {
|
||||
CryptoProvider,
|
||||
@@ -16,25 +16,15 @@ import {
|
||||
SignerID,
|
||||
StreamingHash,
|
||||
} from "../crypto/crypto.js";
|
||||
import {
|
||||
RawCoID,
|
||||
SessionID,
|
||||
TransactionID,
|
||||
getParentGroupId,
|
||||
isParentGroupReference,
|
||||
} from "../ids.js";
|
||||
import { RawCoID, SessionID, TransactionID } from "../ids.js";
|
||||
import { parseJSON, stableStringify } from "../jsonStringify.js";
|
||||
import { JsonValue } from "../jsonValue.js";
|
||||
import { LocalNode, ResolveAccountAgentError } from "../localNode.js";
|
||||
import { logger } from "../logger.js";
|
||||
import {
|
||||
determineValidTransactions,
|
||||
isKeyForKeyField,
|
||||
} from "../permissions.js";
|
||||
import { determineValidTransactions } from "../permissions.js";
|
||||
import { CoValueKnownState, PeerID, emptyKnownState } from "../sync.js";
|
||||
import { accountOrAgentIDfromSessionID } from "../typeUtils/accountOrAgentIDfromSessionID.js";
|
||||
import { expectGroup } from "../typeUtils/expectGroup.js";
|
||||
import { isAccountID } from "../typeUtils/isAccountID.js";
|
||||
import { getDependedOnCoValuesFromRawData } from "./utils.js";
|
||||
import { CoValueHeader, Transaction, VerifiedState } from "./verifiedState.js";
|
||||
|
||||
@@ -50,10 +40,9 @@ export type DecryptedTransaction = {
|
||||
txID: TransactionID;
|
||||
changes: JsonValue[];
|
||||
madeAt: number;
|
||||
trusting?: boolean;
|
||||
};
|
||||
|
||||
const readKeyCache = new WeakMap<CoValueCore, { [id: KeyID]: KeySecret }>();
|
||||
|
||||
export type AvailableCoValueCore = CoValueCore & { verified: VerifiedState };
|
||||
|
||||
export class CoValueCore {
|
||||
@@ -379,7 +368,7 @@ export class CoValueCore {
|
||||
}
|
||||
|
||||
knownStateWithStreaming(): CoValueKnownState {
|
||||
if (this.isAvailable()) {
|
||||
if (this.verified) {
|
||||
return this.verified.knownStateWithStreaming();
|
||||
} else {
|
||||
return emptyKnownState(this.id);
|
||||
@@ -387,7 +376,7 @@ export class CoValueCore {
|
||||
}
|
||||
|
||||
knownState(): CoValueKnownState {
|
||||
if (this.isAvailable()) {
|
||||
if (this.verified) {
|
||||
return this.verified.knownState();
|
||||
} else {
|
||||
return emptyKnownState(this.id);
|
||||
@@ -604,8 +593,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;
|
||||
@@ -657,6 +655,7 @@ export class CoValueCore {
|
||||
txID,
|
||||
madeAt: tx.madeAt,
|
||||
changes: parseJSON(tx.changes),
|
||||
trusting: true,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error("Failed to parse trusting transaction on " + this.id, {
|
||||
@@ -757,20 +756,7 @@ export class CoValueCore {
|
||||
}
|
||||
|
||||
if (this.verified.header.ruleset.type === "group") {
|
||||
const content = expectGroup(this.getCurrentContent());
|
||||
|
||||
const currentKeyId = content.getCurrentReadKeyId();
|
||||
|
||||
if (!currentKeyId) {
|
||||
throw new Error("No readKey set");
|
||||
}
|
||||
|
||||
const secret = this.getReadKey(currentKeyId);
|
||||
|
||||
return {
|
||||
secret: secret,
|
||||
id: currentKeyId,
|
||||
};
|
||||
return expectGroup(this.getCurrentContent()).getCurrentReadKey();
|
||||
} else if (this.verified.header.ruleset.type === "ownedByGroup") {
|
||||
return this.node
|
||||
.expectCoValueLoaded(this.verified.header.ruleset.group)
|
||||
@@ -782,154 +768,36 @@ export class CoValueCore {
|
||||
}
|
||||
}
|
||||
|
||||
readKeyCache = new Map<KeyID, KeySecret>();
|
||||
getReadKey(keyID: KeyID): KeySecret | undefined {
|
||||
let key = readKeyCache.get(this)?.[keyID];
|
||||
if (!key) {
|
||||
key = this.getUncachedReadKey(keyID);
|
||||
if (key) {
|
||||
let cache = readKeyCache.get(this);
|
||||
if (!cache) {
|
||||
cache = {};
|
||||
readKeyCache.set(this, cache);
|
||||
}
|
||||
cache[keyID] = key;
|
||||
}
|
||||
}
|
||||
return key;
|
||||
}
|
||||
// We want to check the cache here, to skip re-computing the group content
|
||||
const cachedSecret = this.readKeyCache.get(keyID);
|
||||
|
||||
if (cachedSecret) {
|
||||
return cachedSecret;
|
||||
}
|
||||
|
||||
getUncachedReadKey(keyID: KeyID): KeySecret | undefined {
|
||||
if (!this.verified) {
|
||||
throw new Error(
|
||||
"CoValueCore: getUncachedReadKey called on coValue without verified state",
|
||||
);
|
||||
}
|
||||
|
||||
// Getting the readKey from accounts
|
||||
if (this.verified.header.ruleset.type === "group") {
|
||||
const content = expectGroup(
|
||||
this.getCurrentContent({ ignorePrivateTransactions: true }), // to prevent recursion
|
||||
);
|
||||
const keyForEveryone = content.get(`${keyID}_for_everyone`);
|
||||
if (keyForEveryone) {
|
||||
return keyForEveryone;
|
||||
}
|
||||
|
||||
// Try to find key revelation for us
|
||||
const currentAgentOrAccountID = accountOrAgentIDfromSessionID(
|
||||
this.node.currentSessionID,
|
||||
// load the account without private transactions, because we are here
|
||||
// to be able to decrypt those
|
||||
this.getCurrentContent({ ignorePrivateTransactions: true }),
|
||||
);
|
||||
|
||||
// being careful here to avoid recursion
|
||||
const lookupAccountOrAgentID = isAccountID(currentAgentOrAccountID)
|
||||
? this.id === currentAgentOrAccountID
|
||||
? this.crypto.getAgentID(this.node.agentSecret) // in accounts, the read key is revealed for the primitive agent
|
||||
: currentAgentOrAccountID // current account ID
|
||||
: currentAgentOrAccountID; // current agent ID
|
||||
|
||||
const lastReadyKeyEdit = content.lastEditAt(
|
||||
`${keyID}_for_${lookupAccountOrAgentID}`,
|
||||
);
|
||||
|
||||
if (lastReadyKeyEdit?.value) {
|
||||
const revealer = lastReadyKeyEdit.by;
|
||||
const revealerAgent = this.node
|
||||
.resolveAccountAgent(revealer, "Expected to know revealer")
|
||||
._unsafeUnwrap({ withStackTrace: true });
|
||||
|
||||
const secret = this.crypto.unseal(
|
||||
lastReadyKeyEdit.value,
|
||||
this.crypto.getAgentSealerSecret(this.node.agentSecret), // being careful here to avoid recursion
|
||||
this.crypto.getAgentSealerID(revealerAgent),
|
||||
{
|
||||
in: this.id,
|
||||
tx: lastReadyKeyEdit.tx,
|
||||
},
|
||||
);
|
||||
|
||||
if (secret) {
|
||||
return secret as KeySecret;
|
||||
}
|
||||
}
|
||||
|
||||
// Try to find indirect revelation through previousKeys
|
||||
|
||||
for (const co of content.keys()) {
|
||||
if (isKeyForKeyField(co) && co.startsWith(keyID)) {
|
||||
const encryptingKeyID = co.split("_for_")[1] as KeyID;
|
||||
const encryptingKeySecret = this.getReadKey(encryptingKeyID);
|
||||
|
||||
if (!encryptingKeySecret) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const encryptedPreviousKey = content.get(co)!;
|
||||
|
||||
const secret = this.crypto.decryptKeySecret(
|
||||
{
|
||||
encryptedID: keyID,
|
||||
encryptingID: encryptingKeyID,
|
||||
encrypted: encryptedPreviousKey,
|
||||
},
|
||||
encryptingKeySecret,
|
||||
);
|
||||
|
||||
if (secret) {
|
||||
return secret as KeySecret;
|
||||
} else {
|
||||
logger.warn(
|
||||
`Encrypting ${encryptingKeyID} key didn't decrypt ${keyID}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// try to find revelation to parent group read keys
|
||||
for (const co of content.keys()) {
|
||||
if (isParentGroupReference(co)) {
|
||||
const parentGroupID = getParentGroupId(co);
|
||||
const parentGroup = this.node.expectCoValueLoaded(
|
||||
parentGroupID,
|
||||
"Expected parent group to be loaded",
|
||||
);
|
||||
|
||||
const parentKeys = this.findValidParentKeys(
|
||||
keyID,
|
||||
content,
|
||||
parentGroup,
|
||||
);
|
||||
|
||||
for (const parentKey of parentKeys) {
|
||||
const revelationForParentKey = content.get(
|
||||
`${keyID}_for_${parentKey.id}`,
|
||||
);
|
||||
|
||||
if (revelationForParentKey) {
|
||||
const secret = parentGroup.crypto.decryptKeySecret(
|
||||
{
|
||||
encryptedID: keyID,
|
||||
encryptingID: parentKey.id,
|
||||
encrypted: revelationForParentKey,
|
||||
},
|
||||
parentKey.secret,
|
||||
);
|
||||
|
||||
if (secret) {
|
||||
return secret as KeySecret;
|
||||
} else {
|
||||
logger.warn(
|
||||
`Encrypting parent ${parentKey.id} key didn't decrypt ${keyID}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
return content.getReadKey(keyID);
|
||||
} else if (this.verified.header.ruleset.type === "ownedByGroup") {
|
||||
return this.node
|
||||
.expectCoValueLoaded(this.verified.header.ruleset.group)
|
||||
.getReadKey(keyID);
|
||||
return expectGroup(
|
||||
this.node
|
||||
.expectCoValueLoaded(this.verified.header.ruleset.group)
|
||||
.getCurrentContent(),
|
||||
).getReadKey(keyID);
|
||||
} else {
|
||||
throw new Error(
|
||||
"Only groups or values owned by groups have read secrets",
|
||||
@@ -937,28 +805,6 @@ export class CoValueCore {
|
||||
}
|
||||
}
|
||||
|
||||
findValidParentKeys(keyID: KeyID, group: RawGroup, parentGroup: CoValueCore) {
|
||||
const validParentKeys: { id: KeyID; secret: KeySecret }[] = [];
|
||||
|
||||
for (const co of group.keys()) {
|
||||
if (isKeyForKeyField(co) && co.startsWith(keyID)) {
|
||||
const encryptingKeyID = co.split("_for_")[1] as KeyID;
|
||||
const encryptingKeySecret = parentGroup.getReadKey(encryptingKeyID);
|
||||
|
||||
if (!encryptingKeySecret) {
|
||||
continue;
|
||||
}
|
||||
|
||||
validParentKeys.push({
|
||||
id: encryptingKeyID,
|
||||
secret: encryptingKeySecret,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return validParentKeys;
|
||||
}
|
||||
|
||||
getGroup(): RawGroup {
|
||||
if (!this.verified) {
|
||||
throw new Error(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ type MapOp<K extends string, V extends JsonValue | undefined> = {
|
||||
madeAt: number;
|
||||
changeIdx: number;
|
||||
change: MapOpPayload<K, V>;
|
||||
trusting?: boolean;
|
||||
};
|
||||
// TODO: add after TransactionID[] for conflicts/ordering
|
||||
|
||||
@@ -112,7 +113,7 @@ export class RawCoMapView<
|
||||
NonNullable<(typeof ops)[keyof typeof ops]>
|
||||
>();
|
||||
|
||||
for (const { txID, changes, madeAt } of newValidTransactions) {
|
||||
for (const { txID, changes, madeAt, trusting } of newValidTransactions) {
|
||||
if (madeAt > this.latestTxMadeAt) {
|
||||
this.latestTxMadeAt = madeAt;
|
||||
}
|
||||
@@ -127,6 +128,7 @@ export class RawCoMapView<
|
||||
madeAt,
|
||||
changeIdx,
|
||||
change,
|
||||
trusting,
|
||||
};
|
||||
|
||||
const entries = ops[change.key];
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { base58 } from "@scure/base";
|
||||
import { CoID } from "../coValue.js";
|
||||
import { AvailableCoValueCore } from "../coValueCore/coValueCore.js";
|
||||
import { CoValueUniqueness } from "../coValueCore/verifiedState.js";
|
||||
import {
|
||||
import type { CoID } from "../coValue.js";
|
||||
import type {
|
||||
AvailableCoValueCore,
|
||||
CoValueCore,
|
||||
} from "../coValueCore/coValueCore.js";
|
||||
import type { CoValueUniqueness } from "../coValueCore/verifiedState.js";
|
||||
import type {
|
||||
CryptoProvider,
|
||||
Encrypted,
|
||||
KeyID,
|
||||
@@ -21,8 +24,10 @@ import {
|
||||
} from "../ids.js";
|
||||
import { JsonObject } from "../jsonValue.js";
|
||||
import { logger } from "../logger.js";
|
||||
import { AccountRole, Role } from "../permissions.js";
|
||||
import { AccountRole, Role, isKeyForKeyField } from "../permissions.js";
|
||||
import { accountOrAgentIDfromSessionID } from "../typeUtils/accountOrAgentIDfromSessionID.js";
|
||||
import { expectGroup } from "../typeUtils/expectGroup.js";
|
||||
import { isAccountID } from "../typeUtils/isAccountID.js";
|
||||
import {
|
||||
ControlledAccountOrAgent,
|
||||
RawAccount,
|
||||
@@ -60,6 +65,59 @@ export type GroupShape = {
|
||||
[child: ChildGroupReference]: "revoked" | "extend";
|
||||
};
|
||||
|
||||
// We had a bug on key rotation, where the new read key was not revealed to everyone
|
||||
// TODO: remove this when we hit the 0.18.0 release (either the groups are healed or they are not used often, it's a minor issue anyway)
|
||||
function healMissingKeyForEveryone(group: RawGroup) {
|
||||
const readKeyId = group.get("readKey");
|
||||
|
||||
if (
|
||||
!readKeyId ||
|
||||
!canRead(group, EVERYONE) ||
|
||||
group.get(`${readKeyId}_for_${EVERYONE}`)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasAccessToReadKey = canRead(
|
||||
group,
|
||||
group.core.node.getCurrentAgent().id,
|
||||
);
|
||||
|
||||
// If the current account has access to the read key, we can fix the group
|
||||
if (hasAccessToReadKey) {
|
||||
const secret = group.getReadKey(readKeyId);
|
||||
if (secret) {
|
||||
group.set(`${readKeyId}_for_${EVERYONE}`, secret, "trusting");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback to the latest readable key for everyone
|
||||
const keys = group
|
||||
.keys()
|
||||
.filter((key) => key.startsWith("key_") && key.endsWith("_for_everyone"));
|
||||
|
||||
let latestKey = keys[0];
|
||||
|
||||
for (const key of keys) {
|
||||
if (!latestKey) {
|
||||
latestKey = key;
|
||||
continue;
|
||||
}
|
||||
|
||||
const keyEntry = group.getRaw(key);
|
||||
const latestKeyEntry = group.getRaw(latestKey);
|
||||
|
||||
if (keyEntry && latestKeyEntry && keyEntry.madeAt > latestKeyEntry.madeAt) {
|
||||
latestKey = key;
|
||||
}
|
||||
}
|
||||
|
||||
if (latestKey) {
|
||||
group._lastReadableKeyId = latestKey.replace("_for_everyone", "") as KeyID;
|
||||
}
|
||||
}
|
||||
|
||||
/** A `Group` is a scope for permissions of its members (`"reader" | "writer" | "admin"`), applying to objects owned by that group.
|
||||
*
|
||||
* A `Group` object exposes methods for permission management and allows you to create new CoValues owned by that group.
|
||||
@@ -86,6 +144,8 @@ export class RawGroup<
|
||||
> extends RawCoMap<GroupShape, Meta> {
|
||||
protected readonly crypto: CryptoProvider;
|
||||
|
||||
_lastReadableKeyId?: KeyID;
|
||||
|
||||
constructor(
|
||||
core: AvailableCoValueCore,
|
||||
options?: {
|
||||
@@ -94,6 +154,8 @@ export class RawGroup<
|
||||
) {
|
||||
super(core, options);
|
||||
this.crypto = core.node.crypto;
|
||||
|
||||
healMissingKeyForEveryone(this);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -191,43 +253,7 @@ export class RawGroup<
|
||||
return groups;
|
||||
}
|
||||
|
||||
loadAllChildGroups() {
|
||||
const requests: Promise<unknown>[] = [];
|
||||
const peers = this.core.node.syncManager.getServerPeers();
|
||||
|
||||
for (const key of this.keys()) {
|
||||
if (!isChildGroupReference(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const id = getChildGroupId(key);
|
||||
const child = this.core.node.getCoValue(id);
|
||||
|
||||
if (
|
||||
child.loadingState === "unknown" ||
|
||||
child.loadingState === "unavailable"
|
||||
) {
|
||||
child.load(peers);
|
||||
}
|
||||
|
||||
requests.push(
|
||||
child.waitForAvailableOrUnavailable().then((coValue) => {
|
||||
if (!coValue.isAvailable()) {
|
||||
throw new Error(`Child group ${child.id} is unavailable`);
|
||||
}
|
||||
|
||||
// Recursively load child groups
|
||||
return expectGroup(coValue.getCurrentContent()).loadAllChildGroups();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.all(requests);
|
||||
}
|
||||
|
||||
getChildGroups() {
|
||||
const groups: RawGroup[] = [];
|
||||
|
||||
forEachChildGroup(callback: (child: RawGroup) => void) {
|
||||
for (const key of this.keys()) {
|
||||
if (isChildGroupReference(key)) {
|
||||
// Check if the child group reference is revoked
|
||||
@@ -235,15 +261,22 @@ export class RawGroup<
|
||||
continue;
|
||||
}
|
||||
|
||||
const child = this.core.node.expectCoValueLoaded(
|
||||
getChildGroupId(key),
|
||||
"Expected child group to be loaded",
|
||||
);
|
||||
groups.push(expectGroup(child.getCurrentContent()));
|
||||
const id = getChildGroupId(key);
|
||||
const child = this.core.node.getCoValue(id);
|
||||
|
||||
if (child.isAvailable()) {
|
||||
callback(expectGroup(child.getCurrentContent()));
|
||||
} else {
|
||||
this.core.node.load(id).then((child) => {
|
||||
if (child !== "unavailable") {
|
||||
callback(expectGroup(child));
|
||||
} else {
|
||||
logger.warn(`Unable to load child group ${id}, skipping`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -279,7 +312,7 @@ export class RawGroup<
|
||||
"Can't make everyone something other than reader, writer or writeOnly",
|
||||
);
|
||||
}
|
||||
const currentReadKey = this.core.getCurrentReadKey();
|
||||
const currentReadKey = this.getCurrentReadKey();
|
||||
|
||||
if (!currentReadKey.secret) {
|
||||
throw new Error("Can't add member without read key secret");
|
||||
@@ -306,7 +339,7 @@ export class RawGroup<
|
||||
|
||||
if (role === "writeOnly") {
|
||||
if (previousRole === "reader" || previousRole === "writer") {
|
||||
this.rotateReadKey();
|
||||
this.rotateReadKey("everyone");
|
||||
}
|
||||
|
||||
this.delete(`${currentReadKey.id}_for_${EVERYONE}`);
|
||||
@@ -349,7 +382,7 @@ export class RawGroup<
|
||||
|
||||
this.internalCreateWriteOnlyKeyForMember(memberKey, agent);
|
||||
} else {
|
||||
const currentReadKey = this.core.getCurrentReadKey();
|
||||
const currentReadKey = this.getCurrentReadKey();
|
||||
|
||||
if (!currentReadKey.secret) {
|
||||
throw new Error("Can't add member without read key secret");
|
||||
@@ -467,6 +500,10 @@ export class RawGroup<
|
||||
}
|
||||
|
||||
getCurrentReadKeyId() {
|
||||
if (this._lastReadableKeyId) {
|
||||
return this._lastReadableKeyId;
|
||||
}
|
||||
|
||||
const myRole = this.myRole();
|
||||
|
||||
if (myRole === "writeOnly") {
|
||||
@@ -518,23 +555,173 @@ export class RawGroup<
|
||||
return memberKeys;
|
||||
}
|
||||
|
||||
getReadKey(keyID: KeyID): KeySecret | undefined {
|
||||
const cache = this.core.readKeyCache;
|
||||
|
||||
let key = cache.get(keyID);
|
||||
if (!key) {
|
||||
key = this.getUncachedReadKey(keyID);
|
||||
if (key) {
|
||||
cache.set(keyID, key);
|
||||
}
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
getUncachedReadKey(keyID: KeyID) {
|
||||
const core = this.core;
|
||||
|
||||
const keyForEveryone = this.get(`${keyID}_for_everyone`);
|
||||
if (keyForEveryone) {
|
||||
return keyForEveryone;
|
||||
}
|
||||
|
||||
// Try to find key revelation for us
|
||||
const currentAgentOrAccountID = accountOrAgentIDfromSessionID(
|
||||
core.node.currentSessionID,
|
||||
);
|
||||
|
||||
// being careful here to avoid recursion
|
||||
const lookupAccountOrAgentID = isAccountID(currentAgentOrAccountID)
|
||||
? core.id === currentAgentOrAccountID
|
||||
? core.node.crypto.getAgentID(core.node.agentSecret) // in accounts, the read key is revealed for the primitive agent
|
||||
: currentAgentOrAccountID // current account ID
|
||||
: currentAgentOrAccountID; // current agent ID
|
||||
|
||||
const lastReadyKeyEdit = this.lastEditAt(
|
||||
`${keyID}_for_${lookupAccountOrAgentID}`,
|
||||
);
|
||||
|
||||
if (lastReadyKeyEdit?.value) {
|
||||
const revealer = lastReadyKeyEdit.by;
|
||||
const revealerAgent = core.node
|
||||
.resolveAccountAgent(revealer, "Expected to know revealer")
|
||||
._unsafeUnwrap({ withStackTrace: true });
|
||||
|
||||
const secret = this.crypto.unseal(
|
||||
lastReadyKeyEdit.value,
|
||||
this.crypto.getAgentSealerSecret(core.node.agentSecret), // being careful here to avoid recursion
|
||||
this.crypto.getAgentSealerID(revealerAgent),
|
||||
{
|
||||
in: this.id,
|
||||
tx: lastReadyKeyEdit.tx,
|
||||
},
|
||||
);
|
||||
|
||||
if (secret) {
|
||||
return secret as KeySecret;
|
||||
}
|
||||
}
|
||||
|
||||
// Try to find indirect revelation through previousKeys
|
||||
for (const co of this.keys()) {
|
||||
if (isKeyForKeyField(co) && co.startsWith(keyID)) {
|
||||
const encryptingKeyID = co.split("_for_")[1] as KeyID;
|
||||
const encryptingKeySecret = this.getReadKey(encryptingKeyID);
|
||||
|
||||
if (!encryptingKeySecret) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const encryptedPreviousKey = this.get(co)!;
|
||||
|
||||
const secret = this.crypto.decryptKeySecret(
|
||||
{
|
||||
encryptedID: keyID,
|
||||
encryptingID: encryptingKeyID,
|
||||
encrypted: encryptedPreviousKey,
|
||||
},
|
||||
encryptingKeySecret,
|
||||
);
|
||||
|
||||
if (secret) {
|
||||
return secret as KeySecret;
|
||||
} else {
|
||||
logger.warn(
|
||||
`Encrypting ${encryptingKeyID} key didn't decrypt ${keyID}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// try to find revelation to parent group read keys
|
||||
for (const co of this.keys()) {
|
||||
if (isParentGroupReference(co)) {
|
||||
const parentGroupID = getParentGroupId(co);
|
||||
const parentGroup = core.node.expectCoValueLoaded(
|
||||
parentGroupID,
|
||||
"Expected parent group to be loaded",
|
||||
);
|
||||
|
||||
const parentKeys = this.findValidParentKeys(keyID, parentGroup);
|
||||
|
||||
for (const parentKey of parentKeys) {
|
||||
const revelationForParentKey = this.get(
|
||||
`${keyID}_for_${parentKey.id}`,
|
||||
);
|
||||
|
||||
if (revelationForParentKey) {
|
||||
const secret = parentGroup.node.crypto.decryptKeySecret(
|
||||
{
|
||||
encryptedID: keyID,
|
||||
encryptingID: parentKey.id,
|
||||
encrypted: revelationForParentKey,
|
||||
},
|
||||
parentKey.secret,
|
||||
);
|
||||
|
||||
if (secret) {
|
||||
return secret as KeySecret;
|
||||
} else {
|
||||
logger.warn(
|
||||
`Encrypting parent ${parentKey.id} key didn't decrypt ${keyID}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
findValidParentKeys(keyID: KeyID, parentGroup: CoValueCore) {
|
||||
const validParentKeys: { id: KeyID; secret: KeySecret }[] = [];
|
||||
|
||||
for (const co of this.keys()) {
|
||||
if (isKeyForKeyField(co) && co.startsWith(keyID)) {
|
||||
const encryptingKeyID = co.split("_for_")[1] as KeyID;
|
||||
const encryptingKeySecret = parentGroup.getReadKey(encryptingKeyID);
|
||||
|
||||
if (!encryptingKeySecret) {
|
||||
continue;
|
||||
}
|
||||
|
||||
validParentKeys.push({
|
||||
id: encryptingKeyID,
|
||||
secret: encryptingKeySecret,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return validParentKeys;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
rotateReadKey(removedMemberKey?: RawAccountID | AgentID | "everyone") {
|
||||
if (removedMemberKey !== EVERYONE && canRead(this, EVERYONE)) {
|
||||
// When everyone has access to the group, rotating the key is useless
|
||||
// because it would be stored unencrypted and available to everyone
|
||||
return;
|
||||
}
|
||||
|
||||
const memberKeys = this.getMemberKeys().filter(
|
||||
(key) => key !== removedMemberKey,
|
||||
);
|
||||
|
||||
const currentlyPermittedReaders = memberKeys.filter((key) => {
|
||||
const role = this.get(key);
|
||||
return (
|
||||
role === "admin" ||
|
||||
role === "writer" ||
|
||||
role === "reader" ||
|
||||
role === "adminInvite" ||
|
||||
role === "writerInvite" ||
|
||||
role === "readerInvite"
|
||||
);
|
||||
});
|
||||
const currentlyPermittedReaders = memberKeys.filter((key) =>
|
||||
canRead(this, key),
|
||||
);
|
||||
|
||||
const writeOnlyMembers = memberKeys.filter((key) => {
|
||||
const role = this.get(key);
|
||||
@@ -543,12 +730,12 @@ export class RawGroup<
|
||||
|
||||
// Get these early, so we fail fast if they are unavailable
|
||||
const parentGroups = this.getParentGroups();
|
||||
const childGroups = this.getChildGroups();
|
||||
|
||||
const maybeCurrentReadKey = this.core.getCurrentReadKey();
|
||||
const maybeCurrentReadKey = this.getCurrentReadKey();
|
||||
|
||||
if (!maybeCurrentReadKey.secret) {
|
||||
throw new Error("Can't rotate read key secret we don't have access to");
|
||||
throw new NoReadKeyAccessError(
|
||||
"Can't rotate read key secret we don't have access to",
|
||||
);
|
||||
}
|
||||
|
||||
const currentReadKey = {
|
||||
@@ -631,7 +818,7 @@ export class RawGroup<
|
||||
*/
|
||||
for (const parent of parentGroups) {
|
||||
const { id: parentReadKeyID, secret: parentReadKeySecret } =
|
||||
parent.core.getCurrentReadKey();
|
||||
parent.getCurrentReadKey();
|
||||
|
||||
if (!parentReadKeySecret) {
|
||||
// We can't reveal the new child key to the parent group where we don't have access to the parent read key
|
||||
@@ -655,33 +842,67 @@ export class RawGroup<
|
||||
);
|
||||
}
|
||||
|
||||
for (const child of childGroups) {
|
||||
this.forEachChildGroup((child) => {
|
||||
// Since child references are mantained only for the key rotation,
|
||||
// circular references are skipped here because it's more performant
|
||||
// than always checking for circular references in childs inside the permission checks
|
||||
if (child.isSelfExtension(this)) {
|
||||
continue;
|
||||
return;
|
||||
}
|
||||
|
||||
child.rotateReadKey(removedMemberKey);
|
||||
}
|
||||
try {
|
||||
child.rotateReadKey(removedMemberKey);
|
||||
} catch (error) {
|
||||
if (error instanceof NoReadKeyAccessError) {
|
||||
logger.warn(
|
||||
`Can't rotate read key on child ${child.id} because we don't have access to the read key`,
|
||||
);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Detect circular references in group inheritance */
|
||||
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;
|
||||
}
|
||||
|
||||
checkedGroups.add(current.id);
|
||||
|
||||
const parentGroups = current.getParentGroups();
|
||||
|
||||
for (const parent of parentGroups) {
|
||||
if (!checkedGroups.has(parent.id)) {
|
||||
queue.push(parent);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getCurrentReadKey() {
|
||||
const keyId = this.getCurrentReadKeyId();
|
||||
|
||||
if (!keyId) {
|
||||
throw new Error("No readKey set");
|
||||
}
|
||||
|
||||
return false;
|
||||
return {
|
||||
secret: this.getReadKey(keyId),
|
||||
id: keyId,
|
||||
};
|
||||
}
|
||||
|
||||
extend(
|
||||
@@ -700,8 +921,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" &&
|
||||
@@ -716,14 +937,15 @@ export class RawGroup<
|
||||
);
|
||||
}
|
||||
|
||||
const { id: parentReadKeyID, secret: parentReadKeySecret } =
|
||||
parent.core.getCurrentReadKey();
|
||||
let { id: parentReadKeyID, secret: parentReadKeySecret } =
|
||||
parent.getCurrentReadKey();
|
||||
|
||||
if (!parentReadKeySecret) {
|
||||
throw new Error("Can't extend group without parent read key secret");
|
||||
}
|
||||
|
||||
const { id: childReadKeyID, secret: childReadKeySecret } =
|
||||
this.core.getCurrentReadKey();
|
||||
this.getCurrentReadKey();
|
||||
if (!childReadKeySecret) {
|
||||
throw new Error("Can't extend group without child read key secret");
|
||||
}
|
||||
@@ -744,7 +966,7 @@ export class RawGroup<
|
||||
);
|
||||
}
|
||||
|
||||
async revokeExtend(parent: RawGroup) {
|
||||
revokeExtend(parent: RawGroup) {
|
||||
if (this.myRole() !== "admin") {
|
||||
throw new Error(
|
||||
"To unextend a group, the current account must be an admin in the child group",
|
||||
@@ -775,8 +997,6 @@ export class RawGroup<
|
||||
// Set the child key on the parent group to `revoked`
|
||||
parent.set(`child_${this.id}`, "revoked", "trusting");
|
||||
|
||||
await this.loadAllChildGroups();
|
||||
|
||||
// Rotate the keys on the child group
|
||||
this.rotateReadKey();
|
||||
}
|
||||
@@ -788,19 +1008,7 @@ export class RawGroup<
|
||||
*
|
||||
* @category 2. Role changing
|
||||
*/
|
||||
async removeMember(
|
||||
account: RawAccount | ControlledAccountOrAgent | Everyone,
|
||||
) {
|
||||
// Ensure all child groups are loaded before removing a member
|
||||
await this.loadAllChildGroups();
|
||||
|
||||
this.removeMemberInternal(account);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
removeMemberInternal(
|
||||
account: RawAccount | ControlledAccountOrAgent | AgentID | Everyone,
|
||||
) {
|
||||
removeMember(account: RawAccount | ControlledAccountOrAgent | Everyone) {
|
||||
const memberKey = typeof account === "string" ? account : account.id;
|
||||
|
||||
if (this.myRole() === "admin") {
|
||||
@@ -1011,3 +1219,25 @@ export function secretSeedFromInviteSecret(inviteSecret: InviteSecret) {
|
||||
|
||||
return base58.decode(inviteSecret.slice("inviteSecret_z".length));
|
||||
}
|
||||
|
||||
const canRead = (
|
||||
group: RawGroup,
|
||||
key: RawAccountID | AgentID | "everyone",
|
||||
): boolean => {
|
||||
const role = group.get(key);
|
||||
return (
|
||||
role === "admin" ||
|
||||
role === "writer" ||
|
||||
role === "reader" ||
|
||||
role === "adminInvite" ||
|
||||
role === "writerInvite" ||
|
||||
role === "readerInvite"
|
||||
);
|
||||
};
|
||||
|
||||
class NoReadKeyAccessError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "NoReadKeyAccessError";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ import { DisconnectedError, SyncManager, emptyKnownState } from "./sync.js";
|
||||
|
||||
type Value = JsonValue | AnyRawCoValue;
|
||||
|
||||
export { PriorityBasedMessageQueue } from "./PriorityBasedMessageQueue.js";
|
||||
export { PriorityBasedMessageQueue } from "./queue/PriorityBasedMessageQueue.js";
|
||||
import { getDependedOnCoValuesFromRawData } from "./coValueCore/utils.js";
|
||||
import {
|
||||
CO_VALUE_LOADING_CONFIG,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { base58 } from "@scure/base";
|
||||
import { CoID } from "./coValue.js";
|
||||
import { RawAccountID } from "./coValues/account.js";
|
||||
import type { CoID } from "./coValue.js";
|
||||
import type { RawAccountID } from "./coValues/account.js";
|
||||
import type { RawGroup } from "./coValues/group.js";
|
||||
import { shortHashLength } from "./crypto/crypto.js";
|
||||
import { RawGroup } from "./exports.js";
|
||||
|
||||
export type RawCoID = `co_z${string}`;
|
||||
export type ParentGroupReference = `parent_${CoID<RawGroup>}`;
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { Result, err, ok } from "neverthrow";
|
||||
import { CoID } from "./coValue.js";
|
||||
import { RawCoValue } from "./coValue.js";
|
||||
import type { CoID } from "./coValue.js";
|
||||
import type { RawCoValue } from "./coValue.js";
|
||||
import {
|
||||
AvailableCoValueCore,
|
||||
type AvailableCoValueCore,
|
||||
CoValueCore,
|
||||
idforHeader,
|
||||
} from "./coValueCore/coValueCore.js";
|
||||
import {
|
||||
CoValueHeader,
|
||||
CoValueUniqueness,
|
||||
type CoValueHeader,
|
||||
type CoValueUniqueness,
|
||||
VerifiedState,
|
||||
} from "./coValueCore/verifiedState.js";
|
||||
import {
|
||||
@@ -26,8 +26,8 @@ import {
|
||||
expectAccount,
|
||||
} from "./coValues/account.js";
|
||||
import {
|
||||
InviteSecret,
|
||||
RawGroup,
|
||||
type InviteSecret,
|
||||
type RawGroup,
|
||||
secretSeedFromInviteSecret,
|
||||
} from "./coValues/group.js";
|
||||
import { CO_VALUE_LOADING_CONFIG } from "./config.js";
|
||||
@@ -313,6 +313,15 @@ export class LocalNode {
|
||||
throw new Error("Account has no profile");
|
||||
}
|
||||
|
||||
const rootID = account.get("root");
|
||||
if (rootID) {
|
||||
const rawEntry = account.getRaw("root");
|
||||
|
||||
if (!rawEntry?.trusting) {
|
||||
account.set("root", rootID, "trusting");
|
||||
}
|
||||
}
|
||||
|
||||
// Preload the profile
|
||||
await node.load(profileID);
|
||||
|
||||
@@ -342,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;
|
||||
}
|
||||
@@ -729,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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { CoID } from "./coValue.js";
|
||||
import { CoValueCore } from "./coValueCore/coValueCore.js";
|
||||
import { Transaction } from "./coValueCore/verifiedState.js";
|
||||
import { RawAccount, RawAccountID, RawProfile } from "./coValues/account.js";
|
||||
import { MapOpPayload } from "./coValues/coMap.js";
|
||||
import { MapOpPayload, RawCoMap } from "./coValues/coMap.js";
|
||||
import {
|
||||
EVERYONE,
|
||||
Everyone,
|
||||
@@ -270,6 +270,7 @@ function determineValidTransactionsForGroup(
|
||||
| MapOpPayload<RawAccountID | AgentID | Everyone, Role>
|
||||
| MapOpPayload<"readKey", JsonValue>
|
||||
| MapOpPayload<"profile", CoID<RawProfile>>
|
||||
| MapOpPayload<"root", CoID<RawCoMap>>
|
||||
| MapOpPayload<`parent_${CoID<RawGroup>}`, CoID<RawGroup>>
|
||||
| MapOpPayload<`child_${CoID<RawGroup>}`, CoID<RawGroup>>;
|
||||
|
||||
@@ -297,6 +298,14 @@ function determineValidTransactionsForGroup(
|
||||
continue;
|
||||
}
|
||||
|
||||
validTransactions.push({ txID: { sessionID, txIndex }, tx });
|
||||
continue;
|
||||
} else if (change.key === "root") {
|
||||
if (memberState[transactor] !== "admin") {
|
||||
logPermissionError("Only admins can set root");
|
||||
continue;
|
||||
}
|
||||
|
||||
validTransactions.push({ txID: { sessionID, txIndex }, tx });
|
||||
continue;
|
||||
} else if (
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Counter, ValueType, metrics } from "@opentelemetry/api";
|
||||
import type { PeerState } from "./PeerState.js";
|
||||
import { LinkedList } from "./PriorityBasedMessageQueue.js";
|
||||
import { SYNC_SCHEDULER_CONFIG } from "./config.js";
|
||||
import { logger } from "./logger.js";
|
||||
import type { SyncMessage } from "./sync.js";
|
||||
import type { PeerState } from "../PeerState.js";
|
||||
import { SYNC_SCHEDULER_CONFIG } from "../config.js";
|
||||
import { logger } from "../logger.js";
|
||||
import type { SyncMessage } from "../sync.js";
|
||||
import { LinkedList } from "./LinkedList.js";
|
||||
|
||||
/**
|
||||
* A queue that schedules messages across different peers using a round-robin approach.
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Counter, ValueType, metrics } from "@opentelemetry/api";
|
||||
import { CO_VALUE_PRIORITY, type CoValuePriority } from "./priority.js";
|
||||
import type { SyncMessage } from "./sync.js";
|
||||
import type { SyncMessage } from "../sync.js";
|
||||
|
||||
/**
|
||||
* Since we have a fixed range of priority values (0-7) we can create a fixed array of queues.
|
||||
@@ -10,18 +9,16 @@ type Tuple<T, N extends number, A extends unknown[] = []> = A extends {
|
||||
}
|
||||
? A
|
||||
: Tuple<T, N, [...A, T]>;
|
||||
|
||||
type QueueTuple = Tuple<LinkedList<SyncMessage>, 3>;
|
||||
|
||||
export type QueueTuple = Tuple<LinkedList<SyncMessage>, 3>;
|
||||
type LinkedListNode<T> = {
|
||||
value: T;
|
||||
next: LinkedListNode<T> | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Using a linked list to make the shift operation O(1) instead of O(n)
|
||||
* as our queues can grow very large when the system is under pressure.
|
||||
*/
|
||||
|
||||
export class LinkedList<T> {
|
||||
constructor(private meter?: QueueMeter) {}
|
||||
|
||||
@@ -70,7 +67,6 @@ export class LinkedList<T> {
|
||||
return this.head === undefined;
|
||||
}
|
||||
}
|
||||
|
||||
class QueueMeter {
|
||||
private pullCounter: Counter;
|
||||
private pushCounter: Counter;
|
||||
@@ -111,52 +107,9 @@ class QueueMeter {
|
||||
this.pushCounter.add(1, this.attrs);
|
||||
}
|
||||
}
|
||||
|
||||
function meteredList<T>(
|
||||
export function meteredList<T>(
|
||||
type: "incoming" | "outgoing",
|
||||
attrs?: Record<string, string | number>,
|
||||
) {
|
||||
return new LinkedList<T>(new QueueMeter("jazz.messagequeue." + type, attrs));
|
||||
}
|
||||
|
||||
const PRIORITY_TO_QUEUE_INDEX = {
|
||||
[CO_VALUE_PRIORITY.HIGH]: 0,
|
||||
[CO_VALUE_PRIORITY.MEDIUM]: 1,
|
||||
[CO_VALUE_PRIORITY.LOW]: 2,
|
||||
} as const;
|
||||
|
||||
export class PriorityBasedMessageQueue {
|
||||
private queues: QueueTuple;
|
||||
|
||||
constructor(
|
||||
private defaultPriority: CoValuePriority,
|
||||
type: "incoming" | "outgoing",
|
||||
/**
|
||||
* Optional attributes to be added to the generated metrics.
|
||||
* By default the metrics will have the priority as an attribute.
|
||||
*/
|
||||
attrs?: Record<string, string | number>,
|
||||
) {
|
||||
this.queues = [
|
||||
meteredList(type, { priority: CO_VALUE_PRIORITY.HIGH, ...attrs }),
|
||||
meteredList(type, { priority: CO_VALUE_PRIORITY.MEDIUM, ...attrs }),
|
||||
meteredList(type, { priority: CO_VALUE_PRIORITY.LOW, ...attrs }),
|
||||
];
|
||||
}
|
||||
|
||||
private getQueue(priority: CoValuePriority) {
|
||||
return this.queues[PRIORITY_TO_QUEUE_INDEX[priority]];
|
||||
}
|
||||
|
||||
public push(msg: SyncMessage) {
|
||||
const priority = "priority" in msg ? msg.priority : this.defaultPriority;
|
||||
|
||||
this.getQueue(priority).push(msg);
|
||||
}
|
||||
|
||||
public pull() {
|
||||
const priority = this.queues.findIndex((queue) => queue.length > 0);
|
||||
|
||||
return this.queues[priority]?.shift();
|
||||
}
|
||||
}
|
||||
96
packages/cojson/src/queue/LocalTransactionsSyncQueue.ts
Normal file
96
packages/cojson/src/queue/LocalTransactionsSyncQueue.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
45
packages/cojson/src/queue/PriorityBasedMessageQueue.ts
Normal file
45
packages/cojson/src/queue/PriorityBasedMessageQueue.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { CO_VALUE_PRIORITY, type CoValuePriority } from "../priority.js";
|
||||
import type { SyncMessage } from "../sync.js";
|
||||
import { QueueTuple, meteredList } from "./LinkedList.js";
|
||||
|
||||
const PRIORITY_TO_QUEUE_INDEX = {
|
||||
[CO_VALUE_PRIORITY.HIGH]: 0,
|
||||
[CO_VALUE_PRIORITY.MEDIUM]: 1,
|
||||
[CO_VALUE_PRIORITY.LOW]: 2,
|
||||
} as const;
|
||||
|
||||
export class PriorityBasedMessageQueue {
|
||||
private queues: QueueTuple;
|
||||
|
||||
constructor(
|
||||
private defaultPriority: CoValuePriority,
|
||||
type: "incoming" | "outgoing",
|
||||
/**
|
||||
* Optional attributes to be added to the generated metrics.
|
||||
* By default the metrics will have the priority as an attribute.
|
||||
*/
|
||||
attrs?: Record<string, string | number>,
|
||||
) {
|
||||
this.queues = [
|
||||
meteredList(type, { priority: CO_VALUE_PRIORITY.HIGH, ...attrs }),
|
||||
meteredList(type, { priority: CO_VALUE_PRIORITY.MEDIUM, ...attrs }),
|
||||
meteredList(type, { priority: CO_VALUE_PRIORITY.LOW, ...attrs }),
|
||||
];
|
||||
}
|
||||
|
||||
private getQueue(priority: CoValuePriority) {
|
||||
return this.queues[PRIORITY_TO_QUEUE_INDEX[priority]];
|
||||
}
|
||||
|
||||
public push(msg: SyncMessage) {
|
||||
const priority = "priority" in msg ? msg.priority : this.defaultPriority;
|
||||
|
||||
this.getQueue(priority).push(msg);
|
||||
}
|
||||
|
||||
public pull() {
|
||||
const priority = this.queues.findIndex((queue) => queue.length > 0);
|
||||
|
||||
return this.queues[priority]?.shift();
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,22 @@
|
||||
import { LinkedList } from "../PriorityBasedMessageQueue.js";
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,29 @@
|
||||
import { LinkedList } from "../PriorityBasedMessageQueue.js";
|
||||
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,
|
||||
NewContentMessage,
|
||||
emptyKnownState,
|
||||
} from "../sync.js";
|
||||
import { StoreQueue } from "./StoreQueue.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,
|
||||
@@ -83,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;
|
||||
@@ -90,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"];
|
||||
@@ -137,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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -195,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);
|
||||
|
||||
@@ -232,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
|
||||
@@ -277,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;
|
||||
@@ -291,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");
|
||||
|
||||
@@ -331,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({
|
||||
@@ -361,7 +383,6 @@ export class StorageApiAsync implements StorageAPI {
|
||||
}
|
||||
|
||||
close() {
|
||||
// Drain the store queue
|
||||
this.storeQueue.drain();
|
||||
return this.storeQueue.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
import { Histogram, ValueType, metrics } from "@opentelemetry/api";
|
||||
import { IncomingMessagesQueue } from "./IncomingMessagesQueue.js";
|
||||
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);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { IncomingMessagesQueue } from "../IncomingMessagesQueue.js";
|
||||
import { PeerState } from "../PeerState.js";
|
||||
import { IncomingMessagesQueue } from "../queue/IncomingMessagesQueue.js";
|
||||
import { ConnectedPeerChannel } from "../streamUtils.js";
|
||||
import { Peer, SyncMessage } from "../sync.js";
|
||||
import {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { LinkedList } from "../PriorityBasedMessageQueue";
|
||||
import { LinkedList } from "../queue/LinkedList.js";
|
||||
|
||||
describe("LinkedList", () => {
|
||||
let list: LinkedList<number>;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { PriorityBasedMessageQueue } from "../PriorityBasedMessageQueue.js";
|
||||
import { CO_VALUE_PRIORITY } from "../priority.js";
|
||||
import { PriorityBasedMessageQueue } from "../queue/PriorityBasedMessageQueue.js";
|
||||
import type { SyncMessage } from "../sync.js";
|
||||
import {
|
||||
createTestMetricReader,
|
||||
|
||||
829
packages/cojson/src/tests/StorageApiAsync.test.ts
Normal file
829
packages/cojson/src/tests/StorageApiAsync.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
628
packages/cojson/src/tests/StorageApiSync.test.ts
Normal file
628
packages/cojson/src/tests/StorageApiSync.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,14 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { StoreQueue } from "../storage/StoreQueue.js";
|
||||
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();
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -74,6 +74,69 @@ test("Can create account with one node, and then load it on another", async () =
|
||||
expect(map2.get("foo")).toEqual("bar");
|
||||
});
|
||||
|
||||
test("Should migrate the root from private to trusting", async () => {
|
||||
const { node, accountID, accountSecret } =
|
||||
await LocalNode.withNewlyCreatedAccount({
|
||||
creationProps: { name: "Hermes Puggington" },
|
||||
crypto: Crypto,
|
||||
});
|
||||
|
||||
const group = await node.createGroup();
|
||||
expect(group).not.toBeNull();
|
||||
|
||||
const map = group.createMap();
|
||||
map.set("foo", "bar", "private");
|
||||
expect(map.get("foo")).toEqual("bar");
|
||||
|
||||
const peers1 = connectedPeers("node1", "node2", {
|
||||
peer1role: "server",
|
||||
peer2role: "client",
|
||||
});
|
||||
|
||||
const account = await node.load(accountID);
|
||||
if (account === "unavailable") throw new Error("Account unavailable");
|
||||
|
||||
account.set("root", map.id, "private");
|
||||
|
||||
node.syncManager.addPeer(peers1[1]);
|
||||
|
||||
const node2 = await LocalNode.withLoadedAccount({
|
||||
accountID,
|
||||
accountSecret,
|
||||
sessionID: Crypto.newRandomSessionID(accountID),
|
||||
peersToLoadFrom: [peers1[0]],
|
||||
crypto: Crypto,
|
||||
});
|
||||
|
||||
const account2 = await node2.load(accountID);
|
||||
if (account2 === "unavailable") throw new Error("Account unavailable");
|
||||
|
||||
expect(account2.getRaw("root")?.trusting).toEqual(true);
|
||||
|
||||
node2.gracefulShutdown(); // Stop getting updates from node1
|
||||
|
||||
const peers2 = connectedPeers("node2", "node3", {
|
||||
peer1role: "server",
|
||||
peer2role: "client",
|
||||
});
|
||||
|
||||
node.syncManager.addPeer(peers2[1]);
|
||||
|
||||
const node3 = await LocalNode.withLoadedAccount({
|
||||
accountID,
|
||||
accountSecret,
|
||||
sessionID: Crypto.newRandomSessionID(accountID),
|
||||
peersToLoadFrom: [peers2[0]],
|
||||
crypto: Crypto,
|
||||
});
|
||||
|
||||
const account3 = await node3.load(accountID);
|
||||
if (account3 === "unavailable") throw new Error("Account unavailable");
|
||||
|
||||
expect(account3.getRaw("root")?.trusting).toEqual(true);
|
||||
expect(account3.ops).toEqual(account2.ops); // No new transactions were made
|
||||
});
|
||||
|
||||
test("throws an error if the user tried to create an invite from an account", async () => {
|
||||
const { node, accountID } = await LocalNode.withNewlyCreatedAccount({
|
||||
creationProps: { name: "Hermes Puggington" },
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { beforeEach, describe, expect, test } from "vitest";
|
||||
import type { CoID, RawGroup } from "../exports";
|
||||
import { NewContentMessage } from "../sync";
|
||||
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");
|
||||
@@ -87,6 +98,32 @@ describe("extend", () => {
|
||||
expect(mapOnNode2.get("test")).toEqual("Written from node2");
|
||||
});
|
||||
|
||||
test("inherited everyone roles should work correctly", async () => {
|
||||
const { node1, node2 } = await createTwoConnectedNodes("server", "server");
|
||||
|
||||
const group = node1.node.createGroup();
|
||||
group.addMember("everyone", "writer");
|
||||
|
||||
const childGroup = node1.node.createGroup();
|
||||
childGroup.extend(group);
|
||||
|
||||
expect(childGroup.roleOf("everyone")).toEqual("writer");
|
||||
|
||||
const map = childGroup.createMap();
|
||||
map.set("test", "Written from the admin");
|
||||
|
||||
await map.core.waitForSync();
|
||||
|
||||
const mapOnNode2 = await loadCoValueOrFail(node2.node, map.id);
|
||||
|
||||
// The writer role should be able to see the edits from the admin
|
||||
expect(mapOnNode2.get("test")).toEqual("Written from the admin");
|
||||
|
||||
mapOnNode2.set("hello", "from node 2");
|
||||
|
||||
expect(mapOnNode2.get("hello")).toEqual("from node 2");
|
||||
});
|
||||
|
||||
test("a user should be able to extend a group when his role on the parent group is writeOnly", async () => {
|
||||
const { node1, node2 } = await createTwoConnectedNodes("server", "server");
|
||||
|
||||
@@ -143,6 +180,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");
|
||||
|
||||
@@ -180,6 +343,257 @@ describe("extend", () => {
|
||||
|
||||
expect(childGroup.roleOf(alice.id)).toBe("writer");
|
||||
});
|
||||
|
||||
test("should be possible to extend a group after getting revoked from the parent group", async () => {
|
||||
const { node1, node2, node3 } = await createThreeConnectedNodes(
|
||||
"server",
|
||||
"server",
|
||||
"server",
|
||||
);
|
||||
|
||||
const parentGroup = node1.node.createGroup();
|
||||
|
||||
const alice = await loadCoValueOrFail(node1.node, node3.accountID);
|
||||
const bob = await loadCoValueOrFail(node1.node, node2.accountID);
|
||||
parentGroup.addMember(alice, "writer");
|
||||
parentGroup.addMember(bob, "reader");
|
||||
parentGroup.removeMember(bob);
|
||||
|
||||
const parentGroupOnNode2 = await loadCoValueOrFail(
|
||||
node2.node,
|
||||
parentGroup.id,
|
||||
);
|
||||
|
||||
const childGroup = node2.node.createGroup();
|
||||
childGroup.extend(parentGroupOnNode2);
|
||||
|
||||
expect(childGroup.roleOf(alice.id)).toBe("writer");
|
||||
});
|
||||
|
||||
test("should be possible to extend when access is everyone reader and the account is revoked from the parent group", async () => {
|
||||
const { node1, node2, node3 } = await createThreeConnectedNodes(
|
||||
"server",
|
||||
"server",
|
||||
"server",
|
||||
);
|
||||
|
||||
const parentGroup = node1.node.createGroup();
|
||||
parentGroup.addMember("everyone", "reader");
|
||||
const alice = await loadCoValueOrFail(node1.node, node3.accountID);
|
||||
const bob = await loadCoValueOrFail(node1.node, node2.accountID);
|
||||
parentGroup.addMember(alice, "writer");
|
||||
parentGroup.addMember(bob, "reader");
|
||||
parentGroup.removeMember(bob);
|
||||
|
||||
const parentGroupOnNode2 = await loadCoValueOrFail(
|
||||
node2.node,
|
||||
parentGroup.id,
|
||||
);
|
||||
|
||||
const childGroup = node2.node.createGroup();
|
||||
childGroup.extend(parentGroupOnNode2);
|
||||
|
||||
expect(childGroup.roleOf(alice.id)).toBe("writer");
|
||||
});
|
||||
|
||||
test("should be able to extend when the last read key is healed", async () => {
|
||||
const clientWithAccess = setupTestNode({
|
||||
secret:
|
||||
"sealerSecret_zBTPp7U58Fzq9o7EvJpu4KEziepi8QVf2Xaxuy5xmmXFx/signerSecret_z62DuviZdXCjz4EZWofvr9vaLYFXDeTaC9KWhoQiQjzKk",
|
||||
connected: true,
|
||||
});
|
||||
const clientWithoutAccess = setupTestNode({
|
||||
connected: true,
|
||||
});
|
||||
|
||||
const brokenGroupContent = {
|
||||
action: "content",
|
||||
id: "co_zW7F36Nnop9A7Er4gUzBcUXnZCK",
|
||||
header: {
|
||||
type: "comap",
|
||||
ruleset: {
|
||||
type: "group",
|
||||
initialAdmin:
|
||||
"sealer_z12QDazYB3ygPZtBV7sMm7iYKMRnNZ6Aaj1dfLXR7LSBm/signer_z2AskZQbc82qxo7iA3oiXoNExHLsAEXC2pHbwJzRnATWv",
|
||||
},
|
||||
meta: null,
|
||||
createdAt: "2025-08-06T10:14:39.617Z",
|
||||
uniqueness: "z3LJjnuPiPJaf5Qb9A",
|
||||
},
|
||||
priority: 0,
|
||||
new: {
|
||||
"sealer_z12QDazYB3ygPZtBV7sMm7iYKMRnNZ6Aaj1dfLXR7LSBm/signer_z2AskZQbc82qxo7iA3oiXoNExHLsAEXC2pHbwJzRnATWv_session_zYLsz2CiW9pW":
|
||||
{
|
||||
after: 0,
|
||||
newTransactions: [
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279619,
|
||||
changes:
|
||||
'[{"key":"sealer_z12QDazYB3ygPZtBV7sMm7iYKMRnNZ6Aaj1dfLXR7LSBm/signer_z2AskZQbc82qxo7iA3oiXoNExHLsAEXC2pHbwJzRnATWv","op":"set","value":"admin"}]',
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279621,
|
||||
changes:
|
||||
'[{"key":"key_z5CVahfMkEWPj1B3zH_for_sealer_z12QDazYB3ygPZtBV7sMm7iYKMRnNZ6Aaj1dfLXR7LSBm/signer_z2AskZQbc82qxo7iA3oiXoNExHLsAEXC2pHbwJzRnATWv","op":"set","value":"sealed_UCg4UkytXF-W8PaIvaDffO3pZ3d9hdXUuNkQQEikPTAuOD9us92Pqb5Vgu7lx1Fpb0X8V5BJ2yxz6_D5WOzK3qjWBSsc7J1xDJA=="}]',
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279621,
|
||||
changes:
|
||||
'[{"key":"readKey","op":"set","value":"key_z5CVahfMkEWPj1B3zH"}]',
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279622,
|
||||
changes: '[{"key":"everyone","op":"set","value":"reader"}]',
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279623,
|
||||
changes:
|
||||
'[{"key":"key_z5CVahfMkEWPj1B3zH_for_everyone","op":"set","value":"keySecret_z9U9gzkahQXCxDoSw7isiUnbobXwuLdcSkL9Ci6ZEEkaL"}]',
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279623,
|
||||
changes:
|
||||
'[{"key":"key_z4Fi7hZNBx7XoVAKkP_for_sealer_z12QDazYB3ygPZtBV7sMm7iYKMRnNZ6Aaj1dfLXR7LSBm/signer_z2AskZQbc82qxo7iA3oiXoNExHLsAEXC2pHbwJzRnATWv","op":"set","value":"sealed_UuCBBfZkTnRTrGraqWWlzm9JE-VFduhsfu7WaZjpCbJYOTXpPhSNOnzGeS8qVuIsG6dORbME22lc5ltLxPjRqofQdDCNGQehCeQ=="}]',
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279624,
|
||||
changes:
|
||||
'[{"key":"key_z5CVahfMkEWPj1B3zH_for_key_z4Fi7hZNBx7XoVAKkP","op":"set","value":"encrypted_USTrBuobwTCORwy5yHxy4sFZ7swfrafP6k5ZwcTf76f0MBu9Ie-JmsX3mNXad4mluI47gvGXzi8I_"}]',
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279624,
|
||||
changes:
|
||||
'[{"key":"readKey","op":"set","value":"key_z4Fi7hZNBx7XoVAKkP"}]',
|
||||
},
|
||||
],
|
||||
lastSignature:
|
||||
"signature_z3tsE7U1JaeNeUmZ4EY3Xq5uQ9jq9jDi6Rkhdt7T7b7z4NCnpMgB4bo8TwLXYVCrRdBm6PoyyPdK8fYFzHJUh5EzA",
|
||||
},
|
||||
},
|
||||
} as unknown as NewContentMessage;
|
||||
|
||||
clientWithAccess.node.syncManager.handleNewContent(
|
||||
brokenGroupContent,
|
||||
"import",
|
||||
);
|
||||
|
||||
// Load the CoValue to recover the key_for_everyone
|
||||
await loadCoValueOrFail(
|
||||
clientWithAccess.node,
|
||||
brokenGroupContent.id as CoID<RawGroup>,
|
||||
);
|
||||
|
||||
const group = await loadCoValueOrFail(
|
||||
clientWithoutAccess.node,
|
||||
brokenGroupContent.id as CoID<RawGroup>,
|
||||
);
|
||||
const childGroup = clientWithoutAccess.node.createGroup();
|
||||
childGroup.extend(group);
|
||||
|
||||
expect(childGroup.getParentGroups()).toEqual([group]);
|
||||
});
|
||||
|
||||
test("should be able to extend when the last read key is missing", async () => {
|
||||
const clientWithoutAccess = setupTestNode({
|
||||
connected: true,
|
||||
});
|
||||
|
||||
const brokenGroupContent = {
|
||||
action: "content",
|
||||
id: "co_zW7F36Nnop9A7Er4gUzBcUXnZCK",
|
||||
header: {
|
||||
type: "comap",
|
||||
ruleset: {
|
||||
type: "group",
|
||||
initialAdmin:
|
||||
"sealer_z12QDazYB3ygPZtBV7sMm7iYKMRnNZ6Aaj1dfLXR7LSBm/signer_z2AskZQbc82qxo7iA3oiXoNExHLsAEXC2pHbwJzRnATWv",
|
||||
},
|
||||
meta: null,
|
||||
createdAt: "2025-08-06T10:14:39.617Z",
|
||||
uniqueness: "z3LJjnuPiPJaf5Qb9A",
|
||||
},
|
||||
priority: 0,
|
||||
new: {
|
||||
"sealer_z12QDazYB3ygPZtBV7sMm7iYKMRnNZ6Aaj1dfLXR7LSBm/signer_z2AskZQbc82qxo7iA3oiXoNExHLsAEXC2pHbwJzRnATWv_session_zYLsz2CiW9pW":
|
||||
{
|
||||
after: 0,
|
||||
newTransactions: [
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279619,
|
||||
changes:
|
||||
'[{"key":"sealer_z12QDazYB3ygPZtBV7sMm7iYKMRnNZ6Aaj1dfLXR7LSBm/signer_z2AskZQbc82qxo7iA3oiXoNExHLsAEXC2pHbwJzRnATWv","op":"set","value":"admin"}]',
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279621,
|
||||
changes:
|
||||
'[{"key":"key_z5CVahfMkEWPj1B3zH_for_sealer_z12QDazYB3ygPZtBV7sMm7iYKMRnNZ6Aaj1dfLXR7LSBm/signer_z2AskZQbc82qxo7iA3oiXoNExHLsAEXC2pHbwJzRnATWv","op":"set","value":"sealed_UCg4UkytXF-W8PaIvaDffO3pZ3d9hdXUuNkQQEikPTAuOD9us92Pqb5Vgu7lx1Fpb0X8V5BJ2yxz6_D5WOzK3qjWBSsc7J1xDJA=="}]',
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279621,
|
||||
changes:
|
||||
'[{"key":"readKey","op":"set","value":"key_z5CVahfMkEWPj1B3zH"}]',
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279622,
|
||||
changes: '[{"key":"everyone","op":"set","value":"reader"}]',
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279623,
|
||||
changes:
|
||||
'[{"key":"key_z5CVahfMkEWPj1B3zH_for_everyone","op":"set","value":"keySecret_z9U9gzkahQXCxDoSw7isiUnbobXwuLdcSkL9Ci6ZEEkaL"}]',
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279623,
|
||||
changes:
|
||||
'[{"key":"key_z4Fi7hZNBx7XoVAKkP_for_sealer_z12QDazYB3ygPZtBV7sMm7iYKMRnNZ6Aaj1dfLXR7LSBm/signer_z2AskZQbc82qxo7iA3oiXoNExHLsAEXC2pHbwJzRnATWv","op":"set","value":"sealed_UuCBBfZkTnRTrGraqWWlzm9JE-VFduhsfu7WaZjpCbJYOTXpPhSNOnzGeS8qVuIsG6dORbME22lc5ltLxPjRqofQdDCNGQehCeQ=="}]',
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279624,
|
||||
changes:
|
||||
'[{"key":"key_z5CVahfMkEWPj1B3zH_for_key_z4Fi7hZNBx7XoVAKkP","op":"set","value":"encrypted_USTrBuobwTCORwy5yHxy4sFZ7swfrafP6k5ZwcTf76f0MBu9Ie-JmsX3mNXad4mluI47gvGXzi8I_"}]',
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279624,
|
||||
changes:
|
||||
'[{"key":"readKey","op":"set","value":"key_z4Fi7hZNBx7XoVAKkP"}]',
|
||||
},
|
||||
],
|
||||
lastSignature:
|
||||
"signature_z3tsE7U1JaeNeUmZ4EY3Xq5uQ9jq9jDi6Rkhdt7T7b7z4NCnpMgB4bo8TwLXYVCrRdBm6PoyyPdK8fYFzHJUh5EzA",
|
||||
},
|
||||
},
|
||||
} as unknown as NewContentMessage;
|
||||
|
||||
clientWithoutAccess.node.syncManager.handleNewContent(
|
||||
brokenGroupContent,
|
||||
"import",
|
||||
);
|
||||
|
||||
const group = await loadCoValueOrFail(
|
||||
clientWithoutAccess.node,
|
||||
brokenGroupContent.id as CoID<RawGroup>,
|
||||
);
|
||||
const childGroup = clientWithoutAccess.node.createGroup();
|
||||
childGroup.extend(group);
|
||||
|
||||
expect(childGroup.getParentGroups()).toEqual([group]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unextend", () => {
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { beforeEach, describe, expect, test } from "vitest";
|
||||
import { setCoValueLoadingRetryDelay } from "../config.js";
|
||||
import {
|
||||
SyncMessagesLog,
|
||||
TEST_NODE_CONFIG,
|
||||
blockMessageTypeOnOutgoingPeer,
|
||||
loadCoValueOrFail,
|
||||
setupTestAccount,
|
||||
setupTestNode,
|
||||
} from "./testUtils.js";
|
||||
|
||||
setCoValueLoadingRetryDelay(10);
|
||||
|
||||
let jazzCloud: ReturnType<typeof setupTestNode>;
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -15,6 +18,65 @@ beforeEach(async () => {
|
||||
});
|
||||
|
||||
describe("Group.removeMember", () => {
|
||||
test("revoking a member access should not affect everyone access", async () => {
|
||||
const admin = await setupTestAccount({
|
||||
connected: true,
|
||||
});
|
||||
|
||||
const alice = await setupTestAccount({
|
||||
connected: true,
|
||||
});
|
||||
|
||||
const group = admin.node.createGroup();
|
||||
group.addMember("everyone", "writer");
|
||||
|
||||
const aliceOnAdminNode = await loadCoValueOrFail(
|
||||
admin.node,
|
||||
alice.accountID,
|
||||
);
|
||||
group.addMember(aliceOnAdminNode, "writer");
|
||||
group.removeMember(aliceOnAdminNode);
|
||||
|
||||
const groupOnAliceNode = await loadCoValueOrFail(alice.node, group.id);
|
||||
expect(groupOnAliceNode.myRole()).toEqual("writer");
|
||||
|
||||
const map = groupOnAliceNode.createMap();
|
||||
|
||||
map.set("test", "test");
|
||||
expect(map.get("test")).toEqual("test");
|
||||
});
|
||||
|
||||
test("revoking a member access should not affect everyone access when everyone access is gained through a group extension", async () => {
|
||||
const admin = await setupTestAccount({
|
||||
connected: true,
|
||||
});
|
||||
|
||||
const alice = await setupTestAccount({
|
||||
connected: true,
|
||||
});
|
||||
|
||||
const parentGroup = admin.node.createGroup();
|
||||
const group = admin.node.createGroup();
|
||||
parentGroup.addMember("everyone", "reader");
|
||||
group.extend(parentGroup);
|
||||
|
||||
const aliceOnAdminNode = await loadCoValueOrFail(
|
||||
admin.node,
|
||||
alice.accountID,
|
||||
);
|
||||
group.addMember(aliceOnAdminNode, "writer");
|
||||
group.removeMember(aliceOnAdminNode);
|
||||
|
||||
const map = group.createMap();
|
||||
map.set("test", "test");
|
||||
|
||||
const groupOnAliceNode = await loadCoValueOrFail(alice.node, group.id);
|
||||
expect(groupOnAliceNode.myRole()).toEqual("reader");
|
||||
|
||||
const mapOnAliceNode = await loadCoValueOrFail(alice.node, map.id);
|
||||
expect(mapOnAliceNode.get("test")).toEqual("test");
|
||||
});
|
||||
|
||||
test("a reader member should be able to revoke themselves", async () => {
|
||||
const admin = await setupTestAccount({
|
||||
connected: true,
|
||||
@@ -294,4 +356,185 @@ describe("Group.removeMember", () => {
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test("removing a member should rotate the readKey on available child groups", async () => {
|
||||
const admin = await setupTestAccount({
|
||||
connected: true,
|
||||
});
|
||||
|
||||
const alice = await setupTestAccount({
|
||||
connected: true,
|
||||
});
|
||||
|
||||
const aliceOnAdminNode = await loadCoValueOrFail(
|
||||
admin.node,
|
||||
alice.accountID,
|
||||
);
|
||||
|
||||
const group = admin.node.createGroup();
|
||||
const childGroup = admin.node.createGroup();
|
||||
group.addMember(aliceOnAdminNode, "reader");
|
||||
|
||||
childGroup.extend(group);
|
||||
|
||||
group.removeMember(aliceOnAdminNode);
|
||||
|
||||
const map = childGroup.createMap();
|
||||
map.set("test", "Not readable by alice");
|
||||
|
||||
await map.core.waitForSync();
|
||||
|
||||
const mapOnAliceNode = await loadCoValueOrFail(alice.node, map.id);
|
||||
expect(mapOnAliceNode.get("test")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("removing a member should rotate the readKey on unloaded child groups", async () => {
|
||||
const admin = await setupTestAccount({
|
||||
connected: true,
|
||||
});
|
||||
|
||||
const bob = await setupTestAccount({
|
||||
connected: true,
|
||||
});
|
||||
|
||||
const alice = await setupTestAccount({
|
||||
connected: true,
|
||||
});
|
||||
|
||||
const bobOnAdminNode = await loadCoValueOrFail(admin.node, bob.accountID);
|
||||
|
||||
const aliceOnAdminNode = await loadCoValueOrFail(
|
||||
admin.node,
|
||||
alice.accountID,
|
||||
);
|
||||
|
||||
const group = admin.node.createGroup();
|
||||
|
||||
const childGroup = bob.node.createGroup();
|
||||
group.addMember(bobOnAdminNode, "reader");
|
||||
group.addMember(aliceOnAdminNode, "reader");
|
||||
|
||||
const groupOnBobNode = await loadCoValueOrFail(bob.node, group.id);
|
||||
|
||||
childGroup.extend(groupOnBobNode);
|
||||
|
||||
await childGroup.core.waitForSync();
|
||||
|
||||
group.removeMember(aliceOnAdminNode);
|
||||
|
||||
// Rotating the child group keys is async when the child group is not loaded
|
||||
await admin.node.getCoValue(childGroup.id).waitForAvailableOrUnavailable();
|
||||
await admin.node.syncManager.waitForAllCoValuesSync();
|
||||
|
||||
const map = childGroup.createMap();
|
||||
map.set("test", "Not readable by alice");
|
||||
|
||||
await map.core.waitForSync();
|
||||
|
||||
const mapOnAliceNode = await loadCoValueOrFail(alice.node, map.id);
|
||||
expect(mapOnAliceNode.get("test")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("removing a member should work even if there are partially available child groups", async () => {
|
||||
const admin = await setupTestAccount({
|
||||
connected: true,
|
||||
});
|
||||
|
||||
const bob = await setupTestAccount();
|
||||
const { peer } = bob.connectToSyncServer();
|
||||
|
||||
const alice = await setupTestAccount({
|
||||
connected: true,
|
||||
});
|
||||
|
||||
const bobOnAdminNode = await loadCoValueOrFail(admin.node, bob.accountID);
|
||||
|
||||
const aliceOnAdminNode = await loadCoValueOrFail(
|
||||
admin.node,
|
||||
alice.accountID,
|
||||
);
|
||||
|
||||
const group = admin.node.createGroup();
|
||||
const childGroup = bob.node.createGroup();
|
||||
|
||||
group.addMember(bobOnAdminNode, "reader");
|
||||
group.addMember(aliceOnAdminNode, "reader");
|
||||
|
||||
await group.core.waitForSync();
|
||||
|
||||
blockMessageTypeOnOutgoingPeer(peer, "content", {
|
||||
id: childGroup.id,
|
||||
});
|
||||
|
||||
const groupOnBobNode = await loadCoValueOrFail(bob.node, group.id);
|
||||
|
||||
childGroup.extend(groupOnBobNode);
|
||||
|
||||
await groupOnBobNode.core.waitForSync();
|
||||
|
||||
group.removeMember(aliceOnAdminNode);
|
||||
|
||||
await admin.node.syncManager.waitForAllCoValuesSync();
|
||||
|
||||
const map = group.createMap();
|
||||
map.set("test", "Not readable by alice");
|
||||
|
||||
await map.core.waitForSync();
|
||||
|
||||
const mapOnAliceNode = await loadCoValueOrFail(alice.node, map.id);
|
||||
expect(mapOnAliceNode.get("test")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("removing a member should work even if there are unavailable child groups", async () => {
|
||||
const admin = await setupTestAccount({
|
||||
connected: true,
|
||||
});
|
||||
|
||||
const { peerOnServer } = admin.connectToSyncServer();
|
||||
|
||||
const bob = await setupTestAccount({
|
||||
connected: true,
|
||||
});
|
||||
|
||||
const alice = await setupTestAccount({
|
||||
connected: true,
|
||||
});
|
||||
|
||||
const bobOnAdminNode = await loadCoValueOrFail(admin.node, bob.accountID);
|
||||
|
||||
const aliceOnAdminNode = await loadCoValueOrFail(
|
||||
admin.node,
|
||||
alice.accountID,
|
||||
);
|
||||
|
||||
const group = admin.node.createGroup();
|
||||
const childGroup = bob.node.createGroup();
|
||||
|
||||
blockMessageTypeOnOutgoingPeer(peerOnServer, "content", {
|
||||
id: childGroup.id,
|
||||
});
|
||||
|
||||
group.addMember(bobOnAdminNode, "reader");
|
||||
group.addMember(aliceOnAdminNode, "reader");
|
||||
|
||||
await group.core.waitForSync();
|
||||
|
||||
const groupOnBobNode = await loadCoValueOrFail(bob.node, group.id);
|
||||
|
||||
childGroup.extend(groupOnBobNode);
|
||||
|
||||
await groupOnBobNode.core.waitForSync();
|
||||
|
||||
group.removeMember(aliceOnAdminNode);
|
||||
|
||||
await group.core.waitForSync();
|
||||
|
||||
const map = group.createMap();
|
||||
map.set("test", "Not readable by alice");
|
||||
|
||||
await map.core.waitForSync();
|
||||
|
||||
const mapOnAliceNode = await loadCoValueOrFail(alice.node, map.id);
|
||||
expect(mapOnAliceNode.get("test")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user