Skip to content

Commit a550cca

Browse files
authored
Merge pull request #37 from geobrowser/events-order
Event order
2 parents 1fbec34 + 19b15fe commit a550cca

File tree

18 files changed

+239
-59
lines changed

18 files changed

+239
-59
lines changed
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/*
2+
Warnings:
3+
4+
- Added the required column `counter` to the `SpaceEvent` table without a default value. This is not possible if the table is not empty.
5+
- Added the required column `state` to the `SpaceEvent` table without a default value. This is not possible if the table is not empty.
6+
7+
*/
8+
-- RedefineTables
9+
PRAGMA defer_foreign_keys=ON;
10+
PRAGMA foreign_keys=OFF;
11+
CREATE TABLE "new_SpaceEvent" (
12+
"id" TEXT NOT NULL PRIMARY KEY,
13+
"event" TEXT NOT NULL,
14+
"state" TEXT NOT NULL,
15+
"counter" INTEGER NOT NULL,
16+
"spaceId" TEXT NOT NULL,
17+
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
18+
CONSTRAINT "SpaceEvent_spaceId_fkey" FOREIGN KEY ("spaceId") REFERENCES "Space" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
19+
);
20+
INSERT INTO "new_SpaceEvent" ("event", "id", "spaceId") SELECT "event", "id", "spaceId" FROM "SpaceEvent";
21+
DROP TABLE "SpaceEvent";
22+
ALTER TABLE "new_SpaceEvent" RENAME TO "SpaceEvent";
23+
CREATE UNIQUE INDEX "SpaceEvent_spaceId_counter_key" ON "SpaceEvent"("spaceId", "counter");
24+
PRAGMA foreign_keys=ON;
25+
PRAGMA defer_foreign_keys=OFF;

apps/server/prisma/schema.prisma

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,15 @@ datasource db {
1111
}
1212

1313
model SpaceEvent {
14-
id String @id
15-
event String
16-
space Space @relation(fields: [spaceId], references: [id])
17-
spaceId String
14+
id String @id
15+
event String
16+
state String
17+
counter Int
18+
space Space @relation(fields: [spaceId], references: [id])
19+
spaceId String
20+
createdAt DateTime @default(now())
21+
22+
@@unique([spaceId, counter])
1823
}
1924

