cojson 0.7.0-alpha.29 → 0.7.0-alpha.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,475 @@
1
+ import {
2
+ ReadableStream,
3
+ WritableStream,
4
+ ReadableStreamDefaultReader,
5
+ WritableStreamDefaultWriter,
6
+ } from "isomorphic-streams";
7
+ import { Effect, Either, SynchronizedRef } from "effect";
8
+ import { RawCoID } from "../ids.js";
9
+ import { CoValueHeader, Transaction } from "../coValueCore.js";
10
+ import { Signature } from "../crypto.js";
11
+ import {
12
+ CoValueKnownState,
13
+ NewContentMessage,
14
+ Peer,
15
+ SyncMessage,
16
+ } from "../sync.js";
17
+ import { CoID, RawCoValue } from "../index.js";
18
+ import { connectedPeers } from "../streamUtils.js";
19
+ import {
20
+ chunkToKnownState,
21
+ contentSinceChunk,
22
+ mergeChunks,
23
+ } from "./chunksAndKnownStates.js";
24
+ import {
25
+ BlockFilename,
26
+ FSErr,
27
+ FileSystem,
28
+ WalEntry,
29
+ WalFilename,
30
+ readChunk,
31
+ readHeader,
32
+ textDecoder,
33
+ writeBlock,
34
+ writeToWal,
35
+ } from "./FileSystem.js";
36
+ export { FSErr, BlockFilename, WalFilename } from "./FileSystem.js";
37
+
38
+ export type CoValueChunk = {
39
+ header?: CoValueHeader;
40
+ sessionEntries: {
41
+ [sessionID: string]: {
42
+ after: number;
43
+ lastSignature: Signature;
44
+ transactions: Transaction[];
45
+ }[];
46
+ };
47
+ };
48
+
49
+ export class LSMStorage<WH, RH, FS extends FileSystem<WH, RH>> {
50
+ fromLocalNode!: ReadableStreamDefaultReader<SyncMessage>;
51
+ toLocalNode: WritableStreamDefaultWriter<SyncMessage>;
52
+ fs: FS;
53
+ currentWal: SynchronizedRef.SynchronizedRef<WH | undefined>;
54
+ coValues: SynchronizedRef.SynchronizedRef<{
55
+ [id: RawCoID]: CoValueChunk | undefined;
56
+ }>;
57
+ fileCache: string[] | undefined;
58
+ headerCache = new Map<
59
+ BlockFilename,
60
+ { [id: RawCoID]: { start: number; length: number } }
61
+ >();
62
+
63
+ constructor(
64
+ fs: FS,
65
+ fromLocalNode: ReadableStream<SyncMessage>,
66
+ toLocalNode: WritableStream<SyncMessage>
67
+ ) {
68
+ this.fs = fs;
69
+ this.fromLocalNode = fromLocalNode.getReader();
70
+ this.toLocalNode = toLocalNode.getWriter();
71
+ this.coValues = SynchronizedRef.unsafeMake({});
72
+ this.currentWal = SynchronizedRef.unsafeMake<WH | undefined>(undefined);
73
+
74
+ void Effect.runPromise(
75
+ Effect.gen(this, function* () {
76
+ let done = false;
77
+ while (!done) {
78
+ const result = yield* Effect.promise(() =>
79
+ this.fromLocalNode.read()
80
+ );
81
+ done = result.done;
82
+
83
+ if (result.value) {
84
+ if (result.value.action === "done") {
85
+ continue;
86
+ }
87
+
88
+ if (result.value.action === "content") {
89
+ yield* this.handleNewContent(result.value);
90
+ } else {
91
+ yield* this.sendNewContent(
92
+ result.value.id,
93
+ result.value,
94
+ undefined
95
+ );
96
+ }
97
+ }
98
+ }
99
+
100
+ return;
101
+ })
102
+ );
103
+
104
+ setTimeout(() => this.compact(), 20000);
105
+ }
106
+
107
+ sendNewContent(
108
+ id: RawCoID,
109
+ known: CoValueKnownState | undefined,
110
+ asDependencyOf: RawCoID | undefined
111
+ ): Effect.Effect<void, FSErr> {
112
+ return SynchronizedRef.updateEffect(this.coValues, (coValues) =>
113
+ this.sendNewContentInner(coValues, id, known, asDependencyOf)
114
+ );
115
+ }
116
+
117
+ private sendNewContentInner(
118
+ coValues: { [id: `co_z${string}`]: CoValueChunk | undefined },
119
+ id: RawCoID,
120
+ known: CoValueKnownState | undefined,
121
+ asDependencyOf: RawCoID | undefined
122
+ ): Effect.Effect<
123
+ { [id: `co_z${string}`]: CoValueChunk | undefined },
124
+ FSErr,
125
+ never
126
+ > {
127
+ return Effect.gen(this, function* () {
128
+ let coValue = coValues[id];
129
+
130
+ if (!coValue) {
131
+ coValue = yield* this.loadCoValue(id, this.fs);
132
+ }
133
+
134
+ if (!coValue) {
135
+ yield* Effect.promise(() =>
136
+ this.toLocalNode.write({
137
+ id: id,
138
+ action: "known",
139
+ header: false,
140
+ sessions: {},
141
+ asDependencyOf,
142
+ })
143
+ );
144
+
145
+ return coValues;
146
+ }
147
+
148
+ if (
149
+ !known?.header &&
150
+ coValue.header?.ruleset.type === "ownedByGroup"
151
+ ) {
152
+ coValues = yield* this.sendNewContentInner(
153
+ coValues,
154
+ coValue.header.ruleset.group,
155
+ undefined,
156
+ asDependencyOf || id
157
+ );
158
+ } else if (
159
+ !known?.header &&
160
+ coValue.header?.ruleset.type === "group"
161
+ ) {
162
+ const dependedOnAccounts = new Set();
163
+ for (const session of Object.values(coValue.sessionEntries)) {
164
+ for (const entry of session) {
165
+ for (const tx of entry.transactions) {
166
+ if (tx.privacy === "trusting") {
167
+ const parsedChanges = JSON.parse(tx.changes);
168
+ for (const change of parsedChanges) {
169
+ if (
170
+ change.op === "set" &&
171
+ change.key.startsWith("co_")
172
+ ) {
173
+ dependedOnAccounts.add(change.key);
174
+ }
175
+ }
176
+ }
177
+ }
178
+ }
179
+ }
180
+ for (const account of dependedOnAccounts) {
181
+ coValues = yield* this.sendNewContentInner(
182
+ coValues,
183
+ account as CoID<RawCoValue>,
184
+ undefined,
185
+ asDependencyOf || id
186
+ );
187
+ }
188
+ }
189
+
190
+ const newContentMessages = contentSinceChunk(
191
+ id,
192
+ coValue,
193
+ known
194
+ ).map((message) => ({ ...message, asDependencyOf }));
195
+
196
+ const ourKnown: CoValueKnownState = chunkToKnownState(id, coValue);
197
+
198
+ yield* Effect.promise(() =>
199
+ this.toLocalNode.write({
200
+ action: "known",
201
+ ...ourKnown,
202
+ asDependencyOf,
203
+ })
204
+ );
205
+
206
+ for (const message of newContentMessages) {
207
+ if (Object.keys(message.new).length === 0) continue;
208
+ yield* Effect.promise(() => this.toLocalNode.write(message));
209
+ }
210
+
211
+ return { ...coValues, [id]: coValue };
212
+ });
213
+ }
214
+
215
+ withWAL(
216
+ handler: (wal: WH) => Effect.Effect<void, FSErr>
217
+ ): Effect.Effect<void, FSErr> {
218
+ return SynchronizedRef.updateEffect(this.currentWal, (wal) =>
219
+ Effect.gen(this, function* () {
220
+ let newWal = wal;
221
+ if (!newWal) {
222
+ newWal = yield* this.fs.createFile(
223
+ `wal-${new Date().toISOString()}-${Math.random()
224
+ .toString(36)
225
+ .slice(2)}.jsonl`
226
+ );
227
+ }
228
+ yield* handler(newWal);
229
+ return newWal;
230
+ })
231
+ );
232
+ }
233
+
234
+ handleNewContent(
235
+ newContent: NewContentMessage
236
+ ): Effect.Effect<void, FSErr> {
237
+ return SynchronizedRef.updateEffect(this.coValues, (coValues) =>
238
+ Effect.gen(this, function* () {
239
+ const coValue = coValues[newContent.id];
240
+
241
+ const newContentAsChunk: CoValueChunk = {
242
+ header: newContent.header,
243
+ sessionEntries: Object.fromEntries(
244
+ Object.entries(newContent.new).map(
245
+ ([sessionID, newInSession]) => [
246
+ sessionID,
247
+ [
248
+ {
249
+ after: newInSession.after,
250
+ lastSignature:
251
+ newInSession.lastSignature,
252
+ transactions:
253
+ newInSession.newTransactions,
254
+ },
255
+ ],
256
+ ]
257
+ )
258
+ ),
259
+ };
260
+
261
+ if (!coValue) {
262
+ if (newContent.header) {
263
+ console.log("Creating in WAL", newContent.id);
264
+ yield* this.withWAL((wal) =>
265
+ writeToWal(
266
+ wal,
267
+ this.fs,
268
+ newContent.id,
269
+ newContentAsChunk
270
+ )
271
+ );
272
+
273
+ return {
274
+ ...coValues,
275
+ [newContent.id]: newContentAsChunk,
276
+ };
277
+ } else {
278
+ // yield*
279
+ // Effect.promise(() =>
280
+ // this.toLocalNode.write({
281
+ // action: "known",
282
+ // id: newContent.id,
283
+ // header: false,
284
+ // sessions: {},
285
+ // isCorrection: true,
286
+ // })
287
+ // )
288
+ // );
289
+ console.warn(
290
+ "Incontiguous incoming update for " + newContent.id
291
+ );
292
+ return coValues;
293
+ }
294
+ } else {
295
+ const merged = mergeChunks(coValue, newContentAsChunk);
296
+ if (Either.isRight(merged)) {
297
+ yield* Effect.logWarning(
298
+ "Non-contigous new content for " + newContent.id
299
+ );
300
+
301
+ // yield* Effect.promise(() =>
302
+ // this.toLocalNode.write({
303
+ // action: "known",
304
+ // ...chunkToKnownState(newContent.id, coValue),
305
+ // isCorrection: true,
306
+ // })
307
+ // );
308
+
309
+ return coValues;
310
+ } else {
311
+ console.log("Appending to WAL", newContent.id);
312
+ yield* this.withWAL((wal) =>
313
+ writeToWal(
314
+ wal,
315
+ this.fs,
316
+ newContent.id,
317
+ newContentAsChunk
318
+ )
319
+ );
320
+
321
+ return { ...coValues, [newContent.id]: merged.left };
322
+ }
323
+ }
324
+ })
325
+ );
326
+ }
327
+
328
+ loadCoValue<WH, RH, FS extends FileSystem<WH, RH>>(
329
+ id: RawCoID,
330
+ fs: FS
331
+ ): Effect.Effect<CoValueChunk | undefined, FSErr> {
332
+ // return _loadChunkFromWal(id, fs);
333
+ return Effect.gen(this, function* () {
334
+ const files = this.fileCache || (yield* fs.listFiles());
335
+ this.fileCache = files;
336
+ const blockFiles = files.filter((name) =>
337
+ name.startsWith("hash_")
338
+ ) as BlockFilename[];
339
+
340
+ for (const blockFile of blockFiles) {
341
+ let cachedHeader:
342
+ | { [id: RawCoID]: { start: number; length: number } }
343
+ | undefined = this.headerCache.get(blockFile);
344
+
345
+ const { handle, size } = yield* fs.openToRead(blockFile);
346
+
347
+ if (!cachedHeader) {
348
+ cachedHeader = {};
349
+ const header = yield* readHeader(blockFile, handle, size, fs);
350
+ for (const entry of header) {
351
+ cachedHeader[entry.id] = {
352
+ start: entry.start,
353
+ length: entry.length,
354
+ };
355
+ }
356
+
357
+ this.headerCache.set(blockFile, cachedHeader);
358
+ }
359
+ const headerEntry = cachedHeader[id];
360
+
361
+ let result;
362
+ if (headerEntry) {
363
+ result = yield* readChunk(handle, headerEntry, fs);
364
+
365
+ }
366
+
367
+ yield* fs.close(handle);
368
+
369
+ return result
370
+ }
371
+
372
+ return undefined;
373
+ });
374
+ }
375
+
376
+ async compact() {
377
+ await Effect.runPromise(
378
+ Effect.gen(this, function* () {
379
+ const fileNames = yield* this.fs.listFiles();
380
+
381
+ const walFiles = fileNames.filter((name) =>
382
+ name.startsWith("wal-")
383
+ ) as WalFilename[];
384
+ walFiles.sort();
385
+
386
+ const coValues = new Map<RawCoID, CoValueChunk>();
387
+
388
+ console.log("Compacting WAL files", walFiles);
389
+ if (walFiles.length === 0) return;
390
+
391
+ yield* SynchronizedRef.updateEffect(this.currentWal, (wal) =>
392
+ Effect.gen(this, function* () {
393
+ if (wal) {
394
+ yield* this.fs.close(wal);
395
+ }
396
+ return undefined;
397
+ })
398
+ );
399
+
400
+ for (const fileName of walFiles) {
401
+ const { handle, size } =
402
+ yield* this.fs.openToRead(fileName);
403
+ if (size === 0) {
404
+ yield* this.fs.close(handle);
405
+ continue
406
+ }
407
+ const bytes = yield* this.fs.read(handle, 0, size);
408
+
409
+ const decoded = textDecoder.decode(bytes);
410
+ const lines = decoded.split("\n");
411
+
412
+ for (const line of lines) {
413
+ if (line.length === 0) continue;
414
+ const chunk = JSON.parse(line) as WalEntry;
415
+
416
+ const existingChunk = coValues.get(chunk.id);
417
+
418
+ if (existingChunk) {
419
+ const merged = mergeChunks(existingChunk, chunk);
420
+ if (Either.isRight(merged)) {
421
+ console.warn(
422
+ "Non-contigous chunks in " +
423
+ chunk.id +
424
+ ", " +
425
+ fileName,
426
+ existingChunk,
427
+ chunk
428
+ );
429
+ } else {
430
+ coValues.set(chunk.id, merged.left);
431
+ }
432
+ } else {
433
+ coValues.set(chunk.id, chunk);
434
+ }
435
+ }
436
+
437
+ yield* this.fs.close(handle);
438
+ }
439
+
440
+ yield* writeBlock(coValues, 0, this.fs);
441
+ for (const walFile of walFiles) {
442
+ yield* this.fs.removeFile(walFile);
443
+ }
444
+ this.fileCache = undefined;
445
+ })
446
+ );
447
+
448
+ setTimeout(() => this.compact(), 5000);
449
+ }
450
+
451
+ static asPeer<WH, RH, FS extends FileSystem<WH, RH>>({
452
+ fs,
453
+ trace,
454
+ localNodeName = "local",
455
+ }: {
456
+ fs: FS;
457
+ trace?: boolean;
458
+ localNodeName?: string;
459
+ }): Peer {
460
+ const [localNodeAsPeer, storageAsPeer] = connectedPeers(
461
+ localNodeName,
462
+ "storage",
463
+ {
464
+ peer1role: "client",
465
+ peer2role: "server",
466
+ trace,
467
+ }
468
+ );
469
+
470
+ new LSMStorage(fs, localNodeAsPeer.incoming, localNodeAsPeer.outgoing);
471
+
472
+ // return { ...storageAsPeer, priority: 200 };
473
+ return storageAsPeer;
474
+ }
475
+ }
package/src/sync.ts CHANGED
@@ -151,25 +151,25 @@ export class SyncManager {
151
151
  throw new Error("Expected firstPeerState to be waiting " + id);
152
152
  }
