Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions apps/events/src/components/debug-space-events.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { SpaceEvent } from 'graph-framework';

export function DebugSpaceEvents({ events }: { events: SpaceEvent[] }) {
return (
<ul className="text-xs">
{events.map((event) => {
return (
<li key={event.transaction.id} className="border border-gray-300">
<pre>{JSON.stringify(event, null, 2)}</pre>
</li>
);
})}
</ul>
);
}
9 changes: 9 additions & 0 deletions apps/events/src/components/debug-space-state.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { SpaceState } from 'graph-framework';

export function DebugSpaceState(props: { state: SpaceState | undefined }) {
return (
<div className="text-xs">
<pre>{JSON.stringify(props, null, 2)}</pre>
</div>
);
}
160 changes: 136 additions & 24 deletions apps/events/src/routes/playground.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,42 @@
import { DebugSpaceEvents } from '@/components/debug-space-events';
import { DebugSpaceState } from '@/components/debug-space-state';
import { Button } from '@/components/ui/button';
import { assertExhaustive } from '@/lib/assertExhaustive';
import { createFileRoute } from '@tanstack/react-router';
import { Effect } from 'effect';
import { Effect, Exit } from 'effect';
import * as Schema from 'effect/Schema';
import type { EventMessage, RequestListSpaces, RequestSubscribeToSpace } from 'graph-framework';
import { ResponseMessage, createSpace } from 'graph-framework';
import type {
EventMessage,
RequestListInvitations,
RequestListSpaces,
RequestSubscribeToSpace,
SpaceEvent,
SpaceState,
} from 'graph-framework';
import { ResponseMessage, applyEvent, createInvitation, createSpace } from 'graph-framework';
import { useEffect, useState } from 'react';

const availableAccounts = [
{
accountId: '0262701b2eb1b6b37ad03e24445dfcad1b91309199e43017b657ce2604417c12f5',
signaturePrivateKey: '88bb6f20de8dc1787c722dc847f4cf3d00285b8955445f23c483d1237fe85366',
},
{
accountId: '03bf5d2a1badf15387b08a007d1a9a13a9bfd6e1c56f681e251514d9ba10b57462',
signaturePrivateKey: '1eee32d3bc202dcb5d17c3b1454fb541d2290cb941860735408f1bfe39e7bc15',
},
{
accountId: '0351460706cf386282d9b6ebee2ccdcb9ba61194fd024345e53037f3036242e6a2',
signaturePrivateKey: '434518a2c9a665a7c20da086232c818b6c1592e2edfeecab29a40cf5925ca8fe',
},
];

type SpaceStorageEntry = {
id: string;
events: SpaceEvent[];
state: SpaceState | undefined;
};

const decodeResponseMessage = Schema.decodeUnknownEither(ResponseMessage);

