Compare commits

...

24 Commits

Author SHA1 Message Date
Emil Sayahi
c8eb75dd1f test: missing service
todo:
- tests for worker accounts (`packages/jazz-nodejs/src/test/startWorker.test.ts`)
  - should include test ensuring a new worker account has service
- tests for `useServiceSender` (`packages/jazz-react-core/src/tests/useServiceSender.test.ts`)
- migration guide
- documentation
2025-06-09 16:34:27 -07:00
Emil Sayahi
61170900bd fix: Service E2E test 2025-06-06 15:24:07 -07:00
Emil Sayahi
4f2f889542 fix: service not created for all accounts
todo: test example apps
2025-06-06 14:48:14 -07:00
Emil Sayahi
719974c46c start making service optional
bug: not including it still makes the service. need to detect if it's present or not
2025-06-06 10:49:58 -07:00
Emil Sayahi
8845ee8601 chore: changeset 2025-06-05 20:47:04 -07:00
Emil Sayahi
42cc41d0cb feat: rename inbox to service
todo:
- review docs
- changeset
- mark ready for review
2025-06-05 20:39:09 -07:00
Emil Sayahi
7a25c49480 fix: group inheritance tests
todo:
- rename inbox
- update docs
- changeset
- mark ready for review
2025-06-05 19:50:26 -07:00
Emil Sayahi
aa202ad175 fix: 'authenticate to an existing account after immediately close the creation node' test 2025-06-05 19:43:21 -07:00
Emil Sayahi
1d3e5c688d fix: 'create a new account' test 2025-06-05 19:40:04 -07:00
Emil Sayahi
cbf7c2bbd8 fix: some sync tests
todo:
- fix more tests
- rename inbox
- update docs
- changeset
- mark ready for review
2025-06-05 19:14:22 -07:00
Emil Sayahi
e5ae4ffb18 remove console.logs
todo:
- fix test failures
- rename inbox
- update docs
- changeset
- mark ready for review
2025-06-05 18:30:11 -07:00
Emil Sayahi
b393c81ce5 fix: inbox visibility to senders
todo:
- remove console.logs
- rename inbox
- update docs
- changeset
- mark ready for review
2025-06-05 18:14:26 -07:00
Emil Sayahi
3c5d9d6f73 inbox.inbox not visible to others
the `inbox` key on raw account *is* set for other users, but the `inbox` property inside of it is missing … still investigating
2025-06-05 17:28:22 -07:00
Emil Sayahi
23115af9c3 fix: inbox key set for self with trusting
problem remains that `inbox` key on raw account is unset for other users

not sure why. maybe not loaded?
2025-06-05 17:09:03 -07:00
Emil Sayahi
cd55fa9fe2 Merge branch 'main' into emil/inbox-changes 2025-06-05 16:16:43 -07:00
Emil Sayahi
9336eaf123 Update account.ts 2025-06-05 16:16:35 -07:00
Emil Sayahi
a6caeea7ac trying to debug
problem is `inbox` key on raw account is unset for everyone (if `trusting`) or is unset for other users (if `private`)

still not certain why
2025-06-05 16:16:18 -07:00
Emil Sayahi
a8f1e6507a Merge branch 'main' into emil/inbox-changes 2025-06-05 11:16:18 -07:00
Emil Sayahi
e2b6ca6f69 fix: merge issue 2025-06-03 14:23:10 -07:00
Emil Sayahi
79dd8bf805 Merge branch 'main' into emil/inbox-changes 2025-06-03 14:16:37 -07:00
Emil Sayahi
da0adbbe9f inbox in RawAccount 2025-06-03 14:16:31 -07:00
Emil Sayahi
2164e6eb36 wip: remove inbox invitations 2025-06-03 13:17:17 -07:00
Emil Sayahi
a976c16b7c Merge branch 'main' into emil/inbox-changes 2025-06-03 09:36:53 -07:00
Emil Sayahi
6f2b79e79a feat: move inbox refs from Profile to Account
see #1297
2025-06-03 07:44:34 -07:00
41 changed files with 970 additions and 652 deletions

View File

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

View File

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

View File

@@ -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: {} },

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -28,5 +28,5 @@ export const features = {
coRichText: "CoRichText",
coPlainText: "CoPlainText",
serverWorker: "Server worker",
inbox: "Inbox",
service: "Service",
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,7 +6,7 @@ import { Linking } from "react-native";
export {
useCoState,
experimental_useInboxSender,
experimental_useServiceSender,
useDemoAuth,
usePassphraseAuth,
useJazzContext,

View File

@@ -47,7 +47,7 @@ export function useAcceptInvite<S extends CoValueOrZodSchema>({
}
export {
experimental_useInboxSender,
experimental_useServiceSender,
useJazzContext,
useAccount,
useAccountOrGuest,

View File

@@ -5,7 +5,7 @@ export {
useAccountOrGuest,
useCoState,
useAcceptInvite,
experimental_useInboxSender,
experimental_useServiceSender,
useJazzContext,
useAuthSecretStorage,
} from "./hooks.js";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,4 +10,5 @@ export {
coImageDefiner as image,
coAccountDefiner as account,
coProfileDefiner as profile,
coServiceDefiner as service,
} from "./zodCo.js";

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,6 +15,7 @@ const WorkerAccount = co
.account({
root: WorkerRoot,
profile: co.profile(),
service: co.service(),
})
.withMigration((account) => {
if (account.root === undefined) {

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

View File

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

View File

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

View File

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