@automerge/automerge-repo 2.0.0-alpha.6 → 2.0.0-beta.1

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 (79) hide show
  1. package/README.md +8 -8
  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 +87 -30
  6. package/dist/DocHandle.d.ts.map +1 -1
  7. package/dist/DocHandle.js +198 -48
  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 +46 -6
  15. package/dist/Repo.d.ts.map +1 -1
  16. package/dist/Repo.js +252 -67
  17. package/dist/helpers/abortable.d.ts +39 -0
  18. package/dist/helpers/abortable.d.ts.map +1 -0
  19. package/dist/helpers/abortable.js +45 -0
  20. package/dist/helpers/arraysAreEqual.d.ts.map +1 -1
  21. package/dist/helpers/bufferFromHex.d.ts +3 -0
  22. package/dist/helpers/bufferFromHex.d.ts.map +1 -0
  23. package/dist/helpers/bufferFromHex.js +13 -0
  24. package/dist/helpers/debounce.d.ts.map +1 -1
  25. package/dist/helpers/eventPromise.d.ts.map +1 -1
  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/pause.d.ts.map +1 -1
  31. package/dist/helpers/tests/network-adapter-tests.d.ts.map +1 -1
  32. package/dist/helpers/tests/network-adapter-tests.js +13 -13
  33. package/dist/helpers/tests/storage-adapter-tests.d.ts +2 -2
  34. package/dist/helpers/tests/storage-adapter-tests.d.ts.map +1 -1
  35. package/dist/helpers/tests/storage-adapter-tests.js +25 -48
  36. package/dist/helpers/throttle.d.ts.map +1 -1
  37. package/dist/helpers/withTimeout.d.ts.map +1 -1
  38. package/dist/index.d.ts +2 -1
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +1 -1
  41. package/dist/network/messages.d.ts.map +1 -1
  42. package/dist/storage/StorageSubsystem.d.ts +15 -1
  43. package/dist/storage/StorageSubsystem.d.ts.map +1 -1
  44. package/dist/storage/StorageSubsystem.js +50 -14
  45. package/dist/synchronizer/CollectionSynchronizer.d.ts +4 -3
  46. package/dist/synchronizer/CollectionSynchronizer.d.ts.map +1 -1
  47. package/dist/synchronizer/CollectionSynchronizer.js +34 -15
  48. package/dist/synchronizer/DocSynchronizer.d.ts +3 -2
  49. package/dist/synchronizer/DocSynchronizer.d.ts.map +1 -1
  50. package/dist/synchronizer/DocSynchronizer.js +51 -27
  51. package/dist/synchronizer/Synchronizer.d.ts +11 -0
  52. package/dist/synchronizer/Synchronizer.d.ts.map +1 -1
  53. package/dist/types.d.ts +4 -1
  54. package/dist/types.d.ts.map +1 -1
  55. package/fuzz/fuzz.ts +3 -3
  56. package/package.json +3 -4
  57. package/src/AutomergeUrl.ts +101 -26
  58. package/src/DocHandle.ts +268 -58
  59. package/src/FindProgress.ts +48 -0
  60. package/src/RemoteHeadsSubscriptions.ts +11 -9
  61. package/src/Repo.ts +364 -74
  62. package/src/helpers/abortable.ts +61 -0
  63. package/src/helpers/bufferFromHex.ts +14 -0
  64. package/src/helpers/headsAreSame.ts +2 -2
  65. package/src/helpers/tests/network-adapter-tests.ts +14 -13
  66. package/src/helpers/tests/storage-adapter-tests.ts +44 -86
  67. package/src/index.ts +7 -0
  68. package/src/storage/StorageSubsystem.ts +66 -16
  69. package/src/synchronizer/CollectionSynchronizer.ts +37 -16
  70. package/src/synchronizer/DocSynchronizer.ts +59 -32
  71. package/src/synchronizer/Synchronizer.ts +14 -0
  72. package/src/types.ts +4 -1
  73. package/test/AutomergeUrl.test.ts +130 -0
  74. package/test/CollectionSynchronizer.test.ts +4 -4
  75. package/test/DocHandle.test.ts +255 -30
  76. package/test/DocSynchronizer.test.ts +10 -3
  77. package/test/Repo.test.ts +376 -203
  78. package/test/StorageSubsystem.test.ts +80 -1
  79. 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,10 @@ 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> = {}
