Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
45 changes: 45 additions & 0 deletions apps/server/src/handlers/listInvitations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { Schema } from 'effect';
import { SpaceState } from 'graph-framework-space-events';
import { prisma } from '../prisma.js';

type Params = {
accountId: string;
};

const decodeSpaceState = Schema.decodeUnknownEither(SpaceState);

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 result = decodeSpaceState(JSON.parse(invitation.space.events[0].state));
if (result._tag === 'Right') {
const state = result.right;
return {
id: invitation.id,
previousEventHash: state.lastEventHash,
spaceId: invitation.spaceId,
};
}
console.error('Invalid space state from the DB', result.left);
return null;
})
.filter((invitation) => invitation !== null);
};
Loading
Loading