153
153
  await new Promise<void>((resolve) => {
154
- const timeout = setTimeout(() => {
155
- if (this.local.coValues[id]?.state === "loading") {
156
- // console.warn(
157
- // "Timeout waiting for peer to load",
158
- // id,
159
- // "from",
160
- // peer.id,
161
- // "and it hasn't loaded from other peers yet"
162
- // );
163
- }
164
- resolve();
165
- }, 1000);
154
+ // const timeout = setTimeout(() => {
155
+ // if (this.local.coValues[id]?.state === "loading") {
156
+ // console.warn(
157
+ // "Timeout waiting for peer to load",
158
+ // id,
159
+ // "from",
160
+ // peer.id,
161
+ // "and it hasn't loaded from other peers yet"
162
+ // );
163
+ // }
164
+ // resolve();
165
+ // }, 1000);
166
166
  firstStateEntry.done
167
167
  .then(() => {
168
- clearTimeout(timeout);
168
+ // clearTimeout(timeout);
169
169
  resolve();
170
170
  })
171
171
  .catch((e) => {
172
- clearTimeout(timeout);
172
+ // clearTimeout(timeout);
173
173
  console.error(
174
174
  "Error waiting for peer to load",
175
175
  id,
@@ -688,14 +688,14 @@ export class SyncManager {
688
688
  return this.requestedSyncs[coValue.id]!.done;
689
689
  } else {
690
690
  const done = new Promise<void>((resolve) => {
691
- setTimeout(async () => {
691
+ queueMicrotask(async () => {
692
692
  delete this.requestedSyncs[coValue.id];
693
693
  // if (entry.nRequestsThisTick >= 2) {
694
694
  // console.log("Syncing", coValue.id, "for", entry.nRequestsThisTick, "requests");
695
695
  // }
696
696
  await this.actuallySyncCoValue(coValue);
697
697
  resolve();
698
- }, 0);
698
+ });
699
699
  });
700
700
  const entry = {
701
701
  done,
@@ -707,14 +707,14 @@ export class SyncManager {
707
707
  }
708
708
 
709
709
  async actuallySyncCoValue(coValue: CoValueCore) {
710
- let blockingSince = performance.now();
710
+ // let blockingSince = performance.now();
711
711
  for (const peer of this.peersInPriorityOrder()) {
712
- if (performance.now() - blockingSince > 5) {
713
- await new Promise<void>((resolve) => {
714
- setTimeout(resolve, 0);
715
- });
716
- blockingSince = performance.now();
717
- }
712
+ // if (performance.now() - blockingSince > 5) {
713
+ // await new Promise<void>((resolve) => {
714
+ // setTimeout(resolve, 0);
715
+ // });
716
+ // blockingSince = performance.now();
717
+ // }
718
718
  const optimisticKnownState = peer.optimisticKnownStates[coValue.id];
719
719
 
720
720
  if (optimisticKnownState) {
@@ -0,0 +1,100 @@
1
+ import { expect, test, beforeEach } from "vitest";
2
+ import { expectList, expectMap, expectStream } from "../coValue.js";
3
+ import { RawBinaryCoStream } from "../coValues/coStream.js";
4
+ import { createdNowUnique } from "../crypto.js";
5
+ import { MAX_RECOMMENDED_TX_SIZE, cojsonReady } from "../index.js";
6
+ import { LocalNode } from "../localNode.js";
7
+ import { accountOrAgentIDfromSessionID } from "../typeUtils/accountOrAgentIDfromSessionID.js";
8
+ import { randomAnonymousAccountAndSessionID } from "./testUtils.js";
9
+
10
+ import { webcrypto } from "node:crypto";
11
+ if (!("crypto" in globalThis)) {
12
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
13
+ (globalThis as any).crypto = webcrypto;
14
+ }
15
+
16
+ beforeEach(async () => {
17
+ await cojsonReady;
18
+ });
19
+
20
+ test("Empty CoList works", () => {
21
+ const node = new LocalNode(...randomAnonymousAccountAndSessionID());
22
+
23
+ const coValue = node.createCoValue({
24
+ type: "colist",
25
+ ruleset: { type: "unsafeAllowAll" },
26
+ meta: null,
27
+ ...createdNowUnique(),
28
+ });
29
+
30
+ const content = expectList(coValue.getCurrentContent());
31
+
32
+ expect(content.type).toEqual("colist");
33
+ expect(content.toJSON()).toEqual([]);
34
+ });
35
+
36
+ test("Can append, prepend, delete and replace items in CoList", () => {
37
+ const node = new LocalNode(...randomAnonymousAccountAndSessionID());
38
+
39
+ const coValue = node.createCoValue({
40
+ type: "colist",
41
+ ruleset: { type: "unsafeAllowAll" },
42
+ meta: null,
43
+ ...createdNowUnique(),
44
+ });
45
+
46
+ const content = expectList(coValue.getCurrentContent());
47
+
48
+ content.append("hello", 0, "trusting");
49
+ expect(content.toJSON()).toEqual(["hello"]);
50
+ content.append("world", 0, "trusting");
51
+ expect(content.toJSON()).toEqual(["hello", "world"]);
52
+ content.prepend("beautiful", 1, "trusting");
53
+ expect(content.toJSON()).toEqual(["hello", "beautiful", "world"]);
54
+ content.prepend("hooray", 3, "trusting");
55
+ expect(content.toJSON()).toEqual(["hello", "beautiful", "world", "hooray"]);
56
+ content.replace(2, "universe", "trusting");
57
+ expect(content.toJSON()).toEqual(["hello", "beautiful", "universe", "hooray"]);
58
+ content.delete(2, "trusting");
59
+ expect(content.toJSON()).toEqual(["hello", "beautiful", "hooray"]);
60
+ });
61
+
62
+ test("Push is equivalent to append after last item", () => {
63
+ const node = new LocalNode(...randomAnonymousAccountAndSessionID());
64
+
65
+ const coValue = node.createCoValue({
66
+ type: "colist",
67
+ ruleset: { type: "unsafeAllowAll" },
68
+ meta: null,
69
+ ...createdNowUnique(),
70
+ });
71
+
72
+ const content = expectList(coValue.getCurrentContent());
73
+
74
+ expect(content.type).toEqual("colist");
75
+
76
+ content.append("hello", 0, "trusting");
77
+ expect(content.toJSON()).toEqual(["hello"]);
78
+ content.append("world", undefined, "trusting");
79
+ expect(content.toJSON()).toEqual(["hello", "world"]);
80
+ content.append("hooray", undefined, "trusting");
81
+ expect(content.toJSON()).toEqual(["hello", "world", "hooray"]);
82
+ });
83
+
84
+ test("Can push into empty list", () => {
85
+ const node = new LocalNode(...randomAnonymousAccountAndSessionID());
86
+
87
+ const coValue = node.createCoValue({
88
+ type: "colist",
89
+ ruleset: { type: "unsafeAllowAll" },
90
+ meta: null,
91
+ ...createdNowUnique(),
92
+ });
93
+
94
+ const content = expectList(coValue.getCurrentContent());
95
+
96
+ expect(content.type).toEqual("colist");
97
+
98
+ content.append("hello", undefined, "trusting");
99
+ expect(content.toJSON()).toEqual(["hello"]);
100
+ });