@automerge/automerge-repo 2.0.0-alpha.2 → 2.0.0-alpha.22

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 (76) hide show
  1. package/README.md +5 -6
  2. package/dist/AutomergeUrl.d.ts +17 -5
  3. package/dist/AutomergeUrl.d.ts.map +1 -1
  4. package/dist/AutomergeUrl.js +71 -24
  5. package/dist/DocHandle.d.ts +89 -20
  6. package/dist/DocHandle.d.ts.map +1 -1
  7. package/dist/DocHandle.js +189 -28
  8. package/dist/FindProgress.d.ts +30 -0
  9. package/dist/FindProgress.d.ts.map +1 -0
  10. package/dist/FindProgress.js +1 -0
  11. package/dist/RemoteHeadsSubscriptions.d.ts +4 -5
  12. package/dist/RemoteHeadsSubscriptions.d.ts.map +1 -1
  13. package/dist/RemoteHeadsSubscriptions.js +4 -1
  14. package/dist/Repo.d.ts +44 -6
  15. package/dist/Repo.d.ts.map +1 -1
  16. package/dist/Repo.js +226 -87
  17. package/dist/entrypoints/fullfat.d.ts +1 -0
  18. package/dist/entrypoints/fullfat.d.ts.map +1 -1
  19. package/dist/entrypoints/fullfat.js +1 -2
  20. package/dist/helpers/abortable.d.ts +39 -0
  21. package/dist/helpers/abortable.d.ts.map +1 -0
  22. package/dist/helpers/abortable.js +45 -0
  23. package/dist/helpers/bufferFromHex.d.ts +3 -0
  24. package/dist/helpers/bufferFromHex.d.ts.map +1 -0
  25. package/dist/helpers/bufferFromHex.js +13 -0
  26. package/dist/helpers/headsAreSame.d.ts +2 -2
  27. package/dist/helpers/headsAreSame.d.ts.map +1 -1
  28. package/dist/helpers/mergeArrays.d.ts +1 -1
  29. package/dist/helpers/mergeArrays.d.ts.map +1 -1
  30. package/dist/helpers/tests/network-adapter-tests.d.ts.map +1 -1
  31. package/dist/helpers/tests/network-adapter-tests.js +13 -13
  32. package/dist/helpers/tests/storage-adapter-tests.d.ts +2 -2
  33. package/dist/helpers/tests/storage-adapter-tests.d.ts.map +1 -1
  34. package/dist/helpers/tests/storage-adapter-tests.js +25 -48
  35. package/dist/index.d.ts +1 -1
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +1 -1
  38. package/dist/storage/StorageSubsystem.d.ts +11 -1
  39. package/dist/storage/StorageSubsystem.d.ts.map +1 -1
  40. package/dist/storage/StorageSubsystem.js +20 -4
  41. package/dist/synchronizer/CollectionSynchronizer.d.ts +17 -3
  42. package/dist/synchronizer/CollectionSynchronizer.d.ts.map +1 -1
  43. package/dist/synchronizer/CollectionSynchronizer.js +43 -18
  44. package/dist/synchronizer/DocSynchronizer.d.ts +10 -2
  45. package/dist/synchronizer/DocSynchronizer.d.ts.map +1 -1
  46. package/dist/synchronizer/DocSynchronizer.js +30 -8
  47. package/dist/synchronizer/Synchronizer.d.ts +11 -0
  48. package/dist/synchronizer/Synchronizer.d.ts.map +1 -1
  49. package/dist/types.d.ts +4 -1
  50. package/dist/types.d.ts.map +1 -1
  51. package/fuzz/fuzz.ts +3 -3
  52. package/package.json +3 -3
  53. package/src/AutomergeUrl.ts +101 -26
  54. package/src/DocHandle.ts +256 -38
  55. package/src/FindProgress.ts +48 -0
  56. package/src/RemoteHeadsSubscriptions.ts +11 -9
  57. package/src/Repo.ts +310 -95
  58. package/src/entrypoints/fullfat.ts +1 -2
  59. package/src/helpers/abortable.ts +61 -0
  60. package/src/helpers/bufferFromHex.ts +14 -0
  61. package/src/helpers/headsAreSame.ts +2 -2
  62. package/src/helpers/tests/network-adapter-tests.ts +14 -13
  63. package/src/helpers/tests/storage-adapter-tests.ts +44 -86
  64. package/src/index.ts +2 -0
  65. package/src/storage/StorageSubsystem.ts +29 -4
  66. package/src/synchronizer/CollectionSynchronizer.ts +56 -19
  67. package/src/synchronizer/DocSynchronizer.ts +34 -9
  68. package/src/synchronizer/Synchronizer.ts +14 -0
  69. package/src/types.ts +4 -1
  70. package/test/AutomergeUrl.test.ts +130 -0
  71. package/test/CollectionSynchronizer.test.ts +4 -4
  72. package/test/DocHandle.test.ts +189 -29
  73. package/test/DocSynchronizer.test.ts +10 -3
  74. package/test/Repo.test.ts +377 -191
  75. package/test/StorageSubsystem.test.ts +17 -0
  76. package/test/remoteHeads.test.ts +27 -12
