Compare commits
24 Commits
fix-RNQuic
...
emil/inbox
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8eb75dd1f | ||
|
|
61170900bd | ||
|
|
4f2f889542 | ||
|
|
719974c46c | ||
|
|
8845ee8601 | ||
|
|
42cc41d0cb | ||
|
|
7a25c49480 | ||
|
|
aa202ad175 | ||
|
|
1d3e5c688d | ||
|
|
cbf7c2bbd8 | ||
|
|
e5ae4ffb18 | ||
|
|
b393c81ce5 | ||
|
|
3c5d9d6f73 | ||
|
|
23115af9c3 | ||
|
|
cd55fa9fe2 | ||
|
|
9336eaf123 | ||
|
|
a6caeea7ac | ||
|
|
a8f1e6507a | ||
|
|
e2b6ca6f69 | ||
|
|
79dd8bf805 | ||
|
|
da0adbbe9f | ||
|
|
2164e6eb36 | ||
|
|
a976c16b7c | ||
|
|
6f2b79e79a |
@@ -17,7 +17,7 @@ export const FileShareAccountRoot = co.map({
|
||||
|
||||
export const FileShareAccount = co.account({
|
||||
profile: co.profile(),
|
||||
root: FileShareAccountRoot,
|
||||
root: FileShareAccountRoot
|
||||
}).withMigration((account) => {
|
||||
if (account.root === undefined) {
|
||||
const publicGroup = Group.create({ owner: account });
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import { WORKER_ID } from "@/constants";
|
||||
import { Game, NewGameIntent, PlayIntent } from "@/schema";
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
import { experimental_useInboxSender, useCoState } from "jazz-react";
|
||||
import { experimental_useServiceSender, useCoState } from "jazz-react";
|
||||
import { Badge, CircleHelp, Scissors, ScrollText } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
@@ -52,7 +52,7 @@ function RouteComponent() {
|
||||
const [playSelection, setPlaySelection] = useState<
|
||||
"rock" | "paper" | "scissors" | undefined
|
||||
>(loaderGame[player]?.playSelection);
|
||||
const sendInboxMessage = experimental_useInboxSender(WORKER_ID);
|
||||
const sendServiceMessage = experimental_useServiceSender(WORKER_ID);
|
||||
|
||||
const game = useCoState(Game, gameId);
|
||||
|
||||
@@ -85,13 +85,13 @@ function RouteComponent() {
|
||||
playSelection: "rock" | "paper" | "scissors" | undefined,
|
||||
) => {
|
||||
if (!playSelection) return;
|
||||
sendInboxMessage(
|
||||
sendServiceMessage(
|
||||
PlayIntent.create({ type: "play", gameId, player, playSelection }),
|
||||
);
|
||||
};
|
||||
|
||||
const onNewGame = async () => {
|
||||
sendInboxMessage(NewGameIntent.create({ type: "newGame", gameId }));
|
||||
sendServiceMessage(NewGameIntent.create({ type: "newGame", gameId }));
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { WORKER_ID } from "@/constants";
|
||||
import { JoinGameRequest, WaitingRoom } from "@/schema";
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
import { Group, InboxSender } from "jazz-tools";
|
||||
import { Group, ServiceSender } from "jazz-tools";
|
||||
import { ClipboardCopyIcon, Loader2Icon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
@@ -29,7 +29,7 @@ export const Route = createFileRoute(
|
||||
throw redirect({ to: "/" });
|
||||
}
|
||||
if (!waitingRoom?.account1?.isMe) {
|
||||
const sender = await InboxSender.load<JoinGameRequest, WaitingRoom>(
|
||||
const sender = await ServiceSender.load<JoinGameRequest, WaitingRoom>(
|
||||
WORKER_ID,
|
||||
me,
|
||||
// { account1: {}, account2: {}, me, game: {} },
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { WORKER_ID } from "@/constants";
|
||||
import { CreateGameRequest } from "@/schema";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { experimental_useInboxSender as useInboxSender } from "jazz-react";
|
||||
import { experimental_useServiceSender as useServiceSender } from "jazz-react";
|
||||
import { useState } from "react";
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
@@ -11,7 +11,7 @@ export const Route = createFileRoute("/")({
|
||||
});
|
||||
|
||||
function HomeComponent() {
|
||||
const createGame = useInboxSender(WORKER_ID);
|
||||
const createGame = useServiceSender(WORKER_ID);
|
||||
const navigate = useNavigate({ from: "/" });
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
|
||||
@@ -47,10 +47,10 @@ export const JoinGameRequest = co.map({
|
||||
});
|
||||
export type JoinGameRequest = co.loaded<typeof JoinGameRequest>;
|
||||
|
||||
export const InboxMessage = z.discriminatedUnion("type", [
|
||||
export const ServiceMessage = z.discriminatedUnion("type", [
|
||||
PlayIntent,
|
||||
NewGameIntent,
|
||||
CreateGameRequest,
|
||||
JoinGameRequest,
|
||||
]);
|
||||
export type InboxMessage = co.loaded<typeof InboxMessage>;
|
||||
export type ServiceMessage = co.loaded<typeof ServiceMessage>;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {
|
||||
Game,
|
||||
InboxMessage,
|
||||
NewGameIntent,
|
||||
PlayIntent,
|
||||
Player,
|
||||
ServiceMessage,
|
||||
WaitingRoom,
|
||||
} from "@/schema";
|
||||
import { startWorker } from "jazz-nodejs";
|
||||
@@ -16,14 +16,14 @@ if (!process.env.VITE_JAZZ_WORKER_ACCOUNT || !process.env.JAZZ_WORKER_SECRET) {
|
||||
|
||||
const {
|
||||
worker,
|
||||
experimental: { inbox },
|
||||
experimental: { service },
|
||||
} = await startWorker({
|
||||
accountID: process.env.VITE_JAZZ_WORKER_ACCOUNT,
|
||||
syncServer: "wss://cloud.jazz.tools/?key=jazz-paper-scissors@garden.co ",
|
||||
});
|
||||
|
||||
inbox.subscribe(
|
||||
InboxMessage,
|
||||
service.subscribe(
|
||||
ServiceMessage,
|
||||
async (message, senderID) => {
|
||||
const playerAccount = await co.account().load(senderID, { loadAs: worker });
|
||||
if (!playerAccount) {
|
||||
|
||||
@@ -531,9 +531,9 @@ const reactExamples: Example[] = [
|
||||
// name: "Jazz paper scissors",
|
||||
// slug: "jazz-paper-scissors",
|
||||
// description:
|
||||
// "A game that shows how to communicate with other accounts through the experimental Inbox API.",
|
||||
// "A game that shows how to communicate with other accounts through the experimental Service API.",
|
||||
// tech: [tech.react],
|
||||
// features: [features.serverWorker, features.inbox],
|
||||
// features: [features.serverWorker, features.service],
|
||||
// illustration: <JazzPaperScissorsIllustration />,
|
||||
// demoUrl: "https://jazz-paper-scissors.demo.jazz.tools",
|
||||
// },
|
||||
|
||||
@@ -344,7 +344,7 @@ const JazzProfile = co.profile({
|
||||
|
||||
const JazzAccount = co.account({
|
||||
profile: JazzProfile,
|
||||
root: co.map({})
|
||||
root: co.map({}),
|
||||
});
|
||||
|
||||
import { useAccount, useCoState } from "jazz-react";
|
||||
|
||||
@@ -148,9 +148,9 @@ function GalleryView({ image }: { image: co.loaded<typeof Image> }) {
|
||||
targetWidth={800} // Looks for the best available resolution for a 800px image
|
||||
>
|
||||
{({ src }) => (
|
||||
<img
|
||||
src={src}
|
||||
alt="Gallery image"
|
||||
<img
|
||||
src={src}
|
||||
alt="Gallery image"
|
||||
className="gallery-image"
|
||||
/>
|
||||
)}
|
||||
@@ -194,7 +194,7 @@ function CustomImageComponent({ image }: { image: co.loaded<typeof Image> }) {
|
||||
if (!src) {
|
||||
return <div className="image-loading-fallback">Loading image...</div>;
|
||||
}
|
||||
|
||||
|
||||
// When image is loading, show a placeholder
|
||||
if (res === "placeholder") {
|
||||
return <img src={src} alt="Loading..." className="blur-effect" />;
|
||||
@@ -203,9 +203,9 @@ function CustomImageComponent({ image }: { image: co.loaded<typeof Image> }) {
|
||||
// Full image display with custom overlay
|
||||
return (
|
||||
<div className="custom-image-wrapper">
|
||||
<img
|
||||
src={src}
|
||||
alt="Custom image"
|
||||
<img
|
||||
src={src}
|
||||
alt="Custom image"
|
||||
className="custom-image"
|
||||
/>
|
||||
<div className="image-overlay">
|
||||
|
||||
@@ -28,5 +28,5 @@ export const features = {
|
||||
coRichText: "CoRichText",
|
||||
coPlainText: "CoPlainText",
|
||||
serverWorker: "Server worker",
|
||||
inbox: "Inbox",
|
||||
service: "Service",
|
||||
};
|
||||
|
||||
@@ -602,6 +602,8 @@ test("should sync and load accounts from storage", async () => {
|
||||
const account1 = node1.getCoValue(accountID);
|
||||
const profile = node1.expectProfileLoaded(accountID);
|
||||
const profileGroup = profile.group;
|
||||
const service = node1.expectServiceLoaded(accountID);
|
||||
const serviceGroup = service.group;
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
@@ -611,17 +613,23 @@ test("should sync and load accounts from storage", async () => {
|
||||
Account: account1,
|
||||
Profile: profile.core,
|
||||
ProfileGroup: profileGroup.core,
|
||||
Service: service.core,
|
||||
ServiceGroup: serviceGroup.core,
|
||||
},
|
||||
syncMessages.messages,
|
||||
),
|
||||
).toMatchInlineSnapshot(`
|
||||
[
|
||||
"client -> CONTENT Account header: true new: After: 0 New: 4",
|
||||
"storage -> KNOWN Account sessions: header/4",
|
||||
"client -> CONTENT Account header: true new: After: 0 New: 5",
|
||||
"storage -> KNOWN Account sessions: header/5",
|
||||
"client -> CONTENT ProfileGroup header: true new: After: 0 New: 5",
|
||||
"storage -> KNOWN ProfileGroup sessions: header/5",
|
||||
"client -> CONTENT ServiceGroup header: true new: After: 0 New: 5",
|
||||
"storage -> KNOWN ServiceGroup sessions: header/5",
|
||||
"client -> CONTENT Profile header: true new: After: 0 New: 1",
|
||||
"storage -> KNOWN Profile sessions: header/1",
|
||||
"client -> CONTENT Service header: true new: ",
|
||||
"storage -> KNOWN Service sessions: header/0",
|
||||
]
|
||||
`);
|
||||
|
||||
@@ -645,19 +653,26 @@ test("should sync and load accounts from storage", async () => {
|
||||
Account: account1,
|
||||
Profile: profile.core,
|
||||
ProfileGroup: profileGroup.core,
|
||||
Service: service.core,
|
||||
ServiceGroup: serviceGroup.core,
|
||||
},
|
||||
syncMessages.messages,
|
||||
),
|
||||
).toMatchInlineSnapshot(`
|
||||
[
|
||||
"client -> LOAD Account sessions: empty",
|
||||
"storage -> CONTENT Account header: true new: After: 0 New: 4",
|
||||
"client -> KNOWN Account sessions: header/4",
|
||||
"storage -> CONTENT Account header: true new: After: 0 New: 5",
|
||||
"client -> KNOWN Account sessions: header/5",
|
||||
"client -> LOAD Profile sessions: empty",
|
||||
"storage -> CONTENT ProfileGroup header: true new: After: 0 New: 5",
|
||||
"storage -> CONTENT Profile header: true new: After: 0 New: 1",
|
||||
"client -> KNOWN ProfileGroup sessions: header/5",
|
||||
"client -> KNOWN Profile sessions: header/1",
|
||||
"client -> LOAD Service sessions: empty",
|
||||
"storage -> CONTENT ServiceGroup header: true new: After: 0 New: 5",
|
||||
"storage -> CONTENT Service header: true new: ",
|
||||
"client -> KNOWN ServiceGroup sessions: header/5",
|
||||
"client -> KNOWN Service sessions: header/0",
|
||||
]
|
||||
`);
|
||||
|
||||
|
||||
@@ -173,6 +173,15 @@ export class RawProfile<
|
||||
Meta extends JsonObject | null = JsonObject | null,
|
||||
> extends RawCoMap<Shape, Meta> {}
|
||||
|
||||
export type ServiceShape = {
|
||||
service: string;
|
||||
};
|
||||
|
||||
export class RawService<
|
||||
Shape extends ServiceShape = ServiceShape,
|
||||
Meta extends JsonObject | null = JsonObject | null,
|
||||
> extends RawCoMap<Shape, Meta> {}
|
||||
|
||||
export type RawAccountMigration<Meta extends AccountMeta = AccountMeta> = (
|
||||
account: RawAccount<Meta>,
|
||||
localNode: LocalNode,
|
||||
|
||||
@@ -46,6 +46,7 @@ export type ParentGroupReferenceRole =
|
||||
export type GroupShape = {
|
||||
profile: CoID<RawCoMap> | null;
|
||||
root: CoID<RawCoMap> | null;
|
||||
service: CoID<RawCoMap> | null;
|
||||
[key: RawAccountID | AgentID]: Role;
|
||||
[EVERYONE]?: Role;
|
||||
readKey?: KeyID;
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
RawAccountID,
|
||||
RawAccountMigration,
|
||||
RawProfile,
|
||||
RawService,
|
||||
accountHeaderForInitialAgentSecret,
|
||||
expectAccount,
|
||||
} from "./coValues/account.js";
|
||||
@@ -226,9 +227,15 @@ export class LocalNode {
|
||||
name: creationProps.name,
|
||||
});
|
||||
account.set("profile", profile.id, "trusting");
|
||||
|
||||
const serviceGroup = node.createGroup();
|
||||
serviceGroup.addMember("everyone", "reader"); // Allows others to read the account's service ID stored in the `RawService`
|
||||
const service = serviceGroup.createMap<RawService>();
|
||||
account.set("service", service.id, "trusting");
|
||||
}
|
||||
|
||||
const profileId = account.get("profile");
|
||||
const serviceID = account.get("service");
|
||||
|
||||
if (!profileId) {
|
||||
throw new Error("Must set account profile in initial migration");
|
||||
@@ -238,6 +245,9 @@ export class LocalNode {
|
||||
await Promise.all([
|
||||
node.syncManager.waitForStorageSync(account.id),
|
||||
node.syncManager.waitForStorageSync(profileId),
|
||||
serviceID
|
||||
? node.syncManager.waitForStorageSync(serviceID)
|
||||
: Promise.resolve(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -283,6 +293,7 @@ export class LocalNode {
|
||||
}
|
||||
|
||||
const profileID = account.get("profile");
|
||||
const serviceID = account.get("service");
|
||||
if (!profileID) {
|
||||
throw new Error("Account has no profile");
|
||||
}
|
||||
@@ -290,6 +301,11 @@ export class LocalNode {
|
||||
// Preload the profile
|
||||
await node.load(profileID);
|
||||
|
||||
if (serviceID) {
|
||||
// Preload the service if it exists
|
||||
await node.load(serviceID);
|
||||
}
|
||||
|
||||
if (migration) {
|
||||
await migration(account, node);
|
||||
}
|
||||
@@ -578,6 +594,21 @@ export class LocalNode {
|
||||
).getCurrentContent() as RawProfile;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
expectServiceLoaded(id: RawAccountID, expectation?: string): RawService {
|
||||
const account = this.expectCoValueLoaded(id, expectation);
|
||||
const serviceID = expectGroup(account.getCurrentContent()).get("service");
|
||||
if (!serviceID) {
|
||||
throw new Error(
|
||||
`${expectation ? expectation + ": " : ""}Account ${id} has no service`,
|
||||
);
|
||||
}
|
||||
return this.expectCoValueLoaded(
|
||||
serviceID,
|
||||
expectation,
|
||||
).getCurrentContent() as RawService;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
resolveAccountAgent(
|
||||
id: RawAccountID | AgentID,
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { CoID } from "./coValue.js";
|
||||
import { CoValueCore } from "./coValueCore/coValueCore.js";
|
||||
import { Transaction } from "./coValueCore/verifiedState.js";
|
||||
import { RawAccount, RawAccountID, RawProfile } from "./coValues/account.js";
|
||||
import {
|
||||
RawAccount,
|
||||
RawAccountID,
|
||||
RawProfile,
|
||||
RawService,
|
||||
} from "./coValues/account.js";
|
||||
import { MapOpPayload } from "./coValues/coMap.js";
|
||||
import {
|
||||
EVERYONE,
|
||||
@@ -270,6 +275,7 @@ function determineValidTransactionsForGroup(
|
||||
| MapOpPayload<RawAccountID | AgentID | Everyone, Role>
|
||||
| MapOpPayload<"readKey", JsonValue>
|
||||
| MapOpPayload<"profile", CoID<RawProfile>>
|
||||
| MapOpPayload<"service", CoID<RawService>>
|
||||
| MapOpPayload<`parent_${CoID<RawGroup>}`, CoID<RawGroup>>
|
||||
| MapOpPayload<`child_${CoID<RawGroup>}`, CoID<RawGroup>>;
|
||||
|
||||
@@ -297,6 +303,14 @@ function determineValidTransactionsForGroup(
|
||||
continue;
|
||||
}
|
||||
|
||||
validTransactions.push({ txID: { sessionID, txIndex }, tx });
|
||||
continue;
|
||||
} else if (change.key === "service") {
|
||||
if (memberState[transactor] !== "admin") {
|
||||
logPermissionError("Only admins can set service");
|
||||
continue;
|
||||
}
|
||||
|
||||
validTransactions.push({ txID: { sessionID, txIndex }, tx });
|
||||
continue;
|
||||
} else if (
|
||||
|
||||
@@ -175,6 +175,7 @@ describe("extend", () => {
|
||||
node2.node,
|
||||
parentGroup.id,
|
||||
);
|
||||
await parentGroupOnNode2.core.waitForAvailable();
|
||||
|
||||
childGroup.extend(parentGroupOnNode2);
|
||||
|
||||
@@ -231,6 +232,7 @@ describe("unextend", () => {
|
||||
node2.node,
|
||||
parentGroup.id,
|
||||
);
|
||||
await parentGroupOnNode2.core.waitForAvailable();
|
||||
|
||||
childGroup.extend(parentGroupOnNode2);
|
||||
|
||||
@@ -273,6 +275,7 @@ describe("unextend", () => {
|
||||
aliceNode.node,
|
||||
childGroup.id,
|
||||
);
|
||||
await childGroupOnAlice.core.waitForAvailable();
|
||||
|
||||
// `childGroup` no longer has `parentGroup`'s members
|
||||
await childGroupOnAlice.revokeExtend(parentGroup);
|
||||
|
||||
@@ -34,27 +34,39 @@ describe("LocalNode auth sync", () => {
|
||||
await account.core.waitForSync();
|
||||
|
||||
const profileID = account.get("profile")!;
|
||||
const serviceID = account.get("service")!;
|
||||
|
||||
const profileCoreOnSyncServer = jazzCloud.node.getCoValue(profileID);
|
||||
const serviceCoreOnSyncServer = jazzCloud.node.getCoValue(serviceID);
|
||||
await profileCoreOnSyncServer.waitForAvailable();
|
||||
await serviceCoreOnSyncServer.waitForAvailable();
|
||||
|
||||
expect(profileCoreOnSyncServer.isAvailable()).toBe(true);
|
||||
expect(serviceCoreOnSyncServer.isAvailable()).toBe(true);
|
||||
|
||||
assert(profileCoreOnSyncServer.isAvailable());
|
||||
assert(serviceCoreOnSyncServer.isAvailable());
|
||||
|
||||
expect(
|
||||
SyncMessagesLog.getMessages({
|
||||
Account: account.core,
|
||||
Profile: profileCoreOnSyncServer,
|
||||
ProfileGroup: profileCoreOnSyncServer.getGroup().core,
|
||||
Service: serviceCoreOnSyncServer,
|
||||
ServiceGroup: serviceCoreOnSyncServer.getGroup().core,
|
||||
}),
|
||||
).toMatchInlineSnapshot(`
|
||||
[
|
||||
"client -> server | CONTENT Account header: true new: After: 0 New: 4",
|
||||
"server -> client | KNOWN Account sessions: header/4",
|
||||
"client -> server | CONTENT Account header: true new: After: 0 New: 5",
|
||||
"server -> client | KNOWN Account sessions: header/5",
|
||||
"client -> server | CONTENT ProfileGroup header: true new: After: 0 New: 5",
|
||||
"server -> client | KNOWN ProfileGroup sessions: header/5",
|
||||
"client -> server | CONTENT ServiceGroup header: true new: After: 0 New: 5",
|
||||
"server -> client | KNOWN ServiceGroup sessions: header/5",
|
||||
"client -> server | CONTENT Profile header: true new: After: 0 New: 1",
|
||||
"server -> client | KNOWN Profile sessions: header/1",
|
||||
"client -> server | CONTENT Service header: true new: ",
|
||||
"server -> client | KNOWN Service sessions: header/0",
|
||||
]
|
||||
`);
|
||||
});
|
||||
@@ -150,8 +162,10 @@ describe("LocalNode auth sync", () => {
|
||||
|
||||
const account = node.expectCurrentAccount("after login");
|
||||
const profile = node.getCoValue(account.get("profile")!);
|
||||
const service = node.getCoValue(account.get("service")!);
|
||||
|
||||
assert(profile.isAvailable());
|
||||
assert(service.isAvailable());
|
||||
|
||||
expect(account.id).toBe(accountID);
|
||||
expect(node.agentSecret).toBe(accountSecret);
|
||||
@@ -161,23 +175,34 @@ describe("LocalNode auth sync", () => {
|
||||
Account: account.core,
|
||||
Profile: profile,
|
||||
ProfileGroup: profile.getGroup().core,
|
||||
Service: service,
|
||||
ServiceGroup: service.getGroup().core,
|
||||
}),
|
||||
).toMatchInlineSnapshot(`
|
||||
[
|
||||
"creation-node -> server | CONTENT Account header: true new: After: 0 New: 4",
|
||||
"creation-node -> server | CONTENT Account header: true new: After: 0 New: 5",
|
||||
"auth-node -> server | LOAD Account sessions: empty",
|
||||
"server -> creation-node | KNOWN Account sessions: header/4",
|
||||
"server -> creation-node | KNOWN Account sessions: header/5",
|
||||
"creation-node -> server | CONTENT ProfileGroup header: true new: After: 0 New: 5",
|
||||
"server -> auth-node | CONTENT Account header: true new: After: 0 New: 4",
|
||||
"server -> auth-node | CONTENT Account header: true new: After: 0 New: 5",
|
||||
"server -> creation-node | KNOWN ProfileGroup sessions: header/5",
|
||||
"creation-node -> server | CONTENT ServiceGroup header: true new: After: 0 New: 5",
|
||||
"auth-node -> server | KNOWN Account sessions: header/5",
|
||||
"server -> creation-node | KNOWN ServiceGroup sessions: header/5",
|
||||
"creation-node -> server | CONTENT Profile header: true new: After: 0 New: 1",
|
||||
"auth-node -> server | KNOWN Account sessions: header/4",
|
||||
"server -> creation-node | KNOWN Profile sessions: header/1",
|
||||
"creation-node -> server | CONTENT Service header: true new: ",
|
||||
"server -> creation-node | KNOWN Service sessions: header/0",
|
||||
"auth-node -> server | LOAD Profile sessions: empty",
|
||||
"server -> auth-node | CONTENT ProfileGroup header: true new: After: 0 New: 5",
|
||||
"auth-node -> server | KNOWN ProfileGroup sessions: header/5",
|
||||
"server -> auth-node | CONTENT Profile header: true new: After: 0 New: 1",
|
||||
"auth-node -> server | KNOWN Profile sessions: header/1",
|
||||
"auth-node -> server | LOAD Service sessions: empty",
|
||||
"server -> auth-node | CONTENT ServiceGroup header: true new: After: 0 New: 5",
|
||||
"auth-node -> server | KNOWN ServiceGroup sessions: header/5",
|
||||
"server -> auth-node | CONTENT Service header: true new: ",
|
||||
"auth-node -> server | KNOWN Service sessions: header/0",
|
||||
]
|
||||
`);
|
||||
});
|
||||
@@ -217,8 +242,10 @@ describe("LocalNode auth sync", () => {
|
||||
|
||||
const account = node.expectCurrentAccount("after login");
|
||||
const profile = creationNode.getCoValue(account.get("profile")!);
|
||||
const service = creationNode.getCoValue(account.get("service")!);
|
||||
|
||||
assert(profile.isAvailable());
|
||||
assert(service.isAvailable());
|
||||
|
||||
expect(account.id).toBe(accountID);
|
||||
expect(node.agentSecret).toBe(accountSecret);
|
||||
@@ -228,19 +255,25 @@ describe("LocalNode auth sync", () => {
|
||||
Account: account.core,
|
||||
Profile: profile,
|
||||
ProfileGroup: profile.getGroup().core,
|
||||
Service: service,
|
||||
ServiceGroup: service.getGroup().core,
|
||||
}),
|
||||
).toMatchInlineSnapshot(`
|
||||
[
|
||||
"creation-node -> server | CONTENT Account header: true new: After: 0 New: 4",
|
||||
"creation-node -> server | CONTENT Account header: true new: After: 0 New: 5",
|
||||
"auth-node -> server | LOAD Account sessions: empty",
|
||||
"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",
|
||||
"server -> creation-node | KNOWN Account sessions: header/5",
|
||||
"server -> auth-node | CONTENT Account header: true new: After: 0 New: 5",
|
||||
"auth-node -> server | KNOWN Account sessions: header/5",
|
||||
"auth-node -> server | LOAD Profile sessions: empty",
|
||||
"server -> auth-node | KNOWN Profile sessions: empty",
|
||||
"auth-node -> server | LOAD Profile sessions: empty",
|
||||
"server -> auth-node | KNOWN Profile sessions: empty",
|
||||
"auth-node -> server | LOAD Service sessions: empty",
|
||||
"server -> auth-node | KNOWN Service sessions: empty",
|
||||
"auth-node -> server | LOAD Service sessions: empty",
|
||||
"server -> auth-node | KNOWN Service sessions: empty",
|
||||
]
|
||||
`);
|
||||
});
|
||||
}, 10000);
|
||||
});
|
||||
|
||||
@@ -241,39 +241,67 @@ describe("peer reconciliation", () => {
|
||||
Profile: client.node.expectProfileLoaded(client.accountID).core,
|
||||
ProfileGroup: client.node.expectProfileLoaded(client.accountID).group
|
||||
.core,
|
||||
Service: client.node.expectServiceLoaded(client.accountID).core,
|
||||
ServiceGroup: client.node.expectServiceLoaded(client.accountID).group
|
||||
.core,
|
||||
Group: group.core,
|
||||
Map: map.core,
|
||||
}),
|
||||
).toMatchInlineSnapshot(`
|
||||
[
|
||||
"client -> server | LOAD Account sessions: header/4",
|
||||
"client -> server | LOAD Account sessions: header/5",
|
||||
"server -> client | KNOWN Account sessions: empty",
|
||||
"client -> server | LOAD ProfileGroup sessions: header/5",
|
||||
"server -> client | KNOWN ProfileGroup sessions: empty",
|
||||
"client -> server | LOAD Profile sessions: header/1",
|
||||
"server -> client | KNOWN Profile sessions: empty",
|
||||
"client -> server | LOAD ServiceGroup sessions: header/5",
|
||||
"server -> client | KNOWN ServiceGroup sessions: empty",
|
||||
"client -> server | LOAD Service sessions: header/0",
|
||||
"server -> client | KNOWN Service sessions: empty",
|
||||
"client -> server | LOAD Group sessions: header/3",
|
||||
"server -> client | KNOWN Group sessions: empty",
|
||||
"client -> server | LOAD Map sessions: header/2",
|
||||
"server -> client | KNOWN Map sessions: empty",
|
||||
"client -> server | CONTENT Map header: false new: After: 1 New: 1",
|
||||
"server -> client | KNOWN CORRECTION Map sessions: empty",
|
||||
"client -> server | CONTENT Service header: true new: ",
|
||||
"server -> client | LOAD ServiceGroup sessions: empty",
|
||||
"client -> server | CONTENT Map header: true new: After: 0 New: 2",
|
||||
"server -> client | KNOWN CORRECTION Service sessions: empty",
|
||||
"client -> server | CONTENT ServiceGroup header: true new: After: 0 New: 5",
|
||||
"server -> client | LOAD Account sessions: empty",
|
||||
"client -> server | CONTENT Account header: true new: After: 0 New: 4",
|
||||
"client -> server | CONTENT Service header: true new: ",
|
||||
"server -> client | LOAD Group sessions: empty",
|
||||
"client -> server | CONTENT Group header: true new: After: 0 New: 3",
|
||||
"client -> server | CONTENT Account header: true new: After: 0 New: 5",
|
||||
"server -> client | KNOWN CORRECTION Map sessions: empty",
|
||||
"client -> server | CONTENT Group header: true new: After: 0 New: 3",
|
||||
"server -> client | LOAD Account sessions: empty",
|
||||
"client -> server | CONTENT Map header: true new: After: 0 New: 2",
|
||||
"server -> client | KNOWN Account sessions: header/4",
|
||||
"server -> client | KNOWN CORRECTION ServiceGroup sessions: empty",
|
||||
"client -> server | CONTENT Account header: true new: After: 0 New: 5",
|
||||
"server -> client | LOAD ServiceGroup sessions: empty",
|
||||
"client -> server | CONTENT ServiceGroup header: true new: After: 0 New: 5",
|
||||
"server -> client | KNOWN CORRECTION Service sessions: empty",
|
||||
"client -> server | CONTENT ServiceGroup header: true new: After: 0 New: 5",
|
||||
"server -> client | KNOWN Account sessions: header/5",
|
||||
"client -> server | CONTENT Service header: true new: ",
|
||||
"server -> client | KNOWN Group sessions: header/3",
|
||||
"server -> client | KNOWN Map sessions: header/2",
|
||||
"client -> server | LOAD Account sessions: header/4",
|
||||
"server -> client | KNOWN Account sessions: header/4",
|
||||
"server -> client | KNOWN Account sessions: header/5",
|
||||
"server -> client | KNOWN ServiceGroup sessions: header/5",
|
||||
"server -> client | KNOWN ServiceGroup sessions: header/5",
|
||||
"server -> client | KNOWN Service sessions: header/0",
|
||||
"client -> server | LOAD Account sessions: header/5",
|
||||
"server -> client | KNOWN Account sessions: header/5",
|
||||
"client -> server | LOAD ProfileGroup sessions: header/5",
|
||||
"server -> client | KNOWN ProfileGroup sessions: empty",
|
||||
"client -> server | LOAD Profile sessions: header/1",
|
||||
"server -> client | KNOWN Profile sessions: empty",
|
||||
"client -> server | LOAD ServiceGroup sessions: header/5",
|
||||
"server -> client | KNOWN ServiceGroup sessions: header/5",
|
||||
"client -> server | LOAD Service sessions: header/0",
|
||||
"server -> client | KNOWN Service sessions: header/0",
|
||||
"client -> server | LOAD Group sessions: header/3",
|
||||
"server -> client | KNOWN Group sessions: header/3",
|
||||
"client -> server | LOAD Map sessions: header/2",
|
||||
|
||||
@@ -782,7 +782,7 @@ The authentication hooks must always be used inside the `<JazzProvider />` compo
|
||||
Implementing PassphraseAuth is straightforward:
|
||||
|
||||
1. Import the [wordlist](https://github.com/bitcoinjs/bip39/tree/a7ecbfe2e60d0214ce17163d610cad9f7b23140c/src/wordlists) for generating recovery phrases
|
||||
2. Use the `usePassphraseAuth` hook to handle authentication
|
||||
2. Use the `usePassphraseAuth` hook to handle authentication
|
||||
3. Create simple registration and sign-in screens
|
||||
|
||||
<CodeGroup>
|
||||
@@ -803,7 +803,7 @@ function JazzAuthentication({ children }: { children: ReactNode }) {
|
||||
if (auth.state === "signedIn") {
|
||||
return children
|
||||
}
|
||||
|
||||
|
||||
// Otherwise, show a sign-in screen
|
||||
return <SignInScreen auth={auth} />;
|
||||
}
|
||||
@@ -894,7 +894,7 @@ The authentication hooks must always be used inside the `<JazzProvider />` compo
|
||||
Implementing PassphraseAuth is straightforward:
|
||||
|
||||
1. Import the [wordlist](https://github.com/bitcoinjs/bip39/tree/a7ecbfe2e60d0214ce17163d610cad9f7b23140c/src/wordlists) for generating recovery phrases
|
||||
2. Use the `usePassphraseAuth` hook to handle authentication
|
||||
2. Use the `usePassphraseAuth` hook to handle authentication
|
||||
3. Create simple registration and sign-in screens
|
||||
|
||||
<CodeGroup>
|
||||
@@ -915,7 +915,7 @@ function JazzAuthentication({ children }: { children: ReactNode }) {
|
||||
if (auth.state === "signedIn") {
|
||||
return children
|
||||
}
|
||||
|
||||
|
||||
// Otherwise, show a sign-in screen
|
||||
return <SignInScreen auth={auth} />;
|
||||
}
|
||||
@@ -1159,7 +1159,7 @@ The `<JazzProvider />` accepts several configuration options:
|
||||
</script>
|
||||
|
||||
<JazzProvider
|
||||
sync={{
|
||||
sync={{
|
||||
peer: "wss://cloud.jazz.tools/?key=your-api-key",
|
||||
when: "always" // When to sync: "always", "never", or "signedUp"
|
||||
}}
|
||||
@@ -1186,7 +1186,7 @@ The `sync` property configures how your application connects to the Jazz network
|
||||
const syncConfig: SyncConfig = {
|
||||
// Connection to Jazz Cloud or your own sync server
|
||||
peer: "wss://cloud.jazz.tools/?key=your-api-key",
|
||||
|
||||
|
||||
// When to sync: "always" (default), "never", or "signedUp"
|
||||
when: "always",
|
||||
}
|
||||
@@ -1238,13 +1238,13 @@ The provider accepts these additional options:
|
||||
import { JazzProvider } from "jazz-svelte";
|
||||
import { syncConfig } from "$lib/syncConfig";
|
||||
let { children } = $props();
|
||||
|
||||
|
||||
// Enable guest mode for account-less access
|
||||
const guestMode = false;
|
||||
|
||||
const guestMode = false;
|
||||
|
||||
// Default name for new user profiles
|
||||
const defaultProfileName = "New User";
|
||||
|
||||
const defaultProfileName = "New User";
|
||||
|
||||
// Handle user logout
|
||||
const onLogOut = () => {
|
||||
console.log("User logged out");
|
||||
@@ -1717,7 +1717,7 @@ const Person = co.map({
|
||||
pets: co.list(Pet),
|
||||
});
|
||||
type Person = co.loaded<typeof Person>;
|
||||
```
|
||||
```
|
||||
```svelte twoslash filename="app.svelte"
|
||||
// @filename: app.svelte
|
||||
<script lang="ts">
|
||||
@@ -1743,7 +1743,7 @@ const person = new CoState(Person, id);
|
||||
|
||||
<script lang="ts">
|
||||
import { Person } from './schema';
|
||||
|
||||
|
||||
let props: Props = $props();
|
||||
</script>
|
||||
|
||||
@@ -1774,7 +1774,7 @@ When using `useAccount` you should now pass the `Account` schema directly:
|
||||
|
||||
export const MyAccount = co.account({
|
||||
profile: co.profile(),
|
||||
root: co.map({})
|
||||
root: co.map({}),
|
||||
});
|
||||
|
||||
// @filename: app.tsx
|
||||
@@ -1868,7 +1868,7 @@ TODO
|
||||
The type of `_refs` and `_edits` is now nullable.
|
||||
|
||||
<CodeGroup>
|
||||
```ts twoslash
|
||||
```ts twoslash
|
||||
// ---cut---
|
||||
const Person = co.map({
|
||||
name: z.string(),
|
||||
@@ -1879,7 +1879,7 @@ const person = Person.create({ name: "John", age: 30 });
|
||||
|
||||
person._refs; // now nullable
|
||||
person._edits; // now nullable
|
||||
```
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
### `members` and `by` now return basic `Account`
|
||||
@@ -3978,7 +3978,7 @@ const JazzProfile = co.profile({
|
||||
|
||||
const JazzAccount = co.account({
|
||||
profile: JazzProfile,
|
||||
root: co.map({})
|
||||
root: co.map({}),
|
||||
});
|
||||
|
||||
// ---cut---
|
||||
@@ -5047,9 +5047,9 @@ function GalleryView({ image }: { image: Loaded<typeof Image> }) {
|
||||
targetWidth={800} // Looks for the best available resolution for a 800px image
|
||||
>
|
||||
{({ src }) => (
|
||||
<img
|
||||
src={src}
|
||||
alt="Gallery image"
|
||||
<img
|
||||
src={src}
|
||||
alt="Gallery image"
|
||||
className="gallery-image"
|
||||
/>
|
||||
)}
|
||||
@@ -5090,7 +5090,7 @@ function CustomImageComponent({ image }: { image: Loaded<typeof Image> }) {
|
||||
if (!src) {
|
||||
return <div className="image-loading-fallback">Loading image...</div>;
|
||||
}
|
||||
|
||||
|
||||
// When image is loading, show a placeholder
|
||||
if (res === "placeholder") {
|
||||
return <img src={src} alt="Loading..." className="blur-effect" />;
|
||||
@@ -5099,9 +5099,9 @@ function CustomImageComponent({ image }: { image: Loaded<typeof Image> }) {
|
||||
// Full image display with custom overlay
|
||||
return (
|
||||
<div className="custom-image-wrapper">
|
||||
<img
|
||||
src={src}
|
||||
alt="Custom image"
|
||||
<img
|
||||
src={src}
|
||||
alt="Custom image"
|
||||
className="custom-image"
|
||||
/>
|
||||
<div className="image-overlay">
|
||||
@@ -6158,7 +6158,7 @@ const value = await MyCoMap.create({ color: "red"})
|
||||
const me = Account.getMe();
|
||||
|
||||
if (me.canAdmin(value)) {
|
||||
console.log("I can share value with others");
|
||||
console.log("I can share value with others");
|
||||
} else if (me.canWrite(value)) {
|
||||
console.log("I can edit value");
|
||||
} else if (me.canRead(value)) {
|
||||
@@ -6271,7 +6271,7 @@ useAcceptInvite({
|
||||
### Requesting Invites
|
||||
|
||||
To allow a non-group member to request an invitation to a group you can use the `writeOnly` role.
|
||||
This means that users only have write access to a specific requests list (they can't read other requests).
|
||||
This means that users only have write access to a specific requests list (they can't read other requests).
|
||||
However, Administrators can review and approve these requests.
|
||||
|
||||
Create the data models.
|
||||
@@ -6369,7 +6369,7 @@ async function approveJoinRequest(
|
||||
|
||||
# Group Inheritance
|
||||
|
||||
Groups can inherit members from other groups using the `extend` method.
|
||||
Groups can inherit members from other groups using the `extend` method.
|
||||
|
||||
When a group extends another group, members of the parent group will become automatically part of the child group.
|
||||
|
||||
@@ -6407,7 +6407,7 @@ organizationGroup.addMember(bob, "admin");
|
||||
const billingGroup = Group.create();
|
||||
|
||||
// This way the members of the organization can only read the billing data
|
||||
billingGroup.extend(organizationGroup, "reader");
|
||||
billingGroup.extend(organizationGroup, "reader");
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
@@ -6438,7 +6438,7 @@ Groups can be extended multiple levels deep:
|
||||
// ---cut---
|
||||
const grandParentGroup = Group.create();
|
||||
const parentGroup = Group.create();
|
||||
const childGroup = Group.create();
|
||||
const childGroup = Group.create();
|
||||
|
||||
childGroup.extend(parentGroup);
|
||||
parentGroup.extend(grandParentGroup);
|
||||
@@ -6513,7 +6513,7 @@ const companyGroup = company._owner.castAs(Group)
|
||||
const teamGroup = Group.create();
|
||||
|
||||
// Works only if I'm a member of companyGroup
|
||||
teamGroup.extend(companyGroup);
|
||||
teamGroup.extend(companyGroup);
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
@@ -6527,7 +6527,7 @@ You can revoke a group extension by using the `revokeExtend` method:
|
||||
const parentGroup = Group.create();
|
||||
const childGroup = Group.create();
|
||||
|
||||
childGroup.extend(parentGroup);
|
||||
childGroup.extend(parentGroup);
|
||||
|
||||
// Revoke the extension
|
||||
await childGroup.revokeExtend(parentGroup);
|
||||
@@ -11911,4 +11911,4 @@ export function cn(...inputs: ClassValue[]) {
|
||||
```ts
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
```
|
||||
```
|
||||
|
||||
@@ -10,8 +10,9 @@ import {
|
||||
AccountSchema,
|
||||
AnyAccountSchema,
|
||||
CoValueFromRaw,
|
||||
Inbox,
|
||||
InstanceOfSchema,
|
||||
Service,
|
||||
co,
|
||||
createJazzContextFromExistingCredentials,
|
||||
randomSessionProvider,
|
||||
} from "jazz-tools";
|
||||
@@ -94,7 +95,10 @@ export async function startWorker<
|
||||
throw new Error("Account has no profile");
|
||||
}
|
||||
|
||||
const inbox = await Inbox.load(account);
|
||||
if (!account.service?.service) {
|
||||
account.service = co.service().create({}, account);
|
||||
}
|
||||
const service = await Service.load(account);
|
||||
|
||||
async function done() {
|
||||
await context.account.waitForAllCoValuesSync();
|
||||
@@ -103,14 +107,14 @@ export async function startWorker<
|
||||
context.done();
|
||||
}
|
||||
|
||||
const inboxPublicApi = {
|
||||
subscribe: inbox.subscribe.bind(inbox) as Inbox["subscribe"],
|
||||
const servicePublicApi = {
|
||||
subscribe: service.subscribe.bind(service) as Service["subscribe"],
|
||||
};
|
||||
|
||||
return {
|
||||
worker: context.account as InstanceOfSchema<S>,
|
||||
experimental: {
|
||||
inbox: inboxPublicApi,
|
||||
service: servicePublicApi,
|
||||
},
|
||||
waitForConnection() {
|
||||
return wsPeer.waitUntilConnected();
|
||||
|
||||
@@ -12,8 +12,8 @@ import {
|
||||
CoMap,
|
||||
CoValueFromRaw,
|
||||
Group,
|
||||
InboxSender,
|
||||
Loaded,
|
||||
ServiceSender,
|
||||
co,
|
||||
coField,
|
||||
z,
|
||||
@@ -194,7 +194,7 @@ describe("startWorker integration", () => {
|
||||
await worker2.done();
|
||||
});
|
||||
|
||||
test("reiceves the messages from the inbox", async () => {
|
||||
test("receives the messages from the service", async () => {
|
||||
const worker1 = await setup();
|
||||
const worker2 = await setupWorker(worker1.syncServer);
|
||||
|
||||
@@ -206,16 +206,16 @@ describe("startWorker integration", () => {
|
||||
{ owner: group },
|
||||
);
|
||||
|
||||
worker2.experimental.inbox.subscribe(TestMap, async (value) => {
|
||||
worker2.experimental.service.subscribe(TestMap, async (value) => {
|
||||
return TestMap.create(
|
||||
{
|
||||
value: value.value + " Responded from the inbox",
|
||||
value: value.value + " Responded from the service",
|
||||
},
|
||||
{ owner: value._owner },
|
||||
);
|
||||
});
|
||||
|
||||
const sender = await InboxSender.load<
|
||||
const sender = await ServiceSender.load<
|
||||
Loaded<typeof TestMap>,
|
||||
Loaded<typeof TestMap>
|
||||
>(worker2.worker.id, worker1.worker);
|
||||
@@ -224,7 +224,7 @@ describe("startWorker integration", () => {
|
||||
|
||||
const result = await TestMap.load(resultId, { loadAs: worker2.worker });
|
||||
|
||||
expect(result?.value).toEqual("Hello! Responded from the inbox");
|
||||
expect(result?.value).toEqual("Hello! Responded from the service");
|
||||
|
||||
await worker1.done();
|
||||
await worker2.done();
|
||||
|
||||
@@ -12,13 +12,13 @@ import {
|
||||
AnyAccountSchema,
|
||||
CoValue,
|
||||
CoValueOrZodSchema,
|
||||
InboxSender,
|
||||
InstanceOfSchema,
|
||||
JazzContextManager,
|
||||
JazzContextType,
|
||||
Loaded,
|
||||
ResolveQuery,
|
||||
ResolveQueryStrict,
|
||||
ServiceSender,
|
||||
SubscriptionScope,
|
||||
anySchemaToCoSchema,
|
||||
} from "jazz-tools";
|
||||
@@ -331,41 +331,43 @@ function useAccountOrGuest<
|
||||
|
||||
export { useAccount, useAccountOrGuest };
|
||||
|
||||
export function experimental_useInboxSender<
|
||||
export function experimental_useServiceSender<
|
||||
I extends CoValue,
|
||||
O extends CoValue | undefined,
|
||||
>(inboxOwnerID: string | undefined) {
|
||||
>(serviceOwnerID: string | undefined) {
|
||||
const context = useJazzContext();
|
||||
|
||||
if (!("me" in context)) {
|
||||
throw new Error(
|
||||
"useInboxSender can't be used in a JazzProvider with auth === 'guest'.",
|
||||
"useServiceSender can't be used in a JazzProvider with auth === 'guest'.",
|
||||
);
|
||||
}
|
||||
|
||||
const me = context.me;
|
||||
const inboxRef = useRef<Promise<InboxSender<I, O>> | undefined>(undefined);
|
||||
const serviceRef = useRef<Promise<ServiceSender<I, O>> | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
const sendMessage = useCallback(
|
||||
async (message: I) => {
|
||||
if (!inboxOwnerID) throw new Error("Inbox owner ID is required");
|
||||
if (!serviceOwnerID) throw new Error("Service owner ID is required");
|
||||
|
||||
if (!inboxRef.current) {
|
||||
const inbox = InboxSender.load<I, O>(inboxOwnerID, me);
|
||||
inboxRef.current = inbox;
|
||||
if (!serviceRef.current) {
|
||||
const service = ServiceSender.load<I, O>(serviceOwnerID, me);
|
||||
serviceRef.current = service;
|
||||
}
|
||||
|
||||
let inbox = await inboxRef.current;
|
||||
let service = await serviceRef.current;
|
||||
|
||||
if (inbox.owner.id !== inboxOwnerID) {
|
||||
const req = InboxSender.load<I, O>(inboxOwnerID, me);
|
||||
inboxRef.current = req;
|
||||
inbox = await req;
|
||||
if (service.owner.id !== serviceOwnerID) {
|
||||
const req = ServiceSender.load<I, O>(serviceOwnerID, me);
|
||||
serviceRef.current = req;
|
||||
service = await req;
|
||||
}
|
||||
|
||||
return inbox.sendMessage(message);
|
||||
return service.sendMessage(message);
|
||||
},
|
||||
[inboxOwnerID],
|
||||
[serviceOwnerID],
|
||||
);
|
||||
|
||||
return sendMessage;
|
||||
|
||||
@@ -1,28 +1,34 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { CoMap, Group, Inbox, Loaded, co, z } from "jazz-tools";
|
||||
import { Group, Loaded, Service, co, z } from "jazz-tools";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { experimental_useInboxSender } from "../index.js";
|
||||
import { experimental_useServiceSender } from "../index.js";
|
||||
import { createJazzTestAccount, linkAccounts } from "../testing.js";
|
||||
import { renderHook } from "./testUtils.js";
|
||||
|
||||
describe("useInboxSender", () => {
|
||||
it("should send the message to the inbox", async () => {
|
||||
describe("useServiceSender", () => {
|
||||
it("should send the message to the service", async () => {
|
||||
const TestMap = co.map({
|
||||
value: z.string(),
|
||||
});
|
||||
|
||||
const account = await createJazzTestAccount();
|
||||
const inboxReceiver = await createJazzTestAccount();
|
||||
const serviceReceiver = await createJazzTestAccount({
|
||||
AccountSchema: co.account().withMigration((account) => {
|
||||
if (!account.service?.service) {
|
||||
account.service = co.service().create({}, account);
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
await linkAccounts(account, inboxReceiver);
|
||||
await linkAccounts(account, serviceReceiver);
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
experimental_useInboxSender<
|
||||
experimental_useServiceSender<
|
||||
Loaded<typeof TestMap>,
|
||||
Loaded<typeof TestMap>
|
||||
>(inboxReceiver.id),
|
||||
>(serviceReceiver.id),
|
||||
{
|
||||
account,
|
||||
},
|
||||
@@ -37,10 +43,10 @@ describe("useInboxSender", () => {
|
||||
),
|
||||
);
|
||||
|
||||
const inbox = await Inbox.load(inboxReceiver);
|
||||
const service = await Service.load(serviceReceiver);
|
||||
|
||||
const incoming = await new Promise<Loaded<typeof TestMap>>((resolve) => {
|
||||
inbox.subscribe(TestMap, async (message) => {
|
||||
service.subscribe(TestMap, async (message) => {
|
||||
resolve(message);
|
||||
|
||||
return TestMap.create({ value: "got it" }, { owner: message._owner });
|
||||
@@ -6,7 +6,7 @@ import { Linking } from "react-native";
|
||||
|
||||
export {
|
||||
useCoState,
|
||||
experimental_useInboxSender,
|
||||
experimental_useServiceSender,
|
||||
useDemoAuth,
|
||||
usePassphraseAuth,
|
||||
useJazzContext,
|
||||
|
||||
@@ -47,7 +47,7 @@ export function useAcceptInvite<S extends CoValueOrZodSchema>({
|
||||
}
|
||||
|
||||
export {
|
||||
experimental_useInboxSender,
|
||||
experimental_useServiceSender,
|
||||
useJazzContext,
|
||||
useAccount,
|
||||
useAccountOrGuest,
|
||||
|
||||
@@ -5,7 +5,7 @@ export {
|
||||
useAccountOrGuest,
|
||||
useCoState,
|
||||
useAcceptInvite,
|
||||
experimental_useInboxSender,
|
||||
experimental_useServiceSender,
|
||||
useJazzContext,
|
||||
useAuthSecretStorage,
|
||||
} from "./hooks.js";
|
||||
|
||||
@@ -16,15 +16,13 @@ import {
|
||||
} from "cojson";
|
||||
import {
|
||||
AnonymousJazzAgent,
|
||||
AnyAccountSchema,
|
||||
type CoMap,
|
||||
CoMap,
|
||||
type CoValue,
|
||||
CoValueBase,
|
||||
CoValueClass,
|
||||
CoValueOrZodSchema,
|
||||
type Group,
|
||||
ID,
|
||||
InstanceOfSchema,
|
||||
InstanceOrPrimitiveOfSchema,
|
||||
Profile,
|
||||
Ref,
|
||||
@@ -41,8 +39,9 @@ import {
|
||||
accessChildByKey,
|
||||
activeAccountContext,
|
||||
anySchemaToCoSchema,
|
||||
co,
|
||||
coField,
|
||||
coValuesCache,
|
||||
createInboxRoot,
|
||||
ensureCoValueLoaded,
|
||||
inspect,
|
||||
loadCoValue,
|
||||
@@ -66,6 +65,10 @@ type AccountMembers<A extends Account> = [
|
||||
},
|
||||
];
|
||||
|
||||
export class AccountService extends CoMap {
|
||||
service? = coField.optional.string;
|
||||
}
|
||||
|
||||
/** @category Identity & Permissions */
|
||||
export class Account extends CoValueBase implements CoValue {
|
||||
declare id: ID<this>;
|
||||
@@ -77,6 +80,7 @@ export class Account extends CoValueBase implements CoValue {
|
||||
get _schema(): {
|
||||
profile: Schema;
|
||||
root: Schema;
|
||||
service: Schema;
|
||||
} {
|
||||
return (this.constructor as typeof Account)._schema;
|
||||
}
|
||||
@@ -90,6 +94,10 @@ export class Account extends CoValueBase implements CoValue {
|
||||
ref: () => RegisteredSchemas["CoMap"],
|
||||
optional: true,
|
||||
} satisfies RefEncoded<CoMap>,
|
||||
service: {
|
||||
ref: () => AccountService,
|
||||
optional: false,
|
||||
} satisfies RefEncoded<AccountService>,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -112,12 +120,15 @@ export class Account extends CoValueBase implements CoValue {
|
||||
|
||||
declare profile: Profile | null;
|
||||
declare root: CoMap | null;
|
||||
declare service: AccountService | null | undefined;
|
||||
|
||||
getDescriptor(key: string) {
|
||||
if (key === "profile") {
|
||||
return this._schema.profile;
|
||||
} else if (key === "root") {
|
||||
return this._schema.root;
|
||||
} else if (key === "service") {
|
||||
return this._schema.service;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
@@ -126,6 +137,7 @@ export class Account extends CoValueBase implements CoValue {
|
||||
get _refs(): {
|
||||
profile: RefIfCoValue<Profile> | undefined;
|
||||
root: RefIfCoValue<CoMap> | undefined;
|
||||
service: RefIfCoValue<AccountService> | undefined;
|
||||
} {
|
||||
const profileID = this._raw.get("profile") as unknown as
|
||||
| ID<NonNullable<this["profile"]>>
|
||||
@@ -133,6 +145,9 @@ export class Account extends CoValueBase implements CoValue {
|
||||
const rootID = this._raw.get("root") as unknown as
|
||||
| ID<NonNullable<this["root"]>>
|
||||
| undefined;
|
||||
const serviceID = this._raw.get("service") as unknown as
|
||||
| ID<NonNullable<this["service"]>>
|
||||
| undefined;
|
||||
|
||||
return {
|
||||
profile: profileID
|
||||
@@ -157,6 +172,17 @@ export class Account extends CoValueBase implements CoValue {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
) as any as RefIfCoValue<this["root"]>)
|
||||
: undefined,
|
||||
service: serviceID
|
||||
? (new Ref(
|
||||
serviceID,
|
||||
this._loadedAs,
|
||||
this._schema.service as RefEncoded<
|
||||
NonNullable<this["service"]> & CoValue
|
||||
>,
|
||||
this,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
) as any as RefIfCoValue<this["service"]>)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -352,6 +378,14 @@ export class Account extends CoValueBase implements CoValue {
|
||||
async applyMigration(creationProps?: AccountCreationProps) {
|
||||
await this.migrate(creationProps);
|
||||
|
||||
if (this.service && this.service.service === undefined) {
|
||||
this.service = co.service().create({}, this);
|
||||
} else if (this.service && this.service._owner._type !== "Group") {
|
||||
throw new Error("Service must be owned by a Group", {
|
||||
cause: `The service of the account "${this.id}" was created with an Account as owner, which is not allowed.`,
|
||||
});
|
||||
}
|
||||
|
||||
// if the user has not defined a profile themselves, we create one
|
||||
if (this.profile === undefined && creationProps) {
|
||||
const profileGroup = RegisteredSchemas["Group"].create({ owner: this });
|
||||
@@ -367,15 +401,9 @@ export class Account extends CoValueBase implements CoValue {
|
||||
}
|
||||
|
||||
const node = this._raw.core.node;
|
||||
const profile = node
|
||||
node
|
||||
.expectCoValueLoaded(this._raw.get("profile")!)
|
||||
.getCurrentContent() as RawCoMap;
|
||||
|
||||
if (!profile.get("inbox")) {
|
||||
const inboxRoot = createInboxRoot(this);
|
||||
profile.set("inbox", inboxRoot.id);
|
||||
profile.set("inboxInvite", inboxRoot.inviteLink);
|
||||
}
|
||||
}
|
||||
|
||||
// Placeholder method for subclasses to override
|
||||
@@ -467,9 +495,13 @@ export class Account extends CoValueBase implements CoValue {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* For accounts and groups, the values for the `profile`, `root`, and `service` keys are their IDs, not the values themselves.
|
||||
* Setting any other property will set a key-value mapping where the key is the property name and the value is the property value.
|
||||
*/
|
||||
export const AccountAndGroupProxyHandler: ProxyHandler<Account | Group> = {
|
||||
get(target, key, receiver) {
|
||||
if (key === "profile" || key === "root") {
|
||||
if (key === "profile" || key === "root" || key === "service") {
|
||||
const id = target._raw.get(key);
|
||||
|
||||
if (id) {
|
||||
@@ -483,15 +515,28 @@ export const AccountAndGroupProxyHandler: ProxyHandler<Account | Group> = {
|
||||
},
|
||||
set(target, key, value, receiver) {
|
||||
if (
|
||||
(key === "profile" || key === "root") &&
|
||||
(key === "profile" || key === "root" || key === "service") &&
|
||||
typeof value === "object" &&
|
||||
SchemaInit in value
|
||||
) {
|
||||
(target.constructor as typeof CoMap)._schema ||= {};
|
||||
(target.constructor as typeof CoMap)._schema[key] = value[SchemaInit];
|
||||
return true;
|
||||
} else if (key === "service") {
|
||||
if (value) {
|
||||
// The 'trusting' privacy level means that the service ID is readable by anyone, allowing other accounts to load this account's service ID.
|
||||
target._raw.set(
|
||||
"service",
|
||||
value.id as unknown as CoID<RawCoMap>,
|
||||
"trusting",
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
} else if (key === "profile") {
|
||||
if (value) {
|
||||
// The 'trusting' privacy level allows other accounts to load this account's profile ID.
|
||||
// This is unlike the account root, whose ID is not visible to other accounts (unless shared out-of-band).
|
||||
target._raw.set(
|
||||
"profile",
|
||||
value.id as unknown as CoID<RawCoMap>,
|
||||
@@ -511,7 +556,7 @@ export const AccountAndGroupProxyHandler: ProxyHandler<Account | Group> = {
|
||||
},
|
||||
defineProperty(target, key, descriptor) {
|
||||
if (
|
||||
(key === "profile" || key === "root") &&
|
||||
(key === "profile" || key === "root" || key === "service") &&
|
||||
typeof descriptor.value === "object" &&
|
||||
SchemaInit in descriptor.value
|
||||
) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
Role,
|
||||
} from "cojson";
|
||||
import type {
|
||||
AccountService,
|
||||
AnyAccountSchema,
|
||||
CoMap,
|
||||
CoValue,
|
||||
@@ -54,6 +55,7 @@ export class Group extends CoValueBase implements CoValue {
|
||||
get _schema(): {
|
||||
profile: Schema;
|
||||
root: Schema;
|
||||
service: Schema;
|
||||
} {
|
||||
return (this.constructor as typeof Group)._schema;
|
||||
}
|
||||
@@ -61,6 +63,7 @@ export class Group extends CoValueBase implements CoValue {
|
||||
this._schema = {
|
||||
profile: "json" satisfies Schema,
|
||||
root: "json" satisfies Schema,
|
||||
service: "json" satisfies Schema,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any;
|
||||
Object.defineProperty(this.prototype, "_schema", {
|
||||
@@ -70,10 +73,12 @@ export class Group extends CoValueBase implements CoValue {
|
||||
|
||||
declare profile: Profile | null;
|
||||
declare root: CoMap | null;
|
||||
declare service: AccountService | null;
|
||||
|
||||
get _refs(): {
|
||||
profile: Ref<Profile> | undefined;
|
||||
root: Ref<CoMap> | undefined;
|
||||
service: Ref<AccountService> | undefined;
|
||||
} {
|
||||
const profileID = this._raw.get("profile") as unknown as
|
||||
| ID<NonNullable<this["profile"]>>
|
||||
@@ -81,6 +86,9 @@ export class Group extends CoValueBase implements CoValue {
|
||||
const rootID = this._raw.get("root") as unknown as
|
||||
| ID<NonNullable<this["root"]>>
|
||||
| undefined;
|
||||
const serviceID = this._raw.get("service") as unknown as
|
||||
| ID<NonNullable<this["service"]>>
|
||||
| undefined;
|
||||
return {
|
||||
profile: profileID
|
||||
? (new Ref(
|
||||
@@ -102,6 +110,17 @@ export class Group extends CoValueBase implements CoValue {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
) as any as this["root"] extends CoMap ? Ref<this["root"]> : never)
|
||||
: undefined,
|
||||
service: serviceID
|
||||
? (new Ref(
|
||||
serviceID,
|
||||
this._loadedAs,
|
||||
this._schema.service as RefEncoded<NonNullable<this["service"]>>,
|
||||
this,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
) as any as this["service"] extends AccountService
|
||||
? Ref<this["service"]>
|
||||
: never)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -11,8 +11,6 @@ import {
|
||||
/** @category Identity & Permissions */
|
||||
export class Profile extends CoMap {
|
||||
name = coField.string;
|
||||
inbox? = coField.optional.string;
|
||||
inboxInvite? = coField.optional.string;
|
||||
|
||||
/**
|
||||
* Creates a new profile with the given initial values and owner.
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { CoID, InviteSecret, RawAccount, RawCoMap, SessionID } from "cojson";
|
||||
import { CoStreamItem, RawCoStream } from "cojson";
|
||||
import {
|
||||
type Account,
|
||||
Account,
|
||||
AccountService,
|
||||
CoMap,
|
||||
CoValue,
|
||||
CoValueClass,
|
||||
CoValueOrZodSchema,
|
||||
Group,
|
||||
ID,
|
||||
InstanceOfSchema,
|
||||
activeAccountContext,
|
||||
@@ -13,69 +16,71 @@ import {
|
||||
zodSchemaToCoSchema,
|
||||
} from "../internal.js";
|
||||
|
||||
export type InboxInvite = `${CoID<MessagesStream>}/${InviteSecret}`;
|
||||
type TxKey = `${SessionID}/${number}`;
|
||||
|
||||
type MessagesStream = RawCoStream<CoID<InboxMessage<CoValue, any>>>;
|
||||
type MessagesStream = RawCoStream<CoID<ServiceMessage<CoValue, any>>>;
|
||||
type FailedMessagesStream = RawCoStream<{
|
||||
errors: string[];
|
||||
value: CoID<InboxMessage<CoValue, any>>;
|
||||
value: CoID<ServiceMessage<CoValue, any>>;
|
||||
}>;
|
||||
type TxKeyStream = RawCoStream<TxKey>;
|
||||
export type InboxRoot = RawCoMap<{
|
||||
export type ServiceRoot = RawCoMap<{
|
||||
messages: CoID<MessagesStream>;
|
||||
processed: CoID<TxKeyStream>;
|
||||
failed: CoID<FailedMessagesStream>;
|
||||
inviteLink: InboxInvite;
|
||||
}>;
|
||||
|
||||
export function createInboxRoot(account: Account) {
|
||||
export function createServiceRoot(account: Account) {
|
||||
if (!account.isLocalNodeOwner) {
|
||||
throw new Error("Account is not controlled");
|
||||
}
|
||||
|
||||
const rawAccount = account._raw;
|
||||
|
||||
// Service root needs to be publicly readable so the ID of its properties (in particular `messages`) can be read.
|
||||
const group = rawAccount.core.node.createGroup();
|
||||
const messagesFeed = group.createStream<MessagesStream>();
|
||||
group.addMember("everyone", "reader");
|
||||
|
||||
const inboxRoot = rawAccount.createMap<InboxRoot>();
|
||||
const processedFeed = rawAccount.createStream<TxKeyStream>();
|
||||
const failedFeed = rawAccount.createStream<FailedMessagesStream>();
|
||||
// The `messages` property (despite having a public ID) is write-only.
|
||||
const messagesGroup = rawAccount.core.node.createGroup();
|
||||
messagesGroup.addMember("everyone", "writeOnly");
|
||||
const messagesFeed = messagesGroup.createStream<MessagesStream>();
|
||||
|
||||
const inviteLink =
|
||||
`${messagesFeed.id}/${group.createInvite("writeOnly")}` as const;
|
||||
const serviceRoot = group.createMap<ServiceRoot>();
|
||||
const processedFeed = group.createStream<TxKeyStream>();
|
||||
const failedFeed = group.createStream<FailedMessagesStream>();
|
||||
|
||||
inboxRoot.set("messages", messagesFeed.id);
|
||||
inboxRoot.set("processed", processedFeed.id);
|
||||
inboxRoot.set("failed", failedFeed.id);
|
||||
serviceRoot.set("messages", messagesFeed.id);
|
||||
serviceRoot.set("processed", processedFeed.id);
|
||||
serviceRoot.set("failed", failedFeed.id);
|
||||
|
||||
return {
|
||||
id: inboxRoot.id,
|
||||
inviteLink,
|
||||
id: serviceRoot.id,
|
||||
};
|
||||
}
|
||||
|
||||
type InboxMessage<I extends CoValue, O extends CoValue | undefined> = RawCoMap<{
|
||||
type ServiceMessage<
|
||||
I extends CoValue,
|
||||
O extends CoValue | undefined,
|
||||
> = RawCoMap<{
|
||||
payload: ID<I>;
|
||||
result: ID<O> | undefined;
|
||||
processed: boolean;
|
||||
error: string | undefined;
|
||||
}>;
|
||||
|
||||
async function createInboxMessage<
|
||||
async function createServiceMessage<
|
||||
I extends CoValue,
|
||||
O extends CoValue | undefined,
|
||||
>(payload: I, inboxOwner: RawAccount) {
|
||||
>(payload: I, serviceOwner: RawAccount) {
|
||||
const group = payload._raw.group;
|
||||
|
||||
if (group instanceof RawAccount) {
|
||||
throw new Error("Inbox messages should be owned by a group");
|
||||
throw new Error("Service messages should be owned by a group");
|
||||
}
|
||||
|
||||
group.addMember(inboxOwner, "writer");
|
||||
group.addMember(serviceOwner, "writer");
|
||||
|
||||
const message = group.createMap<InboxMessage<I, O>>({
|
||||
const message = group.createMap<ServiceMessage<I, O>>({
|
||||
payload: payload.id,
|
||||
result: undefined,
|
||||
processed: false,
|
||||
@@ -88,17 +93,17 @@ async function createInboxMessage<
|
||||
return message;
|
||||
}
|
||||
|
||||
export class Inbox {
|
||||
export class Service {
|
||||
account: Account;
|
||||
messages: MessagesStream;
|
||||
processed: TxKeyStream;
|
||||
failed: FailedMessagesStream;
|
||||
root: InboxRoot;
|
||||
root: ServiceRoot;
|
||||
processing = new Set<`${SessionID}/${number}`>();
|
||||
|
||||
private constructor(
|
||||
account: Account,
|
||||
root: InboxRoot,
|
||||
root: ServiceRoot,
|
||||
messages: MessagesStream,
|
||||
processed: TxKeyStream,
|
||||
failed: FailedMessagesStream,
|
||||
@@ -146,7 +151,7 @@ export class Inbox {
|
||||
|
||||
for (const [sessionID, items] of Object.entries(stream.items) as [
|
||||
SessionID,
|
||||
CoStreamItem<CoID<InboxMessage<InstanceOfSchema<M>, O>>>[],
|
||||
CoStreamItem<CoID<ServiceMessage<InstanceOfSchema<M>, O>>>[],
|
||||
][]) {
|
||||
const accountID = getAccountIDfromSessionID(sessionID);
|
||||
|
||||
@@ -168,7 +173,7 @@ export class Inbox {
|
||||
.then((message) => {
|
||||
if (message === "unavailable") {
|
||||
return Promise.reject(
|
||||
new Error("Unable to load inbox message " + id),
|
||||
new Error("Unable to load service message " + id),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -183,46 +188,46 @@ export class Inbox {
|
||||
.then((value) => {
|
||||
if (!value) {
|
||||
return Promise.reject(
|
||||
new Error("Unable to load inbox message " + id),
|
||||
new Error("Unable to load service message " + id),
|
||||
);
|
||||
}
|
||||
|
||||
return callback(value as InstanceOfSchema<M>, accountID);
|
||||
})
|
||||
.then((result) => {
|
||||
const inboxMessage = node
|
||||
const serviceMessage = node
|
||||
.expectCoValueLoaded(item.value)
|
||||
.getCurrentContent() as RawCoMap;
|
||||
|
||||
if (result) {
|
||||
inboxMessage.set("result", result.id);
|
||||
serviceMessage.set("result", result.id);
|
||||
}
|
||||
|
||||
inboxMessage.set("processed", true);
|
||||
serviceMessage.set("processed", true);
|
||||
|
||||
this.processed.push(txKey);
|
||||
this.processing.delete(txKey);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error processing inbox message", error);
|
||||
console.error("Error processing service message", error);
|
||||
this.processing.delete(txKey);
|
||||
const errors = failed.get(txKey) ?? [];
|
||||
|
||||
const stringifiedError = String(error);
|
||||
errors.push(stringifiedError);
|
||||
|
||||
let inboxMessage: RawCoMap | undefined;
|
||||
let serviceMessage: RawCoMap | undefined;
|
||||
|
||||
try {
|
||||
inboxMessage = node
|
||||
serviceMessage = node
|
||||
.expectCoValueLoaded(item.value)
|
||||
.getCurrentContent() as RawCoMap;
|
||||
|
||||
inboxMessage.set("error", stringifiedError);
|
||||
serviceMessage.set("error", stringifiedError);
|
||||
} catch (error) {}
|
||||
|
||||
if (errors.length > retries) {
|
||||
inboxMessage?.set("processed", true);
|
||||
serviceMessage?.set("processed", true);
|
||||
this.processed.push(txKey);
|
||||
this.failed.push({ errors, value: item.value });
|
||||
} else {
|
||||
@@ -249,22 +254,16 @@ export class Inbox {
|
||||
}
|
||||
|
||||
static async load(account: Account) {
|
||||
const profile = account.profile;
|
||||
|
||||
if (!profile) {
|
||||
throw new Error("Account profile should already be loaded");
|
||||
}
|
||||
|
||||
if (!profile.inbox) {
|
||||
throw new Error("The account has not set up their inbox");
|
||||
if (!account.service?.service) {
|
||||
throw new Error("The account has not set up their service");
|
||||
}
|
||||
|
||||
const node = account._raw.core.node;
|
||||
|
||||
const root = await node.load(profile.inbox as CoID<InboxRoot>);
|
||||
const root = await node.load(account.service.service as CoID<ServiceRoot>);
|
||||
|
||||
if (root === "unavailable") {
|
||||
throw new Error("Inbox not found");
|
||||
throw new Error("Service not found");
|
||||
}
|
||||
|
||||
const [messages, processed, failed] = await Promise.all([
|
||||
@@ -278,14 +277,14 @@ export class Inbox {
|
||||
processed === "unavailable" ||
|
||||
failed === "unavailable"
|
||||
) {
|
||||
throw new Error("Inbox not found");
|
||||
throw new Error("Service not found");
|
||||
}
|
||||
|
||||
return new Inbox(account, root, messages, processed, failed);
|
||||
return new Service(account, root, messages, processed, failed);
|
||||
}
|
||||
}
|
||||
|
||||
export class InboxSender<I extends CoValue, O extends CoValue | undefined> {
|
||||
export class ServiceSender<I extends CoValue, O extends CoValue | undefined> {
|
||||
currentAccount: Account;
|
||||
owner: RawAccount;
|
||||
messages: MessagesStream;
|
||||
@@ -307,12 +306,15 @@ export class InboxSender<I extends CoValue, O extends CoValue | undefined> {
|
||||
async sendMessage(
|
||||
message: I,
|
||||
): Promise<O extends CoValue ? ID<O> : undefined> {
|
||||
const inboxMessage = await createInboxMessage<I, O>(message, this.owner);
|
||||
const serviceMessage = await createServiceMessage<I, O>(
|
||||
message,
|
||||
this.owner,
|
||||
);
|
||||
|
||||
this.messages.push(inboxMessage.id);
|
||||
this.messages.push(serviceMessage.id);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
inboxMessage.subscribe((message) => {
|
||||
serviceMessage.subscribe((message) => {
|
||||
if (message.get("processed")) {
|
||||
const error = message.get("error");
|
||||
if (error) {
|
||||
@@ -330,73 +332,72 @@ export class InboxSender<I extends CoValue, O extends CoValue | undefined> {
|
||||
static async load<
|
||||
I extends CoValue,
|
||||
O extends CoValue | undefined = undefined,
|
||||
>(inboxOwnerID: ID<Account>, currentAccount?: Account) {
|
||||
>(serviceOwnerID: ID<Account>, currentAccount?: Account) {
|
||||
currentAccount ||= activeAccountContext.get();
|
||||
|
||||
const node = currentAccount._raw.core.node;
|
||||
|
||||
const inboxOwnerRaw = await node.load(
|
||||
inboxOwnerID as unknown as CoID<RawAccount>,
|
||||
const serviceOwnerRaw = await node.load(
|
||||
serviceOwnerID as unknown as CoID<RawAccount>,
|
||||
);
|
||||
|
||||
if (inboxOwnerRaw === "unavailable") {
|
||||
throw new Error("Failed to load the inbox owner");
|
||||
if (serviceOwnerRaw === "unavailable") {
|
||||
throw new Error("Failed to load the service owner");
|
||||
}
|
||||
|
||||
const inboxOwnerProfileRaw = await node.load(inboxOwnerRaw.get("profile")!);
|
||||
|
||||
if (inboxOwnerProfileRaw === "unavailable") {
|
||||
throw new Error("Failed to load the inbox owner profile");
|
||||
}
|
||||
|
||||
if (
|
||||
inboxOwnerProfileRaw.group.roleOf(currentAccount._raw.id) !== "reader" &&
|
||||
inboxOwnerProfileRaw.group.roleOf(currentAccount._raw.id) !== "writer" &&
|
||||
inboxOwnerProfileRaw.group.roleOf(currentAccount._raw.id) !== "admin"
|
||||
) {
|
||||
const serviceOwnerServiceValue = serviceOwnerRaw.get("service");
|
||||
if (!serviceOwnerServiceValue) {
|
||||
throw new Error(
|
||||
"Insufficient permissions to access the inbox, make sure its user profile is publicly readable.",
|
||||
"Service owner does not have their service setup (service field is missing)",
|
||||
);
|
||||
}
|
||||
|
||||
const inboxInvite = inboxOwnerProfileRaw.get("inboxInvite");
|
||||
const serviceOwnerService = await node.load(serviceOwnerServiceValue);
|
||||
|
||||
if (!inboxInvite) {
|
||||
throw new Error("The user has not set up their inbox");
|
||||
if (serviceOwnerService === "unavailable") {
|
||||
throw new Error("Failed to load the service owner's service ID");
|
||||
}
|
||||
|
||||
const id = await acceptInvite(inboxInvite as InboxInvite, currentAccount);
|
||||
if (
|
||||
serviceOwnerService.group.roleOf(currentAccount._raw.id) !== "reader" &&
|
||||
serviceOwnerService.group.roleOf(currentAccount._raw.id) !== "writer" &&
|
||||
serviceOwnerService.group.roleOf(currentAccount._raw.id) !== "admin"
|
||||
) {
|
||||
throw new Error(
|
||||
"Insufficient permissions to access the service, make sure it's publicly readable.",
|
||||
);
|
||||
}
|
||||
|
||||
const messages = await node.load(id);
|
||||
const serviceRootId = serviceOwnerService.get("service") as
|
||||
| CoID<ServiceRoot>
|
||||
| undefined;
|
||||
|
||||
if (!serviceRootId) {
|
||||
throw new Error(
|
||||
"Service owner does not have their service setup (service root ID is missing)",
|
||||
);
|
||||
}
|
||||
|
||||
const serviceRoot = await node.load(serviceRootId);
|
||||
|
||||
if (serviceRoot === "unavailable") {
|
||||
throw new Error("Failed to load the service root");
|
||||
}
|
||||
|
||||
const messagesId = serviceRoot.get("messages");
|
||||
if (!messagesId) {
|
||||
throw new Error("Service root does not have a messages ID");
|
||||
}
|
||||
const messages = await node.load(messagesId);
|
||||
|
||||
if (messages === "unavailable") {
|
||||
throw new Error("Inbox not found");
|
||||
throw new Error("Service messages are unavailable");
|
||||
}
|
||||
|
||||
return new InboxSender<I, O>(currentAccount, inboxOwnerRaw, messages);
|
||||
return new ServiceSender<I, O>(currentAccount, serviceOwnerRaw, messages);
|
||||
}
|
||||
}
|
||||
|
||||
async function acceptInvite(invite: string, account?: Account) {
|
||||
account ||= activeAccountContext.get();
|
||||
|
||||
const id = invite.slice(0, invite.indexOf("/")) as CoID<MessagesStream>;
|
||||
|
||||
const inviteSecret = invite.slice(invite.indexOf("/") + 1) as InviteSecret;
|
||||
|
||||
if (!id?.startsWith("co_z") || !inviteSecret.startsWith("inviteSecret_")) {
|
||||
throw new Error("Invalid inbox ticket");
|
||||
}
|
||||
|
||||
if (!account.isLocalNodeOwner) {
|
||||
throw new Error("Account is not controlled");
|
||||
}
|
||||
|
||||
await account._raw.core.node.acceptInvite(id, inviteSecret);
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
function getAccountIDfromSessionID(sessionID: SessionID) {
|
||||
const until = sessionID.indexOf("_session");
|
||||
const accountID = sessionID.slice(0, until);
|
||||
@@ -14,7 +14,7 @@ export type { CoValue, ID } from "./internal.js";
|
||||
|
||||
export { Encoders, coField } from "./internal.js";
|
||||
|
||||
export { Inbox, InboxSender } from "./internal.js";
|
||||
export { Service, ServiceSender } from "./internal.js";
|
||||
|
||||
export { Group } from "./internal.js";
|
||||
export { CoValueBase } from "./internal.js";
|
||||
|
||||
@@ -10,4 +10,5 @@ export {
|
||||
coImageDefiner as image,
|
||||
coAccountDefiner as account,
|
||||
coProfileDefiner as profile,
|
||||
coServiceDefiner as service,
|
||||
} from "./zodCo.js";
|
||||
|
||||
@@ -21,15 +21,11 @@ export type AccountSchema<
|
||||
Shape extends {
|
||||
profile: AnyCoMapSchema<{
|
||||
name: z.core.$ZodString<string>;
|
||||
inbox?: z.core.$ZodOptional<z.core.$ZodString>;
|
||||
inboxInvite?: z.core.$ZodOptional<z.core.$ZodString>;
|
||||
}>;
|
||||
root: AnyCoMapSchema;
|
||||
} = {
|
||||
profile: CoMapSchema<{
|
||||
name: z.core.$ZodString<string>;
|
||||
inbox?: z.core.$ZodOptional<z.core.$ZodString>;
|
||||
inboxInvite?: z.core.$ZodOptional<z.core.$ZodString>;
|
||||
}>;
|
||||
root: CoMapSchema<{}>;
|
||||
},
|
||||
@@ -68,10 +64,17 @@ export type AccountSchema<
|
||||
getCoSchema: () => typeof Account;
|
||||
};
|
||||
|
||||
export type ServiceShape = {
|
||||
service: z.core.$ZodOptional<z.core.$ZodString>;
|
||||
};
|
||||
|
||||
export type CoServiceSchema<
|
||||
Shape extends z.core.$ZodLooseShape = ServiceShape,
|
||||
Config extends z.core.$ZodObjectConfig = z.core.$ZodObjectConfig,
|
||||
> = CoMapSchema<Shape & ServiceShape, Config>;
|
||||
|
||||
export type DefaultProfileShape = {
|
||||
name: z.core.$ZodString<string>;
|
||||
inbox: z.core.$ZodOptional<z.core.$ZodString>;
|
||||
inboxInvite: z.core.$ZodOptional<z.core.$ZodString>;
|
||||
};
|
||||
|
||||
export type CoProfileSchema<
|
||||
|
||||
@@ -11,12 +11,18 @@ import {
|
||||
CoProfileSchema,
|
||||
CoRecordSchema,
|
||||
CoRichText,
|
||||
CoServiceSchema,
|
||||
DefaultProfileShape,
|
||||
FileStream,
|
||||
FileStreamSchema,
|
||||
ImageDefinition,
|
||||
PlainTextSchema,
|
||||
RegisteredSchemas,
|
||||
ServiceShape,
|
||||
Simplify,
|
||||
activeAccountContext,
|
||||
createServiceRoot,
|
||||
isAccountInstance,
|
||||
zodSchemaToCoSchema,
|
||||
} from "../../internal.js";
|
||||
import { RichTextSchema } from "./schemaTypes/RichTextSchema.js";
|
||||
@@ -80,8 +86,6 @@ function enrichAccountSchema<
|
||||
Shape extends {
|
||||
profile: AnyCoMapSchema<{
|
||||
name: z.core.$ZodString<string>;
|
||||
inbox?: z.core.$ZodOptional<z.core.$ZodString>;
|
||||
inboxInvite?: z.core.$ZodOptional<z.core.$ZodString>;
|
||||
}>;
|
||||
root: AnyCoMapSchema;
|
||||
},
|
||||
@@ -140,8 +144,6 @@ export const coAccountDefiner = <
|
||||
Shape extends {
|
||||
profile: AnyCoMapSchema<{
|
||||
name: z.core.$ZodString<string>;
|
||||
inbox?: z.core.$ZodOptional<z.core.$ZodString>;
|
||||
inboxInvite?: z.core.$ZodOptional<z.core.$ZodString>;
|
||||
}>;
|
||||
root: AnyCoMapSchema;
|
||||
},
|
||||
@@ -149,8 +151,6 @@ export const coAccountDefiner = <
|
||||
shape: Shape = {
|
||||
profile: coMapDefiner({
|
||||
name: z.string(),
|
||||
inbox: z.optional(z.string()),
|
||||
inboxInvite: z.optional(z.string()),
|
||||
}),
|
||||
root: coMapDefiner({}),
|
||||
} as unknown as Shape,
|
||||
@@ -211,19 +211,40 @@ export const coListDefiner = <T extends z.core.$ZodType>(
|
||||
return enrichCoListSchema(arraySchema);
|
||||
};
|
||||
|
||||
export const coServiceDefiner = (): CoServiceSchema<ServiceShape> => {
|
||||
const coService = coMapDefiner({
|
||||
service: z.optional(z.string()),
|
||||
}) as CoServiceSchema<ServiceShape>;
|
||||
|
||||
const superCreate = coService.create;
|
||||
coService.create = (init, options) => {
|
||||
const { owner } = options
|
||||
? "_type" in options && isAccountInstance(options)
|
||||
? { owner: options }
|
||||
: !("_type" in options) && isAccountInstance(options.owner)
|
||||
? { owner: options.owner }
|
||||
: { owner: activeAccountContext.get() }
|
||||
: { owner: activeAccountContext.get() };
|
||||
const serviceGroup = RegisteredSchemas["Group"].create(owner);
|
||||
serviceGroup.addMember("everyone", "reader");
|
||||
const serviceRoot = init.service
|
||||
? { id: init.service }
|
||||
: createServiceRoot(owner);
|
||||
return superCreate({ service: serviceRoot.id }, serviceGroup);
|
||||
};
|
||||
|
||||
return coService;
|
||||
};
|
||||
|
||||
export const coProfileDefiner = <
|
||||
Shape extends z.core.$ZodLooseShape = Simplify<DefaultProfileShape>,
|
||||
>(
|
||||
shape: Shape & {
|
||||
name?: z.core.$ZodString<string>;
|
||||
inbox?: z.core.$ZodOptional<z.core.$ZodString>;
|
||||
inboxInvite?: z.core.$ZodOptional<z.core.$ZodString>;
|
||||
} = {} as any,
|
||||
): CoProfileSchema<Shape> => {
|
||||
const ehnancedShape = Object.assign(shape ?? {}, {
|
||||
name: z.string(),
|
||||
inbox: z.optional(z.string()),
|
||||
inboxInvite: z.optional(z.string()),
|
||||
});
|
||||
|
||||
return coMapDefiner(ehnancedShape) as CoProfileSchema<Shape>;
|
||||
|
||||
@@ -11,7 +11,7 @@ export * from "./coValues/coFeed.js";
|
||||
export * from "./coValues/account.js";
|
||||
export * from "./coValues/group.js";
|
||||
export * from "./coValues/profile.js";
|
||||
export * from "./coValues/inbox.js";
|
||||
export * from "./coValues/service.js";
|
||||
export * from "./coValues/coPlainText.js";
|
||||
export * from "./coValues/coRichText.js";
|
||||
export * from "./coValues/schemaUnion.js";
|
||||
|
||||
@@ -1,386 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
Account,
|
||||
CoMap,
|
||||
Group,
|
||||
Inbox,
|
||||
InboxSender,
|
||||
Profile,
|
||||
z,
|
||||
} from "../exports";
|
||||
import { Loaded, co, coField, zodSchemaToCoSchema } from "../internal";
|
||||
import { setupTwoNodes, waitFor } from "./utils";
|
||||
|
||||
const Message = co.map({
|
||||
text: z.string(),
|
||||
});
|
||||
|
||||
describe("Inbox", () => {
|
||||
describe("Private profile", () => {
|
||||
it("Should throw if the inbox owner profile is private", async () => {
|
||||
const WorkerAccount = co.account().withMigration((account) => {
|
||||
account.profile = co
|
||||
.profile()
|
||||
.create({ name: "Worker" }, Group.create({ owner: account }));
|
||||
});
|
||||
|
||||
const { clientAccount: sender, serverAccount: receiver } =
|
||||
await setupTwoNodes({
|
||||
ServerAccountSchema: zodSchemaToCoSchema(WorkerAccount),
|
||||
});
|
||||
|
||||
await expect(() => InboxSender.load(receiver.id, sender)).rejects.toThrow(
|
||||
"Insufficient permissions to access the inbox, make sure its user profile is publicly readable.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should create inbox and allow message exchange between accounts", async () => {
|
||||
const { clientAccount: sender, serverAccount: receiver } =
|
||||
await setupTwoNodes();
|
||||
|
||||
const receiverInbox = await Inbox.load(receiver);
|
||||
|
||||
// Create a message from sender
|
||||
const message = Message.create(
|
||||
{ text: "Hello" },
|
||||
{
|
||||
owner: Group.create({ owner: sender }),
|
||||
},
|
||||
);
|
||||
|
||||
// Setup inbox sender
|
||||
const inboxSender = await InboxSender.load(receiver.id, sender);
|
||||
inboxSender.sendMessage(message);
|
||||
|
||||
// Track received messages
|
||||
const receivedMessages: Loaded<typeof Message>[] = [];
|
||||
let senderAccountID: unknown = undefined;
|
||||
|
||||
// Subscribe to inbox messages
|
||||
const unsubscribe = receiverInbox.subscribe(
|
||||
Message,
|
||||
async (message, id) => {
|
||||
senderAccountID = id;
|
||||
receivedMessages.push(message);
|
||||
},
|
||||
);
|
||||
|
||||
// Wait for message to be received
|
||||
await waitFor(() => receivedMessages.length === 1);
|
||||
|
||||
expect(receivedMessages.length).toBe(1);
|
||||
expect(receivedMessages[0]?.text).toBe("Hello");
|
||||
expect(senderAccountID).toBe(sender.id);
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it("should work with empty CoMaps", async () => {
|
||||
const { clientAccount: sender, serverAccount: receiver } =
|
||||
await setupTwoNodes();
|
||||
|
||||
const EmptyMessage = co.map({});
|
||||
|
||||
const receiverInbox = await Inbox.load(receiver);
|
||||
|
||||
// Create a message from sender
|
||||
const message = EmptyMessage.create(
|
||||
{},
|
||||
{
|
||||
owner: Group.create({ owner: sender }),
|
||||
},
|
||||
);
|
||||
|
||||
// Setup inbox sender
|
||||
const inboxSender = await InboxSender.load(receiver.id, sender);
|
||||
inboxSender.sendMessage(message);
|
||||
|
||||
// Track received messages
|
||||
const receivedMessages: Loaded<typeof EmptyMessage>[] = [];
|
||||
let senderAccountID: unknown = undefined;
|
||||
|
||||
// Subscribe to inbox messages
|
||||
const unsubscribe = receiverInbox.subscribe(
|
||||
EmptyMessage,
|
||||
async (message, id) => {
|
||||
senderAccountID = id;
|
||||
receivedMessages.push(message);
|
||||
},
|
||||
);
|
||||
|
||||
// Wait for message to be received
|
||||
await waitFor(() => receivedMessages.length === 1);
|
||||
|
||||
expect(receivedMessages.length).toBe(1);
|
||||
expect(receivedMessages[0]?.id).toBe(message.id);
|
||||
expect(senderAccountID).toBe(sender.id);
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it("should return the result of the message", async () => {
|
||||
const { clientAccount: sender, serverAccount: receiver } =
|
||||
await setupTwoNodes();
|
||||
|
||||
const receiverInbox = await Inbox.load(receiver);
|
||||
|
||||
// Create a message from sender
|
||||
const message = Message.create(
|
||||
{ text: "Hello" },
|
||||
{
|
||||
owner: Group.create({ owner: sender }),
|
||||
},
|
||||
);
|
||||
|
||||
const unsubscribe = receiverInbox.subscribe(Message, async (message) => {
|
||||
return Message.create(
|
||||
{ text: "Responded from the inbox" },
|
||||
{ owner: message._owner },
|
||||
);
|
||||
});
|
||||
|
||||
// Setup inbox sender
|
||||
const inboxSender = await InboxSender.load<
|
||||
Loaded<typeof Message>,
|
||||
Loaded<typeof Message>
|
||||
>(receiver.id, sender);
|
||||
const resultId = await inboxSender.sendMessage(message);
|
||||
|
||||
const result = await Message.load(resultId, { loadAs: receiver });
|
||||
expect(result?.text).toBe("Responded from the inbox");
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it("should return the undefined if the subscription returns undefined", async () => {
|
||||
const { clientAccount: sender, serverAccount: receiver } =
|
||||
await setupTwoNodes();
|
||||
|
||||
const receiverInbox = await Inbox.load(receiver);
|
||||
|
||||
// Create a message from sender
|
||||
const message = Message.create(
|
||||
{ text: "Hello" },
|
||||
{
|
||||
owner: Group.create({ owner: sender }),
|
||||
},
|
||||
);
|
||||
|
||||
const unsubscribe = receiverInbox.subscribe(Message, async (message) => {});
|
||||
|
||||
// Setup inbox sender
|
||||
const inboxSender = await InboxSender.load<Loaded<typeof Message>>(
|
||||
receiver.id,
|
||||
sender,
|
||||
);
|
||||
const result = await inboxSender.sendMessage(message);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it("should reject if the subscription throws an error", async () => {
|
||||
const { clientAccount: sender, serverAccount: receiver } =
|
||||
await setupTwoNodes();
|
||||
|
||||
const receiverInbox = await Inbox.load(receiver);
|
||||
|
||||
// Create a message from sender
|
||||
const message = Message.create(
|
||||
{ text: "Hello" },
|
||||
{
|
||||
owner: Group.create({ owner: sender }),
|
||||
},
|
||||
);
|
||||
|
||||
const errorLogSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
const unsubscribe = receiverInbox.subscribe(Message, async () => {
|
||||
return Promise.reject(new Error("Failed"));
|
||||
});
|
||||
|
||||
// Setup inbox sender
|
||||
const inboxSender = await InboxSender.load<Loaded<typeof Message>>(
|
||||
receiver.id,
|
||||
sender,
|
||||
);
|
||||
|
||||
await expect(inboxSender.sendMessage(message)).rejects.toThrow(
|
||||
"Error: Failed",
|
||||
);
|
||||
|
||||
unsubscribe();
|
||||
|
||||
expect(errorLogSpy).toHaveBeenCalledWith(
|
||||
"Error processing inbox message",
|
||||
expect.any(Error),
|
||||
);
|
||||
|
||||
errorLogSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should mark messages as processed", async () => {
|
||||
const { clientAccount: sender, serverAccount: receiver } =
|
||||
await setupTwoNodes();
|
||||
|
||||
const receiverInbox = await Inbox.load(receiver);
|
||||
|
||||
// Create a message from sender
|
||||
const message = Message.create(
|
||||
{ text: "Hello" },
|
||||
{
|
||||
owner: Group.create({ owner: sender }),
|
||||
},
|
||||
);
|
||||
|
||||
// Setup inbox sender
|
||||
const inboxSender = await InboxSender.load(receiver.id, sender);
|
||||
inboxSender.sendMessage(message);
|
||||
|
||||
// Track received messages
|
||||
const receivedMessages: Loaded<typeof Message>[] = [];
|
||||
|
||||
// Subscribe to inbox messages
|
||||
const unsubscribe = receiverInbox.subscribe(Message, async (message) => {
|
||||
receivedMessages.push(message);
|
||||
});
|
||||
|
||||
// Wait for message to be received
|
||||
await waitFor(() => receivedMessages.length === 1);
|
||||
|
||||
inboxSender.sendMessage(message);
|
||||
|
||||
await waitFor(() => receivedMessages.length === 2);
|
||||
|
||||
expect(receivedMessages.length).toBe(2);
|
||||
expect(receivedMessages[0]?.text).toBe("Hello");
|
||||
expect(receivedMessages[1]?.text).toBe("Hello");
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it("should unsubscribe correctly", async () => {
|
||||
const { clientAccount: sender, serverAccount: receiver } =
|
||||
await setupTwoNodes();
|
||||
|
||||
const receiverInbox = await Inbox.load(receiver);
|
||||
|
||||
// Create a message from sender
|
||||
const message = Message.create(
|
||||
{ text: "Hello" },
|
||||
{
|
||||
owner: Group.create({ owner: sender }),
|
||||
},
|
||||
);
|
||||
|
||||
// Setup inbox sender
|
||||
const inboxSender = await InboxSender.load(receiver.id, sender);
|
||||
inboxSender.sendMessage(message);
|
||||
|
||||
// Track received messages
|
||||
const receivedMessages: Loaded<typeof Message>[] = [];
|
||||
|
||||
// Subscribe to inbox messages
|
||||
const unsubscribe = receiverInbox.subscribe(Message, async (message) => {
|
||||
receivedMessages.push(message);
|
||||
});
|
||||
|
||||
// Wait for message to be received
|
||||
await waitFor(() => receivedMessages.length === 1);
|
||||
|
||||
unsubscribe();
|
||||
|
||||
inboxSender.sendMessage(message);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
expect(receivedMessages.length).toBe(1);
|
||||
expect(receivedMessages[0]?.text).toBe("Hello");
|
||||
});
|
||||
|
||||
it("should retry failed messages", async () => {
|
||||
const { clientAccount: sender, serverAccount: receiver } =
|
||||
await setupTwoNodes();
|
||||
|
||||
const receiverInbox = await Inbox.load(receiver);
|
||||
|
||||
// Create a message from sender
|
||||
const message = Message.create(
|
||||
{ text: "Hello" },
|
||||
{
|
||||
owner: Group.create({ owner: sender }),
|
||||
},
|
||||
);
|
||||
const errorLogSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
// Setup inbox sender
|
||||
const inboxSender = await InboxSender.load(receiver.id, sender);
|
||||
const promise = inboxSender.sendMessage(message);
|
||||
|
||||
let failures = 0;
|
||||
|
||||
// Subscribe to inbox messages
|
||||
const unsubscribe = receiverInbox.subscribe(
|
||||
Message,
|
||||
async () => {
|
||||
failures++;
|
||||
throw new Error("Failed");
|
||||
},
|
||||
{ retries: 2 },
|
||||
);
|
||||
|
||||
await expect(promise).rejects.toThrow();
|
||||
expect(failures).toBe(3);
|
||||
const [failed] = Object.values(receiverInbox.failed.items).flat();
|
||||
expect(failed?.value.errors.length).toBe(3);
|
||||
unsubscribe();
|
||||
|
||||
expect(errorLogSpy).toHaveBeenCalledWith(
|
||||
"Error processing inbox message",
|
||||
expect.any(Error),
|
||||
);
|
||||
|
||||
errorLogSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should not break the subscription if the message is unavailable", async () => {
|
||||
const { clientAccount: sender, serverAccount: receiver } =
|
||||
await setupTwoNodes();
|
||||
|
||||
const receiverInbox = await Inbox.load(receiver);
|
||||
|
||||
const inboxSender = await InboxSender.load(receiver.id, sender);
|
||||
inboxSender.messages.push(`co_z123234` as any);
|
||||
|
||||
const spy = vi.fn();
|
||||
|
||||
const errorLogSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
// Subscribe to inbox messages
|
||||
const unsubscribe = receiverInbox.subscribe(
|
||||
Message,
|
||||
async () => {
|
||||
spy();
|
||||
},
|
||||
{ retries: 2 },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const [failed] = Object.values(receiverInbox.failed.items).flat();
|
||||
|
||||
expect(failed?.value.errors.length).toBe(3);
|
||||
});
|
||||
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
unsubscribe();
|
||||
|
||||
expect(errorLogSpy).toHaveBeenCalledWith(
|
||||
"Error processing inbox message",
|
||||
expect.any(Error),
|
||||
);
|
||||
|
||||
errorLogSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ const WorkerAccount = co
|
||||
.account({
|
||||
root: WorkerRoot,
|
||||
profile: co.profile(),
|
||||
service: co.service(),
|
||||
})
|
||||
.withMigration((account) => {
|
||||
if (account.root === undefined) {
|
||||
|
||||
466
packages/jazz-tools/src/tests/service.test.ts
Normal file
466
packages/jazz-tools/src/tests/service.test.ts
Normal file
@@ -0,0 +1,466 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { Group, Service, ServiceSender, z } from "../exports";
|
||||
import {
|
||||
Loaded,
|
||||
co,
|
||||
createServiceRoot,
|
||||
zodSchemaToCoSchema,
|
||||
} from "../internal";
|
||||
import { setupTwoNodes, waitFor } from "./utils";
|
||||
|
||||
const Message = co.map({
|
||||
text: z.string(),
|
||||
});
|
||||
|
||||
const GenericWorkerAccount = co.account().withMigration((account) => {
|
||||
// Worker accounts automatically create a service if they don't have one
|
||||
// This is behaviour copied from `jazz-nodejs` into this test file
|
||||
if (!account.service?.service) {
|
||||
account.service = co.service().create({}, account);
|
||||
}
|
||||
});
|
||||
|
||||
describe("Service", () => {
|
||||
describe("Incorrect service property", () => {
|
||||
it("Should throw if the service owner's service property doesn't exist", async () => {
|
||||
// Account with no service field added
|
||||
const DefaultAccount = co.account();
|
||||
|
||||
const { clientAccount: sender, serverAccount: receiver } =
|
||||
await setupTwoNodes({
|
||||
ServerAccountSchema: zodSchemaToCoSchema(DefaultAccount),
|
||||
});
|
||||
|
||||
expect(receiver.service?.service).toBeUndefined();
|
||||
expect(receiver.service).toBeUndefined();
|
||||
|
||||
await expect(() =>
|
||||
ServiceSender.load(receiver.id, sender),
|
||||
).rejects.toThrow(
|
||||
"Service owner does not have their service setup (service field is missing)",
|
||||
);
|
||||
|
||||
// Add service field with correct permissions but no service root ID
|
||||
const publicGroup = Group.create({ owner: receiver });
|
||||
publicGroup.addMember("everyone", "reader");
|
||||
receiver.service = co
|
||||
.map({
|
||||
service: z.string().optional(),
|
||||
})
|
||||
.create({ service: undefined }, publicGroup);
|
||||
|
||||
expect(receiver.service?.service).toBeUndefined();
|
||||
expect(receiver.service).toBeDefined();
|
||||
expect(receiver.service).not.toBeNull();
|
||||
expect(receiver.service).toBeTruthy();
|
||||
|
||||
await expect(() =>
|
||||
ServiceSender.load(receiver.id, sender),
|
||||
).rejects.toThrow(
|
||||
"Service owner does not have their service setup (service root ID is missing)",
|
||||
);
|
||||
});
|
||||
|
||||
it("Should throw if the service owner's service property is private", async () => {
|
||||
const WorkerAccount = co
|
||||
.account({
|
||||
profile: co.profile(),
|
||||
root: co.map({}),
|
||||
})
|
||||
.withMigration((account) => {
|
||||
if (account.service?.service === undefined) {
|
||||
const serviceRoot = createServiceRoot(account);
|
||||
account.service = co
|
||||
.map({
|
||||
service: z.string(),
|
||||
})
|
||||
.create(
|
||||
{ service: serviceRoot.id },
|
||||
Group.create({ owner: account }),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const { clientAccount: sender, serverAccount: receiver } =
|
||||
await setupTwoNodes({
|
||||
ServerAccountSchema: zodSchemaToCoSchema(WorkerAccount),
|
||||
});
|
||||
|
||||
await expect(() =>
|
||||
ServiceSender.load(receiver.id, sender),
|
||||
).rejects.toThrow(
|
||||
"Insufficient permissions to access the service, make sure it's publicly readable.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should create service and allow message exchange between accounts", async () => {
|
||||
const { clientAccount: sender, serverAccount: receiver } =
|
||||
await setupTwoNodes({
|
||||
ServerAccountSchema: zodSchemaToCoSchema(GenericWorkerAccount),
|
||||
});
|
||||
|
||||
const receiverService = await Service.load(receiver);
|
||||
|
||||
// Create a message from sender
|
||||
const message = Message.create(
|
||||
{ text: "Hello" },
|
||||
{
|
||||
owner: Group.create({ owner: sender }),
|
||||
},
|
||||
);
|
||||
|
||||
// Setup service sender
|
||||
const serviceSender = await ServiceSender.load(receiver.id, sender);
|
||||
serviceSender.sendMessage(message);
|
||||
|
||||
// Track received messages
|
||||
const receivedMessages: Loaded<typeof Message>[] = [];
|
||||
let senderAccountID: unknown = undefined;
|
||||
|
||||
// Subscribe to service messages
|
||||
const unsubscribe = receiverService.subscribe(
|
||||
Message,
|
||||
async (message, id) => {
|
||||
senderAccountID = id;
|
||||
receivedMessages.push(message);
|
||||
},
|
||||
);
|
||||
|
||||
// Wait for message to be received
|
||||
await waitFor(() => receivedMessages.length === 1);
|
||||
|
||||
expect(receivedMessages.length).toBe(1);
|
||||
expect(receivedMessages[0]?.text).toBe("Hello");
|
||||
expect(senderAccountID).toBe(sender.id);
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it("should work with empty CoMaps", async () => {
|
||||
const { clientAccount: sender, serverAccount: receiver } =
|
||||
await setupTwoNodes({
|
||||
ServerAccountSchema: zodSchemaToCoSchema(GenericWorkerAccount),
|
||||
});
|
||||
|
||||
const EmptyMessage = co.map({});
|
||||
|
||||
const receiverService = await Service.load(receiver);
|
||||
|
||||
// Create a message from sender
|
||||
const message = EmptyMessage.create(
|
||||
{},
|
||||
{
|
||||
owner: Group.create({ owner: sender }),
|
||||
},
|
||||
);
|
||||
|
||||
// Setup service sender
|
||||
const serviceSender = await ServiceSender.load(receiver.id, sender);
|
||||
serviceSender.sendMessage(message);
|
||||
|
||||
// Track received messages
|
||||
const receivedMessages: Loaded<typeof EmptyMessage>[] = [];
|
||||
let senderAccountID: unknown = undefined;
|
||||
|
||||
// Subscribe to service messages
|
||||
const unsubscribe = receiverService.subscribe(
|
||||
EmptyMessage,
|
||||
async (message, id) => {
|
||||
senderAccountID = id;
|
||||
receivedMessages.push(message);
|
||||
},
|
||||
);
|
||||
|
||||
// Wait for message to be received
|
||||
await waitFor(() => receivedMessages.length === 1);
|
||||
|
||||
expect(receivedMessages.length).toBe(1);
|
||||
expect(receivedMessages[0]?.id).toBe(message.id);
|
||||
expect(senderAccountID).toBe(sender.id);
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it("should return the result of the message", async () => {
|
||||
const { clientAccount: sender, serverAccount: receiver } =
|
||||
await setupTwoNodes({
|
||||
ServerAccountSchema: zodSchemaToCoSchema(GenericWorkerAccount),
|
||||
});
|
||||
|
||||
const receiverService = await Service.load(receiver);
|
||||
|
||||
// Create a message from sender
|
||||
const message = Message.create(
|
||||
{ text: "Hello" },
|
||||
{
|
||||
owner: Group.create({ owner: sender }),
|
||||
},
|
||||
);
|
||||
|
||||
const unsubscribe = receiverService.subscribe(Message, async (message) => {
|
||||
return Message.create(
|
||||
{ text: "Responded from the service" },
|
||||
{ owner: message._owner },
|
||||
);
|
||||
});
|
||||
|
||||
// Setup service sender
|
||||
const serviceSender = await ServiceSender.load<
|
||||
Loaded<typeof Message>,
|
||||
Loaded<typeof Message>
|
||||
>(receiver.id, sender);
|
||||
const resultId = await serviceSender.sendMessage(message);
|
||||
|
||||
const result = await Message.load(resultId, { loadAs: receiver });
|
||||
expect(result?.text).toBe("Responded from the service");
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it("should return the undefined if the subscription returns undefined", async () => {
|
||||
const { clientAccount: sender, serverAccount: receiver } =
|
||||
await setupTwoNodes({
|
||||
ServerAccountSchema: zodSchemaToCoSchema(GenericWorkerAccount),
|
||||
});
|
||||
|
||||
const receiverService = await Service.load(receiver);
|
||||
|
||||
// Create a message from sender
|
||||
const message = Message.create(
|
||||
{ text: "Hello" },
|
||||
{
|
||||
owner: Group.create({ owner: sender }),
|
||||
},
|
||||
);
|
||||
|
||||
const unsubscribe = receiverService.subscribe(
|
||||
Message,
|
||||
async (message) => {},
|
||||
);
|
||||
|
||||
// Setup service sender
|
||||
const serviceSender = await ServiceSender.load<Loaded<typeof Message>>(
|
||||
receiver.id,
|
||||
sender,
|
||||
);
|
||||
const result = await serviceSender.sendMessage(message);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it("should reject if the subscription throws an error", async () => {
|
||||
const { clientAccount: sender, serverAccount: receiver } =
|
||||
await setupTwoNodes({
|
||||
ServerAccountSchema: zodSchemaToCoSchema(GenericWorkerAccount),
|
||||
});
|
||||
|
||||
const receiverService = await Service.load(receiver);
|
||||
|
||||
// Create a message from sender
|
||||
const message = Message.create(
|
||||
{ text: "Hello" },
|
||||
{
|
||||
owner: Group.create({ owner: sender }),
|
||||
},
|
||||
);
|
||||
|
||||
const errorLogSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
const unsubscribe = receiverService.subscribe(Message, async () => {
|
||||
return Promise.reject(new Error("Failed"));
|
||||
});
|
||||
|
||||
// Setup service sender
|
||||
const serviceSender = await ServiceSender.load<Loaded<typeof Message>>(
|
||||
receiver.id,
|
||||
sender,
|
||||
);
|
||||
|
||||
await expect(serviceSender.sendMessage(message)).rejects.toThrow(
|
||||
"Error: Failed",
|
||||
);
|
||||
|
||||
unsubscribe();
|
||||
|
||||
expect(errorLogSpy).toHaveBeenCalledWith(
|
||||
"Error processing service message",
|
||||
expect.any(Error),
|
||||
);
|
||||
|
||||
errorLogSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should mark messages as processed", async () => {
|
||||
const { clientAccount: sender, serverAccount: receiver } =
|
||||
await setupTwoNodes({
|
||||
ServerAccountSchema: zodSchemaToCoSchema(GenericWorkerAccount),
|
||||
});
|
||||
|
||||
const receiverService = await Service.load(receiver);
|
||||
|
||||
// Create a message from sender
|
||||
const message = Message.create(
|
||||
{ text: "Hello" },
|
||||
{
|
||||
owner: Group.create({ owner: sender }),
|
||||
},
|
||||
);
|
||||
|
||||
// Setup service sender
|
||||
const serviceSender = await ServiceSender.load(receiver.id, sender);
|
||||
serviceSender.sendMessage(message);
|
||||
|
||||
// Track received messages
|
||||
const receivedMessages: Loaded<typeof Message>[] = [];
|
||||
|
||||
// Subscribe to service messages
|
||||
const unsubscribe = receiverService.subscribe(Message, async (message) => {
|
||||
receivedMessages.push(message);
|
||||
});
|
||||
|
||||
// Wait for message to be received
|
||||
await waitFor(() => receivedMessages.length === 1);
|
||||
|
||||
serviceSender.sendMessage(message);
|
||||
|
||||
await waitFor(() => receivedMessages.length === 2);
|
||||
|
||||
expect(receivedMessages.length).toBe(2);
|
||||
expect(receivedMessages[0]?.text).toBe("Hello");
|
||||
expect(receivedMessages[1]?.text).toBe("Hello");
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it("should unsubscribe correctly", async () => {
|
||||
const { clientAccount: sender, serverAccount: receiver } =
|
||||
await setupTwoNodes({
|
||||
ServerAccountSchema: zodSchemaToCoSchema(GenericWorkerAccount),
|
||||
});
|
||||
|
||||
const receiverService = await Service.load(receiver);
|
||||
|
||||
// Create a message from sender
|
||||
const message = Message.create(
|
||||
{ text: "Hello" },
|
||||
{
|
||||
owner: Group.create({ owner: sender }),
|
||||
},
|
||||
);
|
||||
|
||||
// Setup service sender
|
||||
const serviceSender = await ServiceSender.load(receiver.id, sender);
|
||||
serviceSender.sendMessage(message);
|
||||
|
||||
// Track received messages
|
||||
const receivedMessages: Loaded<typeof Message>[] = [];
|
||||
|
||||
// Subscribe to service messages
|
||||
const unsubscribe = receiverService.subscribe(Message, async (message) => {
|
||||
receivedMessages.push(message);
|
||||
});
|
||||
|
||||
// Wait for message to be received
|
||||
await waitFor(() => receivedMessages.length === 1);
|
||||
|
||||
unsubscribe();
|
||||
|
||||
serviceSender.sendMessage(message);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
expect(receivedMessages.length).toBe(1);
|
||||
expect(receivedMessages[0]?.text).toBe("Hello");
|
||||
});
|
||||
|
||||
it("should retry failed messages", async () => {
|
||||
const { clientAccount: sender, serverAccount: receiver } =
|
||||
await setupTwoNodes({
|
||||
ServerAccountSchema: zodSchemaToCoSchema(GenericWorkerAccount),
|
||||
});
|
||||
|
||||
const receiverService = await Service.load(receiver);
|
||||
|
||||
// Create a message from sender
|
||||
const message = Message.create(
|
||||
{ text: "Hello" },
|
||||
{
|
||||
owner: Group.create({ owner: sender }),
|
||||
},
|
||||
);
|
||||
const errorLogSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
// Setup service sender
|
||||
const serviceSender = await ServiceSender.load(receiver.id, sender);
|
||||
const promise = serviceSender.sendMessage(message);
|
||||
|
||||
let failures = 0;
|
||||
|
||||
// Subscribe to service messages
|
||||
const unsubscribe = receiverService.subscribe(
|
||||
Message,
|
||||
async () => {
|
||||
failures++;
|
||||
throw new Error("Failed");
|
||||
},
|
||||
{ retries: 2 },
|
||||
);
|
||||
|
||||
await expect(promise).rejects.toThrow();
|
||||
expect(failures).toBe(3);
|
||||
const [failed] = Object.values(receiverService.failed.items).flat();
|
||||
expect(failed?.value.errors.length).toBe(3);
|
||||
unsubscribe();
|
||||
|
||||
expect(errorLogSpy).toHaveBeenCalledWith(
|
||||
"Error processing service message",
|
||||
expect.any(Error),
|
||||
);
|
||||
|
||||
errorLogSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should not break the subscription if the message is unavailable", async () => {
|
||||
const { clientAccount: sender, serverAccount: receiver } =
|
||||
await setupTwoNodes({
|
||||
ServerAccountSchema: zodSchemaToCoSchema(GenericWorkerAccount),
|
||||
});
|
||||
|
||||
const receiverService = await Service.load(receiver);
|
||||
|
||||
const serviceSender = await ServiceSender.load(receiver.id, sender);
|
||||
serviceSender.messages.push(`co_z123234` as any);
|
||||
|
||||
const spy = vi.fn();
|
||||
|
||||
const errorLogSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
// Subscribe to service messages
|
||||
const unsubscribe = receiverService.subscribe(
|
||||
Message,
|
||||
async () => {
|
||||
spy();
|
||||
},
|
||||
{ retries: 2 },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const [failed] = Object.values(receiverService.failed.items).flat();
|
||||
|
||||
expect(failed?.value.errors.length).toBe(3);
|
||||
});
|
||||
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
unsubscribe();
|
||||
|
||||
expect(errorLogSpy).toHaveBeenCalledWith(
|
||||
"Error processing service message",
|
||||
expect.any(Error),
|
||||
);
|
||||
|
||||
errorLogSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -4,9 +4,9 @@ import { Link, RouterProvider, createBrowserRouter } from "react-router-dom";
|
||||
import { AuthAndJazz } from "./jazz";
|
||||
import { ConcurrentChanges } from "./pages/ConcurrentChanges";
|
||||
import { FileStreamTest } from "./pages/FileStream";
|
||||
import { InboxPage } from "./pages/Inbox";
|
||||
import { ResumeSyncState } from "./pages/ResumeSyncState";
|
||||
import { RetryUnavailable } from "./pages/RetryUnavailable";
|
||||
import { ServicePage } from "./pages/Service";
|
||||
import { Sharing } from "./pages/Sharing";
|
||||
import { TestInput } from "./pages/TestInput";
|
||||
import { WriteOnlyRole } from "./pages/WriteOnly";
|
||||
@@ -36,7 +36,7 @@ function Index() {
|
||||
<Link to="/concurrent-changes">Concurrent Changes</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/inbox">Inbox</Link>
|
||||
<Link to="/service">Service</Link>
|
||||
</li>
|
||||
</ul>
|
||||
);
|
||||
@@ -68,8 +68,8 @@ const router = createBrowserRouter([
|
||||
element: <WriteOnlyRole />,
|
||||
},
|
||||
{
|
||||
path: "/inbox",
|
||||
element: <InboxPage />,
|
||||
path: "/service",
|
||||
element: <ServicePage />,
|
||||
},
|
||||
{
|
||||
path: "/concurrent-changes",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {
|
||||
useAccount,
|
||||
experimental_useInboxSender as useInboxSender,
|
||||
experimental_useServiceSender as useServiceSender,
|
||||
} from "jazz-react";
|
||||
import { Account, CoMap, Group, ID, Inbox, coField } from "jazz-tools";
|
||||
import { Account, CoMap, Group, ID, Service, co, coField } from "jazz-tools";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createCredentiallessIframe } from "../lib/createCredentiallessIframe";
|
||||
|
||||
@@ -16,7 +16,7 @@ function getIdParam() {
|
||||
return (url.searchParams.get("id") as ID<Account> | undefined) ?? undefined;
|
||||
}
|
||||
|
||||
export function InboxPage() {
|
||||
export function ServicePage() {
|
||||
const [id] = useState(getIdParam);
|
||||
const { me } = useAccount();
|
||||
const [pingPong, setPingPong] = useState<PingPong | null>(null);
|
||||
@@ -27,11 +27,14 @@ export function InboxPage() {
|
||||
let unmounted = false;
|
||||
|
||||
async function load() {
|
||||
const inbox = await Inbox.load(me);
|
||||
if (!me.service?.service) {
|
||||
me.service = co.service().create({}, me);
|
||||
}
|
||||
const service = await Service.load(me);
|
||||
|
||||
if (unmounted) return;
|
||||
|
||||
unsubscribe = inbox.subscribe(PingPong, async (message) => {
|
||||
unsubscribe = service.subscribe(PingPong, async (message) => {
|
||||
const pingPong = PingPong.create(
|
||||
{ ping: message.ping, pong: Date.now() },
|
||||
{ owner: message._owner },
|
||||
@@ -48,7 +51,7 @@ export function InboxPage() {
|
||||
};
|
||||
}, [me]);
|
||||
|
||||
const sendPingPong = useInboxSender(id);
|
||||
const sendPingPong = useServiceSender(id);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
@@ -82,7 +85,7 @@ export function InboxPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Inbox test</h1>
|
||||
<h1>Service test</h1>
|
||||
<button onClick={handlePingPong}>Start a ping-pong</button>
|
||||
{pingPong && (
|
||||
<div data-testid="ping-pong">
|
||||
@@ -1,8 +1,8 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe("Inbox - Sync", () => {
|
||||
test.describe("Service - Sync", () => {
|
||||
test("should pass the message between the two peers", async ({ page }) => {
|
||||
await page.goto("/inbox");
|
||||
await page.goto("/service");
|
||||
|
||||
await page.getByRole("button", { name: "Start a ping-pong" }).click();
|
||||
|
||||
Reference in New Issue
Block a user