cojson 0.19.3 → 0.19.5

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.
Files changed (52) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +15 -0
  3. package/dist/coValues/coList.d.ts +10 -4
  4. package/dist/coValues/coList.d.ts.map +1 -1
  5. package/dist/coValues/coList.js +30 -4
  6. package/dist/coValues/coList.js.map +1 -1
  7. package/dist/coValues/coMap.d.ts +3 -2
  8. package/dist/coValues/coMap.d.ts.map +1 -1
  9. package/dist/coValues/coMap.js +8 -10
  10. package/dist/coValues/coMap.js.map +1 -1
  11. package/dist/coValues/coPlainText.d.ts +2 -1
  12. package/dist/coValues/coPlainText.d.ts.map +1 -1
  13. package/dist/coValues/coPlainText.js +5 -2
  14. package/dist/coValues/coPlainText.js.map +1 -1
  15. package/dist/coValues/group.d.ts +7 -3
  16. package/dist/coValues/group.d.ts.map +1 -1
  17. package/dist/coValues/group.js +70 -24
  18. package/dist/coValues/group.js.map +1 -1
  19. package/dist/storage/sqlite/client.d.ts +3 -3
  20. package/dist/storage/sqlite/client.d.ts.map +1 -1
  21. package/dist/storage/sqlite/client.js +1 -1
  22. package/dist/storage/sqlite/client.js.map +1 -1
  23. package/dist/storage/sqliteAsync/client.d.ts +3 -3
  24. package/dist/storage/sqliteAsync/client.d.ts.map +1 -1
  25. package/dist/storage/sqliteAsync/client.js +1 -1
  26. package/dist/storage/sqliteAsync/client.js.map +1 -1
  27. package/dist/storage/storageAsync.d.ts.map +1 -1
  28. package/dist/storage/storageAsync.js +7 -7
  29. package/dist/storage/storageAsync.js.map +1 -1
  30. package/dist/storage/storageSync.d.ts.map +1 -1
  31. package/dist/storage/storageSync.js +7 -7
  32. package/dist/storage/storageSync.js.map +1 -1
  33. package/dist/storage/types.d.ts +18 -14
  34. package/dist/storage/types.d.ts.map +1 -1
  35. package/dist/tests/coList.test.js +28 -1
  36. package/dist/tests/coList.test.js.map +1 -1
  37. package/dist/tests/group.parentGroupCache.test.d.ts +2 -0
  38. package/dist/tests/group.parentGroupCache.test.d.ts.map +1 -0
  39. package/dist/tests/group.parentGroupCache.test.js +216 -0
  40. package/dist/tests/group.parentGroupCache.test.js.map +1 -0
  41. package/package.json +3 -3
  42. package/src/coValues/coList.ts +41 -8
  43. package/src/coValues/coMap.ts +15 -11
  44. package/src/coValues/coPlainText.ts +6 -2
  45. package/src/coValues/group.ts +99 -24
  46. package/src/storage/sqlite/client.ts +6 -3
  47. package/src/storage/sqliteAsync/client.ts +8 -3
  48. package/src/storage/storageAsync.ts +8 -5
  49. package/src/storage/storageSync.ts +8 -5
  50. package/src/storage/types.ts +43 -37
  51. package/src/tests/coList.test.ts +36 -3
  52. package/src/tests/group.parentGroupCache.test.ts +293 -0
@@ -3,6 +3,7 @@ import type { CoID } from "../coValue.js";
3
3
  import type {
4
4
  AvailableCoValueCore,
5
5
  CoValueCore,
6
+ DecryptedTransaction,
6
7
  } from "../coValueCore/coValueCore.js";
7
8
  import type { CoValueUniqueness } from "../coValueCore/verifiedState.js";
8
9
  import type {
@@ -161,6 +162,40 @@ function rotateReadKeyIfNeeded(group: RawGroup) {
161
162
  }
162
163
  }
163
164
 