package/src/DocHandle.ts CHANGED
@@ -2,11 +2,15 @@ import * as A from "@automerge/automerge/slim/next"
2
2
  import debug from "debug"
3
3
  import { EventEmitter } from "eventemitter3"
4
4
  import { assertEvent, assign, createActor, setup, waitFor } from "xstate"
5
- import { stringifyAutomergeUrl } from "./AutomergeUrl.js"
5
+ import {
6
+ decodeHeads,
7
+ encodeHeads,
8
+ stringifyAutomergeUrl,
9
+ } from "./AutomergeUrl.js"
6
10
  import { encode } from "./helpers/cbor.js"
7
11
  import { headsAreSame } from "./helpers/headsAreSame.js"
8
12
  import { withTimeout } from "./helpers/withTimeout.js"
9
- import type { AutomergeUrl, DocumentId, PeerId } from "./types.js"
13
+ import type { AutomergeUrl, DocumentId, PeerId, UrlHeads } from "./types.js"
10
14
  import { StorageId } from "./storage/types.js"
11
15
 
12
16
  /**
@@ -28,6 +32,9 @@ export class DocHandle<T> extends EventEmitter<DocHandleEvents<T>> {
28
32
  /** The XState actor running our state machine. */
29
33
  #machine
30
34
 
35
+ /** If set, this handle will only show the document at these heads */
36
+ #fixedHeads?: UrlHeads
37
+
31
38
  /** The last known state of our document. */
32
39
  #prevDocState: T = A.init<T>()
33
40
 
@@ -36,7 +43,7 @@ export class DocHandle<T> extends EventEmitter<DocHandleEvents<T>> {
36
43
  #timeoutDelay = 60_000
37
44
 
38
45
  /** A dictionary mapping each peer to the last heads we know they have. */
39
- #remoteHeads: Record<StorageId, A.Heads> = {}
46
+ #remoteHeads: Record<StorageId, UrlHeads> = {}
40
47
 
41
48
  /** @hidden */
42
49
  constructor(
@@ -49,6 +56,10 @@ export class DocHandle<T> extends EventEmitter<DocHandleEvents<T>> {
49
56
  this.#timeoutDelay = options.timeoutDelay
50
57
  }
51
58
 
59
+ if ("heads" in options) {
60
+ this.#fixedHeads = options.heads
61
+ }
62
+
52
63
  const doc = A.init<T>()
53
64
 
54
65
  this.#log = debug(`automerge-repo:dochandle:${this.documentId.slice(0, 5)}`)
@@ -72,9 +83,12 @@ export class DocHandle<T> extends EventEmitter<DocHandleEvents<T>> {
72
83
  this.emit("delete", { handle: this })
73
84
  return { doc: A.init() }
74
85
  }),
75
- onUnavailable: () => {
76
- this.emit("unavailable", { handle: this })
77
- },
86
+ onUnavailable: assign(() => {
87
+ return { doc: A.init() }
88
+ }),
89
+ onUnload: assign(() => {
90
+ return { doc: A.init() }
91
+ }),
78
92
  },
