Compare commits
74 Commits
feat/prelo
...
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 | ||
|
|
acecffaeb2 | ||
|
|
5a48c9c44c | ||
|
|
5c98ff4e4f | ||
|
|
51fcb8a44b | ||
|
|
c5888c39f5 | ||
|
|
2defcfae67 | ||
|
|
213de11c3b | ||
|
|
1b881cc89f | ||
|
|
af295d816a | ||
|
|
fe8d3497c0 | ||
|
|
c2899e94ca | ||
|
|
f4be67e9b6 | ||
|
|
ba9ad295b6 | ||
|
|
9ed5a96ef8 | ||
|
|
4272ea9019 | ||
|
|
9509307ed1 | ||
|
|
be08921bc5 | ||
|
|
25be055a51 | ||
|
|
b173e0884a | ||
|
|
231947c97a | ||
|
|
d5b57ad1fc | ||
|
|
0bf5c53bec | ||
|
|
e7b1550003 |
@@ -1,5 +1,20 @@
|
||||
# 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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "chat-svelte",
|
||||
"version": "0.0.109",
|
||||
"version": "0.0.111",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -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";
|
||||
@@ -21,7 +20,7 @@ export function CreateOrder() {
|
||||
|
||||
if (!me?.root) return;
|
||||
|
||||
const onSave = (draft: Loaded<typeof DraftBubbleTeaOrder>) => {
|
||||
const onSave = (draft: DraftBubbleTeaOrder) => {
|
||||
const validation = validateDraftOrder(draft);
|
||||
setErrors(validation.errors);
|
||||
if (validation.errors.length > 0) {
|
||||
@@ -29,7 +28,7 @@ export function CreateOrder() {
|
||||
}
|
||||
|
||||
// turn the draft into a real order
|
||||
me.root.orders.push(draft as Loaded<typeof BubbleTeaOrder>);
|
||||
me.root.orders.push(draft as BubbleTeaOrder);
|
||||
|
||||
// reset the draft
|
||||
me.root.draft = DraftBubbleTeaOrder.create({
|
||||
@@ -59,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,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",
|
||||
@@ -18,8 +18,9 @@ export const BubbleTeaBaseTeaTypes = [
|
||||
export const ListOfBubbleTeaAddOns = co.list(
|
||||
z.literal([...BubbleTeaAddOnTypes]),
|
||||
);
|
||||
export type ListOfBubbleTeaAddOns = co.loaded<typeof ListOfBubbleTeaAddOns>;
|
||||
|
||||
function hasAddOnsChanges(list?: Loaded<typeof ListOfBubbleTeaAddOns> | null) {
|
||||
function hasAddOnsChanges(list?: ListOfBubbleTeaAddOns | null) {
|
||||
return list && Object.entries(list._raw.insertions).length > 0;
|
||||
}
|
||||
|
||||
@@ -30,16 +31,12 @@ export const BubbleTeaOrder = co.map({
|
||||
withMilk: z.boolean(),
|
||||
instructions: co.optional(co.plainText()),
|
||||
});
|
||||
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
|
||||
|
||||
export const DraftBubbleTeaOrder = co.map({
|
||||
baseTea: z.optional(z.literal([...BubbleTeaBaseTeaTypes])),
|
||||
addOns: co.optional(ListOfBubbleTeaAddOns),
|
||||
deliveryDate: z.optional(z.date()),
|
||||
withMilk: z.optional(z.boolean()),
|
||||
instructions: co.optional(co.plainText()),
|
||||
});
|
||||
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
|
||||
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
|
||||
|
||||
export function validateDraftOrder(order: Loaded<typeof DraftBubbleTeaOrder>) {
|
||||
export function validateDraftOrder(order: DraftBubbleTeaOrder) {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!order.baseTea) {
|
||||
@@ -52,7 +49,7 @@ export function validateDraftOrder(order: Loaded<typeof DraftBubbleTeaOrder>) {
|
||||
return { errors };
|
||||
}
|
||||
|
||||
export function hasChanges(order?: Loaded<typeof DraftBubbleTeaOrder> | null) {
|
||||
export function hasChanges(order?: DraftBubbleTeaOrder | null) {
|
||||
return (
|
||||
!!order &&
|
||||
(Object.keys(order._edits).length > 1 || hasAddOnsChanges(order.addOns))
|
||||
|
||||
@@ -116,7 +116,7 @@ export async function removeTrackFromPlaylist(
|
||||
|
||||
if (track._owner._type === "Group" && playlist._owner._type === "Group") {
|
||||
const trackGroup = track._owner;
|
||||
await trackGroup.removeMember(playlist._owner);
|
||||
trackGroup.removeMember(playlist._owner);
|
||||
|
||||
const index =
|
||||
playlist.tracks?.findIndex(
|
||||
|
||||
@@ -54,10 +54,10 @@ import { co, z, CoMap } from "jazz-tools";
|
||||
export const BubbleTeaOrder = co.map({
|
||||
name: z.string(),
|
||||
});
|
||||
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
|
||||
|
||||
export const DraftBubbleTeaOrder = co.map({
|
||||
name: z.optional(z.string()),
|
||||
});
|
||||
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
|
||||
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
@@ -73,17 +73,17 @@ import { co, z } from "jazz-tools";
|
||||
export const BubbleTeaOrder = co.map({
|
||||
name: z.string(),
|
||||
});
|
||||
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
|
||||
|
||||
export const DraftBubbleTeaOrder = co.map({
|
||||
name: z.optional(z.string()),
|
||||
});
|
||||
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
|
||||
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
|
||||
// ---cut---
|
||||
// OrderForm.tsx
|
||||
export function OrderForm({
|
||||
order,
|
||||
onSave,
|
||||
}: {
|
||||
order: co.loaded<typeof BubbleTeaOrder> | co.loaded<typeof DraftBubbleTeaOrder>;
|
||||
order: BubbleTeaOrder | DraftBubbleTeaOrder;
|
||||
onSave?: (e: React.FormEvent<HTMLFormElement>) => void;
|
||||
}) {
|
||||
return (
|
||||
@@ -118,16 +118,16 @@ import * as React from "react";
|
||||
export const BubbleTeaOrder = co.map({
|
||||
name: z.string(),
|
||||
});
|
||||
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
|
||||
|
||||
export const DraftBubbleTeaOrder = co.map({
|
||||
name: z.optional(z.string()),
|
||||
});
|
||||
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
|
||||
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
|
||||
|
||||
export function OrderForm({
|
||||
order,
|
||||
onSave,
|
||||
}: {
|
||||
order: co.loaded<typeof BubbleTeaOrder> | co.loaded<typeof DraftBubbleTeaOrder>;
|
||||
order: BubbleTeaOrder | DraftBubbleTeaOrder;
|
||||
onSave?: (e: React.FormEvent<HTMLFormElement>) => void;
|
||||
}) {
|
||||
return (
|
||||
@@ -177,10 +177,10 @@ import { useState, useEffect } from "react";
|
||||
export const BubbleTeaOrder = co.map({
|
||||
name: z.string(),
|
||||
});
|
||||
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
|
||||
|
||||
export const DraftBubbleTeaOrder = co.map({
|
||||
name: z.optional(z.string()),
|
||||
});
|
||||
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
|
||||
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
|
||||
|
||||
export const AccountRoot = co.map({
|
||||
draft: DraftBubbleTeaOrder,
|
||||
@@ -218,7 +218,7 @@ export function OrderForm({
|
||||
// CreateOrder.tsx
|
||||
export function CreateOrder() {
|
||||
const { me } = useAccount();
|
||||
const [draft, setDraft] = useState<co.loaded<typeof DraftBubbleTeaOrder>>();
|
||||
const [draft, setDraft] = useState<DraftBubbleTeaOrder>();
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(DraftBubbleTeaOrder.create({}));
|
||||
@@ -228,7 +228,7 @@ export function CreateOrder() {
|
||||
e.preventDefault();
|
||||
if (!draft || !draft.name) return;
|
||||
|
||||
const order = draft as co.loaded<typeof BubbleTeaOrder>; // TODO: this should narrow correctly
|
||||
const order = draft as BubbleTeaOrder; // TODO: this should narrow correctly
|
||||
|
||||
console.log("Order created:", order);
|
||||
};
|
||||
@@ -251,11 +251,15 @@ Update the schema to include a `validateDraftOrder` helper.
|
||||
import { co, z } from "jazz-tools";
|
||||
// ---cut---
|
||||
// schema.ts
|
||||
export const DraftBubbleTeaOrder = co.map({
|
||||
name: z.optional(z.string()),
|
||||
export const BubbleTeaOrder = co.map({
|
||||
name: z.string(),
|
||||
});
|
||||
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
|
||||
|
||||
export function validateDraftOrder(draft: co.loaded<typeof DraftBubbleTeaOrder>) { // [!code ++:9]
|
||||
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
|
||||
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
|
||||
|
||||
export function validateDraftOrder(draft: DraftBubbleTeaOrder) { // [!code ++:9]
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!draft.name) {
|
||||
@@ -279,12 +283,12 @@ import { useState, useEffect } from "react";
|
||||
export const BubbleTeaOrder = co.map({
|
||||
name: z.string(),
|
||||
});
|
||||
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
|
||||
|
||||
export const DraftBubbleTeaOrder = co.map({
|
||||
name: z.optional(z.string()),
|
||||
});
|
||||
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
|
||||
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
|
||||
|
||||
export function validateDraftOrder(draft: co.loaded<typeof DraftBubbleTeaOrder>) {
|
||||
export function validateDraftOrder(draft: DraftBubbleTeaOrder) {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!draft.name) {
|
||||
@@ -307,7 +311,7 @@ export function OrderForm({
|
||||
order,
|
||||
onSave,
|
||||
}: {
|
||||
order: co.loaded<typeof BubbleTeaOrder> | co.loaded<typeof DraftBubbleTeaOrder>;
|
||||
order: BubbleTeaOrder | DraftBubbleTeaOrder;
|
||||
onSave?: (e: React.FormEvent<HTMLFormElement>) => void;
|
||||
}) {
|
||||
return (
|
||||
@@ -330,7 +334,7 @@ export function OrderForm({
|
||||
// CreateOrder.tsx
|
||||
export function CreateOrder() {
|
||||
const { me } = useAccount();
|
||||
const [draft, setDraft] = useState<co.loaded<typeof DraftBubbleTeaOrder>>();
|
||||
const [draft, setDraft] = useState<DraftBubbleTeaOrder>();
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(DraftBubbleTeaOrder.create({}));
|
||||
@@ -346,7 +350,7 @@ export function CreateOrder() {
|
||||
return;
|
||||
}
|
||||
|
||||
const order = draft as co.loaded<typeof BubbleTeaOrder>;
|
||||
const order = draft as BubbleTeaOrder;
|
||||
|
||||
console.log("Order created:", order);
|
||||
};
|
||||
@@ -372,10 +376,10 @@ import { co, z } from "jazz-tools";
|
||||
export const BubbleTeaOrder = co.map({
|
||||
name: z.string(),
|
||||
});
|
||||
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
|
||||
|
||||
export const DraftBubbleTeaOrder = co.map({
|
||||
name: z.optional(z.string()),
|
||||
});
|
||||
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
|
||||
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
|
||||
|
||||
export const AccountRoot = co.map({ // [!code ++:15]
|
||||
draft: DraftBubbleTeaOrder,
|
||||
@@ -403,10 +407,10 @@ import { co, z } from "jazz-tools";
|
||||
export const BubbleTeaOrder = co.map({
|
||||
name: z.string(),
|
||||
});
|
||||
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
|
||||
|
||||
export const DraftBubbleTeaOrder = co.map({
|
||||
name: z.optional(z.string()),
|
||||
});
|
||||
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
|
||||
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
|
||||
|
||||
export const AccountRoot = co.map({
|
||||
draft: DraftBubbleTeaOrder,
|
||||
@@ -452,12 +456,12 @@ import { co, z } from "jazz-tools";
|
||||
export const BubbleTeaOrder = co.map({
|
||||
name: z.string(),
|
||||
});
|
||||
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
|
||||
|
||||
export const DraftBubbleTeaOrder = co.map({
|
||||
name: z.optional(z.string()),
|
||||
});
|
||||
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
|
||||
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
|
||||
|
||||
export function validateDraftOrder(draft: co.loaded<typeof DraftBubbleTeaOrder>) {
|
||||
export function validateDraftOrder(draft: DraftBubbleTeaOrder) {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!draft.name) {
|
||||
@@ -492,7 +496,7 @@ export function OrderForm({
|
||||
order,
|
||||
onSave,
|
||||
}: {
|
||||
order: co.loaded<typeof BubbleTeaOrder> | co.loaded<typeof DraftBubbleTeaOrder>;
|
||||
order: BubbleTeaOrder | DraftBubbleTeaOrder;
|
||||
onSave?: (e: React.FormEvent<HTMLFormElement>) => void;
|
||||
}) {
|
||||
return (
|
||||
@@ -533,7 +537,7 @@ export function CreateOrder() {
|
||||
return;
|
||||
}
|
||||
|
||||
const order = draft as co.loaded<typeof BubbleTeaOrder>;
|
||||
const order = draft as BubbleTeaOrder;
|
||||
console.log("Order created:", order);
|
||||
|
||||
// create a new empty draft
|
||||
@@ -577,11 +581,15 @@ Simply add a `hasChanges` helper to your schema.
|
||||
import { co, z } from "jazz-tools";
|
||||
// ---cut---
|
||||
// schema.ts
|
||||
export const DraftBubbleTeaOrder = co.map({
|
||||
name: z.optional(z.string()),
|
||||
export const BubbleTeaOrder = co.map({
|
||||
name: z.string(),
|
||||
});
|
||||
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
|
||||
|
||||
export function validateDraftOrder(draft: co.loaded<typeof DraftBubbleTeaOrder>) {
|
||||
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
|
||||
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
|
||||
|
||||
export function validateDraftOrder(draft: DraftBubbleTeaOrder) {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!draft.name) {
|
||||
@@ -591,7 +599,7 @@ export function validateDraftOrder(draft: co.loaded<typeof DraftBubbleTeaOrder>)
|
||||
return { errors };
|
||||
};
|
||||
|
||||
export function hasChanges(draft?: co.loaded<typeof DraftBubbleTeaOrder>) { // [!code ++:3]
|
||||
export function hasChanges(draft?: DraftBubbleTeaOrder) { // [!code ++:3]
|
||||
return draft ? Object.keys(draft._edits).length : false;
|
||||
};
|
||||
```
|
||||
@@ -608,12 +616,12 @@ import * as React from "react";
|
||||
export const BubbleTeaOrder = co.map({
|
||||
name: z.string(),
|
||||
});
|
||||
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
|
||||
|
||||
export const DraftBubbleTeaOrder = co.map({
|
||||
name: z.optional(z.string()),
|
||||
});
|
||||
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
|
||||
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
|
||||
|
||||
export function validateDraftOrder(draft: co.loaded<typeof DraftBubbleTeaOrder>) {
|
||||
export function validateDraftOrder(draft: DraftBubbleTeaOrder) {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!draft.name) {
|
||||
@@ -623,7 +631,7 @@ export function validateDraftOrder(draft: co.loaded<typeof DraftBubbleTeaOrder>)
|
||||
return { errors };
|
||||
};
|
||||
|
||||
export function hasChanges(draft?: co.loaded<typeof DraftBubbleTeaOrder>) {
|
||||
export function hasChanges(draft?: DraftBubbleTeaOrder) {
|
||||
return draft ? Object.keys(draft._edits).length : false;
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
```
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,21 @@
|
||||
# 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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cojson-storage-indexeddb",
|
||||
"version": "0.16.3",
|
||||
"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,21 @@
|
||||
# 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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "cojson-storage-sqlite",
|
||||
"type": "module",
|
||||
"version": "0.16.3",
|
||||
"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,21 @@
|
||||
# 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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "cojson-transport-ws",
|
||||
"type": "module",
|
||||
"version": "0.16.3",
|
||||
"version": "0.16.5",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
# 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
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
},
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "0.16.3",
|
||||
"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";
|
||||
|
||||
@@ -53,8 +43,6 @@ export type DecryptedTransaction = {
|
||||
trusting?: boolean;
|
||||
};
|
||||
|
||||
const readKeyCache = new WeakMap<CoValueCore, { [id: KeyID]: KeySecret }>();
|
||||
|
||||
export type AvailableCoValueCore = CoValueCore & { verified: VerifiedState };
|
||||
|
||||
export class CoValueCore {
|
||||
@@ -380,7 +368,7 @@ export class CoValueCore {
|
||||
}
|
||||
|
||||
knownStateWithStreaming(): CoValueKnownState {
|
||||
if (this.isAvailable()) {
|
||||
if (this.verified) {
|
||||
return this.verified.knownStateWithStreaming();
|
||||
} else {
|
||||
return emptyKnownState(this.id);
|
||||
@@ -388,7 +376,7 @@ export class CoValueCore {
|
||||
}
|
||||
|
||||
knownState(): CoValueKnownState {
|
||||
if (this.isAvailable()) {
|
||||
if (this.verified) {
|
||||
return this.verified.knownState();
|
||||
} else {
|
||||
return emptyKnownState(this.id);
|
||||
@@ -605,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;
|
||||
@@ -759,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)
|
||||
@@ -784,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",
|
||||
@@ -939,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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
@@ -351,7 +351,7 @@ export class LocalNode {
|
||||
new VerifiedState(id, this.crypto, header, new Map()),
|
||||
);
|
||||
|
||||
void this.syncManager.requestCoValueSync(coValue);
|
||||
this.syncManager.syncHeader(coValue.verified);
|
||||
|
||||
return coValue;
|
||||
}
|
||||
@@ -738,9 +738,14 @@ export class LocalNode {
|
||||
}
|
||||
}
|
||||
|
||||
gracefulShutdown() {
|
||||
this.storage?.close();
|
||||
/**
|
||||
* Closes all the peer connections, drains all the queues and closes the storage.
|
||||
*
|
||||
* @returns Promise of the current pending store operation, if any.
|
||||
*/
|
||||
gracefulShutdown(): Promise<unknown> | undefined {
|
||||
this.syncManager.gracefulShutdown();
|
||||
return this.storage?.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,22 @@
|
||||
import { CorrectionCallback } from "../exports.js";
|
||||
import { logger } from "../logger.js";
|
||||
import { CoValueKnownState, NewContentMessage } from "../sync.js";
|
||||
import { NewContentMessage } from "../sync.js";
|
||||
import { LinkedList } from "./LinkedList.js";
|
||||
|
||||
type StoreQueueEntry = {
|
||||
data: NewContentMessage[];
|
||||
correctionCallback: (data: CoValueKnownState) => void;
|
||||
data: NewContentMessage;
|
||||
correctionCallback: CorrectionCallback;
|
||||
};
|
||||
|
||||
export class StoreQueue {
|
||||
private queue = new LinkedList<StoreQueueEntry>();
|
||||
closed = false;
|
||||
|
||||
public push(data: NewContentMessage, correctionCallback: CorrectionCallback) {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
public push(
|
||||
data: NewContentMessage[],
|
||||
correctionCallback: (data: CoValueKnownState) => void,
|
||||
) {
|
||||
this.queue.push({ data, correctionCallback });
|
||||
}
|
||||
|
||||
@@ -22,12 +25,13 @@ export class StoreQueue {
|
||||
}
|
||||
|
||||
processing = false;
|
||||
lastCallback: Promise<unknown> | undefined;
|
||||
|
||||
async processQueue(
|
||||
callback: (
|
||||
data: NewContentMessage[],
|
||||
correctionCallback: (data: CoValueKnownState) => void,
|
||||
) => Promise<void>,
|
||||
data: NewContentMessage,
|
||||
correctionCallback: CorrectionCallback,
|
||||
) => Promise<unknown>,
|
||||
) {
|
||||
if (this.processing) {
|
||||
return;
|
||||
@@ -41,16 +45,22 @@ export class StoreQueue {
|
||||
const { data, correctionCallback } = entry;
|
||||
|
||||
try {
|
||||
await callback(data, correctionCallback);
|
||||
this.lastCallback = callback(data, correctionCallback);
|
||||
await this.lastCallback;
|
||||
} catch (err) {
|
||||
logger.error("Error processing message in store queue", { err });
|
||||
}
|
||||
}
|
||||
|
||||
this.lastCallback = undefined;
|
||||
this.processing = false;
|
||||
}
|
||||
|
||||
drain() {
|
||||
close() {
|
||||
this.closed = true;
|
||||
|
||||
while (this.pull()) {}
|
||||
|
||||
return this.lastCallback;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import {
|
||||
createContentMessage,
|
||||
exceedsRecommendedSize,
|
||||
getTransactionSize,
|
||||
} from "../coValueContentMessage.js";
|
||||
import {
|
||||
type CoValueCore,
|
||||
MAX_RECOMMENDED_TX_SIZE,
|
||||
type RawCoID,
|
||||
type SessionID,
|
||||
type StorageAPI,
|
||||
logger,
|
||||
} from "../exports.js";
|
||||
import { getPriorityFromHeader } from "../priority.js";
|
||||
import { StoreQueue } from "../queue/StoreQueue.js";
|
||||
import {
|
||||
CoValueKnownState,
|
||||
@@ -13,8 +17,13 @@ import {
|
||||
emptyKnownState,
|
||||
} from "../sync.js";
|
||||
import { StorageKnownState } from "./knownState.js";
|
||||
import { collectNewTxs, getDependedOnCoValues } from "./syncUtils.js";
|
||||
import {
|
||||
collectNewTxs,
|
||||
getDependedOnCoValues,
|
||||
getNewTransactionsSize,
|
||||
} from "./syncUtils.js";
|
||||
import type {
|
||||
CorrectionCallback,
|
||||
DBClientInterfaceAsync,
|
||||
SignatureAfterRow,
|
||||
StoredCoValueRow,
|
||||
@@ -82,6 +91,7 @@ export class StorageApiAsync implements StorageAPI {
|
||||
);
|
||||
|
||||
const knownState = this.knwonStates.getKnownState(coValueRow.id);
|
||||
knownState.header = true;
|
||||
|
||||
for (const sessionRow of allCoValueSessions) {
|
||||
knownState.sessions[sessionRow.sessionID] = sessionRow.lastIdx;
|
||||
@@ -89,13 +99,7 @@ export class StorageApiAsync implements StorageAPI {
|
||||
|
||||
this.loadedCoValues.add(coValueRow.id);
|
||||
|
||||
let contentMessage = {
|
||||
action: "content",
|
||||
id: coValueRow.id,
|
||||
header: coValueRow.header,
|
||||
new: {},
|
||||
priority: getPriorityFromHeader(coValueRow.header),
|
||||
} as NewContentMessage;
|
||||
let contentMessage = createContentMessage(coValueRow.id, coValueRow.header);
|
||||
|
||||
if (contentStreaming) {
|
||||
contentMessage.expectContentUntil = knownState["sessions"];
|
||||
@@ -136,13 +140,10 @@ export class StorageApiAsync implements StorageAPI {
|
||||
contentMessage,
|
||||
callback,
|
||||
);
|
||||
contentMessage = {
|
||||
action: "content",
|
||||
id: coValueRow.id,
|
||||
header: coValueRow.header,
|
||||
new: {},
|
||||
priority: getPriorityFromHeader(coValueRow.header),
|
||||
} satisfies NewContentMessage;
|
||||
contentMessage = createContentMessage(
|
||||
coValueRow.id,
|
||||
coValueRow.header,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -194,33 +195,64 @@ export class StorageApiAsync implements StorageAPI {
|
||||
|
||||
storeQueue = new StoreQueue();
|
||||
|
||||
async store(
|
||||
msgs: NewContentMessage[],
|
||||
correctionCallback: (data: CoValueKnownState) => void,
|
||||
) {
|
||||
async store(msg: NewContentMessage, correctionCallback: CorrectionCallback) {
|
||||
/**
|
||||
* The store operations must be done one by one, because we can't start a new transaction when there
|
||||
* is already a transaction open.
|
||||
*/
|
||||
this.storeQueue.push(msgs, correctionCallback);
|
||||
this.storeQueue.push(msg, correctionCallback);
|
||||
|
||||
this.storeQueue.processQueue(async (data, correctionCallback) => {
|
||||
for (const msg of data) {
|
||||
const success = await this.storeSingle(msg, correctionCallback);
|
||||
|
||||
if (!success) {
|
||||
// Stop processing the messages for this entry, because the data is out of sync with storage
|
||||
// and the other transactions will be rejected anyway.
|
||||
break;
|
||||
}
|
||||
}
|
||||
return this.storeSingle(data, correctionCallback);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* This function is called when the storage lacks the information required to store the incoming content.
|
||||
*
|
||||
* It triggers a `correctionCallback` to ask the syncManager to provide the missing information.
|
||||
*
|
||||
* The correction is applied immediately, to ensure that, when applicable, the dependent content in the queue won't require additional corrections.
|
||||
*/
|
||||
private async handleCorrection(
|
||||
knownState: CoValueKnownState,
|
||||
correctionCallback: CorrectionCallback,
|
||||
) {
|
||||
const correction = correctionCallback(knownState);
|
||||
|
||||
if (!correction) {
|
||||
logger.error("Correction callback returned undefined", {
|
||||
knownState,
|
||||
correction: correction ?? null,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const msg of correction) {
|
||||
const success = await this.storeSingle(msg, (knownState) => {
|
||||
logger.error("Double correction requested", {
|
||||
msg,
|
||||
knownState,
|
||||
});
|
||||
return undefined;
|
||||
});
|
||||
|
||||
if (!success) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async storeSingle(
|
||||
msg: NewContentMessage,
|
||||
correctionCallback: (data: CoValueKnownState) => void,
|
||||
correctionCallback: CorrectionCallback,
|
||||
): Promise<boolean> {
|
||||
if (this.storeQueue.closed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const id = msg.id;
|
||||
const coValueRow = await this.dbClient.getCoValue(id);
|
||||
|
||||
@@ -231,8 +263,7 @@ export class StorageApiAsync implements StorageAPI {
|
||||
const knownState = emptyKnownState(id as RawCoID);
|
||||
this.knwonStates.setKnownState(id, knownState);
|
||||
|
||||
correctionCallback(knownState);
|
||||
return false;
|
||||
return this.handleCorrection(knownState, correctionCallback);
|
||||
}
|
||||
|
||||
const storedCoValueRowID: number = coValueRow
|
||||
@@ -276,8 +307,7 @@ export class StorageApiAsync implements StorageAPI {
|
||||
this.knwonStates.handleUpdate(id, knownState);
|
||||
|
||||
if (invalidAssumptions) {
|
||||
correctionCallback(knownState);
|
||||
return false;
|
||||
return this.handleCorrection(knownState, correctionCallback);
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -290,38 +320,31 @@ export class StorageApiAsync implements StorageAPI {
|
||||
storedCoValueRowID: number,
|
||||
) {
|
||||
const newTransactions = msg.new[sessionID]?.newTransactions || [];
|
||||
const lastIdx = sessionRow?.lastIdx || 0;
|
||||
|
||||
const actuallyNewOffset =
|
||||
(sessionRow?.lastIdx || 0) - (msg.new[sessionID]?.after || 0);
|
||||
const actuallyNewOffset = lastIdx - (msg.new[sessionID]?.after || 0);
|
||||
|
||||
const actuallyNewTransactions = newTransactions.slice(actuallyNewOffset);
|
||||
|
||||
if (actuallyNewTransactions.length === 0) {
|
||||
return sessionRow?.lastIdx || 0;
|
||||
return lastIdx;
|
||||
}
|
||||
|
||||
let newBytesSinceLastSignature =
|
||||
(sessionRow?.bytesSinceLastSignature || 0) +
|
||||
actuallyNewTransactions.reduce(
|
||||
(sum, tx) =>
|
||||
sum +
|
||||
(tx.privacy === "private"
|
||||
? tx.encryptedChanges.length
|
||||
: tx.changes.length),
|
||||
0,
|
||||
);
|
||||
let bytesSinceLastSignature = sessionRow?.bytesSinceLastSignature || 0;
|
||||
const newTransactionsSize = getNewTransactionsSize(actuallyNewTransactions);
|
||||
|
||||
const newLastIdx =
|
||||
(sessionRow?.lastIdx || 0) + actuallyNewTransactions.length;
|
||||
const newLastIdx = lastIdx + actuallyNewTransactions.length;
|
||||
|
||||
let shouldWriteSignature = false;
|
||||
|
||||
if (newBytesSinceLastSignature > MAX_RECOMMENDED_TX_SIZE) {
|
||||
if (exceedsRecommendedSize(bytesSinceLastSignature, newTransactionsSize)) {
|
||||
shouldWriteSignature = true;
|
||||
newBytesSinceLastSignature = 0;
|
||||
bytesSinceLastSignature = 0;
|
||||
} else {
|
||||
bytesSinceLastSignature += newTransactionsSize;
|
||||
}
|
||||
|
||||
const nextIdx = sessionRow?.lastIdx || 0;
|
||||
const nextIdx = lastIdx;
|
||||
|
||||
if (!msg.new[sessionID]) throw new Error("Session ID not found");
|
||||
|
||||
@@ -330,7 +353,7 @@ export class StorageApiAsync implements StorageAPI {
|
||||
sessionID,
|
||||
lastIdx: newLastIdx,
|
||||
lastSignature: msg.new[sessionID].lastSignature,
|
||||
bytesSinceLastSignature: newBytesSinceLastSignature,
|
||||
bytesSinceLastSignature,
|
||||
};
|
||||
|
||||
const sessionRowID: number = await this.dbClient.addSessionUpdate({
|
||||
@@ -360,7 +383,6 @@ export class StorageApiAsync implements StorageAPI {
|
||||
}
|
||||
|
||||
close() {
|
||||
// Drain the store queue
|
||||
this.storeQueue.drain();
|
||||
return this.storeQueue.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 { 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);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,15 +2,13 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { StoreQueue } from "../queue/StoreQueue.js";
|
||||
import type { CoValueKnownState, NewContentMessage } from "../sync.js";
|
||||
|
||||
function createMockNewContentMessage(id: string): NewContentMessage[] {
|
||||
return [
|
||||
{
|
||||
action: "content",
|
||||
id: id as any,
|
||||
priority: 0,
|
||||
new: {},
|
||||
},
|
||||
];
|
||||
function createMockNewContentMessage(id: string): NewContentMessage {
|
||||
return {
|
||||
action: "content",
|
||||
id: id as any,
|
||||
priority: 0,
|
||||
new: {},
|
||||
};
|
||||
}
|
||||
|
||||
function setup() {
|
||||
@@ -154,14 +152,14 @@ describe("StoreQueue", () => {
|
||||
storeQueue.push(data1, mockCorrectionCallback);
|
||||
storeQueue.push(data2, mockCorrectionCallback);
|
||||
|
||||
storeQueue.drain();
|
||||
storeQueue.close();
|
||||
|
||||
expect(storeQueue.pull()).toBeUndefined();
|
||||
});
|
||||
|
||||
test("should handle empty queue", () => {
|
||||
const { storeQueue } = setup();
|
||||
expect(() => storeQueue.drain()).not.toThrow();
|
||||
expect(() => storeQueue.close()).not.toThrow();
|
||||
expect(storeQueue.pull()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -240,23 +238,11 @@ describe("StoreQueue", () => {
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
test("should handle undefined data", () => {
|
||||
const { storeQueue, mockCorrectionCallback } = setup();
|
||||
const data: NewContentMessage[] = [];
|
||||
storeQueue.push(data, mockCorrectionCallback);
|
||||
|
||||
const entry = storeQueue.pull();
|
||||
expect(entry).toEqual({
|
||||
data,
|
||||
correctionCallback: mockCorrectionCallback,
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle null correction callback", () => {
|
||||
const { storeQueue } = setup();
|
||||
const data = createMockNewContentMessage("co1");
|
||||
|
||||
const nullCallback = () => {};
|
||||
const nullCallback = () => undefined;
|
||||
storeQueue.push(data, nullCallback);
|
||||
|
||||
const entry = storeQueue.pull();
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,7 +33,7 @@ describe("roleOf", () => {
|
||||
const [agent2] = randomAgentAndSessionID();
|
||||
|
||||
group.addMember(agent2, "writer");
|
||||
group.removeMemberInternal(agent2);
|
||||
group.removeMember(agent2);
|
||||
expect(group.roleOfInternal(agent2.id)).toEqual(undefined);
|
||||
});
|
||||
|
||||
@@ -63,7 +63,7 @@ describe("roleOf", () => {
|
||||
|
||||
group.addMemberInternal("everyone", "reader");
|
||||
group.addMember(agent2, "writer");
|
||||
group.removeMemberInternal("everyone");
|
||||
group.removeMember("everyone");
|
||||
expect(group.roleOfInternal(agent2.id)).toEqual("writer");
|
||||
expect(group.roleOfInternal("123" as RawAccountID)).toEqual(undefined);
|
||||
});
|
||||
|
||||
@@ -3,18 +3,27 @@ import { RawCoList } from "../coValues/coList.js";
|
||||
import { RawCoMap } from "../coValues/coMap.js";
|
||||
import { RawCoStream } from "../coValues/coStream.js";
|
||||
import { RawBinaryCoStream } from "../coValues/coStream.js";
|
||||
import { WasmCrypto } from "../crypto/WasmCrypto.js";
|
||||
import { RawAccountID } from "../exports.js";
|
||||
import type { RawCoValue, RawGroup } from "../exports.js";
|
||||
import type { NewContentMessage } from "../sync.js";
|
||||
import {
|
||||
createThreeConnectedNodes,
|
||||
createTwoConnectedNodes,
|
||||
loadCoValueOrFail,
|
||||
nodeWithRandomAgentAndSessionID,
|
||||
randomAgentAndSessionID,
|
||||
waitFor,
|
||||
setupTestNode,
|
||||
} from "./testUtils.js";
|
||||
|
||||
const Crypto = await WasmCrypto.create();
|
||||
function expectGroup(content: RawCoValue): RawGroup {
|
||||
if (content.type !== "comap") {
|
||||
throw new Error("Expected group");
|
||||
}
|
||||
|
||||
if (content.core.verified.header.ruleset.type !== "group") {
|
||||
throw new Error("Expected group ruleset in group");
|
||||
}
|
||||
|
||||
return content as RawGroup;
|
||||
}
|
||||
|
||||
test("Can create a RawCoMap in a group", () => {
|
||||
const node = nodeWithRandomAgentAndSessionID();
|
||||
@@ -307,6 +316,97 @@ test("Invites should have access to the new keys", async () => {
|
||||
expect(mapOnNode2.get("test")).toEqual("Written from node1");
|
||||
});
|
||||
|
||||
test("Should heal the missing key_for_everyone", async () => {
|
||||
const client = setupTestNode({
|
||||
secret:
|
||||
"sealerSecret_zBTPp7U58Fzq9o7EvJpu4KEziepi8QVf2Xaxuy5xmmXFx/signerSecret_z62DuviZdXCjz4EZWofvr9vaLYFXDeTaC9KWhoQiQjzKk",
|
||||
});
|
||||
|
||||
const brokenGroupContent = {
|
||||
action: "content",
|
||||
id: "co_zW7F36Nnop9A7Er4gUzBcUXnZCK",
|
||||
header: {
|
||||
type: "comap",
|
||||
ruleset: {
|
||||
type: "group",
|
||||
initialAdmin:
|
||||
"sealer_z12QDazYB3ygPZtBV7sMm7iYKMRnNZ6Aaj1dfLXR7LSBm/signer_z2AskZQbc82qxo7iA3oiXoNExHLsAEXC2pHbwJzRnATWv",
|
||||
},
|
||||
meta: null,
|
||||
createdAt: "2025-08-06T10:14:39.617Z",
|
||||
uniqueness: "z3LJjnuPiPJaf5Qb9A",
|
||||
},
|
||||
priority: 0,
|
||||
new: {
|
||||
"sealer_z12QDazYB3ygPZtBV7sMm7iYKMRnNZ6Aaj1dfLXR7LSBm/signer_z2AskZQbc82qxo7iA3oiXoNExHLsAEXC2pHbwJzRnATWv_session_zYLsz2CiW9pW":
|
||||
{
|
||||
after: 0,
|
||||
newTransactions: [
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279619,
|
||||
changes:
|
||||
'[{"key":"sealer_z12QDazYB3ygPZtBV7sMm7iYKMRnNZ6Aaj1dfLXR7LSBm/signer_z2AskZQbc82qxo7iA3oiXoNExHLsAEXC2pHbwJzRnATWv","op":"set","value":"admin"}]',
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279621,
|
||||
changes:
|
||||
'[{"key":"key_z5CVahfMkEWPj1B3zH_for_sealer_z12QDazYB3ygPZtBV7sMm7iYKMRnNZ6Aaj1dfLXR7LSBm/signer_z2AskZQbc82qxo7iA3oiXoNExHLsAEXC2pHbwJzRnATWv","op":"set","value":"sealed_UCg4UkytXF-W8PaIvaDffO3pZ3d9hdXUuNkQQEikPTAuOD9us92Pqb5Vgu7lx1Fpb0X8V5BJ2yxz6_D5WOzK3qjWBSsc7J1xDJA=="}]',
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279621,
|
||||
changes:
|
||||
'[{"key":"readKey","op":"set","value":"key_z5CVahfMkEWPj1B3zH"}]',
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279622,
|
||||
changes: '[{"key":"everyone","op":"set","value":"reader"}]',
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279623,
|
||||
changes:
|
||||
'[{"key":"key_z5CVahfMkEWPj1B3zH_for_everyone","op":"set","value":"keySecret_z9U9gzkahQXCxDoSw7isiUnbobXwuLdcSkL9Ci6ZEEkaL"}]',
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279623,
|
||||
changes:
|
||||
'[{"key":"key_z4Fi7hZNBx7XoVAKkP_for_sealer_z12QDazYB3ygPZtBV7sMm7iYKMRnNZ6Aaj1dfLXR7LSBm/signer_z2AskZQbc82qxo7iA3oiXoNExHLsAEXC2pHbwJzRnATWv","op":"set","value":"sealed_UuCBBfZkTnRTrGraqWWlzm9JE-VFduhsfu7WaZjpCbJYOTXpPhSNOnzGeS8qVuIsG6dORbME22lc5ltLxPjRqofQdDCNGQehCeQ=="}]',
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279624,
|
||||
changes:
|
||||
'[{"key":"key_z5CVahfMkEWPj1B3zH_for_key_z4Fi7hZNBx7XoVAKkP","op":"set","value":"encrypted_USTrBuobwTCORwy5yHxy4sFZ7swfrafP6k5ZwcTf76f0MBu9Ie-JmsX3mNXad4mluI47gvGXzi8I_"}]',
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279624,
|
||||
changes:
|
||||
'[{"key":"readKey","op":"set","value":"key_z4Fi7hZNBx7XoVAKkP"}]',
|
||||
},
|
||||
],
|
||||
lastSignature:
|
||||
"signature_z3tsE7U1JaeNeUmZ4EY3Xq5uQ9jq9jDi6Rkhdt7T7b7z4NCnpMgB4bo8TwLXYVCrRdBm6PoyyPdK8fYFzHJUh5EzA",
|
||||
},
|
||||
},
|
||||
} as unknown as NewContentMessage;
|
||||
|
||||
client.node.syncManager.handleNewContent(brokenGroupContent, "import");
|
||||
|
||||
const group = expectGroup(
|
||||
client.node.getCoValue(brokenGroupContent.id).getCurrentContent(),
|
||||
);
|
||||
|
||||
expect(group.get(`${group.get("readKey")!}_for_everyone`)).toBe(
|
||||
group.core.getCurrentReadKey()?.secret,
|
||||
);
|
||||
});
|
||||
|
||||
describe("writeOnly", () => {
|
||||
test("Admins can invite writeOnly members", async () => {
|
||||
const { node1, node2 } = await createTwoConnectedNodes("server", "server");
|
||||
|
||||
@@ -53,12 +53,14 @@ describe("LocalNode auth sync", () => {
|
||||
}),
|
||||
).toMatchInlineSnapshot(`
|
||||
[
|
||||
"client -> server | CONTENT Account header: true new: After: 0 New: 4",
|
||||
"client -> server | CONTENT Account header: true new: After: 0 New: 3",
|
||||
"client -> server | CONTENT ProfileGroup header: true new: After: 0 New: 5",
|
||||
"client -> server | CONTENT Profile header: true new: After: 0 New: 1",
|
||||
"server -> client | KNOWN Account sessions: header/4",
|
||||
"client -> server | CONTENT Account header: false new: After: 3 New: 1",
|
||||
"server -> client | KNOWN Account sessions: header/3",
|
||||
"server -> client | KNOWN ProfileGroup sessions: header/5",
|
||||
"server -> client | KNOWN Profile sessions: header/1",
|
||||
"server -> client | KNOWN Account sessions: header/4",
|
||||
]
|
||||
`);
|
||||
});
|
||||
@@ -114,12 +116,18 @@ describe("LocalNode auth sync", () => {
|
||||
}),
|
||||
).toMatchInlineSnapshot(`
|
||||
[
|
||||
"client -> server | CONTENT Account header: true new: After: 0 New: 5",
|
||||
"client -> server | CONTENT Root header: true new: After: 0 New: 1",
|
||||
"client -> server | CONTENT Profile header: true new: After: 0 New: 1",
|
||||
"server -> client | KNOWN Account sessions: header/5",
|
||||
"client -> server | CONTENT Account header: true new: After: 0 New: 3",
|
||||
"client -> server | CONTENT Root header: true new: ",
|
||||
"client -> server | CONTENT Profile header: true new: ",
|
||||
"client -> server | CONTENT Root header: false new: After: 0 New: 1",
|
||||
"client -> server | CONTENT Profile header: false new: After: 0 New: 1",
|
||||
"client -> server | CONTENT Account header: false new: After: 3 New: 2",
|
||||
"server -> client | KNOWN Account sessions: header/3",
|
||||
"server -> client | KNOWN Root sessions: header/0",
|
||||
"server -> client | KNOWN Profile sessions: header/0",
|
||||
"server -> client | KNOWN Root sessions: header/1",
|
||||
"server -> client | KNOWN Profile sessions: header/1",
|
||||
"server -> client | KNOWN Account sessions: header/5",
|
||||
]
|
||||
`);
|
||||
});
|
||||
@@ -168,13 +176,15 @@ describe("LocalNode auth sync", () => {
|
||||
}),
|
||||
).toMatchInlineSnapshot(`
|
||||
[
|
||||
"creation-node -> server | CONTENT Account header: true new: After: 0 New: 4",
|
||||
"creation-node -> server | CONTENT Account header: true new: After: 0 New: 3",
|
||||
"creation-node -> server | CONTENT ProfileGroup header: true new: After: 0 New: 5",
|
||||
"creation-node -> server | CONTENT Profile header: true new: After: 0 New: 1",
|
||||
"creation-node -> server | CONTENT Account header: false new: After: 3 New: 1",
|
||||
"auth-node -> server | LOAD Account sessions: empty",
|
||||
"server -> creation-node | KNOWN Account sessions: header/4",
|
||||
"server -> creation-node | KNOWN Account sessions: header/3",
|
||||
"server -> creation-node | KNOWN ProfileGroup sessions: header/5",
|
||||
"server -> creation-node | KNOWN Profile sessions: header/1",
|
||||
"server -> creation-node | KNOWN Account sessions: header/4",
|
||||
"server -> auth-node | CONTENT Account header: true new: After: 0 New: 4",
|
||||
"auth-node -> server | KNOWN Account sessions: header/4",
|
||||
"auth-node -> server | LOAD Profile sessions: empty",
|
||||
@@ -236,12 +246,14 @@ describe("LocalNode auth sync", () => {
|
||||
}),
|
||||
).toMatchInlineSnapshot(`
|
||||
[
|
||||
"creation-node -> server | CONTENT Account header: true new: After: 0 New: 4",
|
||||
"creation-node -> server | CONTENT Account header: true new: After: 0 New: 3",
|
||||
"creation-node -> server | CONTENT ProfileGroup header: true new: After: 0 New: 5",
|
||||
"creation-node -> server | CONTENT Profile header: true new: After: 0 New: 1",
|
||||
"server -> creation-node | KNOWN Account sessions: header/4",
|
||||
"creation-node -> server | CONTENT Account header: false new: After: 3 New: 1",
|
||||
"server -> creation-node | KNOWN Account sessions: header/3",
|
||||
"server -> creation-node | KNOWN ProfileGroup sessions: header/5",
|
||||
"server -> creation-node | KNOWN Profile sessions: header/1",
|
||||
"server -> creation-node | KNOWN Account sessions: header/4",
|
||||
"auth-node -> server | LOAD Account sessions: empty",
|
||||
"server -> auth-node | CONTENT Account header: true new: After: 0 New: 4",
|
||||
"auth-node -> server | KNOWN Account sessions: header/4",
|
||||
|
||||
@@ -15,15 +15,15 @@ import {
|
||||
waitFor,
|
||||
} from "./testUtils";
|
||||
|
||||
// We want to simulate a real world communication that happens asynchronously
|
||||
TEST_NODE_CONFIG.withAsyncPeers = true;
|
||||
|
||||
let jazzCloud: ReturnType<typeof setupTestNode>;
|
||||
|
||||
// Set a short timeout to make the tests on unavailable complete faster
|
||||
setCoValueLoadingRetryDelay(100);
|
||||
|
||||
beforeEach(async () => {
|
||||
// We want to simulate a real world communication that happens asynchronously
|
||||
TEST_NODE_CONFIG.withAsyncPeers = true;
|
||||
|
||||
SyncMessagesLog.clear();
|
||||
jazzCloud = setupTestNode({ isSyncServer: true });
|
||||
});
|
||||
@@ -295,10 +295,9 @@ describe("loading coValues from server", () => {
|
||||
[
|
||||
"client -> server | LOAD Group sessions: header/3",
|
||||
"client -> server | LOAD Map sessions: header/1",
|
||||
"server -> client | CONTENT Group header: true new: After: 0 New: 3",
|
||||
"server -> client | CONTENT Map header: true new: After: 0 New: 2",
|
||||
"server -> client | CONTENT Map header: false new: After: 1 New: 1",
|
||||
"client -> server | KNOWN Group sessions: header/3",
|
||||
"server -> client | KNOWN Group sessions: header/3",
|
||||
"server -> client | CONTENT Map header: false new: After: 1 New: 1",
|
||||
"client -> server | KNOWN Map sessions: header/2",
|
||||
"client -> server | KNOWN Map sessions: header/2",
|
||||
]
|
||||
@@ -340,11 +339,10 @@ describe("loading coValues from server", () => {
|
||||
[
|
||||
"client -> server | LOAD Group sessions: header/5",
|
||||
"client -> server | LOAD Map sessions: header/2",
|
||||
"server -> client | CONTENT Group header: true new: After: 0 New: 5",
|
||||
"server -> client | CONTENT Map header: true new: After: 0 New: 2",
|
||||
"client -> server | CONTENT Map header: false new: After: 0 New: 1",
|
||||
"server -> client | CONTENT Map header: false new: After: 1 New: 1",
|
||||
"client -> server | KNOWN Group sessions: header/5",
|
||||
"client -> server | CONTENT Map header: false new: After: 0 New: 1",
|
||||
"server -> client | KNOWN Group sessions: header/5",
|
||||
"server -> client | CONTENT Map header: false new: After: 1 New: 1",
|
||||
"client -> server | KNOWN Map sessions: header/3",
|
||||
"server -> client | KNOWN Map sessions: header/3",
|
||||
"client -> server | KNOWN Map sessions: header/3",
|
||||
@@ -550,6 +548,8 @@ describe("loading coValues from server", () => {
|
||||
});
|
||||
|
||||
test("should handle reconnections in the middle of a load with a persistent peer", async () => {
|
||||
TEST_NODE_CONFIG.withAsyncPeers = false; // To avoid flakiness
|
||||
|
||||
const client = setupTestNode();
|
||||
const connection1 = client.connectToSyncServer({
|
||||
persistent: true,
|
||||
@@ -590,11 +590,14 @@ describe("loading coValues from server", () => {
|
||||
"client -> server | LOAD Map sessions: empty",
|
||||
"server -> client | CONTENT Group header: true new: After: 0 New: 5",
|
||||
"client -> server | KNOWN Group sessions: header/5",
|
||||
"client -> server | LOAD Map sessions: empty",
|
||||
"client -> server | LOAD Group sessions: header/5",
|
||||
"server -> client | KNOWN Group sessions: header/5",
|
||||
"server -> client | CONTENT Group header: true new: After: 0 New: 5",
|
||||
"client -> server | KNOWN Group sessions: header/5",
|
||||
"server -> client | CONTENT Map header: true new: After: 0 New: 1",
|
||||
"client -> server | KNOWN Map sessions: header/1",
|
||||
"client -> server | LOAD Group sessions: header/5",
|
||||
"server -> client | KNOWN Group sessions: header/5",
|
||||
"client -> server | LOAD Map sessions: header/1",
|
||||
"server -> client | KNOWN Map sessions: header/1",
|
||||
]
|
||||
`);
|
||||
});
|
||||
@@ -634,7 +637,7 @@ describe("loading coValues from server", () => {
|
||||
"server -> client | CONTENT Map header: true new: After: 0 New: 1",
|
||||
"client -> server | KNOWN ParentGroup sessions: header/6",
|
||||
"client -> server | LOAD Group sessions: empty",
|
||||
"client -> server | KNOWN Map sessions: empty",
|
||||
"client -> server | KNOWN Map sessions: header/1",
|
||||
"server -> client | CONTENT Group header: true new: After: 0 New: 5",
|
||||
"client -> server | KNOWN Group sessions: header/5",
|
||||
]
|
||||
@@ -677,8 +680,8 @@ describe("loading coValues from server", () => {
|
||||
"server -> client | CONTENT Group header: true new: After: 0 New: 5",
|
||||
"server -> client | CONTENT Map header: true new: After: 0 New: 1",
|
||||
"client -> server | LOAD ParentGroup sessions: empty",
|
||||
"client -> server | KNOWN Group sessions: empty",
|
||||
"client -> server | KNOWN Map sessions: empty",
|
||||
"client -> server | KNOWN Group sessions: header/5",
|
||||
"client -> server | KNOWN Map sessions: header/1",
|
||||
"server -> client | CONTENT ParentGroup header: true new: After: 0 New: 6",
|
||||
"client -> server | KNOWN ParentGroup sessions: header/6",
|
||||
]
|
||||
@@ -723,8 +726,8 @@ describe("loading coValues from server", () => {
|
||||
"server -> client | CONTENT Group header: true new: After: 0 New: 5",
|
||||
"server -> client | CONTENT Map header: true new: After: 0 New: 1",
|
||||
"client -> server | LOAD Account sessions: empty",
|
||||
"client -> server | KNOWN Group sessions: empty",
|
||||
"client -> server | KNOWN Map sessions: empty",
|
||||
"client -> server | KNOWN Group sessions: header/0",
|
||||
"client -> server | KNOWN Map sessions: header/0",
|
||||
"server -> client | CONTENT Account header: true new: After: 0 New: 4",
|
||||
"client -> server | KNOWN Account sessions: header/4",
|
||||
"client -> server | KNOWN Group sessions: header/5",
|
||||
@@ -787,7 +790,7 @@ describe("loading coValues from server", () => {
|
||||
"server -> client | CONTENT Map header: true new: After: 0 New: 1 | After: 0 New: 1",
|
||||
"client -> server | KNOWN Group sessions: header/5",
|
||||
"client -> server | LOAD Account sessions: empty",
|
||||
"client -> server | KNOWN Map sessions: empty",
|
||||
"client -> server | KNOWN Map sessions: header/1",
|
||||
"server -> client | CONTENT Account header: true new: After: 0 New: 4",
|
||||
"client -> server | KNOWN Account sessions: header/4",
|
||||
"client -> server | KNOWN Map sessions: header/2",
|
||||
@@ -851,8 +854,8 @@ describe("loading coValues from server", () => {
|
||||
"server -> client | CONTENT Group header: true new: After: 0 New: 5",
|
||||
"server -> client | CONTENT Map header: true new: After: 0 New: 1",
|
||||
"client -> server | LOAD Account sessions: empty",
|
||||
"client -> server | KNOWN Group sessions: empty",
|
||||
"client -> server | KNOWN Map sessions: empty",
|
||||
"client -> server | KNOWN Group sessions: header/0",
|
||||
"client -> server | KNOWN Map sessions: header/0",
|
||||
"server -> client | CONTENT Account header: true new: After: 0 New: 4",
|
||||
"server -> client | CONTENT Account header: true new: After: 0 New: 4",
|
||||
"client -> server | KNOWN Account sessions: header/4",
|
||||
@@ -922,7 +925,7 @@ describe("loading coValues from server", () => {
|
||||
"client -> server | LOAD Map sessions: empty",
|
||||
"server -> client | CONTENT Map header: true new: After: 0 New: 1",
|
||||
"client -> server | LOAD Account sessions: empty",
|
||||
"client -> server | KNOWN Map sessions: empty",
|
||||
"client -> server | KNOWN Map sessions: header/0",
|
||||
"server -> client | CONTENT Account header: true new: After: 0 New: 4",
|
||||
"server -> client | CONTENT Account header: true new: After: 0 New: 4",
|
||||
"client -> server | KNOWN Account sessions: header/4",
|
||||
@@ -960,14 +963,17 @@ describe("loading coValues from server", () => {
|
||||
}),
|
||||
).toMatchInlineSnapshot(`
|
||||
[
|
||||
"client -> server | CONTENT ParentGroup header: true new: After: 0 New: 8",
|
||||
"client -> server | CONTENT Group header: true new: After: 0 New: 6",
|
||||
"client -> server | CONTENT Group header: true new: After: 0 New: 3",
|
||||
"client -> server | CONTENT ParentGroup header: true new: After: 0 New: 6",
|
||||
"client -> server | CONTENT Group header: false new: After: 3 New: 3",
|
||||
"client -> server | CONTENT ParentGroup header: false new: After: 6 New: 2",
|
||||
"client -> server | CONTENT Map header: true new: After: 0 New: 1",
|
||||
"server -> client | LOAD Group sessions: empty",
|
||||
"server -> client | KNOWN ParentGroup sessions: empty",
|
||||
"server -> client | KNOWN Group sessions: header/3",
|
||||
"server -> client | KNOWN ParentGroup sessions: header/6",
|
||||
"server -> client | KNOWN Group sessions: header/6",
|
||||
"server -> client | KNOWN ParentGroup sessions: header/8",
|
||||
"server -> client | KNOWN Map sessions: header/1",
|
||||
"client -> server | CONTENT Group header: true new: After: 0 New: 6",
|
||||
"client -> server | CONTENT ParentGroup header: true new: ",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
@@ -142,19 +142,26 @@ describe("multiple clients syncing with the a cloud-like server mesh", () => {
|
||||
}),
|
||||
).toMatchInlineSnapshot(`
|
||||
[
|
||||
"edge-france -> storage | CONTENT Group header: true new: After: 0 New: 5",
|
||||
"edge-france -> core | CONTENT ParentGroup header: true new: After: 0 New: 6",
|
||||
"edge-france -> core | CONTENT Group header: true new: After: 0 New: 5",
|
||||
"edge-france -> storage | CONTENT Group header: true new: After: 0 New: 3",
|
||||
"edge-france -> core | CONTENT Group header: true new: After: 0 New: 3",
|
||||
"edge-france -> storage | CONTENT ParentGroup header: true new: After: 0 New: 6",
|
||||
"edge-france -> core | CONTENT ParentGroup header: true new: After: 0 New: 6",
|
||||
"edge-france -> storage | CONTENT Group header: false new: After: 3 New: 2",
|
||||
"edge-france -> core | CONTENT Group header: false new: After: 3 New: 2",
|
||||
"edge-france -> storage | CONTENT Map header: true new: After: 0 New: 1",
|
||||
"edge-france -> core | CONTENT Map header: true new: After: 0 New: 1",
|
||||
"core -> edge-france | KNOWN Group sessions: header/3",
|
||||
"core -> storage | CONTENT Group header: true new: After: 0 New: 3",
|
||||
"core -> edge-france | KNOWN ParentGroup sessions: header/6",
|
||||
"core -> storage | CONTENT ParentGroup header: true new: After: 0 New: 6",
|
||||
"core -> edge-france | KNOWN Group sessions: header/5",
|
||||
"core -> storage | CONTENT Group header: true new: After: 0 New: 5",
|
||||
"core -> storage | CONTENT Group header: false new: After: 3 New: 2",
|
||||
"core -> edge-france | KNOWN Map sessions: header/1",
|
||||
"core -> storage | CONTENT Map header: true new: After: 0 New: 1",
|
||||
"edge-france -> core | CONTENT ParentGroup header: true new: ",
|
||||
"client -> edge-italy | LOAD Map sessions: empty",
|
||||
"core -> edge-france | KNOWN ParentGroup sessions: header/6",
|
||||
"core -> storage | CONTENT ParentGroup header: true new: ",
|
||||
"edge-italy -> storage | LOAD Map sessions: empty",
|
||||
"storage -> edge-italy | KNOWN Map sessions: empty",
|
||||
"edge-italy -> core | LOAD Map sessions: empty",
|
||||
@@ -509,8 +516,7 @@ describe("multiple clients syncing with the a cloud-like server mesh", () => {
|
||||
).toMatchInlineSnapshot(`
|
||||
[
|
||||
"edge -> storage | CONTENT Group header: true new: After: 0 New: 5",
|
||||
"edge -> storage | CONTENT Map header: true new: expectContentUntil: header/200",
|
||||
"edge -> storage | CONTENT Map header: false new: After: 0 New: 73",
|
||||
"edge -> storage | CONTENT Map header: true new: After: 0 New: 73",
|
||||
"edge -> storage | CONTENT Map header: false new: After: 73 New: 73",
|
||||
"edge -> storage | CONTENT Map header: false new: After: 146 New: 54",
|
||||
]
|
||||
|
||||
@@ -47,6 +47,8 @@ describe("peer reconciliation", () => {
|
||||
"server -> client | KNOWN Map sessions: empty",
|
||||
"server -> client | KNOWN Group sessions: header/3",
|
||||
"server -> client | KNOWN Map sessions: header/1",
|
||||
"client -> server | CONTENT Group header: true new: ",
|
||||
"client -> server | CONTENT Map header: true new: ",
|
||||
]
|
||||
`);
|
||||
});
|
||||
@@ -203,7 +205,7 @@ describe("peer reconciliation", () => {
|
||||
"server -> client | KNOWN CORRECTION Map sessions: empty",
|
||||
"client -> server | CONTENT Map header: true new: After: 0 New: 2",
|
||||
"server -> client | LOAD Group sessions: empty",
|
||||
"server -> client | KNOWN Map sessions: empty",
|
||||
"server -> client | KNOWN Map sessions: header/2",
|
||||
"client -> server | CONTENT Group header: true new: After: 0 New: 3",
|
||||
"server -> client | KNOWN Group sessions: header/3",
|
||||
]
|
||||
@@ -276,8 +278,8 @@ describe("peer reconciliation", () => {
|
||||
"client -> server | CONTENT Profile header: true new: After: 0 New: 1",
|
||||
"client -> server | CONTENT Map header: false new: After: 1 New: 1",
|
||||
"server -> client | LOAD Account sessions: empty",
|
||||
"server -> client | KNOWN ProfileGroup sessions: empty",
|
||||
"server -> client | KNOWN Profile sessions: empty",
|
||||
"server -> client | KNOWN ProfileGroup sessions: header/0",
|
||||
"server -> client | KNOWN Profile sessions: header/0",
|
||||
"server -> client | KNOWN CORRECTION Map sessions: empty",
|
||||
"client -> server | CONTENT Account header: true new: After: 0 New: 4",
|
||||
"client -> server | CONTENT Map header: true new: After: 0 New: 2",
|
||||
@@ -285,7 +287,7 @@ describe("peer reconciliation", () => {
|
||||
"server -> client | KNOWN ProfileGroup sessions: header/5",
|
||||
"server -> client | KNOWN Profile sessions: header/1",
|
||||
"server -> client | LOAD Group sessions: empty",
|
||||
"server -> client | KNOWN Map sessions: empty",
|
||||
"server -> client | KNOWN Map sessions: header/2",
|
||||
"client -> server | CONTENT Group header: true new: After: 0 New: 3",
|
||||
"server -> client | KNOWN Group sessions: header/3",
|
||||
]
|
||||
|
||||
@@ -181,13 +181,11 @@ describe("client with storage syncs with server", () => {
|
||||
[
|
||||
"client -> server | LOAD Group sessions: header/3",
|
||||
"client -> server | LOAD Map sessions: header/1",
|
||||
"server -> client | CONTENT Group header: true new: After: 0 New: 3",
|
||||
"server -> client | CONTENT Map header: true new: After: 0 New: 2",
|
||||
"server -> client | CONTENT Map header: false new: After: 1 New: 1",
|
||||
"client -> server | KNOWN Group sessions: header/3",
|
||||
"client -> storage | CONTENT Group header: true new: After: 0 New: 3",
|
||||
"server -> client | KNOWN Group sessions: header/3",
|
||||
"server -> client | CONTENT Map header: false new: After: 1 New: 1",
|
||||
"client -> server | KNOWN Map sessions: header/2",
|
||||
"client -> storage | CONTENT Map header: true new: After: 0 New: 2",
|
||||
"client -> storage | CONTENT Map header: false new: After: 1 New: 1",
|
||||
"client -> server | KNOWN Map sessions: header/2",
|
||||
"client -> storage | CONTENT Map header: false new: After: 1 New: 1",
|
||||
]
|
||||
@@ -291,20 +289,16 @@ describe("client syncs with a server with storage", () => {
|
||||
[
|
||||
"client -> storage | CONTENT Group header: true new: After: 0 New: 5",
|
||||
"client -> server | CONTENT Group header: true new: After: 0 New: 5",
|
||||
"client -> storage | CONTENT Map header: true new: expectContentUntil: header/200",
|
||||
"client -> storage | CONTENT Map header: false new: After: 0 New: 73",
|
||||
"client -> storage | CONTENT Map header: true new: After: 0 New: 73",
|
||||
"client -> server | CONTENT Map header: true new: After: 0 New: 73",
|
||||
"client -> storage | CONTENT Map header: false new: After: 73 New: 73",
|
||||
"client -> storage | CONTENT Map header: false new: After: 146 New: 54",
|
||||
"client -> server | CONTENT Map header: true new: expectContentUntil: header/200",
|
||||
"client -> server | CONTENT Map header: false new: After: 0 New: 73",
|
||||
"client -> server | CONTENT Map header: false new: After: 73 New: 73",
|
||||
"client -> storage | CONTENT Map header: false new: After: 146 New: 54",
|
||||
"client -> server | CONTENT Map header: false new: After: 146 New: 54",
|
||||
"server -> client | KNOWN Group sessions: header/5",
|
||||
"server -> storage | CONTENT Group header: true new: After: 0 New: 5",
|
||||
"server -> client | KNOWN Map sessions: header/0",
|
||||
"server -> storage | CONTENT Map header: true new: expectContentUntil: header/200",
|
||||
"server -> client | KNOWN Map sessions: header/73",
|
||||
"server -> storage | CONTENT Map header: false new: After: 0 New: 73",
|
||||
"server -> storage | CONTENT Map header: true new: After: 0 New: 73",
|
||||
"server -> client | KNOWN Map sessions: header/146",
|
||||
"server -> storage | CONTENT Map header: false new: After: 73 New: 73",
|
||||
"server -> client | KNOWN Map sessions: header/200",
|
||||
@@ -410,7 +404,7 @@ describe("client syncs with a server with storage", () => {
|
||||
|
||||
const correctionSpy = vi.fn();
|
||||
|
||||
client.node.storage?.store(newContentChunks.slice(1, 2), correctionSpy);
|
||||
client.node.storage?.store(newContentChunks[1]!, correctionSpy);
|
||||
|
||||
expect(correctionSpy).not.toHaveBeenCalled();
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ describe("client with storage syncs with server", () => {
|
||||
let jazzCloud: ReturnType<typeof setupTestNode>;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetAllMocks();
|
||||
SyncMessagesLog.clear();
|
||||
jazzCloud = setupTestNode({
|
||||
isSyncServer: true,
|
||||
@@ -174,15 +175,43 @@ describe("client with storage syncs with server", () => {
|
||||
[
|
||||
"client -> server | LOAD Group sessions: header/3",
|
||||
"client -> server | LOAD Map sessions: header/1",
|
||||
"server -> client | CONTENT Group header: true new: After: 0 New: 3",
|
||||
"server -> client | CONTENT Map header: true new: After: 0 New: 2",
|
||||
"server -> client | CONTENT Map header: false new: After: 1 New: 1",
|
||||
"client -> server | KNOWN Group sessions: header/3",
|
||||
"client -> storage | CONTENT Group header: true new: After: 0 New: 3",
|
||||
"client -> server | KNOWN Map sessions: header/2",
|
||||
"client -> storage | CONTENT Map header: true new: After: 0 New: 2",
|
||||
"server -> client | KNOWN Group sessions: header/3",
|
||||
"server -> client | CONTENT Map header: false new: After: 1 New: 1",
|
||||
"client -> server | KNOWN Map sessions: header/2",
|
||||
"client -> storage | CONTENT Map header: false new: After: 1 New: 1",
|
||||
"client -> server | KNOWN Map sessions: header/2",
|
||||
"client -> storage | CONTENT Map header: false new: After: 1 New: 1",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
test("the order of updates between CoValues should be preserved to ensure consistency in case of shutdown in the middle of sync", async () => {
|
||||
const client = setupTestNode();
|
||||
|
||||
await client.addAsyncStorage();
|
||||
|
||||
const group = client.node.createGroup();
|
||||
const initialMap = group.createMap();
|
||||
|
||||
const child = group.createMap();
|
||||
child.set("parent", initialMap.id);
|
||||
initialMap.set("child", child.id);
|
||||
|
||||
await initialMap.core.waitForSync();
|
||||
|
||||
expect(
|
||||
SyncMessagesLog.getMessages({
|
||||
Group: group.core,
|
||||
InitialMap: initialMap.core,
|
||||
ChildMap: child.core,
|
||||
}),
|
||||
).toMatchInlineSnapshot(`
|
||||
[
|
||||
"client -> storage | CONTENT Group header: true new: After: 0 New: 3",
|
||||
"client -> storage | CONTENT InitialMap header: true new: ",
|
||||
"client -> storage | CONTENT ChildMap header: true new: After: 0 New: 1",
|
||||
"client -> storage | CONTENT InitialMap header: false new: After: 0 New: 1",
|
||||
]
|
||||
`);
|
||||
});
|
||||
@@ -271,20 +300,16 @@ describe("client syncs with a server with storage", () => {
|
||||
[
|
||||
"client -> storage | CONTENT Group header: true new: After: 0 New: 5",
|
||||
"client -> server | CONTENT Group header: true new: After: 0 New: 5",
|
||||
"client -> storage | CONTENT Map header: true new: expectContentUntil: header/200",
|
||||
"client -> storage | CONTENT Map header: false new: After: 0 New: 73",
|
||||
"client -> storage | CONTENT Map header: true new: After: 0 New: 73",
|
||||
"client -> server | CONTENT Map header: true new: After: 0 New: 73",
|
||||
"client -> storage | CONTENT Map header: false new: After: 73 New: 73",
|
||||
"client -> storage | CONTENT Map header: false new: After: 146 New: 54",
|
||||
"client -> server | CONTENT Map header: true new: expectContentUntil: header/200",
|
||||
"client -> server | CONTENT Map header: false new: After: 0 New: 73",
|
||||
"client -> server | CONTENT Map header: false new: After: 73 New: 73",
|
||||
"client -> storage | CONTENT Map header: false new: After: 146 New: 54",
|
||||
"client -> server | CONTENT Map header: false new: After: 146 New: 54",
|
||||
"server -> client | KNOWN Group sessions: header/5",
|
||||
"server -> storage | CONTENT Group header: true new: After: 0 New: 5",
|
||||
"server -> client | KNOWN Map sessions: header/0",
|
||||
"server -> storage | CONTENT Map header: true new: expectContentUntil: header/200",
|
||||
"server -> client | KNOWN Map sessions: header/73",
|
||||
"server -> storage | CONTENT Map header: false new: After: 0 New: 73",
|
||||
"server -> storage | CONTENT Map header: true new: After: 0 New: 73",
|
||||
"server -> client | KNOWN Map sessions: header/146",
|
||||
"server -> storage | CONTENT Map header: false new: After: 73 New: 73",
|
||||
"server -> client | KNOWN Map sessions: header/200",
|
||||
@@ -369,7 +394,7 @@ describe("client syncs with a server with storage", () => {
|
||||
|
||||
const correctionSpy = vi.fn();
|
||||
|
||||
client.node.storage?.store(newContentChunks.slice(1, 2), correctionSpy);
|
||||
client.node.storage?.store(newContentChunks[1]!, correctionSpy);
|
||||
|
||||
// Wait for the content to be stored in the storage
|
||||
// We can't use waitForSync because we are trying to store stale data
|
||||
|
||||
@@ -100,15 +100,18 @@ test("Can sync a coValue with private transactions through a server to another c
|
||||
|
||||
const map = group.createMap();
|
||||
map.set("hello", "world", "private");
|
||||
|
||||
group.addMember("everyone", "reader");
|
||||
|
||||
const { node: client2 } = await setupTestAccount({
|
||||
connected: true,
|
||||
});
|
||||
|
||||
const mapOnClient2 = await loadCoValueOrFail(client2, map.id);
|
||||
await waitFor(async () => {
|
||||
const loadedMap = await loadCoValueOrFail(client2, map.id);
|
||||
|
||||
expect(mapOnClient2.get("hello")).toEqual("world");
|
||||
expect(loadedMap.get("hello")).toEqual("world");
|
||||
});
|
||||
});
|
||||
|
||||
test("should keep the peer state when the peer closes if persistent is true", async () => {
|
||||
@@ -563,8 +566,6 @@ describe("SyncManager - knownStates vs optimisticKnownStates", () => {
|
||||
const mapOnClient = group.createMap();
|
||||
mapOnClient.set("key1", "value1", "trusting");
|
||||
|
||||
await client.syncManager.syncCoValue(mapOnClient.core);
|
||||
|
||||
// Wait for the full sync to complete
|
||||
await mapOnClient.core.waitForSync();
|
||||
|
||||
@@ -594,7 +595,6 @@ describe("SyncManager - knownStates vs optimisticKnownStates", () => {
|
||||
const map = group.createMap();
|
||||
map.set("key1", "value1", "trusting");
|
||||
|
||||
await client.node.syncManager.syncCoValue(map.core);
|
||||
await map.core.waitForSync();
|
||||
|
||||
// Block the content messages
|
||||
@@ -606,7 +606,7 @@ describe("SyncManager - knownStates vs optimisticKnownStates", () => {
|
||||
|
||||
map.set("key2", "value2", "trusting");
|
||||
|
||||
await client.node.syncManager.syncCoValue(map.core);
|
||||
await new Promise<void>(queueMicrotask);
|
||||
|
||||
expect(peerState.optimisticKnownStates.get(map.core.id)).not.toEqual(
|
||||
peerState.knownStates.get(map.core.id),
|
||||
@@ -638,8 +638,6 @@ describe("SyncManager.addPeer", () => {
|
||||
const map = group.createMap();
|
||||
map.set("key1", "value1", "trusting");
|
||||
|
||||
await client.node.syncManager.syncCoValue(map.core);
|
||||
|
||||
// Wait for initial sync
|
||||
await map.core.waitForSync();
|
||||
|
||||
@@ -671,8 +669,6 @@ describe("SyncManager.addPeer", () => {
|
||||
const map = group.createMap();
|
||||
map.set("key1", "value1", "trusting");
|
||||
|
||||
await client.node.syncManager.syncCoValue(map.core);
|
||||
|
||||
// Wait for initial sync
|
||||
await map.core.waitForSync();
|
||||
|
||||
@@ -843,8 +839,6 @@ describe("waitForSyncWithPeer", () => {
|
||||
const map = group.createMap();
|
||||
map.set("key1", "value1", "trusting");
|
||||
|
||||
await client.node.syncManager.syncCoValue(map.core);
|
||||
|
||||
await expect(
|
||||
client.node.syncManager.waitForSyncWithPeer(
|
||||
peerState.id,
|
||||
@@ -868,8 +862,6 @@ describe("waitForSyncWithPeer", () => {
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
await client.node.syncManager.syncCoValue(map.core);
|
||||
|
||||
await expect(
|
||||
client.node.syncManager.waitForSyncWithPeer(
|
||||
peerState.id,
|
||||
|
||||
@@ -78,12 +78,15 @@ describe("client to server upload", () => {
|
||||
}),
|
||||
).toMatchInlineSnapshot(`
|
||||
[
|
||||
"client -> server | CONTENT Group header: true new: After: 0 New: 3",
|
||||
"client -> server | CONTENT ParentGroup header: true new: After: 0 New: 6",
|
||||
"client -> server | CONTENT Group header: true new: After: 0 New: 5",
|
||||
"client -> server | CONTENT Group header: false new: After: 3 New: 2",
|
||||
"client -> server | CONTENT Map header: true new: After: 0 New: 1",
|
||||
"server -> client | KNOWN Group sessions: header/3",
|
||||
"server -> client | KNOWN ParentGroup sessions: header/6",
|
||||
"server -> client | KNOWN Group sessions: header/5",
|
||||
"server -> client | KNOWN Map sessions: header/1",
|
||||
"client -> server | CONTENT ParentGroup header: true new: ",
|
||||
]
|
||||
`);
|
||||
});
|
||||
@@ -253,6 +256,40 @@ describe("client to server upload", () => {
|
||||
`);
|
||||
});
|
||||
|
||||
test("local updates batching", async () => {
|
||||
const client = setupTestNode({
|
||||
connected: true,
|
||||
});
|
||||
|
||||
const group = client.node.createGroup();
|
||||
const initialMap = group.createMap();
|
||||
|
||||
const child = group.createMap();
|
||||
child.set("parent", initialMap.id);
|
||||
initialMap.set("child", child.id);
|
||||
|
||||
await initialMap.core.waitForSync();
|
||||
|
||||
expect(
|
||||
SyncMessagesLog.getMessages({
|
||||
Group: group.core,
|
||||
InitialMap: initialMap.core,
|
||||
ChildMap: child.core,
|
||||
}),
|
||||
).toMatchInlineSnapshot(`
|
||||
[
|
||||
"client -> server | CONTENT Group header: true new: After: 0 New: 3",
|
||||
"client -> server | CONTENT InitialMap header: true new: ",
|
||||
"client -> server | CONTENT ChildMap header: true new: After: 0 New: 1",
|
||||
"client -> server | CONTENT InitialMap header: false new: After: 0 New: 1",
|
||||
"server -> client | KNOWN Group sessions: header/3",
|
||||
"server -> client | KNOWN InitialMap sessions: header/0",
|
||||
"server -> client | KNOWN ChildMap sessions: header/1",
|
||||
"server -> client | KNOWN InitialMap sessions: header/1",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
test("large coValue upload streaming", async () => {
|
||||
const client = setupTestNode({
|
||||
connected: true,
|
||||
|
||||
@@ -5,11 +5,7 @@ import { join } from "node:path";
|
||||
import Database, { type Database as DatabaseT } from "libsql";
|
||||
import { onTestFinished } from "vitest";
|
||||
import { RawCoID, StorageAPI } from "../exports";
|
||||
import {
|
||||
SQLiteDatabaseDriver,
|
||||
StorageApiAsync,
|
||||
StorageApiSync,
|
||||
} from "../storage";
|
||||
import { SQLiteDatabaseDriver } from "../storage";
|
||||
import { getSqliteStorage } from "../storage/sqlite";
|
||||
import {
|
||||
SQLiteDatabaseDriverAsync,
|
||||
@@ -148,13 +144,11 @@ function trackStorageMessages(
|
||||
const originalLoad = storage.load;
|
||||
|
||||
storage.store = function (data, correctionCallback) {
|
||||
for (const msg of data ?? []) {
|
||||
SyncMessagesLog.add({
|
||||
from: nodeName,
|
||||
to: storageName,
|
||||
msg,
|
||||
});
|
||||
}
|
||||
SyncMessagesLog.add({
|
||||
from: nodeName,
|
||||
to: storageName,
|
||||
msg: data,
|
||||
});
|
||||
|
||||
return originalStore.call(storage, data, (correction) => {
|
||||
SyncMessagesLog.add({
|
||||
@@ -167,7 +161,19 @@ function trackStorageMessages(
|
||||
},
|
||||
});
|
||||
|
||||
return correctionCallback(correction);
|
||||
const correctionMessages = correctionCallback(correction);
|
||||
|
||||
if (correctionMessages) {
|
||||
for (const msg of correctionMessages) {
|
||||
SyncMessagesLog.add({
|
||||
from: nodeName,
|
||||
to: storageName,
|
||||
msg,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return correctionMessages;
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -40,6 +40,14 @@ export function randomAgentAndSessionID(): [ControlledAgent, SessionID] {
|
||||
return [new ControlledAgent(agentSecret, Crypto), sessionID];
|
||||
}
|
||||
|
||||
export function agentAndSessionIDFromSecret(
|
||||
secret: AgentSecret,
|
||||
): [ControlledAgent, SessionID] {
|
||||
const sessionID = Crypto.newRandomSessionID(Crypto.getAgentID(secret));
|
||||
|
||||
return [new ControlledAgent(secret, Crypto), sessionID];
|
||||
}
|
||||
|
||||
export function nodeWithRandomAgentAndSessionID() {
|
||||
const [agent, session] = randomAgentAndSessionID();
|
||||
return new LocalNode(agent.agentSecret, session, Crypto);
|
||||
@@ -154,8 +162,8 @@ export function connectTwoPeers(
|
||||
bRole: "client" | "server",
|
||||
) {
|
||||
const [aAsPeer, bAsPeer] = connectedPeers(
|
||||
"peer:" + a.getCurrentAgent().id,
|
||||
"peer:" + b.getCurrentAgent().id,
|
||||
"peer:" + a.currentSessionID,
|
||||
"peer:" + b.currentSessionID,
|
||||
{
|
||||
peer1role: aRole,
|
||||
peer2role: bRole,
|
||||
@@ -443,7 +451,7 @@ export function getSyncServerConnectedPeer(opts: {
|
||||
|
||||
const { peer1, peer2 } = connectedPeersWithMessagesTracking({
|
||||
peer1: {
|
||||
id: currentSyncServer.getCurrentAgent().id,
|
||||
id: currentSyncServer.currentSessionID,
|
||||
role: "server",
|
||||
name: opts.syncServerName,
|
||||
},
|
||||
@@ -472,9 +480,13 @@ export function setupTestNode(
|
||||
opts: {
|
||||
isSyncServer?: boolean;
|
||||
connected?: boolean;
|
||||
secret?: AgentSecret;
|
||||
} = {},
|
||||
) {
|
||||
const [admin, session] = randomAgentAndSessionID();
|
||||
const [admin, session] = opts.secret
|
||||
? agentAndSessionIDFromSecret(opts.secret)
|
||||
: randomAgentAndSessionID();
|
||||
|
||||
let node = new LocalNode(admin.agentSecret, session, Crypto);
|
||||
|
||||
if (opts.isSyncServer) {
|
||||
@@ -489,7 +501,7 @@ export function setupTestNode(
|
||||
}) {
|
||||
const { peer, peerStateOnServer, peerOnServer } =
|
||||
getSyncServerConnectedPeer({
|
||||
peerId: node.getCurrentAgent().id,
|
||||
peerId: session,
|
||||
syncServerName: opts?.syncServerName,
|
||||
ourName: opts?.ourName,
|
||||
syncServer: opts?.syncServer,
|
||||
@@ -551,6 +563,13 @@ export function setupTestNode(
|
||||
|
||||
return node;
|
||||
},
|
||||
spawnNewSession: () => {
|
||||
return setupTestNode({
|
||||
secret: node.agentSecret,
|
||||
connected: opts.connected,
|
||||
isSyncServer: opts.isSyncServer,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
return ctx;
|
||||
@@ -638,6 +657,11 @@ export async function setupTestAccount(
|
||||
connectToSyncServer,
|
||||
addStorage,
|
||||
addAsyncStorage,
|
||||
disconnect: () => {
|
||||
ctx.node.syncManager.getPeers().forEach((peer) => {
|
||||
peer.gracefulShutdown();
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { RawAccountID } from "../coValues/account.js";
|
||||
import { AgentID, SessionID } from "../ids.js";
|
||||
import type { RawAccountID } from "../coValues/account.js";
|
||||
import type { AgentID, SessionID } from "../ids.js";
|
||||
|
||||
export function accountOrAgentIDfromSessionID(
|
||||
sessionID: SessionID,
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { type RawCoValue, expectMap } from "../coValue.js";
|
||||
import { type RawCoValue } from "../coValue.js";
|
||||
import { RawGroup } from "../coValues/group.js";
|
||||
|
||||
export function expectGroup(content: RawCoValue): RawGroup {
|
||||
const map = expectMap(content);
|
||||
if (map.core.verified.header.ruleset.type !== "group") {
|
||||
throw new Error("Expected group ruleset in group");
|
||||
}
|
||||
|
||||
if (!(map instanceof RawGroup)) {
|
||||
if (content.type !== "comap") {
|
||||
throw new Error("Expected group");
|
||||
}
|
||||
|
||||
return map;
|
||||
if (content.core.verified.header.ruleset.type !== "group") {
|
||||
throw new Error("Expected group ruleset in group");
|
||||
}
|
||||
|
||||
if (!(content instanceof RawGroup)) {
|
||||
throw new Error("Expected group");
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,27 @@
|
||||
# jazz-auth-betterauth
|
||||
|
||||
## 0.16.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3cd1586]
|
||||
- Updated dependencies [267f689]
|
||||
- Updated dependencies [33ebbf0]
|
||||
- jazz-tools@0.16.5
|
||||
- cojson@0.16.5
|
||||
- jazz-betterauth-client-plugin@0.16.5
|
||||
|
||||
## 0.16.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [f9d538f]
|
||||
- Updated dependencies [16764f6]
|
||||
- Updated dependencies [802b5a3]
|
||||
- cojson@0.16.4
|
||||
- jazz-tools@0.16.4
|
||||
- jazz-betterauth-client-plugin@0.16.4
|
||||
|
||||
## 0.16.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jazz-auth-betterauth",
|
||||
"version": "0.16.3",
|
||||
"version": "0.16.5",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "src/index.ts",
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# jazz-betterauth-client-plugin
|
||||
|
||||
## 0.16.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-betterauth-server-plugin@0.16.5
|
||||
|
||||
## 0.16.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-betterauth-server-plugin@0.16.4
|
||||
|
||||
## 0.16.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jazz-betterauth-client-plugin",
|
||||
"version": "0.16.3",
|
||||
"version": "0.16.5",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "src/index.ts",
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
# jazz-betterauth-server-plugin
|
||||
|
||||
## 0.16.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3cd1586]
|
||||
- Updated dependencies [267f689]
|
||||
- Updated dependencies [33ebbf0]
|
||||
- jazz-tools@0.16.5
|
||||
- cojson@0.16.5
|
||||
|
||||
## 0.16.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [f9d538f]
|
||||
- Updated dependencies [16764f6]
|
||||
- Updated dependencies [802b5a3]
|
||||
- cojson@0.16.4
|
||||
- jazz-tools@0.16.4
|
||||
|
||||
## 0.16.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jazz-betterauth-server-plugin",
|
||||
"version": "0.16.3",
|
||||
"version": "0.16.5",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "src/index.ts",
|
||||
|
||||
@@ -1,5 +1,29 @@
|
||||
# jazz-react-auth-betterauth
|
||||
|
||||
## 0.16.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3cd1586]
|
||||
- Updated dependencies [267f689]
|
||||
- Updated dependencies [33ebbf0]
|
||||
- jazz-tools@0.16.5
|
||||
- cojson@0.16.5
|
||||
- jazz-auth-betterauth@0.16.5
|
||||
- jazz-betterauth-client-plugin@0.16.5
|
||||
|
||||
## 0.16.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [f9d538f]
|
||||
- Updated dependencies [16764f6]
|
||||
- Updated dependencies [802b5a3]
|
||||
- cojson@0.16.4
|
||||
- jazz-tools@0.16.4
|
||||
- jazz-auth-betterauth@0.16.4
|
||||
- jazz-betterauth-client-plugin@0.16.4
|
||||
|
||||
## 0.16.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jazz-react-auth-betterauth",
|
||||
"version": "0.16.3",
|
||||
"version": "0.16.5",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "src/index.tsx",
|
||||
|
||||
@@ -1,5 +1,29 @@
|
||||
# jazz-run
|
||||
|
||||
## 0.16.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3cd1586]
|
||||
- Updated dependencies [267f689]
|
||||
- Updated dependencies [33ebbf0]
|
||||
- jazz-tools@0.16.5
|
||||
- cojson@0.16.5
|
||||
- cojson-storage-sqlite@0.16.5
|
||||
- cojson-transport-ws@0.16.5
|
||||
|
||||
## 0.16.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [f9d538f]
|
||||
- Updated dependencies [16764f6]
|
||||
- Updated dependencies [802b5a3]
|
||||
- cojson@0.16.4
|
||||
- jazz-tools@0.16.4
|
||||
- cojson-storage-sqlite@0.16.4
|
||||
- cojson-transport-ws@0.16.4
|
||||
|
||||
## 0.16.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"bin": "./dist/index.js",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "0.16.3",
|
||||
"version": "0.16.5",
|
||||
"exports": {
|
||||
"./startSyncServer": {
|
||||
"types": "./dist/startSyncServer.d.ts",
|
||||
@@ -28,11 +28,11 @@
|
||||
"@effect/printer-ansi": "^0.34.5",
|
||||
"@effect/schema": "^0.71.1",
|
||||
"@effect/typeclass": "^0.25.5",
|
||||
"cojson": "workspace:0.16.3",
|
||||
"cojson-storage-sqlite": "workspace:0.16.3",
|
||||
"cojson-transport-ws": "workspace:0.16.3",
|
||||
"cojson": "workspace:0.16.5",
|
||||
"cojson-storage-sqlite": "workspace:0.16.5",
|
||||
"cojson-transport-ws": "workspace:0.16.5",
|
||||
"effect": "^3.6.5",
|
||||
"jazz-tools": "workspace:0.16.3",
|
||||
"jazz-tools": "workspace:0.16.5",
|
||||
"ws": "^8.14.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,5 +1,34 @@
|
||||
# jazz-tools
|
||||
|
||||
## 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
|
||||
|
||||
- 33ebbf0: Fix error when using nested discriminatedUnion
|
||||
- Updated dependencies [3cd1586]
|
||||
- Updated dependencies [267f689]
|
||||
- cojson@0.16.5
|
||||
- cojson-storage-indexeddb@0.16.5
|
||||
- cojson-transport-ws@0.16.5
|
||||
|
||||
## 0.16.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 16764f6: Added `pick()` and `partial()` methods to CoMapSchema
|
||||
- Updated dependencies [f9d538f]
|
||||
- Updated dependencies [802b5a3]
|
||||
- cojson@0.16.4
|
||||
- cojson-storage-indexeddb@0.16.4
|
||||
- cojson-transport-ws@0.16.4
|
||||
|
||||
## 0.16.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -139,7 +139,7 @@
|
||||
},
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "0.16.3",
|
||||
"version": "0.16.5",
|
||||
"dependencies": {
|
||||
"@manuscripts/prosemirror-recreate-steps": "^0.1.4",
|
||||
"@scure/base": "1.2.1",
|
||||
|
||||
@@ -110,9 +110,6 @@ function useCoValueSubscription<
|
||||
ref: coValueClassFromCoValueClassOrSchema(Schema),
|
||||
optional: true,
|
||||
},
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -289,18 +286,10 @@ function useAccountSubscription<
|
||||
const resolve: any = options?.resolve ?? true;
|
||||
|
||||
const node = contextManager.getCurrentValue()!.node;
|
||||
const subscription = new SubscriptionScope<any>(
|
||||
node,
|
||||
resolve,
|
||||
agent.id,
|
||||
{
|
||||
ref: coValueClassFromCoValueClassOrSchema(Schema),
|
||||
optional: true,
|
||||
},
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
const subscription = new SubscriptionScope<any>(node, resolve, agent.id, {
|
||||
ref: coValueClassFromCoValueClassOrSchema(Schema),
|
||||
optional: true,
|
||||
});
|
||||
|
||||
return {
|
||||
subscription,
|
||||
|
||||
@@ -167,15 +167,15 @@ export class Group extends CoValueBase implements CoValue {
|
||||
}
|
||||
}
|
||||
|
||||
removeMember(member: Everyone | Account): Promise<void>;
|
||||
removeMember(member: Everyone | Account): void;
|
||||
/** @category Identity & Permissions
|
||||
* Revokes membership from members a parent group.
|
||||
* @param member The group that will lose access to this group.
|
||||
*/
|
||||
removeMember(member: Group): Promise<void>;
|
||||
removeMember(member: Group): void;
|
||||
removeMember(member: Group | Everyone | Account) {
|
||||
if (member !== "everyone" && member._type === "Group") {
|
||||
return this._raw.revokeExtend(member._raw);
|
||||
this._raw.revokeExtend(member._raw);
|
||||
} else {
|
||||
return this._raw.removeMember(
|
||||
member === "everyone" ? member : member._raw,
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
type CoValueUniqueness,
|
||||
type CojsonInternalTypes,
|
||||
type RawCoValue,
|
||||
emptyKnownState,
|
||||
} from "cojson";
|
||||
import { AvailableCoValueCore } from "cojson/dist/coValueCore/coValueCore.js";
|
||||
import {
|
||||
@@ -546,9 +545,9 @@ function loadContentPiecesFromCoValue(
|
||||
}
|
||||
}
|
||||
|
||||
const pieces = core.verified.newContentSince(emptyKnownState(core.id));
|
||||
const pieces = core.verified.newContentSince(undefined) ?? [];
|
||||
|
||||
for (const piece of pieces ?? []) {
|
||||
for (const piece of pieces) {
|
||||
contentPieces.push(piece);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { AuthSecretStorage } from "../auth/AuthSecretStorage.js";
|
||||
import { InMemoryKVStore } from "../auth/InMemoryKVStore.js";
|
||||
import { KvStore, KvStoreContext } from "../auth/KvStoreContext.js";
|
||||
import { Account } from "../coValues/account.js";
|
||||
import { preloadFromCache } from "../internal.js";
|
||||
import { AuthCredentials } from "../types.js";
|
||||
import { JazzContextType } from "../types.js";
|
||||
import { AnonymousJazzAgent } from "./anonymousJazzAgent.js";
|
||||
@@ -138,14 +137,6 @@ export class JazzContextManager<
|
||||
logOut: this.logOut,
|
||||
};
|
||||
|
||||
const keys = JSON.parse(localStorage.getItem("$keys") ?? "[]");
|
||||
|
||||
for (const key of keys) {
|
||||
if (key.startsWith("$content-")) {
|
||||
preloadFromCache(key, context.node);
|
||||
}
|
||||
}
|
||||
|
||||
if (authProps?.credentials) {
|
||||
this.authSecretStorage.emitUpdate(authProps.credentials);
|
||||
} else {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Resolved,
|
||||
Simplify,
|
||||
SubscribeListenerOptions,
|
||||
coMapDefiner,
|
||||
coOptionalDefiner,
|
||||
hydrateCoreCoValueSchema,
|
||||
isAnyCoValueSchema,
|
||||
@@ -20,8 +21,9 @@ import { CoMapSchemaInit } from "../typeConverters/CoFieldInit.js";
|
||||
import { InstanceOrPrimitiveOfSchema } from "../typeConverters/InstanceOrPrimitiveOfSchema.js";
|
||||
import { InstanceOrPrimitiveOfSchemaCoValuesNullable } from "../typeConverters/InstanceOrPrimitiveOfSchemaCoValuesNullable.js";
|
||||
import { z } from "../zodReExport.js";
|
||||
import { AnyZodOrCoValueSchema } from "../zodSchema.js";
|
||||
import { AnyZodOrCoValueSchema, AnyZodSchema } from "../zodSchema.js";
|
||||
import { CoOptionalSchema } from "./CoOptionalSchema.js";
|
||||
import { CoreCoValueSchema } from "./CoValueSchema.js";
|
||||
|
||||
export interface CoMapSchema<
|
||||
Shape extends z.core.$ZodLooseShape,
|
||||
@@ -133,6 +135,23 @@ export interface CoMapSchema<
|
||||
getCoValueClass: () => typeof CoMap;
|
||||
|
||||
optional(): CoOptionalSchema<this>;
|
||||
|
||||
/**
|
||||
* Creates a new CoMap schema by picking the specified keys from the original schema.
|
||||
*
|
||||
* @param keys - The keys to pick from the original schema.
|
||||
* @returns A new CoMap schema with the picked keys.
|
||||
*/
|
||||
pick<Keys extends keyof Shape>(
|
||||
keys: { [key in Keys]: true },
|
||||
): CoMapSchema<Simplify<Pick<Shape, Keys>>, unknown, Owner>;
|
||||
|
||||
/**
|
||||
* Creates a new CoMap schema by making all fields optional.
|
||||
*
|
||||
* @returns A new CoMap schema with all fields optional.
|
||||
*/
|
||||
partial(): CoMapSchema<PartialShape<Shape>, CatchAll, Owner>;
|
||||
}
|
||||
|
||||
export function createCoreCoMapSchema<
|
||||
@@ -218,10 +237,40 @@ export function enrichCoMapSchema<
|
||||
getCoValueClass: () => {
|
||||
return coValueClass;
|
||||
},
|
||||
|
||||
optional: () => {
|
||||
return coOptionalDefiner(coValueSchema);
|
||||
},
|
||||
pick: <Keys extends keyof Shape>(keys: { [key in Keys]: true }) => {
|
||||
const keysSet = new Set(Object.keys(keys));
|
||||
const pickedShape: Record<string, AnyZodOrCoValueSchema> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(coValueSchema.shape)) {
|
||||
if (keysSet.has(key)) {
|
||||
pickedShape[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return coMapDefiner(pickedShape);
|
||||
},
|
||||
partial: () => {
|
||||
const partialShape: Record<string, AnyZodOrCoValueSchema> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(coValueSchema.shape)) {
|
||||
if (isAnyCoValueSchema(value)) {
|
||||
partialShape[key] = coOptionalDefiner(value);
|
||||
} else {
|
||||
partialShape[key] = z.optional(coValueSchema.shape[key]);
|
||||
}
|
||||
}
|
||||
|
||||
const partialCoMapSchema = coMapDefiner(partialShape);
|
||||
if (coValueSchema.catchAll) {
|
||||
return partialCoMapSchema.catchall(
|
||||
coValueSchema.catchAll as unknown as AnyZodOrCoValueSchema,
|
||||
);
|
||||
}
|
||||
return partialCoMapSchema;
|
||||
},
|
||||
}) as unknown as CoMapSchema<Shape, CatchAll>;
|
||||
return coValueSchema;
|
||||
}
|
||||
@@ -262,3 +311,11 @@ export type CoMapInstanceCoValuesNullable<Shape extends z.core.$ZodLooseShape> =
|
||||
Shape[key]
|
||||
>;
|
||||
};
|
||||
|
||||
export type PartialShape<Shape extends z.core.$ZodLooseShape> = Simplify<{
|
||||
-readonly [key in keyof Shape]: Shape[key] extends AnyZodSchema
|
||||
? z.ZodOptional<Shape[key]>
|
||||
: Shape[key] extends CoreCoValueSchema
|
||||
? CoOptionalSchema<Shape[key]>
|
||||
: never;
|
||||
}>;
|
||||
|
||||
@@ -81,8 +81,7 @@ export function schemaUnionDiscriminatorFor(
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (discriminatorDef._zod.def.type !== "literal") {
|
||||
if (discriminatorDef._zod?.def.type !== "literal") {
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -141,3 +140,9 @@ export function isUnionOfPrimitivesDeeply(schema: AnyZodOrCoValueSchema) {
|
||||
return !isAnyCoValueSchema(schema);
|
||||
}
|
||||
}
|
||||
|
||||
function isCoDiscriminatedUnion(
|
||||
def: any,
|
||||
): def is CoreCoDiscriminatedUnionSchema<any> {
|
||||
return def.builtin === "CoDiscriminatedUnion";
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ export type AnyCoreCoValueSchema =
|
||||
| CoreRichTextSchema
|
||||
| CoreFileStreamSchema;
|
||||
|
||||
type AnyZodSchema = z.core.$ZodType;
|
||||
export type AnyZodSchema = z.core.$ZodType;
|
||||
|
||||
export type AnyZodOrCoValueSchema = AnyZodSchema | CoreCoValueSchema;
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { LocalNode, RawCoID, RawCoValue } from "cojson";
|
||||
import { NewContentMessage } from "cojson/dist/sync.js";
|
||||
import type { LocalNode, RawCoValue } from "cojson";
|
||||
import {
|
||||
CoFeed,
|
||||
CoList,
|
||||
@@ -17,89 +16,6 @@ import { JazzError, type JazzErrorIssue } from "./JazzError.js";
|
||||
import type { SubscriptionValue, Unloaded } from "./types.js";
|
||||
import { createCoValue, getOwnerFromRawValue } from "./utils.js";
|
||||
|
||||
function getCacheKey(id: string, resolve: RefsToResolve<any>) {
|
||||
return `$content-${id}-${JSON.stringify(resolve)}`;
|
||||
}
|
||||
|
||||
export function preloadFromCache(
|
||||
key: string,
|
||||
node: LocalNode,
|
||||
): string[] | null {
|
||||
const cache = localStorage.getItem(key);
|
||||
|
||||
if (cache) {
|
||||
const ids = JSON.parse(cache) as NewContentMessage[][];
|
||||
|
||||
ids.forEach((messages) => {
|
||||
const coValue = node.getCoValue(messages[0]!.id as `co_z${string}`);
|
||||
|
||||
if (coValue.verified) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const message of messages) {
|
||||
node.syncManager.handleNewContent(message, "storage");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function addValueToCache(
|
||||
ids: Map<string, NewContentMessage[]>,
|
||||
node: LocalNode,
|
||||
id: string,
|
||||
) {
|
||||
if (!ids.has(id)) {
|
||||
const coValue = node.getCoValue(id as `co_z${string}`);
|
||||
|
||||
if (coValue.verified) {
|
||||
const newContent = coValue.verified.newContentSince(undefined);
|
||||
|
||||
if (newContent) {
|
||||
ids.set(id, newContent);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function collectIds(
|
||||
scope: SubscriptionScope<any>,
|
||||
ids: Map<string, NewContentMessage[]>,
|
||||
) {
|
||||
if (scope.value.type === "loaded") {
|
||||
for (const id of scope.value.value._raw.core.getDependedOnCoValues()) {
|
||||
addValueToCache(ids, scope.node, id);
|
||||
}
|
||||
addValueToCache(ids, scope.node, scope.id);
|
||||
}
|
||||
|
||||
for (const value of scope.childNodes.values()) {
|
||||
collectIds(value, ids);
|
||||
}
|
||||
}
|
||||
|
||||
function setCache(
|
||||
id: string,
|
||||
resolve: RefsToResolve<any>,
|
||||
scope: SubscriptionScope<any>,
|
||||
) {
|
||||
const key = getCacheKey(id, resolve);
|
||||
|
||||
const ids = new Map<string, NewContentMessage[]>();
|
||||
|
||||
collectIds(scope, ids);
|
||||
|
||||
localStorage.setItem(key, JSON.stringify(Array.from(ids.values())));
|
||||
|
||||
const keys = JSON.parse(localStorage.getItem("$keys") ?? "[]");
|
||||
|
||||
keys.push(key);
|
||||
|
||||
localStorage.setItem("$keys", JSON.stringify(keys));
|
||||
}
|
||||
|
||||
export class SubscriptionScope<D extends CoValue> {
|
||||
childNodes = new Map<string, SubscriptionScope<CoValue>>();
|
||||
childValues: Map<string, SubscriptionValue<any, any> | Unloaded> = new Map<
|
||||
@@ -130,15 +46,10 @@ export class SubscriptionScope<D extends CoValue> {
|
||||
public schema: RefEncoded<D>,
|
||||
public skipRetry = false,
|
||||
public bestEffortResolution = false,
|
||||
public cache = false,
|
||||
) {
|
||||
this.resolve = resolve;
|
||||
this.value = { type: "unloaded", id };
|
||||
|
||||
if (cache) {
|
||||
preloadFromCache(getCacheKey(id, resolve), node);
|
||||
}
|
||||
|
||||
let lastUpdate: RawCoValue | "unavailable" | undefined;
|
||||
this.subscription = new CoValueCoreSubscription(
|
||||
node,
|
||||
@@ -412,10 +323,6 @@ export class SubscriptionScope<D extends CoValue> {
|
||||
if (error) {
|
||||
this.subscribers.forEach((listener) => listener(error));
|
||||
} else if (value.type !== "unloaded") {
|
||||
if (this.cache) {
|
||||
setCache(this.id, this.resolve, this);
|
||||
}
|
||||
|
||||
this.subscribers.forEach((listener) => listener(value));
|
||||
}
|
||||
|
||||
|
||||
@@ -308,4 +308,54 @@ describe("co.discriminatedUnion", () => {
|
||||
|
||||
expect(updates[0]?.name).toEqual("Rex");
|
||||
});
|
||||
|
||||
test("should work when one of the options has a dicriminated union field", async () => {
|
||||
const Collie = co.map({
|
||||
type: z.literal("collie"),
|
||||
});
|
||||
const BorderCollie = co.map({
|
||||
type: z.literal("border-collie"),
|
||||
});
|
||||
const Breed = co.discriminatedUnion("type", [Collie, BorderCollie]);
|
||||
|
||||
const Dog = co.map({
|
||||
type: z.literal("dog"),
|
||||
breed: Breed,
|
||||
});
|
||||
|
||||
const Animal = co.discriminatedUnion("type", [Dog]);
|
||||
|
||||
const animal = Dog.create({
|
||||
type: "dog",
|
||||
breed: {
|
||||
type: "collie",
|
||||
},
|
||||
});
|
||||
|
||||
const loadedAnimal = await Animal.load(animal.id);
|
||||
|
||||
expect(loadedAnimal?.breed?.type).toEqual("collie");
|
||||
});
|
||||
|
||||
test("should work with a nested co.discriminatedUnion", async () => {
|
||||
const Collie = co.map({
|
||||
type: z.literal("collie"),
|
||||
});
|
||||
const BorderCollie = co.map({
|
||||
type: z.literal("border-collie"),
|
||||
});
|
||||
const Breed = co.discriminatedUnion("type", [Collie, BorderCollie]);
|
||||
|
||||
const Dog = co.discriminatedUnion("type", [Breed]);
|
||||
|
||||
const Animal = co.discriminatedUnion("type", [Dog]);
|
||||
|
||||
const animal = Collie.create({
|
||||
type: "collie",
|
||||
});
|
||||
|
||||
const loadedAnimal = await Animal.load(animal.id);
|
||||
|
||||
expect(loadedAnimal?.type).toEqual("collie");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { assert, describe, expectTypeOf, test } from "vitest";
|
||||
import { ZodNumber, ZodOptional, ZodString } from "zod/v4";
|
||||
import { Group, co, z } from "../exports.js";
|
||||
import { Account } from "../index.js";
|
||||
import { CoMap, Loaded } from "../internal.js";
|
||||
@@ -350,6 +351,148 @@ describe("CoMap", async () => {
|
||||
matchesNarrowed(mapWithEnum.child);
|
||||
}
|
||||
});
|
||||
|
||||
test("CoMap.pick()", () => {
|
||||
const Person = co.map({
|
||||
name: z.string(),
|
||||
age: z.number(),
|
||||
dog: co.map({
|
||||
name: z.string(),
|
||||
breed: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
const PersonWithoutDog = Person.pick({
|
||||
name: true,
|
||||
age: true,
|
||||
});
|
||||
|
||||
type ExpectedType = co.Map<{
|
||||
name: ZodString;
|
||||
age: ZodNumber;
|
||||
}>;
|
||||
|
||||
function matches(value: ExpectedType) {
|
||||
return value;
|
||||
}
|
||||
|
||||
matches(PersonWithoutDog);
|
||||
});
|
||||
|
||||
test("CoMap.pick() with a recursive reference", () => {
|
||||
const Person = co.map({
|
||||
name: z.string(),
|
||||
age: z.number(),
|
||||
dog: co.map({
|
||||
name: z.string(),
|
||||
breed: z.string(),
|
||||
}),
|
||||
get friend() {
|
||||
return Person.pick({
|
||||
name: true,
|
||||
age: true,
|
||||
}).optional();
|
||||
},
|
||||
});
|
||||
|
||||
type ExpectedType = co.Map<{
|
||||
name: ZodString;
|
||||
age: ZodNumber;
|
||||
dog: co.Map<{
|
||||
name: ZodString;
|
||||
breed: ZodString;
|
||||
}>;
|
||||
friend: co.Optional<
|
||||
co.Map<{
|
||||
name: ZodString;
|
||||
age: ZodNumber;
|
||||
}>
|
||||
>;
|
||||
}>;
|
||||
|
||||
function matches(value: ExpectedType) {
|
||||
return value;
|
||||
}
|
||||
|
||||
matches(Person);
|
||||
});
|
||||
|
||||
test("CoMap.partial()", () => {
|
||||
const Person = co.map({
|
||||
name: z.string(),
|
||||
age: z.number(),
|
||||
dog: co.map({
|
||||
name: z.string(),
|
||||
breed: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
const PersonPartial = Person.partial();
|
||||
|
||||
type ExpectedType = co.Map<{
|
||||
name: ZodOptional<ZodString>;
|
||||
age: ZodOptional<ZodNumber>;
|
||||
dog: co.Optional<
|
||||
co.Map<{
|
||||
name: ZodString;
|
||||
breed: ZodString;
|
||||
}>
|
||||
>;
|
||||
}>;
|
||||
|
||||
function matches(value: ExpectedType) {
|
||||
return value;
|
||||
}
|
||||
|
||||
matches(PersonPartial);
|
||||
});
|
||||
|
||||
test("CoMap.partial() with a recursive reference", () => {
|
||||
const Person = co.map({
|
||||
get draft() {
|
||||
return Person.partial()
|
||||
.pick({
|
||||
name: true,
|
||||
age: true,
|
||||
dog: true,
|
||||
})
|
||||
.optional();
|
||||
},
|
||||
name: z.string(),
|
||||
age: z.number(),
|
||||
dog: co.map({
|
||||
name: z.string(),
|
||||
breed: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
type ExpectedType = co.Map<{
|
||||
draft: co.Optional<
|
||||
co.Map<{
|
||||
name: ZodOptional<ZodString>;
|
||||
age: ZodOptional<ZodNumber>;
|
||||
dog: co.Optional<
|
||||
co.Map<{
|
||||
name: ZodString;
|
||||
breed: ZodString;
|
||||
}>
|
||||
>;
|
||||
}>
|
||||
>;
|
||||
name: ZodString;
|
||||
age: ZodNumber;
|
||||
dog: co.Map<{
|
||||
name: ZodString;
|
||||
breed: ZodString;
|
||||
}>;
|
||||
}>;
|
||||
|
||||
function matches(value: ExpectedType) {
|
||||
return value;
|
||||
}
|
||||
|
||||
matches(Person);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CoMap resolution", async () => {
|
||||
|
||||
@@ -2181,70 +2181,30 @@ describe("CoMap migration", () => {
|
||||
expect(loaded?.friend?.name).toEqual("Charlie");
|
||||
expect(loaded?.friend?.version).toEqual(2);
|
||||
});
|
||||
describe("Time", () => {
|
||||
test("empty map created time", () => {
|
||||
const currentTimestampInSeconds = Math.floor(Date.now() / 1000);
|
||||
const emptyMap = co.map({}).create({});
|
||||
const createdAtInSeconds = Math.floor(emptyMap._createdAt / 1000);
|
||||
});
|
||||
|
||||
expect(createdAtInSeconds).toEqual(currentTimestampInSeconds);
|
||||
expect(emptyMap._lastUpdatedAt).toEqual(emptyMap._createdAt);
|
||||
describe("createdAt & lastUpdatedAt", () => {
|
||||
test("empty map created time", () => {
|
||||
const emptyMap = co.map({}).create({});
|
||||
|
||||
expect(emptyMap._lastUpdatedAt).toEqual(emptyMap._createdAt);
|
||||
});
|
||||
|
||||
test("created time and last updated time", async () => {
|
||||
const Person = co.map({
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
test("created time and last updated time", async () => {
|
||||
const Person = co.map({
|
||||
name: z.string(),
|
||||
});
|
||||
const person = Person.create({ name: "John" });
|
||||
|
||||
let currentTimestampInSeconds = Math.floor(Date.now() / 1000);
|
||||
const person = Person.create({ name: "John" });
|
||||
const createdAt = person._createdAt;
|
||||
expect(person._lastUpdatedAt).toEqual(createdAt);
|
||||
|
||||
const createdAt = person._createdAt;
|
||||
const createdAtInSeconds = Math.floor(createdAt / 1000);
|
||||
expect(createdAtInSeconds).toEqual(currentTimestampInSeconds);
|
||||
expect(person._lastUpdatedAt).toEqual(createdAt);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
person.name = "Jane";
|
||||
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
currentTimestampInSeconds = Math.floor(Date.now() / 1000);
|
||||
person.name = "Jane";
|
||||
|
||||
const lastUpdatedAtInSeconds = Math.floor(person._lastUpdatedAt / 1000);
|
||||
expect(lastUpdatedAtInSeconds).toEqual(currentTimestampInSeconds);
|
||||
expect(person._createdAt).toEqual(createdAt);
|
||||
expect(person._lastUpdatedAt).not.toEqual(createdAt);
|
||||
});
|
||||
|
||||
test("comap with custom uniqueness", () => {
|
||||
const Person = co.map({
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
let currentTimestampInSeconds = Math.floor(Date.now() / 1000);
|
||||
const person = Person.create(
|
||||
{ name: "John" },
|
||||
{ unique: "name", owner: Account.getMe() },
|
||||
);
|
||||
|
||||
const createdAt = person._createdAt;
|
||||
const createdAtInSeconds = Math.floor(createdAt / 1000);
|
||||
expect(createdAtInSeconds).toEqual(currentTimestampInSeconds);
|
||||
});
|
||||
|
||||
test("empty comap with custom uniqueness", () => {
|
||||
const Person = co.map({
|
||||
name: z.optional(z.string()),
|
||||
});
|
||||
|
||||
let currentTimestampInSeconds = Math.floor(Date.now() / 1000);
|
||||
const person = Person.create(
|
||||
{},
|
||||
{ unique: "name", owner: Account.getMe() },
|
||||
);
|
||||
|
||||
const createdAt = person._createdAt;
|
||||
const createdAtInSeconds = Math.floor(createdAt / 1000);
|
||||
expect(createdAtInSeconds).toEqual(currentTimestampInSeconds);
|
||||
});
|
||||
expect(person._createdAt).toEqual(createdAt);
|
||||
expect(person._lastUpdatedAt).not.toEqual(createdAt);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2260,6 +2220,93 @@ describe("co.map schema", () => {
|
||||
|
||||
expect(person.name.toString()).toEqual("John");
|
||||
});
|
||||
|
||||
describe("pick()", () => {
|
||||
test("creates a new CoMap schema by picking fields of another CoMap schema", () => {
|
||||
const Person = co.map({
|
||||
name: z.string(),
|
||||
age: z.number(),
|
||||
});
|
||||
|
||||
const PersonWithName = Person.pick({
|
||||
name: true,
|
||||
});
|
||||
|
||||
const person = PersonWithName.create({
|
||||
name: "John",
|
||||
});
|
||||
|
||||
expect(person.name).toEqual("John");
|
||||
});
|
||||
|
||||
test("the new schema does not include catchall properties", () => {
|
||||
const Person = co
|
||||
.map({
|
||||
name: z.string(),
|
||||
age: z.number(),
|
||||
})
|
||||
.catchall(z.string());
|
||||
|
||||
const PersonWithName = Person.pick({
|
||||
name: true,
|
||||
});
|
||||
|
||||
expect(PersonWithName.catchAll).toBeUndefined();
|
||||
|
||||
const person = PersonWithName.create({
|
||||
name: "John",
|
||||
});
|
||||
// @ts-expect-error - property `extraField` does not exist in person
|
||||
expect(person.extraField).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("partial()", () => {
|
||||
test("creates a new CoMap schema by making all properties optional", () => {
|
||||
const Dog = co.map({
|
||||
name: z.string(),
|
||||
breed: z.string(),
|
||||
});
|
||||
const Person = co.map({
|
||||
name: z.string(),
|
||||
age: z.number(),
|
||||
pet: Dog,
|
||||
});
|
||||
|
||||
const DraftPerson = Person.partial();
|
||||
|
||||
const draftPerson = DraftPerson.create({});
|
||||
|
||||
expect(draftPerson.name).toBeUndefined();
|
||||
expect(draftPerson.age).toBeUndefined();
|
||||
expect(draftPerson.pet).toBeUndefined();
|
||||
|
||||
draftPerson.name = "John";
|
||||
draftPerson.age = 20;
|
||||
const rex = Dog.create({ name: "Rex", breed: "Labrador" });
|
||||
draftPerson.pet = rex;
|
||||
|
||||
expect(draftPerson.name).toEqual("John");
|
||||
expect(draftPerson.age).toEqual(20);
|
||||
expect(draftPerson.pet).toEqual(rex);
|
||||
});
|
||||
|
||||
test("the new schema includes catchall properties", () => {
|
||||
const Person = co
|
||||
.map({
|
||||
name: z.string(),
|
||||
age: z.number(),
|
||||
})
|
||||
.catchall(z.string());
|
||||
|
||||
const DraftPerson = Person.partial();
|
||||
|
||||
const draftPerson = DraftPerson.create({});
|
||||
draftPerson.extraField = "extra";
|
||||
|
||||
expect(draftPerson.extraField).toEqual("extra");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Updating a nested reference", () => {
|
||||
|
||||
@@ -184,6 +184,25 @@ test("returns null if the value is unavailable after retries", async () => {
|
||||
expect(john).toBeNull();
|
||||
});
|
||||
|
||||
test("load works even when the coValue access is granted after the creation", async () => {
|
||||
const alice = await createJazzTestAccount();
|
||||
const bob = await createJazzTestAccount();
|
||||
|
||||
const Person = co.map({
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
const group = Group.create(alice);
|
||||
const map = Person.create({ name: "John" }, group);
|
||||
|
||||
group.addMember("everyone", "reader");
|
||||
|
||||
const mapOnBob = await Person.load(map.id, { loadAs: bob });
|
||||
|
||||
expect(mapOnBob).not.toBeNull();
|
||||
expect(mapOnBob?.name).toBe("John");
|
||||
});
|
||||
|
||||
test("load a large coValue", async () => {
|
||||
const syncServer = await setupJazzTestSync({ asyncPeers: true });
|
||||
|
||||
|
||||
36
pnpm-lock.yaml
generated
36
pnpm-lock.yaml
generated
@@ -129,7 +129,7 @@ importers:
|
||||
version: 0.510.0(react@19.1.0)
|
||||
next:
|
||||
specifier: 15.3.2
|
||||
version: 15.3.2(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
version: 15.3.2(@opentelemetry/api@1.9.0)(@playwright/test@1.50.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
react:
|
||||
specifier: 19.1.0
|
||||
version: 19.1.0
|
||||
@@ -169,7 +169,7 @@ importers:
|
||||
version: 19.1.0(@types/react@19.1.0)
|
||||
react-email:
|
||||
specifier: ^4.0.11
|
||||
version: 4.0.13(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
version: 4.0.13(@opentelemetry/api@1.9.0)(@playwright/test@1.50.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
tailwindcss:
|
||||
specifier: ^4
|
||||
version: 4.1.10
|
||||
@@ -825,7 +825,7 @@ importers:
|
||||
version: link:../../packages/jazz-tools
|
||||
next:
|
||||
specifier: 15.3.2
|
||||
version: 15.3.2(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
version: 15.3.2(@opentelemetry/api@1.9.0)(@playwright/test@1.50.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
react:
|
||||
specifier: 19.1.0
|
||||
version: 19.1.0
|
||||
@@ -1372,7 +1372,7 @@ importers:
|
||||
version: 0.525.0(react@19.1.0)
|
||||
next:
|
||||
specifier: 15.3.2
|
||||
version: 15.3.2(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
version: 15.3.2(@opentelemetry/api@1.9.0)(@playwright/test@1.50.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
next-themes:
|
||||
specifier: ^0.4.6
|
||||
version: 0.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
@@ -1845,19 +1845,19 @@ importers:
|
||||
specifier: ^0.25.5
|
||||
version: 0.25.8(effect@3.11.9)
|
||||
cojson:
|
||||
specifier: workspace:0.16.3
|
||||
specifier: workspace:0.16.5
|
||||
version: link:../cojson
|
||||
cojson-storage-sqlite:
|
||||
specifier: workspace:0.16.3
|
||||
specifier: workspace:0.16.5
|
||||
version: link:../cojson-storage-sqlite
|
||||
cojson-transport-ws:
|
||||
specifier: workspace:0.16.3
|
||||
specifier: workspace:0.16.5
|
||||
version: link:../cojson-transport-ws
|
||||
effect:
|
||||
specifier: ^3.6.5
|
||||
version: 3.11.9
|
||||
jazz-tools:
|
||||
specifier: workspace:0.16.3
|
||||
specifier: workspace:0.16.5
|
||||
version: link:../jazz-tools
|
||||
ws:
|
||||
specifier: ^8.14.2
|
||||
@@ -2053,7 +2053,7 @@ importers:
|
||||
version: 0.525.0(react@19.1.0)
|
||||
next:
|
||||
specifier: 15.4.2
|
||||
version: 15.4.2(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
version: 15.4.2(@opentelemetry/api@1.9.0)(@playwright/test@1.50.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
react:
|
||||
specifier: 19.1.0
|
||||
version: 19.1.0
|
||||
@@ -21124,7 +21124,7 @@ snapshots:
|
||||
sirv: 3.0.1
|
||||
tinyglobby: 0.2.14
|
||||
tinyrainbow: 2.0.0
|
||||
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.16.5)(@vitest/browser@3.2.4)(@vitest/ui@3.2.4)(happy-dom@17.4.4)(jiti@2.4.2)(jsdom@25.0.1)(lightningcss@1.30.1)(msw@2.10.4(@types/node@22.16.5)(typescript@5.8.3))(terser@5.37.0)(tsx@4.20.3)(yaml@2.6.1)
|
||||
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.16.5)(@vitest/browser@3.2.4)(@vitest/ui@3.2.4)(happy-dom@17.4.4)(jiti@2.4.2)(jsdom@25.0.1)(lightningcss@1.30.1)(msw@2.10.4(@types/node@22.16.5)(typescript@5.6.2))(terser@5.37.0)(tsx@4.20.3)(yaml@2.6.1)
|
||||
|
||||
'@vitest/utils@3.1.1':
|
||||
dependencies:
|
||||
@@ -25672,7 +25672,7 @@ snapshots:
|
||||
react: 19.1.0
|
||||
react-dom: 19.1.0(react@19.1.0)
|
||||
|
||||
next@15.3.2(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
|
||||
next@15.3.2(@opentelemetry/api@1.9.0)(@playwright/test@1.50.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
|
||||
dependencies:
|
||||
'@next/env': 15.3.2
|
||||
'@swc/counter': 0.1.3
|
||||
@@ -25682,7 +25682,7 @@ snapshots:
|
||||
postcss: 8.4.31
|
||||
react: 19.1.0
|
||||
react-dom: 19.1.0(react@19.1.0)
|
||||
styled-jsx: 5.1.6(@babel/core@7.27.1)(react@19.1.0)
|
||||
styled-jsx: 5.1.6(react@19.1.0)
|
||||
optionalDependencies:
|
||||
'@next/swc-darwin-arm64': 15.3.2
|
||||
'@next/swc-darwin-x64': 15.3.2
|
||||
@@ -25699,7 +25699,7 @@ snapshots:
|
||||
- '@babel/core'
|
||||
- babel-plugin-macros
|
||||
|
||||
next@15.4.2(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
|
||||
next@15.4.2(@opentelemetry/api@1.9.0)(@playwright/test@1.50.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
|
||||
dependencies:
|
||||
'@next/env': 15.4.2
|
||||
'@swc/helpers': 0.5.15
|
||||
@@ -25707,7 +25707,7 @@ snapshots:
|
||||
postcss: 8.4.31
|
||||
react: 19.1.0
|
||||
react-dom: 19.1.0(react@19.1.0)
|
||||
styled-jsx: 5.1.6(@babel/core@7.27.1)(react@19.1.0)
|
||||
styled-jsx: 5.1.6(react@19.1.0)
|
||||
optionalDependencies:
|
||||
'@next/swc-darwin-arm64': 15.4.2
|
||||
'@next/swc-darwin-x64': 15.4.2
|
||||
@@ -26675,7 +26675,7 @@ snapshots:
|
||||
react: 19.1.0
|
||||
scheduler: 0.26.0
|
||||
|
||||
react-email@4.0.13(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
|
||||
react-email@4.0.13(@opentelemetry/api@1.9.0)(@playwright/test@1.50.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
|
||||
dependencies:
|
||||
'@babel/parser': 7.27.2
|
||||
'@babel/traverse': 7.27.1
|
||||
@@ -26687,7 +26687,7 @@ snapshots:
|
||||
glob: 11.0.2
|
||||
log-symbols: 7.0.0
|
||||
mime-types: 3.0.1
|
||||
next: 15.3.2(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
next: 15.3.2(@opentelemetry/api@1.9.0)(@playwright/test@1.50.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
normalize-path: 3.0.0
|
||||
ora: 8.2.0
|
||||
socket.io: 4.8.1
|
||||
@@ -27973,12 +27973,10 @@ snapshots:
|
||||
|
||||
structured-headers@0.4.1: {}
|
||||
|
||||
styled-jsx@5.1.6(@babel/core@7.27.1)(react@19.1.0):
|
||||
styled-jsx@5.1.6(react@19.1.0):
|
||||
dependencies:
|
||||
client-only: 0.0.1
|
||||
react: 19.1.0
|
||||
optionalDependencies:
|
||||
'@babel/core': 7.27.1
|
||||
|
||||
stylis@4.2.0: {}
|
||||
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
# jazz-react-tailwind-starter
|
||||
|
||||
## 0.0.142
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3cd1586]
|
||||
- Updated dependencies [33ebbf0]
|
||||
- jazz-tools@0.16.5
|
||||
|
||||
## 0.0.141
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [16764f6]
|
||||
- jazz-tools@0.16.4
|
||||
|
||||
## 0.0.140
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "jazz-react-passkey-auth-starter",
|
||||
"private": true,
|
||||
"version": "0.0.140",
|
||||
"version": "0.0.142",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
# svelte-passkey-auth
|
||||
|
||||
## 0.0.116
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3cd1586]
|
||||
- Updated dependencies [33ebbf0]
|
||||
- jazz-tools@0.16.5
|
||||
|
||||
## 0.0.115
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [16764f6]
|
||||
- jazz-tools@0.16.4
|
||||
|
||||
## 0.0.114
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "svelte-passkey-auth",
|
||||
"version": "0.0.114",
|
||||
"version": "0.0.116",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
Reference in New Issue
Block a user