Skip to content

Commit d1955c2

Browse files
authored
Merge pull request #68 from geobrowser/automerge-documents
integrate automerge document per space
2 parents 3e2679f + 8b12637 commit d1955c2

File tree

10 files changed

+187
-106
lines changed

10 files changed

+187
-106
lines changed

apps/events/src/routes/playground.tsx

Lines changed: 6 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -45,20 +45,11 @@ const App = ({
4545
encryptionPrivateKey: string;
4646
encryptionPublicKey: string;
4747
}) => {
48-
const storeState = useSelector(store, (state) => state.context);
49-
const spaces = storeState.spaces;
50-
const updatesInFlight = storeState.updatesInFlight;
51-
const {
52-
createSpace,
53-
listSpaces,
54-
listInvitations,
55-
invitations,
56-
acceptInvitation,
57-
subscribeToSpace,
58-
inviteToSpace,
59-
repo,
60-
automergeHandle,
61-
} = useGraphFramework();
48+
const repo = useSelector(store, (state) => state.context.repo);
49+
const spaces = useSelector(store, (state) => state.context.spaces);
50+
const updatesInFlight = useSelector(store, (state) => state.context.updatesInFlight);
51+
const { createSpace, listSpaces, listInvitations, invitations, acceptInvitation, subscribeToSpace, inviteToSpace } =
52+
useGraphFramework();
6253

6354
return (
6455
<>
@@ -131,7 +122,7 @@ const App = ({
131122
})}
132123
<h3>Updates</h3>
133124
<RepoContext.Provider value={repo}>
134-
<AutomergeApp url={automergeHandle.url} />
125+
{space.automergeDocHandle && <AutomergeApp url={space.automergeDocHandle.url} />}
135126
</RepoContext.Provider>
136127
<h3>Last update clock: {space.lastUpdateClock}</h3>
137128
<h3>Updates in flight</h3>

packages/graph-framework-utils/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,14 @@
2828
"check:fix": "pnpm biome check --write src/*"
2929
},
3030
"devDependencies": {
31+
"@automerge/automerge-repo": "^1.2.1",
3132
"@types/uuid": "^10.0.0",
3233
"uuid": "^11.0.3"
3334
},
3435
"peerDependencies": {
3536
"uuid": "^11"
37+
},
38+
"dependencies": {
39+
"bs58check": "^4.0.0"
3640
}
3741
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import bs58check from 'bs58check';
2+
3+
import { decodeBase58, encodeBase58 } from './internal/base58Utils.js';
4+
5+
/**
6+
* Converts a raw Base58-encoded UUID into Base58Check
7+
*/
8+
export function idToAutomergeId(rawBase58Uuid: string, _versionByte = 0x00) {
9+
const payload = decodeBase58(rawBase58Uuid);
10+
return bs58check.encode(payload);
11+
}
12+
13+
/**
14+
* Converts a Base58Check-encoded UUID back to raw Base58
15+
*/
16+
export function automergeIdToId(base58CheckUuid: string) {
17+
const versionedPayload = bs58check.decode(base58CheckUuid);
18+
return encodeBase58(versionedPayload);
19+
}

packages/graph-framework-utils/src/base58.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
const BASE58_ALLOWED_CHARS = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
1+
import { BASE58_ALLOWED_CHARS } from './internal/base58Utils.js';
22

33
export type Base58 = string;
44