2025
model Space {
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { Effect, Exit } from 'effect';
2+
import type { SpaceEvent } from 'graph-framework-space-events';
3+
import { applyEvent } from 'graph-framework-space-events';
4+
import { prisma } from '../prisma.js';
5+
6+
type Params = {
7+
accountId: string;
8+
spaceId: string;
9+
event: SpaceEvent;
10+
};
11+
12+
export async function applySpaceEvent({ accountId, spaceId, event }: Params) {
13+
return await prisma.$transaction(async (transaction) => {
14+
if (event.transaction.type === 'create-space') {
15+
throw new Error('applySpaceEvent does not support create-space events.');
16+
}
17+
18+
// verify that the account is a member of the space
19+
// TODO verify that the account is a admin of the space
20+
await transaction.space.findUniqueOrThrow({
21+
where: { id: spaceId, members: { some: { id: accountId } } },
22+
});
23+
24+
const lastEvent = await transaction.spaceEvent.findFirstOrThrow({
25+
where: { spaceId },
26+
orderBy: { counter: 'desc' },
27+
});
28+
29+
const result = await Effect.runPromiseExit(applyEvent({ event }));
30+
if (Exit.isFailure(result)) {
31+
throw new Error('Invalid event');
32+
}
33+
34+
return await transaction.spaceEvent.create({
35+
data: {
36+
spaceId,
37+
counter: lastEvent.counter + 1,
38+
event: JSON.stringify(event),
39+
id: event.transaction.id,
40+
state: JSON.stringify(result.value),
41+
},
42+
});
43+
});
44+
}

apps/server/src/handlers/createSpace.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import type { CreateSpaceEvent } from 'graph-framework-space-events';
1+
import { Effect, Exit } from 'effect';
2+
import { type CreateSpaceEvent, applyEvent } from 'graph-framework-space-events';
23
import { prisma } from '../prisma.js';
34

45
type Params = {
@@ -7,10 +8,17 @@ type Params = {
78
};
89

910
export const createSpace = async ({ accountId, event }: Params) => {
11+
const result = await Effect.runPromiseExit(applyEvent({ event }));
12+
if (Exit.isFailure(result)) {
13+
throw new Error('Invalid event');
14+
}
15+
1016
return await prisma.spaceEvent.create({
1117
data: {
1218
event: JSON.stringify(event),
1319
id: event.transaction.id,
20+
counter: 0,
21+
state: JSON.stringify(result.value),
1422
space: {
1523
create: {
1624
id: event.transaction.id,

apps/server/src/handlers/getSpace.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,11 @@ export const getSpace = async ({ spaceId, accountId }: Params) => {
1616
},
1717
},
1818
include: {
19-
events: true,
19+
events: {
20+
orderBy: {
21+
counter: 'asc',
22+
},
23+
},
2024
},
2125
});
2226
};

packages/graph-framework-space-events/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
},
2727
"dependencies": {
2828
"@noble/curves": "^1.6.0",
29+
"@noble/hashes": "^1.5.0",
2930
"graph-framework-utils": "workspace:*",
3031
"uuid": "^11.0.2"
3132
}

packages/graph-framework-space-events/src/apply-event.test.ts

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@ import { Cause, Effect, Exit } from 'effect';
33
import { canonicalize, stringToUint8Array } from 'graph-framework-utils';
44
import { expect, it } from 'vitest';
55
import { applyEvent } from './apply-event.js';
6+
import { createInvitation } from './create-invitation.js';
67
import { createSpace } from './create-space.js';
7-
import { VerifySignatureError } from './types.js';
8+
import { InvalidEventError, VerifySignatureError } from './types.js';
89

910
it('should fail in case of an invalid signature', async () => {
1011
const author = {
@@ -34,3 +35,58 @@ it('should fail in case of an invalid signature', async () => {
3435
}
3536
}
3637
});
38+
39+
it('should fail in case state is not provided for an event other than createSpace', async () => {
40+
const author = {
41+
signaturePublicKey: '03594161eed61407084114a142d1ce05ef4c5a5279479fdd73a2b16944fbff003b',
42+
signaturePrivateKey: '76b78f644c19d6133018a97a3bc2d5038be0af5a2858b9e640ff3e2f2db63a0b',
43+
encryptionPublicKey: 'encryption',
44+
};
45+
46+
const result = await Effect.runPromiseExit(
47+
Effect.gen(function* () {
48+
const spaceEvent = yield* createSpace({ author });
49+
const state = yield* applyEvent({ event: spaceEvent });
50+
51+
const spaceEvent2 = yield* createInvitation({ author, id: state.id, previousEventHash: state.lastEventHash });
52+
return yield* applyEvent({ event: spaceEvent2 });
53+
}),
54+
);
55+
56+
expect(Exit.isFailure(result)).toBe(true);
57+
if (Exit.isFailure(result)) {
58+
const cause = result.cause;
59+
if (Cause.isFailType(cause)) {
60+
expect(cause.error).toBeInstanceOf(InvalidEventError);
61+
}
62+
}
63+
});
64+
65+
it('should fail in case of an event is applied that is not based on the previous event', async () => {
66+
const author = {
67+
signaturePublicKey: '03594161eed61407084114a142d1ce05ef4c5a5279479fdd73a2b16944fbff003b',
68+
signaturePrivateKey: '76b78f644c19d6133018a97a3bc2d5038be0af5a2858b9e640ff3e2f2db63a0b',
69+
encryptionPublicKey: 'encryption',
70+
};
71+
72+
const result = await Effect.runPromiseExit(
73+
Effect.gen(function* () {
74+
const spaceEvent = yield* createSpace({ author });
75+
const state = yield* applyEvent({ event: spaceEvent });
76+
77+
const spaceEvent2 = yield* createSpace({ author });
78+
const state2 = yield* applyEvent({ state, event: spaceEvent2 });
79+
80+
const spaceEvent3 = yield* createInvitation({ author, id: state.id, previousEventHash: state.lastEventHash });
81+
return yield* applyEvent({ state: state2, event: spaceEvent3 });
82+
}),
83+
);
84+
85+
expect(Exit.isFailure(result)).toBe(true);
86+
if (Exit.isFailure(result)) {
87+
const cause = result.cause;
88+
if (Cause.isFailType(cause)) {
89+
expect(cause.error).toBeInstanceOf(InvalidEventError);
90+
}
91+
}
92+
});

packages/graph-framework-space-events/src/apply-event.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,15 @@ import { secp256k1 } from '@noble/curves/secp256k1';
22
import { Effect, Schema } from 'effect';
33
import type { ParseError } from 'effect/ParseResult';
44
import { canonicalize, stringToUint8Array } from 'graph-framework-utils';
5-
import type { SpaceInvitation, SpaceMember, SpaceState } from './types.js';
6-
import { SpaceEvent, VerifySignatureError } from './types.js';
5+
import { hashEvent } from './hash-event.js';
6+
import {
7+
InvalidEventError,
8+
SpaceEvent,
9+
type SpaceInvitation,
10+
type SpaceMember,
11+
type SpaceState,
12+
VerifySignatureError,
13+
} from './types.js';
714

815
type Params = {
916
state?: SpaceState;
@@ -15,13 +22,22 @@ const decodeSpaceEvent = Schema.decodeUnknownEither(SpaceEvent);
1522
export const applyEvent = ({
1623
state,
1724
event: rawEvent,
18-
}: Params): Effect.Effect<SpaceState, ParseError | VerifySignatureError> => {
25+
}: Params): Effect.Effect<SpaceState, ParseError | VerifySignatureError | InvalidEventError> => {
1926
const decodedEvent = decodeSpaceEvent(rawEvent);
2027
if (decodedEvent._tag === 'Left') {
2128
return decodedEvent.left;
2229
}
2330
const event = decodedEvent.right;
2431

32+
if (event.transaction.type !== 'create-space') {
33+
if (state === undefined) {
34+
return Effect.fail(new InvalidEventError());
35+
}
36+
if (event.transaction.previousEventHash !== state.lastEventHash) {
37+
return Effect.fail(new InvalidEventError());
38+
}
39+
}
40+
2541
const encodedTransaction = stringToUint8Array(canonicalize(event.transaction));
2642
const isValidSignature = secp256k1.verify(event.author.signature, encodedTransaction, event.author.publicKey, {
2743
prehash: true,
@@ -68,6 +84,6 @@ export const applyEvent = ({
6884
members,
6985
removedMembers,
7086
invitations,
71-
transactionHash: '', // TODO
87+
lastEventHash: hashEvent(event),
7288
});
7389
};

packages/graph-framework-space-events/src/create-invitation.test.ts

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -16,30 +16,29 @@ it('should create an invitation', async () => {
1616
Effect.gen(function* () {
1717
const spaceEvent = yield* createSpace({ author });
1818
const state = yield* applyEvent({ event: spaceEvent });
19-
const spaceEvent2 = yield* createInvitation({ author, id: state.id });
19+
const spaceEvent2 = yield* createInvitation({ author, id: state.id, previousEventHash: state.lastEventHash });
2020
const state2 = yield* applyEvent({ state, event: spaceEvent2 });
2121
return {
2222
state2,
2323
spaceEvent2,
2424
};
2525
}),
2626
);
27-
expect(state2).toEqual({
28-
id: state2.id,
29-
members: {
30-
[author.signaturePublicKey]: {
31-
signaturePublicKey: author.signaturePublicKey,
32-
encryptionPublicKey: author.encryptionPublicKey,
33-
role: 'admin',
34-
},
27+
28+
expect(state2.id).toBeTypeOf('string');
29+
expect(state2.invitations).toEqual({
30+
[spaceEvent2.transaction.id]: {
31+
signaturePublicKey: '',
32+
encryptionPublicKey: '',
3533
},
36-
removedMembers: {},
37-
invitations: {
38-
[spaceEvent2.transaction.id]: {
39-
signaturePublicKey: '',
40-
encryptionPublicKey: '',
41-
},
34+
});
35+
expect(state2.members).toEqual({
36+
[author.signaturePublicKey]: {
37+
signaturePublicKey: author.signaturePublicKey,
38+
encryptionPublicKey: author.encryptionPublicKey,
39+
role: 'admin',
4240
},
43-
transactionHash: '',
4441
});
42+
expect(state2.removedMembers).toEqual({});
43+
expect(state2.lastEventHash).toBeTypeOf('string');
4544
});

packages/graph-framework-space-events/src/create-invitation.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,18 @@ import type { Author, SpaceEvent } from './types.js';
66
type Params = {
77
author: Author;
88
id: string;
9+
previousEventHash: string;
910
};
1011

11-
export const createInvitation = ({ author, id }: Params): Effect.Effect<SpaceEvent, undefined> => {
12+
export const createInvitation = ({ author, id, previousEventHash }: Params): Effect.Effect<SpaceEvent, undefined> => {
1213
const transaction = {
1314
type: 'create-invitation' as const,
1415
id,
1516
ciphertext: '',
1617
nonce: '',
1718
signaturePublicKey: '',
1819
encryptionPublicKey: '',
20+
previousEventHash,
1921
};
2022
const encodedTransaction = stringToUint8Array(canonicalize(transaction));
2123
const signature = secp256k1.sign(encodedTransaction, author.signaturePrivateKey, { prehash: true }).toCompactHex();

0 commit comments

Comments
 (0)