47
+
48
+ /** Cache for view handles, keyed by the stringified heads */
49
+ #viewCache: Map<string, DocHandle<T>> = new Map()
40
50
 
41
51
  /** @hidden */
42
52
  constructor(
@@ -49,6 +59,10 @@ export class DocHandle<T> extends EventEmitter<DocHandleEvents<T>> {
49
59
  this.#timeoutDelay = options.timeoutDelay
50
60
  }
51
61
 
62
+ if ("heads" in options) {
63
+ this.#fixedHeads = options.heads
64
+ }
65
+
52
66
  const doc = A.init<T>()
53
67
 
54
68
  this.#log = debug(`automerge-repo:dochandle:${this.documentId.slice(0, 5)}`)
@@ -72,9 +86,12 @@ export class DocHandle<T> extends EventEmitter<DocHandleEvents<T>> {
72
86
  this.emit("delete", { handle: this })
73
87
  return { doc: A.init() }
74
88
  }),
75
- onUnavailable: () => {
76
- this.emit("unavailable", { handle: this })
77
- },
89
+ onUnavailable: assign(() => {
90
+ return { doc: A.init() }
91
+ }),
92
+ onUnload: assign(() => {
93
+ return { doc: A.init() }
94
+ }),
78
95
  },
79
96
  }).createMachine({
80
97
  /** @xstate-layout N4IgpgJg5mDOIC5QAoC2BDAxgCwJYDswBKAYgFUAFAEQEEAVAUQG0AGAXUVAAcB7WXAC64e+TiAAeiAOwAOAKwA6ACxSAzKqks1ATjlTdAGhABPRAFolAJksKN2y1KtKAbFLla5AX09G0WPISkVAwAMgyMrBxIILz8QiJikggAjCzOijKqLEqqybJyLizaRqYIFpbJtro5Uo7J2o5S3r4YOATECrgQADZgJADCAEoM9MzsYrGCwqLRSeoyCtra8pa5adquySXmDjY5ac7JljLJeepKzSB+bYGdPX0AYgCSAHJUkRN8UwmziM7HCgqyVcUnqcmScmcMm2ZV2yiyzkOx1OalUFx8V1aAQ63R46AgBCgJGGAEUyAwAMp0D7RSbxGagJKHFgKOSWJTJGRSCosCpKaEmRCqbQKU5yXINeTaer6LwY67YogKXH4wkkKgAeX6AH1hjQqABNGncL70xKIJQ5RY5BHOJag6wwpRyEWImQVeT1aWrVSXBXtJUqgn4Ik0ADqNCedG1L3CYY1gwA0saYqbpuaEG4pKLksKpFDgcsCjDhTnxTKpTLdH6sQGFOgAO7oKYhl5gAQNngAJwA1iRY3R40ndSNDSm6enfpm5BkWAVkvy7bpuTCKq7ndZnfVeSwuTX-HWu2AAI4AVzgQhD6q12rILxoADVIyEaAAhMLjtM-RmIE4LVSQi4nLLDIGzOCWwLKA0cgyLBoFWNy+43B0R5nheaqajqepjuMtJfgyEh-FoixqMCoKqOyhzgYKCDOq6UIeuCSxHOoSGKgop74OgABuzbdOgABGvTXlho5GrhJpxJOP4pLulT6KoMhpJY2hzsWNF0QobqMV6LG+pc+A8BAcBiP6gSfFJ36EQgKksksKxrHamwwmY7gLKB85QjBzoAWxdZdL0FnfARST8ooLC7qoTnWBU4pyC5ViVMKBQaHUDQuM4fm3EGhJBWaU7-CysEAUp3LpEpWw0WYRw2LmqzgqciIsCxWUdI2zaXlAbYdt2PZ5dJ1n5jY2iJY1ikOIcMJHCyUWHC62hRZkUVNPKta3Kh56wJ1-VWUyzhFc64JWJCtQNBBzhQW4cHwbsrVKpxPF8YJgV4ZZIWIKkiKiiNSkqZYWjzCWaQ5hFh0AcCuR3QoR74qUknBRmzholpv3OkpRQNNRpTzaKTWKbIWR5FDxm9AIkA7e9skUYCWayLILBZGoLkUSKbIyIdpxHPoyTeN4QA */
@@ -86,6 +103,7 @@ export class DocHandle<T> extends EventEmitter<DocHandleEvents<T>> {
86
103
  context: { documentId, doc },
87
104
  on: {
88
105
  UPDATE: { actions: "onUpdate" },
106
+ UNLOAD: ".unloaded",
89
107
  DELETE: ".deleted",
90
108
  },
91
109
  states: {
@@ -113,6 +131,12 @@ export class DocHandle<T> extends EventEmitter<DocHandleEvents<T>> {
113
131
  on: { DOC_READY: "ready" },
114
132
  },
115
133
  ready: {},
134
+ unloaded: {
135
+ entry: "onUnload",
136
+ on: {
137
+ RELOAD: "loading",
138
+ },
139
+ },
116
140
  deleted: { entry: "onDelete", type: "final" },
117
141
  },
118
142
  })
@@ -131,7 +155,7 @@ export class DocHandle<T> extends EventEmitter<DocHandleEvents<T>> {
131
155
 
132
156
  // Start the machine, and send a create or find event to get things going
133
157
  this.#machine.start()
134
- this.#machine.send({ type: BEGIN })
158
+ this.begin()
135
159
  }
136
160
 
137
161
  // PRIVATE
@@ -166,7 +190,10 @@ export class DocHandle<T> extends EventEmitter<DocHandleEvents<T>> {
166
190
  #checkForChanges(before: A.Doc<T>, after: A.Doc<T>) {
167
191
  const beforeHeads = A.getHeads(before)
168
192
  const afterHeads = A.getHeads(after)
169
- const docChanged = !headsAreSame(afterHeads, beforeHeads)
193
+ const docChanged = !headsAreSame(
194
+ encodeHeads(afterHeads),
195
+ encodeHeads(beforeHeads)
196
+ )
170
197
  if (docChanged) {
171
198
  this.emit("heads-changed", { handle: this, doc: after })
172
199
 
@@ -192,7 +219,10 @@ export class DocHandle<T> extends EventEmitter<DocHandleEvents<T>> {
192
219
  /** Our documentId in Automerge URL form.
193
220
  */
194
221
  get url(): AutomergeUrl {
195
- return stringifyAutomergeUrl({ documentId: this.documentId })
222
+ return stringifyAutomergeUrl({
223
+ documentId: this.documentId,
224
+ heads: this.#fixedHeads,
225
+ })
196
226
  }
197
227
 
198
228
  /**
@@ -203,6 +233,14 @@ export class DocHandle<T> extends EventEmitter<DocHandleEvents<T>> {
203
233
  */
204
234
  isReady = () => this.inState(["ready"])
205
235
 
236
+ /**
237
+ * @returns true if the document has been unloaded.
238
+ *
239
+ * Unloaded documents are freed from memory but not removed from local storage. It's not currently
240
+ * possible at runtime to reload an unloaded document.
241
+ */
242
+ isUnloaded = () => this.inState(["unloaded"])
243
+
206
244
  /**
207
245
  * @returns true if the document has been marked as deleted.
208
246
  *
@@ -241,42 +279,28 @@ export class DocHandle<T> extends EventEmitter<DocHandleEvents<T>> {
241
279
  }
242
280
 
243
281
  /**
244
- * @returns the current state of this handle's Automerge document.
282
+ * Returns the current state of the Automerge document this handle manages.
283
+ *
284
+ * @returns the current document
285
+ * @throws on deleted and unavailable documents
245
286
  *
246
- * This is the recommended way to access a handle's document. Note that this waits for the handle
247
- * to be ready if necessary. If loading (or synchronization) fails, this will never resolve.
248
287
  */
249
- async doc(
250
- /** states to wait for, such as "LOADING". mostly for internal use. */
251
- awaitStates: HandleState[] = ["ready", "unavailable"]
252
- ) {
253
- try {
254
- // wait for the document to enter one of the desired states
255
- await this.#statePromise(awaitStates)
256
- } catch (error) {
257
- // if we timed out, return undefined
258
- return undefined
288
+ doc() {
289
+ if (!this.isReady()) throw new Error("DocHandle is not ready")
290
+ if (this.#fixedHeads) {
291
+ return A.view(this.#doc, decodeHeads(this.#fixedHeads))
259
292
  }
260
- // Return the document
261
- return !this.isUnavailable() ? this.#doc : undefined
293
+ return this.#doc
262
294
  }
263
295
 
264
296
  /**
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.
268
297
  *
269
- * Not to be confused with the SyncState of the document, which describes the state of the
270
- * synchronization process.
271
- *
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
- */
298
+ * @deprecated */
277
299
  docSync() {
278
- if (!this.isReady()) return undefined
279
- else return this.#doc
300
+ console.warn(
301
+ "docSync is deprecated. Use doc() instead. This function will be removed as part of the 2.0 release."
302
+ )
303
+ return this.doc()
280
304
  }
281
305
 
282
306
  /**
@@ -284,11 +308,155 @@ export class DocHandle<T> extends EventEmitter<DocHandleEvents<T>> {
284
308
  * This precisely defines the state of a document.
285
309
  * @returns the current document's heads, or undefined if the document is not ready
286
310
  */
287
- heads(): A.Heads | undefined {
311
+ heads(): UrlHeads {
312
+ if (!this.isReady()) throw new Error("DocHandle is not ready")
313
+ if (this.#fixedHeads) {
314
+ return this.#fixedHeads
315
+ }
316
+ return encodeHeads(A.getHeads(this.#doc))
317
+ }
318
+
319
+ begin() {
320
+ this.#machine.send({ type: BEGIN })
321
+ }
322
+
323
+ /**
324
+ * Returns an array of all past "heads" for the document in topological order.
325
+ *
326
+ * @remarks
327
+ * A point-in-time in an automerge document is an *array* of heads since there may be
328
+ * concurrent edits. This API just returns a topologically sorted history of all edits
329
+ * so every previous entry will be (in some sense) before later ones, but the set of all possible
330
+ * history views would be quite large under concurrency (every thing in each branch against each other).
331
+ * There might be a clever way to think about this, but we haven't found it yet, so for now at least
332
+ * we present a single traversable view which excludes concurrency.
333
+ * @returns UrlHeads[] - The individual heads for every change in the document. Each item is a tagged string[1].
334
+ */
335
+ history(): UrlHeads[] | undefined {
336
+ if (!this.isReady()) {
337
+ return undefined
338
+ }
339
+ // This just returns all the heads as individual strings.
340
+
341
+ return A.topoHistoryTraversal(this.#doc).map(h =>
342
+ encodeHeads([h])
343
+ ) as UrlHeads[]
344
+ }
345
+
346
+ /**
347
+ * Creates a fixed "view" of an automerge document at the given point in time represented
348
+ * by the `heads` passed in. The return value is the same type as doc() and will return
349
+ * undefined if the object hasn't finished loading.
350
+ *
351
+ * @remarks
352
+ * Note that our Typescript types do not consider change over time and the current version
353
+ * of Automerge doesn't check types at runtime, so if you go back to an old set of heads
354
+ * that doesn't match the heads here, Typescript will not save you.
355
+ *
356
+ * @argument heads - The heads to view the document at. See history().
357
+ * @returns DocHandle<T> at the time of `heads`
358
+ */
359
+ view(heads: UrlHeads): DocHandle<T> {
360
+ if (!this.isReady()) {
361
+ throw new Error(
362
+ `DocHandle#${this.documentId} is not ready. Check \`handle.isReady()\` before calling view().`
363
+ )
364
+ }
365
+
366
+ // Create a cache key from the heads
367
+ const cacheKey = JSON.stringify(heads)
368
+
369
+ // Check if we have a cached handle for these heads
370
+ const cachedHandle = this.#viewCache.get(cacheKey)
371
+ if (cachedHandle) {
372
+ return cachedHandle
373
+ }
374
+
375
+ // Create a new handle with the same documentId but fixed heads
376
+ const handle = new DocHandle<T>(this.documentId, {
377
+ heads,
378
+ timeoutDelay: this.#timeoutDelay,
379
+ })
380
+ handle.update(() => A.clone(this.#doc))
381
+ handle.doneLoading()
382
+
383
+ // Store in cache
384
+ this.#viewCache.set(cacheKey, handle)
385
+
386
+ return handle
387
+ }
388
+
389
+ /**
390
+ * Returns a set of Patch operations that will move a materialized document from one state to another
391
+ * if applied.
392
+ *
393
+ * @remarks
394
+ * We allow specifying either:
395
+ * - Two sets of heads to compare directly
396
+ * - A single set of heads to compare against our current heads
397
+ * - Another DocHandle to compare against (which must share history with this document)
398
+ *
399
+ * @throws Error if the documents don't share history or if either document is not ready
400
+ * @returns Automerge patches that go from one document state to the other
401
+ */
402
+ diff(first: UrlHeads | DocHandle<T>, second?: UrlHeads): A.Patch[] {
403
+ if (!this.isReady()) {
404
+ throw new Error(
405
+ `DocHandle#${this.documentId} is not ready. Check \`handle.isReady()\` before calling diff().`
406
+ )
407
+ }
408
+
409
+ const doc = this.#doc
410
+ if (!doc) throw new Error("Document not available")
411
+
412
+ // If first argument is a DocHandle
413
+ if (first instanceof DocHandle) {
414
+ if (!first.isReady()) {
415
+ throw new Error("Cannot diff against a handle that isn't ready")
416
+ }
417
+ const otherHeads = first.heads()
418
+ if (!otherHeads) throw new Error("Other document's heads not available")
419
+
420
+ // Create a temporary merged doc to verify shared history and compute diff
421
+ const mergedDoc = A.merge(A.clone(doc), first.doc()!)
422
+ // Use the merged doc to compute the diff
423
+ return A.diff(
424
+ mergedDoc,
425
+ decodeHeads(this.heads()!),
426
+ decodeHeads(otherHeads)
427
+ )
428
+ }
429
+
430
+ // Otherwise treat as heads
431
+ const from = second ? first : ((this.heads() || []) as UrlHeads)
432
+ const to = second ? second : first
433
+ return A.diff(doc, decodeHeads(from), decodeHeads(to))
434
+ }
435
+
436
+ /**
437
+ * `metadata(head?)` allows you to look at the metadata for a change
438
+ * this can be used to build history graphs to find commit messages and edit times.
439
+ * this interface.
440
+ *
441
+ * @remarks
442
+ * I'm really not convinced this is the right way to surface this information so
443
+ * I'm leaving this API "hidden".
444
+ *
445
+ * @hidden
446
+ */
447
+ metadata(change?: string): A.DecodedChange | undefined {
288
448
  if (!this.isReady()) {
289
449
  return undefined
290
450
  }
291
- return A.getHeads(this.#doc)
451
+
452
+ if (!change) {
453
+ change = this.heads()![0]
454
+ }
455
+ // we return undefined instead of null by convention in this API
456
+ return (
457
+ A.inspectChange(this.#doc, decodeHeads([change] as UrlHeads)[0]) ||
458
+ undefined
459
+ )
292
460
  }
293
461
 
294
462
  /**
@@ -311,16 +479,16 @@ export class DocHandle<T> extends EventEmitter<DocHandleEvents<T>> {
311
479
  }
312
480
 
313
481
  /**
314
- * Called by the repo either when a doc handle changes or we receive new remote heads.
482
+ * Called by the repo when a doc handle changes or we receive new remote heads.
315
483
  * @hidden
316
484
  */
317
- setRemoteHeads(storageId: StorageId, heads: A.Heads) {
485
+ setRemoteHeads(storageId: StorageId, heads: UrlHeads) {
318
486
  this.#remoteHeads[storageId] = heads
319
487
  this.emit("remote-heads", { storageId, heads })
320
488
  }
321
489
 
322
490
  /** Returns the heads of the storageId. */
323
- getRemoteHeads(storageId: StorageId): A.Heads | undefined {
491
+ getRemoteHeads(storageId: StorageId): UrlHeads | undefined {
324
492
  return this.#remoteHeads[storageId]
325
493
  }
326
494
 
@@ -345,6 +513,13 @@ export class DocHandle<T> extends EventEmitter<DocHandleEvents<T>> {
345
513
  `DocHandle#${this.documentId} is in ${this.state} and not ready. Check \`handle.isReady()\` before accessing the document.`
346
514
  )
347
515
  }
516
+
517
+ if (this.#fixedHeads) {
518
+ throw new Error(
519
+ `DocHandle#${this.documentId} is in view-only mode at specific heads. Use clone() to create a new document from this state.`
520
+ )
521
+ }
522
+
348
523
  this.#machine.send({
349
524
  type: UPDATE,
350
525
  payload: { callback: doc => A.change(doc, options, callback) },
@@ -356,22 +531,29 @@ export class DocHandle<T> extends EventEmitter<DocHandleEvents<T>> {
356
531
  * @returns A set of heads representing the concurrent change that was made.
357
532
  */
358
533
  changeAt(
359
- heads: A.Heads,
534
+ heads: UrlHeads,
360
535
  callback: A.ChangeFn<T>,
361
536
  options: A.ChangeOptions<T> = {}
362
- ): string[] | undefined {
537
+ ): UrlHeads[] | undefined {
363
538
  if (!this.isReady()) {
364
539
  throw new Error(
365
540
  `DocHandle#${this.documentId} is not ready. Check \`handle.isReady()\` before accessing the document.`
366
541
  )
367
542
  }
368
- let resultHeads: string[] | undefined = undefined
543
+ if (this.#fixedHeads) {
544
+ throw new Error(
545
+ `DocHandle#${this.documentId} is in view-only mode at specific heads. Use clone() to create a new document from this state.`
546
+ )
547
+ }
548
+ let resultHeads: UrlHeads | undefined = undefined
369
549
  this.#machine.send({
370
550
  type: UPDATE,
371
551
  payload: {
372
552
  callback: doc => {
373
- const result = A.changeAt(doc, heads, options, callback)
374
- resultHeads = result.newHeads || undefined
553
+ const result = A.changeAt(doc, decodeHeads(heads), options, callback)
554
+ resultHeads = result.newHeads
555
+ ? encodeHeads(result.newHeads)
556
+ : undefined
375
557
  return result.newDoc
376
558
  },
377
559
  },
@@ -396,10 +578,12 @@ export class DocHandle<T> extends EventEmitter<DocHandleEvents<T>> {
396
578
  if (!this.isReady() || !otherHandle.isReady()) {
397
579
  throw new Error("Both handles must be ready to merge")
398
580
  }
399
- const mergingDoc = otherHandle.docSync()
400
- if (!mergingDoc) {
401
- throw new Error("The document to be merged in is falsy, aborting.")
581
+ if (this.#fixedHeads) {
582
+ throw new Error(
583
+ `DocHandle#${this.documentId} is in view-only mode at specific heads. Use clone() to create a new document from this state.`
584
+ )
402
585
  }
586
+ const mergingDoc = otherHandle.doc()
403
587
 
404
588
  this.update(doc => {
405
589
  return A.merge(doc, mergingDoc)
@@ -407,20 +591,31 @@ export class DocHandle<T> extends EventEmitter<DocHandleEvents<T>> {
407
591
  }
408
592
 
409
593
  /**
410
- * Used in testing to mark this document as unavailable.
594
+ * Updates the internal state machine to mark the document unavailable.
411
595
  * @hidden
412
596
  */
413
597
  unavailable() {
414
598
  this.#machine.send({ type: DOC_UNAVAILABLE })
415
599
  }
416
600
 
417
- /** Called by the repo when the document is not found in storage.
601
+ /**
602
+ * Called by the repo either when the document is not found in storage.
418
603
  * @hidden
419
604
  * */
420
605
  request() {
421
606
  if (this.#state === "loading") this.#machine.send({ type: REQUEST })
422
607
  }
423
608
 
609
+ /** Called by the repo to free memory used by the document. */
610
+ unload() {
611
+ this.#machine.send({ type: UNLOAD })
612
+ }
613
+
614
+ /** Called by the repo to reuse an unloaded handle. */
615
+ reload() {
616
+ this.#machine.send({ type: RELOAD })
617
+ }
618
+
424
619
  /** Called by the repo when the document is deleted. */
425
620
  delete() {
426
621
  this.#machine.send({ type: DELETE })
@@ -436,7 +631,7 @@ export class DocHandle<T> extends EventEmitter<DocHandleEvents<T>> {
436
631
  broadcast(message: unknown) {
437
632
  this.emit("ephemeral-message-outbound", {
438
633
  handle: this,
439
- data: encode(message),
634
+ data: new Uint8Array(encode(message)),
440
635
  })
441
636
  }
442
637
 
@@ -461,6 +656,9 @@ export type DocHandleOptions<T> =
461
656
  | {
462
657
  isNew?: false
463
658
 
659
+ // An optional point in time to lock the document to.
660
+ heads?: UrlHeads
661
+
464
662
  /** The number of milliseconds before we mark this document as unavailable if we don't have it and nobody shares it with us. */
465
663
  timeoutDelay?: number
466
664
  }
@@ -472,7 +670,6 @@ export interface DocHandleEvents<T> {
472
670
  "heads-changed": (payload: DocHandleEncodedChangePayload<T>) => void
473
671
  change: (payload: DocHandleChangePayload<T>) => void
474
672
  delete: (payload: DocHandleDeletePayload<T>) => void
475
- unavailable: (payload: DocHandleUnavailablePayload<T>) => void
476
673
  "ephemeral-message": (payload: DocHandleEphemeralMessagePayload<T>) => void
477
674
  "ephemeral-message-outbound": (
478
675
  payload: DocHandleOutboundEphemeralMessagePayload<T>
@@ -524,7 +721,7 @@ export interface DocHandleOutboundEphemeralMessagePayload<T> {
524
721
  /** Emitted when we have new remote heads for this document */
525
722
  export interface DocHandleRemoteHeadsPayload {
526
723
  storageId: StorageId
527
- heads: A.Heads
724
+ heads: UrlHeads
528
725
  }
529
726
 
530
727
  // STATE MACHINE TYPES & CONSTANTS
@@ -543,6 +740,8 @@ export const HandleState = {
543
740
  REQUESTING: "requesting",
544
741
  /** The document is available */
545
742
  READY: "ready",
743
+ /** The document has been unloaded from the handle, to free memory usage */
744
+ UNLOADED: "unloaded",
546
745
  /** The document has been deleted from the repo */
547
746
  DELETED: "deleted",
548
747
  /** The document was not available in storage or from any connected peers */
@@ -550,8 +749,15 @@ export const HandleState = {
550
749
  } as const
551
750
  export type HandleState = (typeof HandleState)[keyof typeof HandleState]
552
751
 
553
- export const { IDLE, LOADING, REQUESTING, READY, DELETED, UNAVAILABLE } =
554
- HandleState
752
+ export const {
753
+ IDLE,
754
+ LOADING,
755
+ REQUESTING,
756
+ READY,
757
+ UNLOADED,
758
+ DELETED,
759
+ UNAVAILABLE,
760
+ } = HandleState
555
761
 
556
762
  // context
557
763
 
@@ -571,14 +777,18 @@ type DocHandleEvent<T> =
571
777
  type: typeof UPDATE
572
778
  payload: { callback: (doc: A.Doc<T>) => A.Doc<T> }
573
779
  }
574
- | { type: typeof TIMEOUT }
780
+ | { type: typeof UNLOAD }
781
+ | { type: typeof RELOAD }
575
782
  | { type: typeof DELETE }
783
+ | { type: typeof TIMEOUT }
576
784
  | { type: typeof DOC_UNAVAILABLE }
577
785
 
578
786
  const BEGIN = "BEGIN"
579
787
  const REQUEST = "REQUEST"
580
788
  const DOC_READY = "DOC_READY"
581
789
  const UPDATE = "UPDATE"
790
+ const UNLOAD = "UNLOAD"
791
+ const RELOAD = "RELOAD"
582
792
  const DELETE = "DELETE"
583
793
  const TIMEOUT = "TIMEOUT"
584
794
  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
  }