79
93
  }).createMachine({
80
94
  /** @xstate-layout N4IgpgJg5mDOIC5QAoC2BDAxgCwJYDswBKAYgFUAFAEQEEAVAUQG0AGAXUVAAcB7WXAC64e+TiAAeiAOwAOAKwA6ACxSAzKqks1ATjlTdAGhABPRAFolAJksKN2y1KtKAbFLla5AX09G0WPISkVAwAMgyMrBxIILz8QiJikggAjCzOijKqLEqqybJyLizaRqYIFpbJtro5Uo7J2o5S3r4YOATECrgQADZgJADCAEoM9MzsYrGCwqLRSeoyCtra8pa5adquySXmDjY5ac7JljLJeepKzSB+bYGdPX0AYgCSAHJUkRN8UwmziM7HCgqyVcUnqcmScmcMm2ZV2yiyzkOx1OalUFx8V1aAQ63R46AgBCgJGGAEUyAwAMp0D7RSbxGagJKHFgKOSWJTJGRSCosCpKaEmRCqbQKU5yXINeTaer6LwY67YogKXH4wkkKgAeX6AH1hjQqABNGncL70xKIJQ5RY5BHOJag6wwpRyEWImQVeT1aWrVSXBXtJUqgn4Ik0ADqNCedG1L3CYY1gwA0saYqbpuaEG4pKLksKpFDgcsCjDhTnxTKpTLdH6sQGFOgAO7oKYhl5gAQNngAJwA1iRY3R40ndSNDSm6enfpm5BkWAVkvy7bpuTCKq7ndZnfVeSwuTX-HWu2AAI4AVzgQhD6q12rILxoADVIyEaAAhMLjtM-RmIE4LVSQi4nLLDIGzOCWwLKA0cgyLBoFWNy+43B0R5nheaqajqepjuMtJfgyEh-FoixqMCoKqOyhzgYKCDOq6UIeuCSxHOoSGKgop74OgABuzbdOgABGvTXlho5GrhJpxJOP4pLulT6KoMhpJY2hzsWNF0QobqMV6LG+pc+A8BAcBiP6gSfFJ36EQgKksksKxrHamwwmY7gLKB85QjBzoAWxdZdL0FnfARST8ooLC7qoTnWBU4pyC5ViVMKBQaHUDQuM4fm3EGhJBWaU7-CysEAUp3LpEpWw0WYRw2LmqzgqciIsCxWUdI2zaXlAbYdt2PZ5dJ1n5jY2iJY1ikOIcMJHCyUWHC62hRZkUVNPKta3Kh56wJ1-VWUyzhFc64JWJCtQNBBzhQW4cHwbsrVKpxPF8YJgV4ZZIWIKkiKiiNSkqZYWjzCWaQ5hFh0AcCuR3QoR74qUknBRmzholpv3OkpRQNNRpTzaKTWKbIWR5FDxm9AIkA7e9skUYCWayLILBZGoLkUSKbIyIdpxHPoyTeN4QA */
@@ -86,6 +100,7 @@ export class DocHandle<T> extends EventEmitter<DocHandleEvents<T>> {
86
100
  context: { documentId, doc },
87
101
  on: {
88
102
  UPDATE: { actions: "onUpdate" },
103
+ UNLOAD: ".unloaded",
89
104
  DELETE: ".deleted",
90
105
  },
91
106
  states: {
@@ -113,6 +128,12 @@ export class DocHandle<T> extends EventEmitter<DocHandleEvents<T>> {
113
128
  on: { DOC_READY: "ready" },
114
129
  },
115
130
  ready: {},
131
+ unloaded: {
132
+ entry: "onUnload",
133
+ on: {
134
+ RELOAD: "loading",
135
+ },
136
+ },
116
137
  deleted: { entry: "onDelete", type: "final" },
117
138
  },
118
139
  })
@@ -131,7 +152,7 @@ export class DocHandle<T> extends EventEmitter<DocHandleEvents<T>> {
131
152
 
132
153
  // Start the machine, and send a create or find event to get things going
133
154
  this.#machine.start()
134
- this.#machine.send({ type: BEGIN })
155
+ this.begin()
135
156
  }
136
157
 
137
158
  // PRIVATE
@@ -166,7 +187,10 @@ export class DocHandle<T> extends EventEmitter<DocHandleEvents<T>> {
166
187
  #checkForChanges(before: A.Doc<T>, after: A.Doc<T>) {
167
188
  const beforeHeads = A.getHeads(before)
168
189
  const afterHeads = A.getHeads(after)
169
- const docChanged = !headsAreSame(afterHeads, beforeHeads)
190
+ const docChanged = !headsAreSame(
191
+ encodeHeads(afterHeads),
192
+ encodeHeads(beforeHeads)
193
+ )
170
194
  if (docChanged) {
171
195
  this.emit("heads-changed", { handle: this, doc: after })
172
196
 
@@ -192,7 +216,10 @@ export class DocHandle<T> extends EventEmitter<DocHandleEvents<T>> {
192
216
  /** Our documentId in Automerge URL form.
193
217
  */
194
218
  get url(): AutomergeUrl {
195
- return stringifyAutomergeUrl({ documentId: this.documentId })
219
+ return stringifyAutomergeUrl({
220
+ documentId: this.documentId,
221
+ heads: this.#fixedHeads,
222
+ })
196
223
  }
197
224
 
198
225
  /**
@@ -203,6 +230,14 @@ export class DocHandle<T> extends EventEmitter<DocHandleEvents<T>> {
203
230
  */
204
231
  isReady = () => this.inState(["ready"])
205
232
 
233
+ /**
234
+ * @returns true if the document has been unloaded.
235
+ *
236
+ * Unloaded documents are freed from memory but not removed from local storage. It's not currently
237
+ * possible at runtime to reload an unloaded document.
238
+ */
239
+ isUnloaded = () => this.inState(["unloaded"])
240
+
206
241
  /**
207
242
  * @returns true if the document has been marked as deleted.
208
243
  *
@@ -246,7 +281,7 @@ export class DocHandle<T> extends EventEmitter<DocHandleEvents<T>> {
246
281
  * This is the recommended way to access a handle's document. Note that this waits for the handle
247
282
  * to be ready if necessary. If loading (or synchronization) fails, this will never resolve.
248
283
  */
249
- async doc(
284
+ async legacyAsyncDoc(
250
285
  /** states to wait for, such as "LOADING". mostly for internal use. */
251
286
  awaitStates: HandleState[] = ["ready", "unavailable"]
252
287
  ) {
@@ -262,21 +297,28 @@ export class DocHandle<T> extends EventEmitter<DocHandleEvents<T>> {
262
297
  }
263
298
 
264
299
  /**
265
- * Synchronously returns the current state of the Automerge document this handle manages, or
266
- * undefined. Consider using `await handle.doc()` instead. Check `isReady()`, or use `whenReady()`
267
- * if you want to make sure loading is complete first.
300
+ * Returns the current state of the Automerge document this handle manages.
268
301
  *
269
- * Not to be confused with the SyncState of the document, which describes the state of the
270
- * synchronization process.
302
+ * @returns the current document
303
+ * @throws on deleted and unavailable documents
271
304
  *
272
- * Note that `undefined` is not a valid Automerge document, so the return from this function is
273
- * unambigous.
274
- *
275
- * @returns the current document, or undefined if the document is not ready.
276
305
  */
306
+ doc() {
307
+ if (!this.isReady()) throw new Error("DocHandle is not ready")
308
+ if (this.#fixedHeads) {
309
+ return A.view(this.#doc, decodeHeads(this.#fixedHeads))
310
+ }
311
+ return this.#doc
312
+ }
313
+
314
+ /**
315
+ *
316
+ * @deprecated */
277
317
  docSync() {
278
- if (!this.isReady()) return undefined
279
- else return this.#doc
318
+ console.warn(
319
+ "docSync is deprecated. Use doc() instead. This function will be removed as part of the 2.0 release."
320
+ )
321
+ return this.doc()
280
322
  }
281
323
 
282
324
  /**
@@ -284,11 +326,142 @@ export class DocHandle<T> extends EventEmitter<DocHandleEvents<T>> {
284
326
  * This precisely defines the state of a document.
285
327
  * @returns the current document's heads, or undefined if the document is not ready
286
328
  */
287
- heads(): A.Heads | undefined {
329
+ heads(): UrlHeads {
330
+ if (!this.isReady()) throw new Error("DocHandle is not ready")
331
+ if (this.#fixedHeads) {
332
+ return this.#fixedHeads
333
+ }
334
+ return encodeHeads(A.getHeads(this.#doc))
335
+ }
336
+
337
+ begin() {
338
+ this.#machine.send({ type: BEGIN })
339
+ }
340
+
341
+ /**
342
+ * Returns an array of all past "heads" for the document in topological order.
343
+ *
344
+ * @remarks
345
+ * A point-in-time in an automerge document is an *array* of heads since there may be
346
+ * concurrent edits. This API just returns a topologically sorted history of all edits
347
+ * so every previous entry will be (in some sense) before later ones, but the set of all possible
348
+ * history views would be quite large under concurrency (every thing in each branch against each other).
349
+ * There might be a clever way to think about this, but we haven't found it yet, so for now at least
350
+ * we present a single traversable view which excludes concurrency.
351
+ * @returns UrlHeads[] - The individual heads for every change in the document. Each item is a tagged string[1].
352
+ */
353
+ history(): UrlHeads[] | undefined {
288
354
  if (!this.isReady()) {
289
355
  return undefined
290
356
  }
291
- return A.getHeads(this.#doc)
357
+ // This just returns all the heads as individual strings.
358
+
359
+ return A.topoHistoryTraversal(this.#doc).map(h =>
360
+ encodeHeads([h])
361
+ ) as UrlHeads[]
362
+ }
363
+
364
+ /**
365
+ * Creates a fixed "view" of an automerge document at the given point in time represented
366
+ * by the `heads` passed in. The return value is the same type as doc() and will return
367
+ * undefined if the object hasn't finished loading.
368
+ *
369
+ * @remarks
370
+ * Note that our Typescript types do not consider change over time and the current version
371
+ * of Automerge doesn't check types at runtime, so if you go back to an old set of heads
372
+ * that doesn't match the heads here, Typescript will not save you.
373
+ *
374
+ * @argument heads - The heads to view the document at. See history().
375
+ * @returns DocHandle<T> at the time of `heads`
376
+ */
377
+ view(heads: UrlHeads): DocHandle<T> {
378
+ if (!this.isReady()) {
379
+ throw new Error(
380
+ `DocHandle#${this.documentId} is not ready. Check \`handle.isReady()\` before calling view().`
381
+ )
382
+ }
383
+ // Create a new handle with the same documentId but fixed heads
384
+ const handle = new DocHandle<T>(this.documentId, {
385
+ heads,
386
+ timeoutDelay: this.#timeoutDelay,
387
+ })
388
+ handle.update(() => A.clone(this.#doc))
389
+ handle.doneLoading()
390
+
391
+ return handle
392
+ }
393
+
394
+ /**
395
+ * Returns a set of Patch operations that will move a materialized document from one state to another
396
+ * if applied.
397
+ *
398
+ * @remarks
399
+ * We allow specifying either:
400
+ * - Two sets of heads to compare directly
401
+ * - A single set of heads to compare against our current heads
402
+ * - Another DocHandle to compare against (which must share history with this document)
403
+ *
404
+ * @throws Error if the documents don't share history or if either document is not ready
405
+ * @returns Automerge patches that go from one document state to the other
406
+ */
407
+ diff(first: UrlHeads | DocHandle<T>, second?: UrlHeads): A.Patch[] {
408
+ if (!this.isReady()) {
409
+ throw new Error(
410
+ `DocHandle#${this.documentId} is not ready. Check \`handle.isReady()\` before calling diff().`
411
+ )
412
+ }
413
+
414
+ const doc = this.#doc
415
+ if (!doc) throw new Error("Document not available")
416
+
417
+ // If first argument is a DocHandle
418
+ if (first instanceof DocHandle) {
419
+ if (!first.isReady()) {
420
+ throw new Error("Cannot diff against a handle that isn't ready")
421
+ }
422
+ const otherHeads = first.heads()
423
+ if (!otherHeads) throw new Error("Other document's heads not available")
424
+
425
+ // Create a temporary merged doc to verify shared history and compute diff
426
+ const mergedDoc = A.merge(A.clone(doc), first.doc()!)
427
+ // Use the merged doc to compute the diff
428
+ return A.diff(
429
+ mergedDoc,
430
+ decodeHeads(this.heads()!),
431
+ decodeHeads(otherHeads)
432
+ )
433
+ }
434
+
435
+ // Otherwise treat as heads
436
+ const from = second ? first : ((this.heads() || []) as UrlHeads)
437
+ const to = second ? second : first
438
+ return A.diff(doc, decodeHeads(from), decodeHeads(to))
439
+ }
440
+
441
+ /**
442
+ * `metadata(head?)` allows you to look at the metadata for a change
443
+ * this can be used to build history graphs to find commit messages and edit times.
444
+ * this interface.
445
+ *
446
+ * @remarks
447
+ * I'm really not convinced this is the right way to surface this information so
448
+ * I'm leaving this API "hidden".
449
+ *
450
+ * @hidden
451
+ */
452
+ metadata(change?: string): A.DecodedChange | undefined {
453
+ if (!this.isReady()) {
454
+ return undefined
455
+ }
456
+
457
+ if (!change) {
458
+ change = this.heads()![0]
459
+ }
460
+ // we return undefined instead of null by convention in this API
461
+ return (
462
+ A.inspectChange(this.#doc, decodeHeads([change] as UrlHeads)[0]) ||
463
+ undefined
464
+ )
292
465
  }
293
466
 
294
467
  /**
@@ -314,13 +487,13 @@ export class DocHandle<T> extends EventEmitter<DocHandleEvents<T>> {
314
487
  * Called by the repo either when a doc handle changes or we receive new remote heads.
315
488
  * @hidden
316
489
  */
317
- setRemoteHeads(storageId: StorageId, heads: A.Heads) {
490
+ setRemoteHeads(storageId: StorageId, heads: UrlHeads) {
318
491
  this.#remoteHeads[storageId] = heads
319
492
  this.emit("remote-heads", { storageId, heads })
320
493
  }
321
494
 
322
495
  /** Returns the heads of the storageId. */
323
- getRemoteHeads(storageId: StorageId): A.Heads | undefined {
496
+ getRemoteHeads(storageId: StorageId): UrlHeads | undefined {
324
497
  return this.#remoteHeads[storageId]
325
498
  }
326
499
 
@@ -345,6 +518,13 @@ export class DocHandle<T> extends EventEmitter<DocHandleEvents<T>> {
345
518
  `DocHandle#${this.documentId} is in ${this.state} and not ready. Check \`handle.isReady()\` before accessing the document.`
346
519
  )
347
520
  }
521
+
522
+ if (this.#fixedHeads) {
523
+ throw new Error(
524
+ `DocHandle#${this.documentId} is in view-only mode at specific heads. Use clone() to create a new document from this state.`
525
+ )
526
+ }
527
+
348
528
  this.#machine.send({
349
529
  type: UPDATE,
350
530
  payload: { callback: doc => A.change(doc, options, callback) },
@@ -356,22 +536,29 @@ export class DocHandle<T> extends EventEmitter<DocHandleEvents<T>> {
356
536
  * @returns A set of heads representing the concurrent change that was made.
357
537
  */
358
538
  changeAt(
359
- heads: A.Heads,
539
+ heads: UrlHeads,
360
540
  callback: A.ChangeFn<T>,
361
541
  options: A.ChangeOptions<T> = {}
362
- ): string[] | undefined {
542
+ ): UrlHeads[] | undefined {
363
543
  if (!this.isReady()) {
364
544
  throw new Error(
365
545
  `DocHandle#${this.documentId} is not ready. Check \`handle.isReady()\` before accessing the document.`
366
546
  )
367
547
  }
368
- let resultHeads: string[] | undefined = undefined
548
+ if (this.#fixedHeads) {
549
+ throw new Error(
550
+ `DocHandle#${this.documentId} is in view-only mode at specific heads. Use clone() to create a new document from this state.`
551
+ )
552
+ }
553
+ let resultHeads: UrlHeads | undefined = undefined
369
554
  this.#machine.send({
370
555
  type: UPDATE,
371
556
  payload: {
372
557
  callback: doc => {
373
- const result = A.changeAt(doc, heads, options, callback)
374
- resultHeads = result.newHeads || undefined
558
+ const result = A.changeAt(doc, decodeHeads(heads), options, callback)
559
+ resultHeads = result.newHeads
560
+ ? encodeHeads(result.newHeads)
561
+ : undefined
375
562
  return result.newDoc
376
563
  },
377
564
  },
@@ -396,10 +583,12 @@ export class DocHandle<T> extends EventEmitter<DocHandleEvents<T>> {
396
583
  if (!this.isReady() || !otherHandle.isReady()) {
397
584
  throw new Error("Both handles must be ready to merge")
398
585
  }
399
- const mergingDoc = otherHandle.docSync()
400
- if (!mergingDoc) {
401
- throw new Error("The document to be merged in is falsy, aborting.")
586
+ if (this.#fixedHeads) {
587
+ throw new Error(
588
+ `DocHandle#${this.documentId} is in view-only mode at specific heads. Use clone() to create a new document from this state.`
589
+ )
402
590
  }
591
+ const mergingDoc = otherHandle.doc()
403
592
 
404
593
  this.update(doc => {
405
594
  return A.merge(doc, mergingDoc)
@@ -421,6 +610,16 @@ export class DocHandle<T> extends EventEmitter<DocHandleEvents<T>> {
421
610
  if (this.#state === "loading") this.#machine.send({ type: REQUEST })
422
611
  }
423
612
 
613
+ /** Called by the repo to free memory used by the document. */
614
+ unload() {
615
+ this.#machine.send({ type: UNLOAD })
616
+ }
617
+
618
+ /** Called by the repo to reuse an unloaded handle. */
619
+ reload() {
620
+ this.#machine.send({ type: RELOAD })
621
+ }
622
+
424
623
  /** Called by the repo when the document is deleted. */
425
624
  delete() {
426
625
  this.#machine.send({ type: DELETE })
@@ -439,6 +638,10 @@ export class DocHandle<T> extends EventEmitter<DocHandleEvents<T>> {
439
638
  data: encode(message),
440
639
  })
441
640
  }
641
+
642
+ metrics(): { numOps: number; numChanges: number } {
643
+ return A.stats(this.#doc)
644
+ }
442
645
  }
443
646
 
444
647
  // TYPES
@@ -457,6 +660,9 @@ export type DocHandleOptions<T> =
457
660
  | {
458
661
  isNew?: false
459
662
 
663
+ // An optional point in time to lock the document to.
664
+ heads?: UrlHeads
665
+
460
666
  /** The number of milliseconds before we mark this document as unavailable if we don't have it and nobody shares it with us. */
461
667
  timeoutDelay?: number
462
668
  }
@@ -468,7 +674,6 @@ export interface DocHandleEvents<T> {
468
674
  "heads-changed": (payload: DocHandleEncodedChangePayload<T>) => void
469
675
  change: (payload: DocHandleChangePayload<T>) => void
470
676
  delete: (payload: DocHandleDeletePayload<T>) => void
471
- unavailable: (payload: DocHandleUnavailablePayload<T>) => void
472
677
  "ephemeral-message": (payload: DocHandleEphemeralMessagePayload<T>) => void
473
678
  "ephemeral-message-outbound": (
474
679
  payload: DocHandleOutboundEphemeralMessagePayload<T>
@@ -520,7 +725,7 @@ export interface DocHandleOutboundEphemeralMessagePayload<T> {
520
725
  /** Emitted when we have new remote heads for this document */
521
726
  export interface DocHandleRemoteHeadsPayload {
522
727
  storageId: StorageId
523
- heads: A.Heads
728
+ heads: UrlHeads
524
729
  }
525
730
 
526
731
  // STATE MACHINE TYPES & CONSTANTS
@@ -539,6 +744,8 @@ export const HandleState = {
539
744
  REQUESTING: "requesting",
540
745
  /** The document is available */
541
746
  READY: "ready",
747
+ /** The document has been unloaded from the handle, to free memory usage */
748
+ UNLOADED: "unloaded",
542
749
  /** The document has been deleted from the repo */
543
750
  DELETED: "deleted",
544
751
  /** The document was not available in storage or from any connected peers */
@@ -546,8 +753,15 @@ export const HandleState = {
546
753
  } as const
547
754
  export type HandleState = (typeof HandleState)[keyof typeof HandleState]
548
755
 
549
- export const { IDLE, LOADING, REQUESTING, READY, DELETED, UNAVAILABLE } =
550
- HandleState
756
+ export const {
757
+ IDLE,
758
+ LOADING,
759
+ REQUESTING,
760
+ READY,
761
+ UNLOADED,
762
+ DELETED,
763
+ UNAVAILABLE,
764
+ } = HandleState
551
765
 
552
766
  // context
553
767
 
@@ -567,14 +781,18 @@ type DocHandleEvent<T> =
567
781
  type: typeof UPDATE
568
782
  payload: { callback: (doc: A.Doc<T>) => A.Doc<T> }
569
783
  }
570
- | { type: typeof TIMEOUT }
784
+ | { type: typeof UNLOAD }
785
+ | { type: typeof RELOAD }
571
786
  | { type: typeof DELETE }
787
+ | { type: typeof TIMEOUT }
572
788
  | { type: typeof DOC_UNAVAILABLE }
573
789
 
574
790
  const BEGIN = "BEGIN"
575
791
  const REQUEST = "REQUEST"
576
792
  const DOC_READY = "DOC_READY"
577
793
  const UPDATE = "UPDATE"
794
+ const UNLOAD = "UNLOAD"
795
+ const RELOAD = "RELOAD"
578
796
  const DELETE = "DELETE"
579
797
  const TIMEOUT = "TIMEOUT"
580
798
  const DOC_UNAVAILABLE = "DOC_UNAVAILABLE"
@@ -0,0 +1,48 @@
1
+ import { DocHandle } from "./DocHandle.js"
2
+
3
+ export type FindProgressState =
4
+ | "loading"
5
+ | "ready"
6
+ | "failed"
7
+ | "aborted"
8
+ | "unavailable"
9
+
10
+ interface FindProgressBase<T> {
11
+ state: FindProgressState
12
+ handle: DocHandle<T>
13
+ }
14
+
15
+ interface FindProgressLoading<T> extends FindProgressBase<T> {
16
+ state: "loading"
17
+ progress: number
18
+ }
19
+
20
+ interface FindProgressReady<T> extends FindProgressBase<T> {
21
+ state: "ready"
22
+ }
23
+
24
+ interface FindProgressFailed<T> extends FindProgressBase<T> {
25
+ state: "failed"
26
+ error: Error
27
+ }
28
+
29
+ interface FindProgressUnavailable<T> extends FindProgressBase<T> {
30
+ state: "unavailable"
31
+ }
32
+
33
+ interface FindProgressAborted<T> extends FindProgressBase<T> {
34
+ state: "aborted"
35
+ }
36
+
37
+ export type FindProgress<T> =
38
+ | FindProgressLoading<T>
39
+ | FindProgressReady<T>
40
+ | FindProgressFailed<T>
41
+ | FindProgressUnavailable<T>
42
+ | FindProgressAborted<T>
43
+
44
+ export type FindProgressWithMethods<T> = FindProgress<T> & {
45
+ next: () => Promise<FindProgressWithMethods<T>>
46
+ // TODO: i don't like this allowableStates
47
+ untilReady: (allowableStates: string[]) => Promise<DocHandle<T>>
48
+ }
@@ -1,6 +1,5 @@
1
- import { next as A } from "@automerge/automerge/slim"
2
1
  import { EventEmitter } from "eventemitter3"
3
- import { DocumentId, PeerId } from "./types.js"
2
+ import { DocumentId, PeerId, UrlHeads } from "./types.js"
4
3
  import {
5
4
  RemoteHeadsChanged,
6
5
  RemoteSubscriptionControlMessage,
@@ -12,7 +11,7 @@ import debug from "debug"
12
11
  export type RemoteHeadsSubscriptionEventPayload = {
13
12
  documentId: DocumentId
14
13
  storageId: StorageId
15
- remoteHeads: A.Heads
14
+ remoteHeads: UrlHeads
16
15
  timestamp: number
17
16
  }
18
17
 
@@ -21,7 +20,7 @@ export type NotifyRemoteHeadsPayload = {
21
20
  targetId: PeerId
22
21
  documentId: DocumentId
23
22
  storageId: StorageId
24
- heads: A.Heads
23
+ heads: UrlHeads
25
24
  timestamp: number
26
25
  }
27
26
 
@@ -216,7 +215,7 @@ export class RemoteHeadsSubscriptions extends EventEmitter<RemoteHeadsSubscripti
216
215
  handleImmediateRemoteHeadsChanged(
217
216
  documentId: DocumentId,
218
217
  storageId: StorageId,
219
- heads: A.Heads
218
+ heads: UrlHeads
220
219
  ) {
221
220
  this.#log("handleLocalHeadsChanged", documentId, storageId, heads)
222
221
  const remote = this.#knownHeads.get(documentId)
@@ -334,7 +333,7 @@ export class RemoteHeadsSubscriptions extends EventEmitter<RemoteHeadsSubscripti
334
333
  #changedHeads(msg: RemoteHeadsChanged): {
335
334
  documentId: DocumentId
336
335
  storageId: StorageId
337
- remoteHeads: A.Heads
336
+ remoteHeads: UrlHeads
338
337
  timestamp: number
339
338
  }[] {
340
339
  const changedHeads = []
@@ -356,11 +355,14 @@ export class RemoteHeadsSubscriptions extends EventEmitter<RemoteHeadsSubscripti
356
355
  if (docRemote && docRemote.timestamp >= timestamp) {
357
356
  continue
358
357
  } else {
359
- remote.set(storageId as StorageId, { timestamp, heads })
358
+ remote.set(storageId as StorageId, {
359
+ timestamp,
360
+ heads: heads as UrlHeads,
361
+ })
360
362
  changedHeads.push({
361
363
  documentId,
362
364
  storageId: storageId as StorageId,
363
- remoteHeads: heads,
365
+ remoteHeads: heads as UrlHeads,
364
366
  timestamp,
365
367
  })
366
368
  }
@@ -371,5 +373,5 @@ export class RemoteHeadsSubscriptions extends EventEmitter<RemoteHeadsSubscripti
371
373
 
372
374
  type LastHeads = {
373
375
  timestamp: number
374
- heads: A.Heads
376
+ heads: UrlHeads
375
377
  }