165
+ class TimeBasedEntry<T> {
166
+ changes: { madeAt: number; value: T }[] = [];
167
+
168
+ addChange(madeAt: number, value: T) {
169
+ const changes = this.changes;
170
+ const newChange = { madeAt, value };
171
+ // Insert the change in chronological order
172
+ // Find the correct position by searching backwards from the end
173
+ let insertIndex = changes.length;
174
+ while (insertIndex > 0 && changes[insertIndex - 1]!.madeAt > madeAt) {
175
+ insertIndex--;
176
+ }
177
+
178
+ // Insert at the correct position to maintain chronological order
179
+ if (insertIndex === changes.length) {
180
+ changes.push(newChange);
181
+ } else {
182
+ changes.splice(insertIndex, 0, newChange);
183
+ }
184
+ }
185
+
186
+ getLatest() {
187
+ return this.changes[this.changes.length - 1]?.value;
188
+ }
189
+
190
+ getAtTime(atTime?: number) {
191
+ if (atTime === undefined) {
192
+ return this.getLatest();
193
+ }
194
+
195
+ return this.changes.findLast((change) => change.madeAt <= atTime)?.value;
196
+ }
197
+ }
198
+
164
199
  /** A `Group` is a scope for permissions of its members (`"reader" | "writer" | "admin"`), applying to objects owned by that group.
165
200
  *
166
201
  * A `Group` object exposes methods for permission management and allows you to create new CoValues owned by that group.
@@ -189,6 +224,19 @@ export class RawGroup<
189
224
 
190
225
  _lastReadableKeyId?: KeyID;
191
226
 
227
+ // Not using class field initializers because they run after that the CoMap constructor
228
+ // calls processNewTransactions, which would reset the parentGroupsChanges map
229
+ private declare parentGroupsChanges: Map<
230
+ CoID<RawGroup>,
231
+ TimeBasedEntry<ParentGroupReferenceRole>
232
+ >;
233
+
234
+ protected resetInternalState() {
235
+ super.resetInternalState();
236
+ this.parentGroupsChanges = new Map();
237
+ this._lastReadableKeyId = undefined;
238
+ }
239
+
192
240
  constructor(
193
241
  core: AvailableCoValueCore,
194
242
  options?: {
@@ -200,6 +248,45 @@ export class RawGroup<
200
248
  this.migrate();
201
249
  }
202
250
 
251
+ // We override the handleNewTransaction hook from CoMap to build the parent group cache
252
+ override handleNewTransaction(transaction: DecryptedTransaction): void {
253
+ if (!this.parentGroupsChanges) {
254
+ this.parentGroupsChanges = new Map();
255
+ }
256
+
257
+ // Build parent group cache incrementally
258
+ for (const changeValue of transaction.changes) {
259
+ const change = changeValue as {
260
+ op: "set" | "del";
261
+ key: string;
262
+ value?: any;
263
+ };
264
+ if (change.op === "set" && isParentGroupReference(change.key)) {
265
+ this.updateParentGroupCache(
266
+ change.key,
267
+ change.value as ParentGroupReferenceRole,
268
+ transaction.madeAt,
269
+ );
270
+ }
271
+ }
272
+ }
273
+
274
+ private updateParentGroupCache(
275
+ key: string,
276
+ value: any,
277
+ timestamp: number,
278
+ ): void {
279
+ const parentGroupId = key.substring(7) as CoID<RawGroup>; // Remove 'parent_' prefix
280
+
281
+ let entry = this.parentGroupsChanges.get(parentGroupId);
282
+ if (!entry) {
283
+ entry = new TimeBasedEntry<ParentGroupReferenceRole>();
284
+ this.parentGroupsChanges.set(parentGroupId, entry);
285
+ }
286
+
287
+ entry.addChange(timestamp, value as ParentGroupReferenceRole);
288
+ }
289
+
203
290
  migrate() {
204
291
  if (!this.core.isGroup()) {
205
292
  return;
@@ -247,15 +334,13 @@ export class RawGroup<
247
334
 
248
335
  let roleInfo: Role | undefined = roleHere;
249
336
 
250
- for (const key of Object.keys(this.ops)) {
251
- if (!isParentGroupReference(key)) continue;
337
+ for (const [parentGroupId, entry] of this.parentGroupsChanges.entries()) {
338
+ const role = entry.getAtTime(this.atTimeFilter);
252
339
 
253
- const group = this.getParentGroupFromKey(key, this.atTimeFilter);
340
+ if (!role || role === "revoked") continue;
254
341
 
255
- if (!group) continue;
256
-
257
- const role = this.get(key) ?? "extend";
258
- const parentRole = group.roleOfInternal(accountID);
342
+ const parentGroup = this.getParentGroup(parentGroupId, this.atTimeFilter);
343
+ const parentRole = parentGroup.roleOfInternal(accountID);
259
344
 
260
345
  if (!isInheritableRole(parentRole)) {
261
346
  continue;
@@ -277,13 +362,9 @@ export class RawGroup<
277
362
  return roleInfo;
278
363
  }
279
364
 
280
- getParentGroupFromKey(key: ParentGroupReference, atTime?: number) {
281
- if (this.get(key) === "revoked") {
282
- return null;
283
- }
284
-
365
+ getParentGroup(id: CoID<RawGroup>, atTime?: number) {
285
366
  const parent = this.core.node.expectCoValueLoaded(
286
- getParentGroupId(key),
367
+ id,
287
368
  "Expected parent group to be loaded",
288
369
  );
289
370
 
@@ -296,21 +377,15 @@ export class RawGroup<
296
377
  }
297
378
  }
298
379
 
299
- getParentGroups(atTime?: number) {
380
+ getParentGroups() {
300
381
  const groups: RawGroup[] = [];
301
382
 
302
- for (const key of Object.keys(this.ops)) {
303
- if (!isParentGroupReference(key)) continue;
383
+ for (const [parentGroupId, entry] of this.parentGroupsChanges.entries()) {
384
+ const role = entry.getAtTime(this.atTimeFilter);
304
385
 
305
- const group = this.getParentGroupFromKey(key, atTime);
386
+ if (!role || role === "revoked") continue;
306
387
 
307
- if (group) {
308
- if (atTime) {
309
- groups.push(group.atTime(atTime));
310
- } else {
311
- groups.push(group);
312
- }
313
- }
388
+ groups.push(this.getParentGroup(parentGroupId, this.atTimeFilter));
314
389
  }
315
390
 
316
391
  return groups;
@@ -8,6 +8,7 @@ import { logger } from "../../logger.js";
8
8
  import type { NewContentMessage } from "../../sync.js";
9
9
  import type {
10
10
  DBClientInterfaceSync,
11
+ DBTransactionInterfaceSync,
11
12
  SessionRow,
12
13
  SignatureAfterRow,
13
14
  StoredCoValueRow,
@@ -31,7 +32,9 @@ export function getErrorMessage(error: unknown) {
31
32
  return error instanceof Error ? error.message : "Unknown error";
32
33
  }
33
34
 
34
- export class SQLiteClient implements DBClientInterfaceSync {
35
+ export class SQLiteClient
36
+ implements DBClientInterfaceSync, DBTransactionInterfaceSync
37
+ {
35
38
  private readonly db: SQLiteDatabaseDriver;
36
39
 
37
40
  constructor(db: SQLiteDatabaseDriver) {
@@ -187,8 +190,8 @@ export class SQLiteClient implements DBClientInterfaceSync {
187
190
  );
188
191
  }
189
192
 
190
- transaction(operationsCallback: () => unknown) {
191
- this.db.transaction(operationsCallback);
193
+ transaction(operationsCallback: (tx: DBTransactionInterfaceSync) => unknown) {
194
+ this.db.transaction(() => operationsCallback(this));
192
195
  return undefined;
193
196
  }
194
197
  }
@@ -8,6 +8,7 @@ import { logger } from "../../logger.js";
8
8
  import type { NewContentMessage } from "../../sync.js";
9
9
  import type {
10
10
  DBClientInterfaceAsync,
11
+ DBTransactionInterfaceAsync,
11
12
  SessionRow,
12
13
  SignatureAfterRow,
13
14
  StoredCoValueRow,
@@ -31,7 +32,9 @@ export function getErrorMessage(error: unknown) {
31
32
  return error instanceof Error ? error.message : "Unknown error";
32
33
  }
33
34
 
34
- export class SQLiteClientAsync implements DBClientInterfaceAsync {
35
+ export class SQLiteClientAsync
36
+ implements DBClientInterfaceAsync, DBTransactionInterfaceAsync
37
+ {
35
38
  private readonly db: SQLiteDatabaseDriverAsync;
36
39
 
37
40
  constructor(db: SQLiteDatabaseDriverAsync) {
@@ -194,7 +197,9 @@ export class SQLiteClientAsync implements DBClientInterfaceAsync {
194
197
  );
195
198
  }
196
199
 
197
- async transaction(operationsCallback: () => unknown) {
198
- return this.db.transaction(operationsCallback);
200
+ async transaction(
201
+ operationsCallback: (tx: DBTransactionInterfaceAsync) => Promise<unknown>,
202
+ ) {
203
+ return this.db.transaction(() => operationsCallback(this));
199
204
  }
200
205
  }
@@ -25,6 +25,7 @@ import {
25
25
  import type {
26
26
  CorrectionCallback,
27
27
  DBClientInterfaceAsync,
28
+ DBTransactionInterfaceAsync,
28
29
  SignatureAfterRow,
29
30
  StoredCoValueRow,
30
31
  StoredSessionRow,
@@ -280,8 +281,8 @@ export class StorageApiAsync implements StorageAPI {
280
281
  let invalidAssumptions = false;
281
282
 
282
283
  for (const sessionID of Object.keys(msg.new) as SessionID[]) {
283
- await this.dbClient.transaction(async () => {
284
- const sessionRow = await this.dbClient.getSingleCoValueSession(
284
+ await this.dbClient.transaction(async (tx) => {
285
+ const sessionRow = await tx.getSingleCoValueSession(
285
286
  storedCoValueRowID,
286
287
  sessionID,
287
288
  );
@@ -301,6 +302,7 @@ export class StorageApiAsync implements StorageAPI {
301
302
  invalidAssumptions = true;
302
303
  } else {
303
304
  const newLastIdx = await this.putNewTxs(
305
+ tx,
304
306
  msg,
305
307
  sessionID,
306
308
  sessionRow,
@@ -321,6 +323,7 @@ export class StorageApiAsync implements StorageAPI {
321
323
  }
322
324
 
323
325
  private async putNewTxs(
326
+ tx: DBTransactionInterfaceAsync,
324
327
  msg: NewContentMessage,
325
328
  sessionID: SessionID,
326
329
  sessionRow: StoredSessionRow | undefined,
@@ -363,13 +366,13 @@ export class StorageApiAsync implements StorageAPI {
363
366
  bytesSinceLastSignature,
364
367
  };
365
368
 
366
- const sessionRowID: number = await this.dbClient.addSessionUpdate({
369
+ const sessionRowID: number = await tx.addSessionUpdate({
367
370
  sessionUpdate,
368
371
  sessionRow,
369
372
  });
370
373
 
371
374
  if (shouldWriteSignature) {
372
- await this.dbClient.addSignatureAfter({
375
+ await tx.addSignatureAfter({
373
376
  sessionRowID,
374
377
  idx: newLastIdx - 1,
375
378
  signature: msg.new[sessionID].lastSignature,
@@ -378,7 +381,7 @@ export class StorageApiAsync implements StorageAPI {
378
381
 
379
382
  await Promise.all(
380
383
  actuallyNewTransactions.map((newTransaction, i) =>
381
- this.dbClient.addTransaction(sessionRowID, nextIdx + i, newTransaction),
384
+ tx.addTransaction(sessionRowID, nextIdx + i, newTransaction),
382
385
  ),
383
386
  );
384
387
 
@@ -25,6 +25,7 @@ import {
25
25
  import type {
26
26
  CorrectionCallback,
27
27
  DBClientInterfaceSync,
28
+ DBTransactionInterfaceSync,
28
29
  SignatureAfterRow,
29
30
  StoredCoValueRow,
30
31
  StoredSessionRow,
@@ -257,8 +258,8 @@ export class StorageApiSync implements StorageAPI {
257
258
  let invalidAssumptions = false;
258
259
 
259
260
  for (const sessionID of Object.keys(msg.new) as SessionID[]) {
260
- this.dbClient.transaction(() => {
261
- const sessionRow = this.dbClient.getSingleCoValueSession(
261
+ this.dbClient.transaction((tx) => {
262
+ const sessionRow = tx.getSingleCoValueSession(
262
263
  storedCoValueRowID,
263
264
  sessionID,
264
265
  );
@@ -275,6 +276,7 @@ export class StorageApiSync implements StorageAPI {
275
276
  invalidAssumptions = true;
276
277
  } else {
277
278
  const newLastIdx = this.putNewTxs(
279
+ tx,
278
280
  msg,
279
281
  sessionID,
280
282
  sessionRow,
@@ -295,6 +297,7 @@ export class StorageApiSync implements StorageAPI {
295
297
  }
296
298
 
297
299
  private putNewTxs(
300
+ tx: DBTransactionInterfaceSync,
298
301
  msg: NewContentMessage,
299
302
  sessionID: SessionID,
300
303
  sessionRow: StoredSessionRow | undefined,
@@ -338,13 +341,13 @@ export class StorageApiSync implements StorageAPI {
338
341
  bytesSinceLastSignature,
339
342
  };
340
343
 
341
- const sessionRowID: number = this.dbClient.addSessionUpdate({
344
+ const sessionRowID: number = tx.addSessionUpdate({
342
345
  sessionUpdate,
343
346
  sessionRow,
344
347
  });
345
348
 
346
349
  if (shouldWriteSignature) {
347
- this.dbClient.addSignatureAfter({
350
+ tx.addSignatureAfter({
348
351
  sessionRowID,
349
352
  idx: newLastIdx - 1,
350
353
  signature: msg.new[sessionID].lastSignature,
@@ -352,7 +355,7 @@ export class StorageApiSync implements StorageAPI {
352
355
  }
353
356
 
354
357
  actuallyNewTransactions.map((newTransaction, i) =>
355
- this.dbClient.addTransaction(sessionRowID, nextIdx + i, newTransaction),
358
+ tx.addTransaction(sessionRowID, nextIdx + i, newTransaction),
356
359
  );
357
360
 
358
361
  return newLastIdx;
@@ -61,34 +61,12 @@ export type SignatureAfterRow = {
61
61
  signature: Signature;
62
62
  };
63
63
 
64
- export interface DBClientInterfaceAsync {
65
- getCoValue(
66
- coValueId: string,
67
- ): Promise<StoredCoValueRow | undefined> | undefined;
68
-
69
- upsertCoValue(
70
- id: string,
71
- header?: CoValueHeader,
72
- ): Promise<number | undefined>;
73
-
74
- getCoValueSessions(coValueRowId: number): Promise<StoredSessionRow[]>;
75
-
64
+ export interface DBTransactionInterfaceAsync {
76
65
  getSingleCoValueSession(
77
66
  coValueRowId: number,
78
67
  sessionID: SessionID,
79
68
  ): Promise<StoredSessionRow | undefined>;
80
69
 
81
- getNewTransactionInSession(
82
- sessionRowId: number,
83
- fromIdx: number,
84
- toIdx: number,
85
- ): Promise<TransactionRow[]>;
86
-
87
- getSignatures(
88
- sessionRowId: number,
89
- firstNewTxIdx: number,
90
- ): Promise<SignatureAfterRow[]>;
91
-
92
70
  addSessionUpdate({
93
71
  sessionUpdate,
94
72
  sessionRow,
@@ -112,32 +90,41 @@ export interface DBClientInterfaceAsync {
112
90
  idx: number;
113
91
  signature: Signature;
114
92
  }): Promise<unknown>;
115
-
116
- transaction(callback: () => unknown): Promise<unknown>;
117
93
  }
118
94
 
119
- export interface DBClientInterfaceSync {
120
- getCoValue(coValueId: string): StoredCoValueRow | undefined;
121
-
122
- upsertCoValue(id: string, header?: CoValueHeader): number | undefined;
95
+ export interface DBClientInterfaceAsync {
96
+ getCoValue(
97
+ coValueId: string,
98
+ ): Promise<StoredCoValueRow | undefined> | undefined;
123
99
 
124
- getCoValueSessions(coValueRowId: number): StoredSessionRow[];
100
+ upsertCoValue(
101
+ id: string,
102
+ header?: CoValueHeader,
103
+ ): Promise<number | undefined>;
125
104
 
126
- getSingleCoValueSession(
127
- coValueRowId: number,
128
- sessionID: SessionID,
129
- ): StoredSessionRow | undefined;
105
+ getCoValueSessions(coValueRowId: number): Promise<StoredSessionRow[]>;
130
106
 
131
107
  getNewTransactionInSession(
132
108
  sessionRowId: number,
133
109
  fromIdx: number,
134
110
  toIdx: number,
135
- ): TransactionRow[];
111
+ ): Promise<TransactionRow[]>;
136
112
 
137
113
  getSignatures(
138
114
  sessionRowId: number,
139
115
  firstNewTxIdx: number,
140
- ): Pick<SignatureAfterRow, "idx" | "signature">[];
116
+ ): Promise<SignatureAfterRow[]>;
117
+
118
+ transaction(
119
+ callback: (tx: DBTransactionInterfaceAsync) => Promise<unknown>,
120
+ ): Promise<unknown>;
121
+ }
122
+
123
+ export interface DBTransactionInterfaceSync {
124
+ getSingleCoValueSession(
125
+ coValueRowId: number,
126
+ sessionID: SessionID,
127
+ ): StoredSessionRow | undefined;
141
128
 
142
129
  addSessionUpdate({
143
130
  sessionUpdate,
@@ -162,6 +149,25 @@ export interface DBClientInterfaceSync {
162
149
  idx: number;
163
150
  signature: Signature;
164
151
  }): number | undefined | unknown;
152
+ }
153
+
154
+ export interface DBClientInterfaceSync {
155
+ getCoValue(coValueId: string): StoredCoValueRow | undefined;
156
+
157
+ upsertCoValue(id: string, header?: CoValueHeader): number | undefined;
158
+
159
+ getCoValueSessions(coValueRowId: number): StoredSessionRow[];
160
+
161
+ getNewTransactionInSession(
162
+ sessionRowId: number,
163
+ fromIdx: number,
164
+ toIdx: number,
165
+ ): TransactionRow[];
166
+
167
+ getSignatures(
168
+ sessionRowId: number,
169
+ firstNewTxIdx: number,
170
+ ): Pick<SignatureAfterRow, "idx" | "signature">[];
165
171
 
166
- transaction(callback: () => unknown): unknown;
172
+ transaction(callback: (tx: DBTransactionInterfaceSync) => unknown): unknown;
167
173
  }
@@ -1,12 +1,10 @@
1
1
  import { beforeEach, describe, expect, test } from "vitest";
2
2
  import { expectList } from "../coValue.js";
3
3
  import { WasmCrypto } from "../crypto/WasmCrypto.js";
4
- import { LocalNode } from "../localNode.js";
5
- import { expectGroup } from "../typeUtils/expectGroup.js";
6
4
  import {
5
+ hotSleep,
7
6
  loadCoValueOrFail,
8
7
  nodeWithRandomAgentAndSessionID,
9
- randomAgentAndSessionID,
10
8
  setupTestNode,
11
9
  waitFor,
12
10
  } from "./testUtils.js";
@@ -429,6 +427,41 @@ test("Should ignore unknown meta transactions", () => {
429
427
  expect(content.toJSON()).toEqual(["first"]);
430
428
  });
431
429
 
430
+ describe("CoList Time Travel", () => {
431
+ test("atTime should return a time travel entity", () => {
432
+ const node = nodeWithRandomAgentAndSessionID();
433
+ const coValue = node.createCoValue({
434
+ type: "colist",
435
+ ruleset: { type: "unsafeAllowAll" },
436
+ meta: null,
437
+ ...Crypto.createdNowUnique(),
438
+ });
439
+
440
+ const content = expectList(coValue.getCurrentContent());
441
+
442
+ const beforeA = hotSleep(10);
443
+ content.append("first", 0, "trusting");
444
+
445
+ const beforeB = hotSleep(10);
446
+ content.append("second", 0, "trusting");
447
+
448
+ const beforeC = hotSleep(10);
449
+ content.delete(0, "trusting");
450
+
451
+ const beforeD = hotSleep(10);
452
+ content.prepend("third", 0, "trusting");
453
+
454
+ expect(content.toJSON()).toEqual(["third", "second"]);
455
+
456
+ expect(content.atTime(0).toJSON()).toEqual([]);
457
+ expect(content.atTime(beforeA).toJSON()).toEqual([]);
458
+ expect(content.atTime(beforeB).toJSON()).toEqual(["first"]);
459
+ expect(content.atTime(beforeC).toJSON()).toEqual(["first", "second"]);
460
+ expect(content.atTime(beforeD).toJSON()).toEqual(["second"]);
461
+ expect(content.atTime(Date.now()).toJSON()).toEqual(["third", "second"]);
462
+ });
463
+ });
464
+
432
465
  describe("CoList Branching", () => {
433
466
  test("should handle concurrent appends from multiple branches", async () => {
434
467
  const client1 = setupTestNode({