packages/graph-framework-utils/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export * from './automergeId.js';
12
export * from './base58.js';
23
export * from './generateId.js';
34
export * from './jsc.js';
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
export const BASE58_ALLOWED_CHARS = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
2+
3+
export function decodeBase58(str: string) {
4+
let x = BigInt(0);
5+
for (let i = 0; i < str.length; i++) {
6+
const charIndex = BASE58_ALLOWED_CHARS.indexOf(str[i]);
7+
if (charIndex < 0) {
8+
throw new Error('Invalid Base58 character');
9+
}
10+
x = x * 58n + BigInt(charIndex);
11+
}
12+
13+
const bytes: number[] = [];
14+
while (x > 0) {
15+
bytes.push(Number(x % 256n));
16+
x = x >> 8n;
17+
}
18+
19+
bytes.reverse();
20+
// Pad to 16 bytes for a UUID
21+
while (bytes.length < 16) {
22+
bytes.unshift(0);
23+
}
24+
25+
return new Uint8Array(bytes);
26+
}
27+
28+
export function encodeBase58(data: Uint8Array) {
29+
let x = BigInt(0);
30+
for (const byte of data) {
31+
x = (x << 8n) + BigInt(byte);
32+
}
33+
34+
let encoded = '';
35+
while (x > 0) {
36+
const remainder = x % 58n;
37+
x = x / 58n;
38+
encoded = BASE58_ALLOWED_CHARS[Number(remainder)] + encoded;
39+
}
40+
41+
// deal with leading zeros (0x00 bytes)
42+
for (let i = 0; i < data.length && data[i] === 0; i++) {
43+
encoded = `1${encoded}`;
44+
}
45+
46+
return encoded;
47+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { isValidDocumentId } from '@automerge/automerge-repo';
2+
import { describe, expect, it } from 'vitest';
3+
4+
import { automergeIdToId, idToAutomergeId } from '../src/automergeId';
5+
import { generateId } from '../src/generateId';
6+
7+
describe('id <> automergeId conversion', () => {
8+
it('converts an id to an automergeId and back', () => {
9+
const id = generateId();
10+
const automergeId = idToAutomergeId(id);
11+
const id2 = automergeIdToId(automergeId);
12+
expect(id).toBe(id2);
13+
expect(isValidDocumentId(automergeId)).toBe(true);
14+
});
15+
16+
it('throws an error for invalid Base58 characters', () => {
17+
expect(() => idToAutomergeId('!@#$%^&*()')).toThrowError();
18+
});
19+
20+
it('throws an error for invalid Base58Check strings', () => {
21+
expect(() => automergeIdToId('11111111111111111111111111111111')).toThrowError();
22+
});
23+
});

packages/graph-framework/src/core.tsx

Lines changed: 41 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import * as automerge from '@automerge/automerge';
22
import { uuid } from '@automerge/automerge';
3-
import { type AutomergeUrl, type DocHandle, Repo } from '@automerge/automerge-repo';
43
import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
54
import { useSelector as useSelectorStore } from '@xstate/store/react';
65
import { Effect, Exit } from 'effect';
@@ -28,8 +27,6 @@ import { assertExhaustive } from './assertExhaustive.js';
2827
import type { SpaceStorageEntry } from './store.js';
2928
import { store } from './store.js';
3029

31-
const hardcodedUrl = 'automerge:2JWupfYZBBm7s2NCy1VnvQa4Vdvf' as AutomergeUrl;
32-
3330
const decodeResponseMessage = Schema.decodeUnknownEither(ResponseMessage);
3431

3532
type Props = {
@@ -63,8 +60,6 @@ const GraphFrameworkContext = createContext<{
6360
encryptionPublicKey: string;
6461
};
6562
}) => Promise<unknown>;
66-
repo: Repo;
67-
automergeHandle: DocHandle<unknown>;
6863
}>({
6964
invitations: [],
7065
createSpace: async () => {},
@@ -73,59 +68,16 @@ const GraphFrameworkContext = createContext<{
7368
acceptInvitation: async () => {},
7469
subscribeToSpace: () => {},
7570
inviteToSpace: async () => {},
76-
// @ts-expect-error repo is always set
77-
repo: undefined,
78-
// @ts-expect-error automergeHandle is always set
79-
automergeHandle: undefined,
8071
});
8172

8273
export function GraphFramework({ children, accountId }: Props) {
8374
const [websocketConnection, setWebsocketConnection] = useState<WebSocket>();
84-
const [repo] = useState<Repo>(() => new Repo({}));
85-
const [automergeHandle] = useState<DocHandle<unknown>>(() => repo.find(hardcodedUrl));
8675
const spaces = useSelectorStore(store, (state) => state.context.spaces);
8776
const invitations = useSelectorStore(store, (state) => state.context.invitations);
8877
// Create a stable WebSocket connection that only depends on accountId
8978
useEffect(() => {
9079
const websocketConnection = new WebSocket(`ws://localhost:3030/?accountId=${accountId}`);
9180

92-
const docHandle = automergeHandle;
93-
// set it to ready to interact with the document
94-
docHandle.doneLoading();
95-
96-
docHandle.on('change', (result) => {
97-
const lastLocalChange = automerge.getLastLocalChange(result.doc);
98-
if (!lastLocalChange) {
99-
return;
100-
}
101-
102-
try {
103-
const storeState = store.getSnapshot();
104-
const space = storeState.context.spaces[0];
105-
106-
const ephemeralId = uuid();
107-
108-
const nonceAndCiphertext = encryptMessage({
109-
message: lastLocalChange,
110-
secretKey: hexToBytes(space.keys[0].key),
111-
});
112-
113-
const messageToSend: RequestCreateUpdate = {
114-
type: 'create-update',
115-
ephemeralId,
116-
update: nonceAndCiphertext,
117-
spaceId: space.id,
118-
};
119-
websocketConnection.send(serialize(messageToSend));
120-
} catch (error) {
121-
console.error('Error sending message', error);
122-
}
123-
});
124-
125-
store.send({
126-
type: 'setAutomergeDocumentId',
127-
automergeDocumentId: docHandle.url.slice(10),
128-
});
12981
setWebsocketConnection(websocketConnection);
13082

13183
const onOpen = () => {
@@ -150,10 +102,9 @@ export function GraphFramework({ children, accountId }: Props) {
150102
websocketConnection.removeEventListener('close', onClose);
151103
websocketConnection.close();
152104
};
153-
}, [accountId, automergeHandle]); // Only recreate when accountId changes
105+
}, [accountId]); // Only recreate when accountId changes
154106

155107
// Handle WebSocket messages in a separate effect
156-
// biome-ignore lint/correctness/useExhaustiveDependencies: automergeHandle is a mutable object
157108
useEffect(() => {
158109
if (!websocketConnection) return;
159110

@@ -189,7 +140,7 @@ export function GraphFramework({ children, accountId }: Props) {
189140

190141
const newState = state as SpaceState;
191142

192-
const storeState = store.getSnapshot();
143+
let storeState = store.getSnapshot();
193144

194145
const keys = response.keyBoxes.map((keyBox) => {
195146
const key = decryptKey({
@@ -210,6 +161,13 @@ export function GraphFramework({ children, accountId }: Props) {
210161
keys,
211162
});
212163

164+
storeState = store.getSnapshot();
165+
const automergeDocHandle = storeState.context.spaces.find((s) => s.id === response.id)?.automergeDocHandle;
166+
if (!automergeDocHandle) {
167+
console.error('No automergeDocHandle found', response.id);
168+
return;
169+
}
170+
213171
if (response.updates) {
214172
const updates = response.updates?.updates.map((update) => {
215173
return decryptMessage({
@@ -219,11 +177,7 @@ export function GraphFramework({ children, accountId }: Props) {
219177
});
220178

221179
for (const update of updates) {
222-
if (!automergeHandle) {
223-
return;
224-
}
225-
226-
automergeHandle.update((existingDoc) => {
180+
automergeDocHandle.update((existingDoc) => {
227181
const [newDoc] = automerge.applyChanges(existingDoc, [update]);
228182
return newDoc;
229183
});
@@ -236,6 +190,36 @@ export function GraphFramework({ children, accountId }: Props) {
236190
lastUpdateClock: response.updates?.lastUpdateClock,
237191
});
238192
}
193+
194+
automergeDocHandle.on('change', (result) => {
195+
const lastLocalChange = automerge.getLastLocalChange(result.doc);
196+
if (!lastLocalChange) {
197+
return;
198+
}
199+
200+
try {
201+
const storeState = store.getSnapshot();
202+
const space = storeState.context.spaces[0];
203+
204+
const ephemeralId = uuid();
205+
206+
const nonceAndCiphertext = encryptMessage({
207+
message: lastLocalChange,
208+
secretKey: hexToBytes(space.keys[0].key),
209+
});
210+
211+
const messageToSend: RequestCreateUpdate = {
212+
type: 'create-update',
213+
ephemeralId,
214+
update: nonceAndCiphertext,
215+
spaceId: space.id,
216+
};
217+
websocketConnection.send(serialize(messageToSend));
218+
} catch (error) {
219+
console.error('Error sending message', error);
220+
}
221+
});
222+
239223
break;
240224
}
241225
case 'space-event': {
@@ -298,7 +282,7 @@ export function GraphFramework({ children, accountId }: Props) {
298282
});
299283
});
300284

301-
automergeHandle?.update((existingDoc) => {
285+
space?.automergeDocHandle?.update((existingDoc) => {
302286
const [newDoc] = automerge.applyChanges(existingDoc, automergeUpdates);
303287
return newDoc;
304288
});
@@ -488,8 +472,6 @@ export function GraphFramework({ children, accountId }: Props) {
488472
acceptInvitation: acceptInvitationForContext,
489473
subscribeToSpace,
490474
inviteToSpace,
491-
repo,
492-
automergeHandle,
493475
}}
494476
>
495477
{children}

0 commit comments

Comments
 (0)