export const Route = createFileRoute('/playground')({
Expand All @@ -15,32 +45,73 @@ export const Route = createFileRoute('/playground')({

const App = ({ accountId, signaturePrivateKey }: { accountId: string; signaturePrivateKey: string }) => {
const [websocketConnection, setWebsocketConnection] = useState<WebSocket>();
const [spaces, setSpaces] = useState<{ id: string }[]>([]);
const [spaces, setSpaces] = useState<SpaceStorageEntry[]>([]);

useEffect(() => {
// temporary until we have a way to create accounts and authenticate them
const websocketConnection = new WebSocket(`ws://localhost:3030/?accountId=${accountId}`);
setWebsocketConnection(websocketConnection);

const onMessage = (event: MessageEvent) => {
const onMessage = async (event: MessageEvent) => {
console.log('message received', event.data);
const data = JSON.parse(event.data);
const message = decodeResponseMessage(data);
if (message._tag === 'Right') {
const response = message.right;
switch (response.type) {
case 'list-spaces': {
setSpaces(response.spaces.map((space) => ({ id: space.id })));
setSpaces((existingSpaces) => {
return response.spaces.map((space) => {
const existingSpace = existingSpaces.find((s) => s.id === space.id);
return { id: space.id, events: existingSpace?.events ?? [], state: existingSpace?.state };
});
});
// fetch all spaces (for debugging purposes)
for (const space of response.spaces) {
const message: RequestSubscribeToSpace = { type: 'subscribe-space', id: space.id };
websocketConnection?.send(JSON.stringify(message));
}
break;
}
case 'space': {
console.log('space', response);
let state: SpaceState | undefined = undefined;

// TODO fix typing
for (const event of response.events) {
if (state === undefined) {
const applyEventResult = await Effect.runPromiseExit(applyEvent({ event }));
if (Exit.isSuccess(applyEventResult)) {
state = applyEventResult.value;
}
} else {
const applyEventResult = await Effect.runPromiseExit(applyEvent({ event, state }));
if (Exit.isSuccess(applyEventResult)) {
state = applyEventResult.value;
}
}
}

const newState = state as SpaceState;

setSpaces((spaces) =>
spaces.map((space) => {
if (space.id === response.id) {
// TODO fix readonly type issue
return { ...space, events: response.events as SpaceEvent[], state: newState };
}
return space;
}),
);
break;
}
case 'event': {
console.log('event', response);
break;
}
case 'list-invitations': {
console.log('list-invitations', response);
break;
}
default:
assertExhaustive(response);
}
Expand Down Expand Up @@ -86,7 +157,7 @@ const App = ({ accountId, signaturePrivateKey }: { accountId: string; signatureP
},
}),
);
const message: EventMessage = { type: 'event', event: spaceEvent };
const message: EventMessage = { type: 'event', event: spaceEvent, spaceId: spaceEvent.transaction.id };
websocketConnection?.send(JSON.stringify(message));
}}
>
Expand All @@ -101,13 +172,22 @@ const App = ({ accountId, signaturePrivateKey }: { accountId: string; signatureP
>
List Spaces
</Button>

<Button
onClick={() => {
const message: RequestListInvitations = { type: 'list-invitations' };
websocketConnection?.send(JSON.stringify(message));
}}
>
List Invitations
</Button>
</div>
<h2>Spaces</h2>
<ul>
{spaces.map((space) => {
return (
<li key={space.id}>
<h3>{space.id}</h3>
<h3>Space id: {space.id}</h3>
<Button
onClick={() => {
const message: RequestSubscribeToSpace = { type: 'subscribe-space', id: space.id };
Expand All @@ -116,6 +196,47 @@ const App = ({ accountId, signaturePrivateKey }: { accountId: string; signatureP
>
Get data and subscribe to Space
</Button>
<br />
{availableAccounts.map((invitee) => {
return (
<Button
key={invitee.accountId}
onClick={async () => {
if (!space.state) {
console.error('No state found for space');
return;
}
const spaceEvent = await Effect.runPromiseExit(
createInvitation({
author: {
signaturePublicKey: accountId,
encryptionPublicKey: 'TODO',
signaturePrivateKey,
},
previousEventHash: space.state.lastEventHash,
invitee: {
signaturePublicKey: invitee.accountId,
encryptionPublicKey: 'TODO',
},
}),
);
if (Exit.isFailure(spaceEvent)) {
console.error('Failed to create invitation', spaceEvent);
return;
}
const message: EventMessage = { type: 'event', event: spaceEvent.value, spaceId: space.id };
websocketConnection?.send(JSON.stringify(message));
}}
>
Invite {invitee.accountId.substring(0, 4)}
</Button>
);
})}
<h3>State</h3>
<DebugSpaceState state={space.state} />
<h3>Events</h3>
<DebugSpaceEvents events={space.events} />
<hr />
</li>
);
})}
Expand All @@ -132,33 +253,24 @@ export const ChooseAccount = () => {
<h1>Choose account</h1>
<Button
onClick={() => {
setAccount({
accountId: '0262701b2eb1b6b37ad03e24445dfcad1b91309199e43017b657ce2604417c12f5',
signaturePrivateKey: '88bb6f20de8dc1787c722dc847f4cf3d00285b8955445f23c483d1237fe85366',
});
setAccount(availableAccounts[0]);
}}
>
`abc`
{availableAccounts[0].accountId.substring(0, 4)}
</Button>
<Button
onClick={() => {
setAccount({
accountId: '03bf5d2a1badf15387b08a007d1a9a13a9bfd6e1c56f681e251514d9ba10b57462',
signaturePrivateKey: '1eee32d3bc202dcb5d17c3b1454fb541d2290cb941860735408f1bfe39e7bc15',
});
setAccount(availableAccounts[1]);
}}
>
`cde`
{availableAccounts[1].accountId.substring(0, 4)}
</Button>
<Button
onClick={() => {
setAccount({
accountId: '0351460706cf386282d9b6ebee2ccdcb9ba61194fd024345e53037f3036242e6a2',
signaturePrivateKey: '434518a2c9a665a7c20da086232c818b6c1592e2edfeecab29a40cf5925ca8fe',
});
setAccount(availableAccounts[2]);
}}
>
`def`
{availableAccounts[2].accountId.substring(0, 4)}
</Button>
Account: {account?.accountId ? account.accountId : 'none'}
<hr />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
-- CreateTable
CREATE TABLE "Invitation" (
"id" TEXT NOT NULL PRIMARY KEY,
"spaceId" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Invitation_spaceId_fkey" FOREIGN KEY ("spaceId") REFERENCES "Space" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "Invitation_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
21 changes: 16 additions & 5 deletions apps/server/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,23 @@ model SpaceEvent {
}

model Space {
id String @id
events SpaceEvent[]
members Account[]
id String @id
events SpaceEvent[]
members Account[]
invitations Invitation[]
}

model Account {
id String @id
spaces Space[]
id String @id
spaces Space[]
invitations Invitation[]
}

model Invitation {
id String @id
space Space @relation(fields: [spaceId], references: [id])
spaceId String
account Account @relation(fields: [accountId], references: [id])
accountId String
createdAt DateTime @default(now())
}
21 changes: 16 additions & 5 deletions apps/server/src/handlers/applySpaceEvent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@ type Params = {
};

export async function applySpaceEvent({ accountId, spaceId, event }: Params) {
return await prisma.$transaction(async (transaction) => {
if (event.transaction.type === 'create-space') {
throw new Error('applySpaceEvent does not support create-space events.');
}
if (event.transaction.type === 'create-space') {
throw new Error('applySpaceEvent does not support create-space events.');
}

return await prisma.$transaction(async (transaction) => {
// verify that the account is a member of the space
// TODO verify that the account is a admin of the space
await transaction.space.findUniqueOrThrow({
Expand All @@ -26,11 +26,22 @@ export async function applySpaceEvent({ accountId, spaceId, event }: Params) {
orderBy: { counter: 'desc' },
});

const result = await Effect.runPromiseExit(applyEvent({ event }));
const result = await Effect.runPromiseExit(applyEvent({ event, state: JSON.parse(lastEvent.state) }));
if (Exit.isFailure(result)) {
console.log('Failed to apply event', result);
throw new Error('Invalid event');
}

if (event.transaction.type === 'create-invitation') {
await transaction.invitation.create({
data: {
id: event.transaction.id,
spaceId,
accountId: event.transaction.signaturePublicKey,
},
});
}

return await transaction.spaceEvent.create({
data: {
spaceId,
Expand Down
35 changes: 35 additions & 0 deletions apps/server/src/handlers/listInvitations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { SpaceState } from 'graph-framework-space-events';
import { prisma } from '../prisma.js';

type Params = {
accountId: string;
};

export const listInvitations = async ({ accountId }: Params) => {
const result = await prisma.invitation.findMany({
where: {
accountId,
},
include: {
space: {
include: {
events: {
orderBy: {
counter: 'asc',
},
take: 1,
},
},
},
},
});

return result.map((invitation) => {
const state = JSON.parse(invitation.space.events[0].state) as SpaceState;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just curious, could we use the effect Schema handling to encode/decode and validate the space object instead of type-casting? (I am learning effect more and more, so could be wrong here, but seems safer?)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good catch! yeah, when I wrote the code I thought: this always only can be a valid state. But you are right! better to be safe than sorry :)

return {
id: invitation.id,
previousEventHash: state.lastEventHash,
spaceId: invitation.spaceId,
};
});
};
Loading
Loading