@automerge/automerge-repo 1.1.5 → 1.1.8

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.
package/src/DocHandle.ts CHANGED
@@ -1,19 +1,7 @@
1
1
  import * as A from "@automerge/automerge/next"
2
2
  import debug from "debug"
3
3
  import { EventEmitter } from "eventemitter3"
4
- import {
5
- assign,
6
- BaseActionObject,
7
- createMachine,
8
- interpret,
9
- Interpreter,
10
- ResolveTypegenMeta,
11
- ServiceMap,
12
- StateSchema,
13
- StateValue,
14
- TypegenDisabled,
15
- } from "xstate"
16
- import { waitFor } from "xstate/lib/waitFor.js"
4
+ import { assertEvent, assign, createActor, setup, waitFor } from "xstate"
17
5
  import { stringifyAutomergeUrl } from "./AutomergeUrl.js"
18
6
  import { encode } from "./helpers/cbor.js"
19
7
  import { headsAreSame } from "./helpers/headsAreSame.js"
@@ -21,35 +9,34 @@ import { withTimeout } from "./helpers/withTimeout.js"
21
9
  import type { AutomergeUrl, DocumentId, PeerId } from "./types.js"
22
10
  import { StorageId } from "./storage/types.js"
23
11
 
24
- /** DocHandle is a wrapper around a single Automerge document that lets us
25
- * listen for changes and notify the network and storage of new changes.
12
+ /**
13
+ * A DocHandle is a wrapper around a single Automerge document that lets us listen for changes and
14
+ * notify the network and storage of new changes.
26
15
  *
27
16
  * @remarks
28
- * A `DocHandle` represents a document which is being managed by a {@link Repo}.
29
- * To obtain `DocHandle` use {@link Repo.find} or {@link Repo.create}.
17
+ * A `DocHandle` represents a document which is being managed by a {@link Repo}. You shouldn't ever
18
+ * instantiate this yourself. To obtain `DocHandle` use {@link Repo.find} or {@link Repo.create}.
30
19
  *
31
20
  * To modify the underlying document use either {@link DocHandle.change} or
32
- * {@link DocHandle.changeAt}. These methods will notify the `Repo` that some
33
- * change has occured and the `Repo` will save any new changes to the
34
- * attached {@link StorageAdapter} and send sync messages to connected peers.
35
- * */
36
- export class DocHandle<T> //
37
- extends EventEmitter<DocHandleEvents<T>>
38
- {
21
+ * {@link DocHandle.changeAt}. These methods will notify the `Repo` that some change has occured and
22
+ * the `Repo` will save any new changes to the attached {@link StorageAdapter} and send sync
23
+ * messages to connected peers.
24
+ */
25
+ export class DocHandle<T> extends EventEmitter<DocHandleEvents<T>> {
39
26
  #log: debug.Debugger
40
27
 
41
- #machine: DocHandleXstateMachine<T>
28
+ /** The XState actor running our state machine. */
29
+ #machine
30
+
31
+ /** The last known state of our document. */
32
+ #prevDocState: T | undefined
33
+
34
+ /** How long to wait before giving up on a document. (Note that a document will be marked
35
+ * unavailable much sooner if all known peers respond that they don't have it.) */
42
36
  #timeoutDelay = 60_000
43
- #remoteHeads: Record<StorageId, A.Heads> = {}
44
37
 
45
- /** The URL of this document
46
- *
47
- * @remarks
48
- * This can be used to request the document from an instance of {@link Repo}
49
- */
50
- get url(): AutomergeUrl {
51
- return stringifyAutomergeUrl({ documentId: this.documentId })
52
- }
38
+ /** A dictionary mapping each peer to the last heads we know they have. */
39
+ #remoteHeads: Record<StorageId, A.Heads> = {}
53
40
 
54
41
  /** @hidden */
55
42
  constructor(
@@ -58,8 +45,6 @@ export class DocHandle<T> //
58
45
  ) {
59
46
  super()
60
47
 
61
- this.documentId = documentId
62
-
63
48
  if ("timeoutDelay" in options && options.timeoutDelay) {
64
49
  this.#timeoutDelay = options.timeoutDelay
65
50
  }
@@ -78,156 +63,90 @@ export class DocHandle<T> //
78
63
 
79
64
  this.#log = debug(`automerge-repo:dochandle:${this.documentId.slice(0, 5)}`)
80
65
 
81
- /**
82
- * Internally we use a state machine to orchestrate document loading and/or syncing, in order to
83
- * avoid requesting data we already have, or surfacing intermediate values to the consumer.
84
- *
85
- * ┌─────────────────────┬─────────TIMEOUT────►┌─────────────┐
86
- * ┌───┴─────┐ ┌───┴────────┐ │ unavailable │
87
- * ┌───────┐ ┌──FIND──┤ loading ├─REQUEST──►│ requesting ├─UPDATE──┐ └─────────────┘
88
- * │ idle ├──┤ └───┬─────┘ └────────────┘ │
89
- * └───────┘ │ │ └─►┌────────┐
90
- * │ └───────LOAD───────────────────────────────►│ ready │
91
- * └──CREATE───────────────────────────────────────────────►└────────┘
92
- */
93
- this.#machine = interpret(
94
- createMachine<DocHandleContext<T>, DocHandleEvent<T>>(
95
- {
96
- predictableActionArguments: true,
97
-
98
- id: "docHandle",
99
- initial: IDLE,
100
- context: { documentId: this.documentId, doc },
101
- states: {
102
- idle: {
103
- on: {
104
- // If we're creating a new document, we don't need to load anything
105
- CREATE: { target: READY },
106
- // If we're accessing an existing document, we need to request it from storage
107
- // and/or the network
108
- FIND: { target: LOADING },
109
- DELETE: { actions: "onDelete", target: DELETED },
110
- },
111
- },
112
- loading: {
113
- on: {
114
- // UPDATE is called by the Repo if the document is found in storage
115
- UPDATE: { actions: "onUpdate", target: READY },
116
- // REQUEST is called by the Repo if the document is not found in storage
117
- REQUEST: { target: REQUESTING },
118
- // AWAIT_NETWORK is called by the repo if the document is not found in storage but the network is not yet ready
119
- AWAIT_NETWORK: { target: AWAITING_NETWORK },
120
- DELETE: { actions: "onDelete", target: DELETED },
121
- },
122
- after: [
123
- {
124
- delay: this.#timeoutDelay,
125
- target: UNAVAILABLE,
126
- },
127
- ],
128
- },
129
- awaitingNetwork: {
130
- on: {
131
- NETWORK_READY: { target: REQUESTING },
132
- },
133
- },
134
- requesting: {
135
- on: {
136
- MARK_UNAVAILABLE: {
137
- target: UNAVAILABLE,
138
- actions: "onUnavailable",
139
- },
140
- // UPDATE is called by the Repo when we receive changes from the network
141
- UPDATE: { actions: "onUpdate" },
142
- // REQUEST_COMPLETE is called from `onUpdate` when the doc has been fully loaded from the network
143
- REQUEST_COMPLETE: { target: READY },
144
- DELETE: { actions: "onDelete", target: DELETED },
145
- },
146
- after: [
147
- {
148
- delay: this.#timeoutDelay,
149
- target: UNAVAILABLE,
150
- },
151
- ],
152
- },
153
- ready: {
154
- on: {
155
- // UPDATE is called by the Repo when we receive changes from the network
156
- UPDATE: { actions: "onUpdate", target: READY },
157
- DELETE: { actions: "onDelete", target: DELETED },
158
- },
159
- },
160
- deleted: {
161
- type: "final",
162
- },
163
- unavailable: {
164
- on: {
165
- UPDATE: { actions: "onUpdate" },
166
- // REQUEST_COMPLETE is called from `onUpdate` when the doc has been fully loaded from the network
167
- REQUEST_COMPLETE: { target: READY },
168
- DELETE: { actions: "onDelete", target: DELETED },
169
- },
170
- },
171
- },
66
+ const delay = this.#timeoutDelay
67
+ const machine = setup({
68
+ types: {
69
+ context: {} as DocHandleContext<T>,
70
+ events: {} as DocHandleEvent<T>,
71
+ },
72
+ actions: {
73
+ /** Update the doc using the given callback and put the modified doc in context */
74
+ onUpdate: assign(({ context, event }) => {
75
+ const oldDoc = context.doc
76
+ assertEvent(event, UPDATE)
77
+ const { callback } = event.payload
78
+ const doc = callback(oldDoc)
79
+ return { doc }
80
+ }),
81
+ onDelete: assign(() => {
82
+ this.emit("delete", { handle: this })
83
+ return { doc: undefined }
84
+ }),
85
+ onUnavailable: () => {
86
+ this.emit("unavailable", { handle: this })
172
87
  },
88
+ },
89
+ }).createMachine({
90
+ /** @xstate-layout N4IgpgJg5mDOIC5QAoC2BDAxgCwJYDswBKAYgFUAFAEQEEAVAUQG0AGAXUVAAcB7WXAC64e+TiAAeiAOwAOAKwA6ACxSAzKqks1ATjlTdAGhABPRAFolAJksKN2y1KtKAbFLla5AX09G0WPISkVAwAMgyMrBxIILz8QiJikggAjCzOijKqLEqqybJyLizaRqYIFpbJtro5Uo7J2o5S3r4YOATECrgQADZgJADCAEoM9MzsYrGCwqLRSeoyCtra8pa5adquySXmDjY5ac7JljLJeepKzSB+bYGdPX0AYgCSAHJUkRN8UwmziM7HCgqyVcUnqcmScmcMm2ZV2yiyzkOx1OalUFx8V1aAQ63R46AgBCgJGGAEUyAwAMp0D7RSbxGagJKHFgKOSWJTJGRSCosCpKaEmRCqbQKU5yXINeTaer6LwY67YogKXH4wkkKgAeX6AH1hjQqABNGncL70xKIJQ5RY5BHOJag6wwpRyEWImQVeT1aWrVSXBXtJUqgn4Ik0ADqNCedG1L3CYY1gwA0saYqbpuaEG4pKLksKpFDgcsCjDhTnxTKpTLdH6sQGFOgAO7oKYhl5gAQNngAJwA1iRY3R40ndSNDSm6enfpm5BkWAVkvy7bpuTCKq7ndZnfVeSwuTX-HWu2AAI4AVzgQhD6q12rILxoADVIyEaAAhMLjtM-RmIE4LVSQi4nLLDIGzOCWwLKA0cgyLBoFWNy+43B0R5nheaqajqepjuMtJfgyEh-FoixqMCoKqOyhzgYKCDOq6UIeuCSxHOoSGKgop74OgABuzbdOgABGvTXlho5GrhJpxJOP4pLulT6KoMhpJY2hzsWNF0QobqMV6LG+pc+A8BAcBiP6gSfFJ36EQgKksksKxrHamwwmY7gLKB85QjBzoAWxdZdL0FnfARST8ooLC7qoTnWBU4pyC5ViVMKBQaHUDQuM4fm3EGhJBWaU7-CysEAUp3LpEpWw0WYRw2LmqzgqciIsCxWUdI2zaXlAbYdt2PZ5dJ1n5jY2iJY1ikOIcMJHCyUWHC62hRZkUVNPKta3Kh56wJ1-VWUyzhFc64JWJCtQNBBzhQW4cHwbsrVKpxPF8YJgV4ZZIWIKkiKiiNSkqZYWjzCWaQ5hFh0AcCuR3QoR74qUknBRmzholpv3OkpRQNNRpTzaKTWKbIWR5FDxm9AIkA7e9skUYCWayLILBZGoLkUSKbIyIdpxHPoyTeN4QA */
173
91
 
174
- {
175
- actions: {
176
- /** Put the updated doc on context */
177
- onUpdate: assign((context, { payload }: UpdateEvent<T>) => {
178
- const { doc: oldDoc } = context
179
-
180
- const { callback } = payload
181
- const newDoc = callback(oldDoc)
182
-
183
- return { doc: newDoc }
184
- }),
185
- onDelete: assign(() => {
186
- this.emit("delete", { handle: this })
187
- return { doc: undefined }
188
- }),
189
- onUnavailable: assign(context => {
190
- const { doc } = context
191
-
192
- this.emit("unavailable", { handle: this })
193
- return { doc }
194
- }),
92
+ // You can use the XState extension for VS Code to visualize this machine.
93
+ // Or, you can see this static visualization (last updated April 2024): https://stately.ai/registry/editor/d7af9b58-c518-44f1-9c36-92a238b04a7a?machineId=91c387e7-0f01-42c9-a21d-293e9bf95bb7
94
+
95
+ initial: "idle",
96
+ context: { documentId, doc },
97
+ on: {
98
+ UPDATE: { actions: "onUpdate" },
99
+ DELETE: ".deleted",
100
+ },
101
+ states: {
102
+ idle: {
103
+ on: {
104
+ CREATE: "ready",
105
+ FIND: "loading",
195
106
  },
196
- }
197
- )
198
- )
199
- .onTransition(({ value: state, history, context }, event) => {
200
- const oldDoc = history?.context?.doc
201
- const newDoc = context.doc
202
-
203
- this.#log(`${history?.value}: ${event.type} ${state}`, newDoc)
204
-
205
- const docChanged =
206
- newDoc &&
207
- oldDoc &&
208
- !headsAreSame(A.getHeads(newDoc), A.getHeads(oldDoc))
209
- if (docChanged) {
210
- this.emit("heads-changed", { handle: this, doc: newDoc })
211
-
212
- const patches = A.diff(newDoc, A.getHeads(oldDoc), A.getHeads(newDoc))
213
- if (patches.length > 0) {
214
- const source = "change" // TODO: pass along the source (load/change/network)
215
- this.emit("change", {
216
- handle: this,
217
- doc: newDoc,
218
- patches,
219
- patchInfo: { before: oldDoc, after: newDoc, source },
220
- })
221
- }
222
-
223
- if (!this.isReady()) {
224
- this.#machine.send(REQUEST_COMPLETE)
225
- }
226
- }
227
- })
228
- .start()
229
-
230
- this.#machine.send(isNew ? CREATE : FIND)
107
+ },
108
+ loading: {
109
+ on: {
110
+ REQUEST: "requesting",
111
+ DOC_READY: "ready",
112
+ AWAIT_NETWORK: "awaitingNetwork",
113
+ },
114
+ after: { [delay]: "unavailable" },
115
+ },
116
+ awaitingNetwork: {
117
+ on: { NETWORK_READY: "requesting" },
118
+ },
119
+ requesting: {
120
+ on: {
121
+ DOC_UNAVAILABLE: "unavailable",
122
+ DOC_READY: "ready",
123
+ },
124
+ after: { [delay]: "unavailable" },
125
+ },
126
+ unavailable: {
127
+ entry: "onUnavailable",
128
+ on: { DOC_READY: "ready" },
129
+ },
130
+ ready: {},
131
+ deleted: { entry: "onDelete", type: "final" },
132
+ },
133
+ })
134
+
135
+ // Instantiate the state machine
136
+ this.#machine = createActor(machine)
137
+
138
+ // Listen for state transitions
139
+ this.#machine.subscribe(state => {
140
+ const before = this.#prevDocState
141
+ const after = state.context.doc
142
+ this.#log(`→ ${state.value} %o`, after)
143
+ // if the document has changed, emit a change event
144
+ this.#checkForChanges(before, after)
145
+ })
146
+
147
+ // Start the machine, and send a create or find event to get things going
148
+ this.#machine.start()
149
+ this.#machine.send(isNew ? { type: CREATE } : { type: FIND })
231
150
  }
232
151
 
233
152
  // PRIVATE
@@ -255,55 +174,101 @@ export class DocHandle<T> //
255
174
  )
256
175
  }
257
176
 
177
+ /**
178
+ * Called after state transitions. If the document has changed, emits a change event. If we just
179
+ * received the document for the first time, signal that our request has been completed.
180
+ */
181
+ #checkForChanges(before: T | undefined, after: T) {
182
+ const docChanged =
183
+ after && before && !headsAreSame(A.getHeads(after), A.getHeads(before))
184
+ if (docChanged) {
185
+ this.emit("heads-changed", { handle: this, doc: after })
186
+
187
+ const patches = A.diff(after, A.getHeads(before), A.getHeads(after))
188
+ if (patches.length > 0) {
189
+ this.emit("change", {
190
+ handle: this,
191
+ doc: after,
192
+ patches,
193
+ // TODO: pass along the source (load/change/network)
194
+ patchInfo: { before, after, source: "change" },
195
+ })
196
+ }
197
+
198
+ // If we didn't have the document yet, signal that we now do
199
+ if (!this.isReady()) this.#machine.send({ type: DOC_READY })
200
+ }
201
+ this.#prevDocState = after
202
+ }
203
+
258
204
  // PUBLIC
259
205
 
206
+ /** Our documentId in Automerge URL form.
207
+ */
208
+ get url(): AutomergeUrl {
209
+ return stringifyAutomergeUrl({ documentId: this.documentId })
210
+ }
211
+
212
+ /**
213
+ * @returns true if the document is ready for accessing or changes.
214
+ *
215
+ * Note that for documents already stored locally this occurs before synchronization with any
216
+ * peers. We do not currently have an equivalent `whenSynced()`.
217
+ */
218
+ isReady = () => this.inState(["ready"])
219
+
260
220
  /**
261
- * Checks if the document is ready for accessing or changes.
262
- * Note that for documents already stored locally this occurs before synchronization
263
- * with any peers. We do not currently have an equivalent `whenSynced()`.
221
+ * @returns true if the document has been marked as deleted.
222
+ *
223
+ * Deleted documents are removed from local storage and the sync process. It's not currently
224
+ * possible at runtime to undelete a document.
264
225
  */
265
- isReady = () => this.inState([HandleState.READY])
226
+ isDeleted = () => this.inState(["deleted"])
227
+
266
228
  /**
267
- * Checks if this document has been marked as deleted.
268
- * Deleted documents are removed from local storage and the sync process.
269
- * It's not currently possible at runtime to undelete a document.
270
- * @returns true if the document has been marked as deleted
229
+ * @returns true if the document is currently unavailable.
230
+ *
231
+ * This will be the case if the document is not found in storage and no peers have shared it with us.
232
+ */
233
+ isUnavailable = () => this.inState(["unavailable"])
234
+
235
+ /**
236
+ * @returns true if the handle is in one of the given states.
271
237
  */
272
- isDeleted = () => this.inState([HandleState.DELETED])
273
- isUnavailable = () => this.inState([HandleState.UNAVAILABLE])
274
238
  inState = (states: HandleState[]) =>
275
- states.some(this.#machine?.getSnapshot().matches)
239
+ states.some(s => this.#machine.getSnapshot().matches(s))
276
240
 
277
241
  /** @hidden */
278
242
  get state() {
279
- return this.#machine?.getSnapshot().value
243
+ return this.#machine.getSnapshot().value
280
244
  }
281
245
 
282
246
  /**
283
- * Use this to block until the document handle has finished loading.
284
- * The async equivalent to checking `inState()`.
285
- * @param awaitStates = [READY]
286
- * @returns
247
+ * @returns a promise that resolves when the document is in one of the given states (if no states
248
+ * are passed, when the document is ready)
249
+ *
250
+ * Use this to block until the document handle has finished loading. The async equivalent to
251
+ * checking `inState()`.
287
252
  */
288
- async whenReady(awaitStates: HandleState[] = [READY]): Promise<void> {
253
+ async whenReady(awaitStates: HandleState[] = ["ready"]) {
289
254
  await withTimeout(this.#statePromise(awaitStates), this.#timeoutDelay)
290
255
  }
291
256
 
292
257
  /**
293
- * Returns the current state of the Automerge document this handle manages.
294
- * Note that this waits for the handle to be ready if necessary, and currently, if
295
- * loading (or synchronization) fails, will never resolve.
258
+ * @returns the current state of this handle's Automerge document.
296
259
  *
297
- * @param {awaitStates=[READY]} optional states to wait for, such as "LOADING". mostly for internal use.
260
+ * This is the recommended way to access a handle's document. Note that this waits for the handle
261
+ * to be ready if necessary. If loading (or synchronization) fails, this will never resolve.
298
262
  */
299
263
  async doc(
300
- awaitStates: HandleState[] = [READY, UNAVAILABLE]
301
- ): Promise<A.Doc<T> | undefined> {
264
+ /** states to wait for, such as "LOADING". mostly for internal use. */
265
+ awaitStates: HandleState[] = ["ready", "unavailable"]
266
+ ) {
302
267
  try {
303
268
  // wait for the document to enter one of the desired states
304
269
  await this.#statePromise(awaitStates)
305
270
  } catch (error) {
306
- // if we timed out (or have determined the document is currently unavailable), return undefined
271
+ // if we timed out, return undefined
307
272
  return undefined
308
273
  }
309
274
  // Return the document
@@ -311,33 +276,46 @@ export class DocHandle<T> //
311
276
  }
312
277
 
313
278
  /**
314
- * Returns the current state of the Automerge document this handle manages, or undefined.
315
- * Useful in a synchronous context. Consider using `await handle.doc()` instead, check `isReady()`,
316
- * or use `whenReady()` if you want to make sure loading is complete first.
279
+ * Synchronously returns the current state of the Automerge document this handle manages, or
280
+ * undefined. Consider using `await handle.doc()` instead. Check `isReady()`, or use `whenReady()`
281
+ * if you want to make sure loading is complete first.
317
282
  *
318
- * Do not confuse this with the SyncState of the document, which describes the state of the synchronization process.
283
+ * Not to be confused with the SyncState of the document, which describes the state of the
284
+ * synchronization process.
319
285
  *
320
- * Note that `undefined` is not a valid Automerge document so the return from this function is unambigous.
321
- * @returns the current document, or undefined if the document is not ready
286
+ * Note that `undefined` is not a valid Automerge document, so the return from this function is
287
+ * unambigous.
288
+ *
289
+ * @returns the current document, or undefined if the document is not ready.
290
+ */
291
+ docSync() {
292
+ if (!this.isReady()) return undefined
293
+ else return this.#doc
294
+ }
295
+
296
+ /**
297
+ * Returns the current "heads" of the document, akin to a git commit.
298
+ * This precisely defines the state of a document.
299
+ * @returns the current document's heads, or undefined if the document is not ready
322
300
  */
323
- docSync(): A.Doc<T> | undefined {
301
+ heads(): A.Heads | undefined {
324
302
  if (!this.isReady()) {
325
303
  return undefined
326
304
  }
327
-
328
- return this.#doc
305
+ return A.getHeads(this.#doc)
329
306
  }
330
307
 
331
- /** `update` is called by the repo when we receive changes from the network
308
+ /**
309
+ * `update` is called by the repo when we receive changes from the network
310
+ * Called by the repo when we receive changes from the network.
332
311
  * @hidden
333
- * */
312
+ */
334
313
  update(callback: (doc: A.Doc<T>) => A.Doc<T>) {
335
- this.#machine.send(UPDATE, {
336
- payload: { callback },
337
- })
314
+ this.#machine.send({ type: UPDATE, payload: { callback } })
338
315
  }
339
316
 
340
- /** `setRemoteHeads` is called by the repo either when a doc handle changes or we receive new remote heads
317
+ /**
318
+ * Called by the repo either when a doc handle changes or we receive new remote heads.
341
319
  * @hidden
342
320
  */
343
321
  setRemoteHeads(storageId: StorageId, heads: A.Heads) {
@@ -345,28 +323,39 @@ export class DocHandle<T> //
345
323
  this.emit("remote-heads", { storageId, heads })
346
324
  }
347
325
 
348
- /** Returns the heads of the storageId */
326
+ /** Returns the heads of the storageId. */
349
327
  getRemoteHeads(storageId: StorageId): A.Heads | undefined {
350
328
  return this.#remoteHeads[storageId]
351
329
  }
352
330
 
353
- /** `change` is called by the repo when the document is changed locally */
331
+ /**
332
+ * All changes to an Automerge document should be made through this method.
333
+ * Inside the callback, the document should be treated as mutable: all edits will be recorded
334
+ * using a Proxy and translated into operations as part of a single recorded "change".
335
+ *
336
+ * Note that assignment via ES6 spread operators will result in *replacing* the object
337
+ * instead of mutating it which will prevent clean merges. This may be what you want, but
338
+ * `doc.foo = { ...doc.foo, bar: "baz" }` is not equivalent to `doc.foo.bar = "baz"`.
339
+ *
340
+ * Local changes will be stored (by the StorageSubsystem) and synchronized (by the
341
+ * DocSynchronizer) to any peers you are sharing it with.
342
+ *
343
+ * @param callback - A function that takes the current document and mutates it.
344
+ *
345
+ */
354
346
  change(callback: A.ChangeFn<T>, options: A.ChangeOptions<T> = {}) {
355
347
  if (!this.isReady()) {
356
348
  throw new Error(
357
349
  `DocHandle#${this.documentId} is not ready. Check \`handle.isReady()\` before accessing the document.`
358
350
  )
359
351
  }
360
- this.#machine.send(UPDATE, {
361
- payload: {
362
- callback: (doc: A.Doc<T>) => {
363
- return A.change(doc, options, callback)
364
- },
365
- },
352
+ this.#machine.send({
353
+ type: UPDATE,
354
+ payload: { callback: doc => A.change(doc, options, callback) },
366
355
  })
367
356
  }
368
-
369
- /** Make a change as if the document were at `heads`
357
+ /**
358
+ * Makes a change as if the document were at `heads`.
370
359
  *
371
360
  * @returns A set of heads representing the concurrent change that was made.
372
361
  */
@@ -381,37 +370,39 @@ export class DocHandle<T> //
381
370
  )
382
371
  }
383
372
  let resultHeads: string[] | undefined = undefined
384
- this.#machine.send(UPDATE, {
373
+ this.#machine.send({
374
+ type: UPDATE,
385
375
  payload: {
386
- callback: (doc: A.Doc<T>) => {
376
+ callback: doc => {
387
377
  const result = A.changeAt(doc, heads, options, callback)
388
378
  resultHeads = result.newHeads || undefined
389
379
  return result.newDoc
390
380
  },
391
381
  },
392
382
  })
383
+
384
+ // the callback above will always run before we get here, so this should always contain the new heads
393
385
  return resultHeads
394
386
  }
395
387
 
396
- /** Merge another document into this document
397
- *
398
- * @param otherHandle - the handle of the document to merge into this one
388
+ /**
389
+ * Merges another document into this document. Any peers we are sharing changes with will be
390
+ * notified of the changes resulting from the merge.
399
391
  *
400
- * @remarks
401
- * This is a convenience method for
402
- * `handle.change(doc => A.merge(doc, otherHandle.docSync()))`. Any peers
403
- * whom we are sharing changes with will be notified of the changes resulting
404
- * from the merge.
392
+ * @returns the merged document.
405
393
  *
406
- * @throws if either document is not ready or if `otherHandle` is unavailable (`otherHandle.docSync() === undefined`)
394
+ * @throws if either document is not ready or if `otherHandle` is unavailable.
407
395
  */
408
- merge(otherHandle: DocHandle<T>) {
396
+ merge(
397
+ /** the handle of the document to merge into this one */
398
+ otherHandle: DocHandle<T>
399
+ ) {
409
400
  if (!this.isReady() || !otherHandle.isReady()) {
410
401
  throw new Error("Both handles must be ready to merge")
411
402
  }
412
403
  const mergingDoc = otherHandle.docSync()
413
404
  if (!mergingDoc) {
414
- throw new Error("The document to be merged in is null, aborting.")
405
+ throw new Error("The document to be merged in is falsy, aborting.")
415
406
  }
416
407
 
417
408
  this.update(doc => {
@@ -419,37 +410,43 @@ export class DocHandle<T> //
419
410
  })
420
411
  }
421
412
 
413
+ /**
414
+ * Used in testing to mark this document as unavailable.
415
+ * @hidden
416
+ */
422
417
  unavailable() {
423
- this.#machine.send(MARK_UNAVAILABLE)
418
+ this.#machine.send({ type: DOC_UNAVAILABLE })
424
419
  }
425
420
 
426
- /** `request` is called by the repo when the document is not found in storage
421
+ /** Called by the repo when the document is not found in storage.
427
422
  * @hidden
428
423
  * */
429
424
  request() {
430
- if (this.#state === LOADING) this.#machine.send(REQUEST)
425
+ if (this.#state === "loading") this.#machine.send({ type: REQUEST })
431
426
  }
432
427
 
433
428
  /** @hidden */
434
429
  awaitNetwork() {
435
- if (this.#state === LOADING) this.#machine.send(AWAIT_NETWORK)
430
+ if (this.#state === "loading") this.#machine.send({ type: AWAIT_NETWORK })
436
431
  }
437
432
 
438
433
  /** @hidden */
439
434
  networkReady() {
440
- if (this.#state === AWAITING_NETWORK) this.#machine.send(NETWORK_READY)
435
+ if (this.#state === "awaitingNetwork")
436
+ this.#machine.send({ type: NETWORK_READY })
441
437
  }
442
438
 
443
- /** `delete` is called by the repo when the document is deleted */
439
+ /** Called by the repo when the document is deleted. */
444
440
  delete() {
445
- this.#machine.send(DELETE)
441
+ this.#machine.send({ type: DELETE })
446
442
  }
447
443
 
448
- /** `broadcast` sends an arbitrary ephemeral message out to all reachable peers who would receive sync messages from you
449
- * it has no guarantee of delivery, and is not persisted to the underlying automerge doc in any way.
450
- * messages will have a sending PeerId but this is *not* a useful user identifier.
451
- * a user could have multiple tabs open and would appear as multiple PeerIds.
452
- * every message source must have a unique PeerId.
444
+ /**
445
+ * Sends an arbitrary ephemeral message out to all reachable peers who would receive sync messages
446
+ * from you. It has no guarantee of delivery, and is not persisted to the underlying automerge doc
447
+ * in any way. Messages will have a sending PeerId but this is *not* a useful user identifier (a
448
+ * user could have multiple tabs open and would appear as multiple PeerIds). Every message source
449
+ * must have a unique PeerId.
453
450
  */
454
451
  broadcast(message: unknown) {
455
452
  this.emit("ephemeral-message-outbound", {
@@ -459,7 +456,7 @@ export class DocHandle<T> //
459
456
  }
460
457
  }
461
458
 
462
- // WRAPPER CLASS TYPES
459
+ // TYPES
463
460
 
464
461
  /** @hidden */
465
462
  export type DocHandleOptions<T> =
@@ -479,24 +476,30 @@ export type DocHandleOptions<T> =
479
476
  timeoutDelay?: number
480
477
  }
481
478
 
482
- export interface DocHandleMessagePayload {
483
- destinationId: PeerId
484
- documentId: DocumentId
485
- data: Uint8Array
479
+ // EXTERNAL EVENTS
480
+
481
+ /** These are the events that this DocHandle emits to external listeners */
482
+ export interface DocHandleEvents<T> {
483
+ "heads-changed": (payload: DocHandleEncodedChangePayload<T>) => void
484
+ change: (payload: DocHandleChangePayload<T>) => void
485
+ delete: (payload: DocHandleDeletePayload<T>) => void
486
+ unavailable: (payload: DocHandleUnavailablePayload<T>) => void
487
+ "ephemeral-message": (payload: DocHandleEphemeralMessagePayload<T>) => void
488
+ "ephemeral-message-outbound": (
489
+ payload: DocHandleOutboundEphemeralMessagePayload<T>
490
+ ) => void
491
+ "remote-heads": (payload: DocHandleRemoteHeadsPayload) => void
486
492
  }
487
493
 
494
+ /** Emitted when this document's heads have changed */
488
495
  export interface DocHandleEncodedChangePayload<T> {
489
496
  handle: DocHandle<T>
490
497
  doc: A.Doc<T>
491
498
  }
492
499
 
493
- export interface DocHandleDeletePayload<T> {
494
- handle: DocHandle<T>
495
- }
496
-
497
- /** Emitted when a document has changed */
500
+ /** Emitted when this document has changed */
498
501
  export interface DocHandleChangePayload<T> {
499
- /** The hande which changed */
502
+ /** The handle that changed */
500
503
  handle: DocHandle<T>
501
504
  /** The value of the document after the change */
502
505
  doc: A.Doc<T>
@@ -506,47 +509,41 @@ export interface DocHandleChangePayload<T> {
506
509
  patchInfo: A.PatchInfo<T>
507
510
  }
508
511
 
512
+ /** Emitted when this document is deleted */
513
+ export interface DocHandleDeletePayload<T> {
514
+ handle: DocHandle<T>
515
+ }
516
+
517
+ /** Emitted when this document has been marked unavailable */
518
+ export interface DocHandleUnavailablePayload<T> {
519
+ handle: DocHandle<T>
520
+ }
521
+
522
+ /** Emitted when an ephemeral message is received for the document */
509
523
  export interface DocHandleEphemeralMessagePayload<T> {
510
524
  handle: DocHandle<T>
511
525
  senderId: PeerId
512
526
  message: unknown
513
527
  }
514
528
 
529
+ /** Emitted when an ephemeral message is sent for this document */
515
530
  export interface DocHandleOutboundEphemeralMessagePayload<T> {
516
531
  handle: DocHandle<T>
517
532
  data: Uint8Array
518
533
  }
519
534
 
535
+ /** Emitted when we have new remote heads for this document */
520
536
  export interface DocHandleRemoteHeadsPayload {
521
537
  storageId: StorageId
522
538
  heads: A.Heads
523
539
  }
524
540
 
525
- export interface DocHandleSyncStatePayload {
526
- peerId: PeerId
527
- syncState: A.SyncState
528
- }
529
-
530
- export interface DocHandleEvents<T> {
531
- "heads-changed": (payload: DocHandleEncodedChangePayload<T>) => void
532
- change: (payload: DocHandleChangePayload<T>) => void
533
- delete: (payload: DocHandleDeletePayload<T>) => void
534
- unavailable: (payload: DocHandleDeletePayload<T>) => void
535
- "ephemeral-message": (payload: DocHandleEphemeralMessagePayload<T>) => void
536
- "ephemeral-message-outbound": (
537
- payload: DocHandleOutboundEphemeralMessagePayload<T>
538
- ) => void
539
- "remote-heads": (payload: DocHandleRemoteHeadsPayload) => void
540
- }
541
-
542
- // STATE MACHINE TYPES
541
+ // STATE MACHINE TYPES & CONSTANTS
543
542
 
544
543
  // state
545
544
 
546
545
  /**
547
- * The state of a document handle
548
- * @enum
549
- *
546
+ * Possible internal states for a DocHandle
550
547
  */
551
548
  export const HandleState = {
552
549
  /** The handle has been created but not yet loaded or requested */
@@ -566,12 +563,15 @@ export const HandleState = {
566
563
  } as const
567
564
  export type HandleState = (typeof HandleState)[keyof typeof HandleState]
568
565
 
569
- type DocHandleMachineState = {
570
- states: Record<
571
- (typeof HandleState)[keyof typeof HandleState],
572
- StateSchema<HandleState>
573
- >
574
- }
566
+ export const {
567
+ IDLE,
568
+ LOADING,
569
+ AWAITING_NETWORK,
570
+ REQUESTING,
571
+ READY,
572
+ DELETED,
573
+ UNAVAILABLE,
574
+ } = HandleState
575
575
 
576
576
  // context
577
577
 
@@ -582,81 +582,29 @@ interface DocHandleContext<T> {
582
582
 
583
583
  // events
584
584
 
585
- export const Event = {
586
- CREATE: "CREATE",
587
- FIND: "FIND",
588
- REQUEST: "REQUEST",
589
- REQUEST_COMPLETE: "REQUEST_COMPLETE",
590
- AWAIT_NETWORK: "AWAIT_NETWORK",
591
- NETWORK_READY: "NETWORK_READY",
592
- UPDATE: "UPDATE",
593
- TIMEOUT: "TIMEOUT",
594
- DELETE: "DELETE",
595
- MARK_UNAVAILABLE: "MARK_UNAVAILABLE",
596
- } as const
597
- type Event = (typeof Event)[keyof typeof Event]
598
-
599
- type CreateEvent = { type: typeof CREATE; payload: { documentId: string } }
600
- type FindEvent = { type: typeof FIND; payload: { documentId: string } }
601
- type RequestEvent = { type: typeof REQUEST }
602
- type RequestCompleteEvent = { type: typeof REQUEST_COMPLETE }
603
- type DeleteEvent = { type: typeof DELETE }
604
- type UpdateEvent<T> = {
605
- type: typeof UPDATE
606
- payload: { callback: (doc: A.Doc<T>) => A.Doc<T> }
607
- }
608
- type TimeoutEvent = { type: typeof TIMEOUT }
609
- type MarkUnavailableEvent = { type: typeof MARK_UNAVAILABLE }
610
- type AwaitNetworkEvent = { type: typeof AWAIT_NETWORK }
611
- type NetworkReadyEvent = { type: typeof NETWORK_READY }
612
-
585
+ /** These are the (internal) events that can be sent to the state machine */
613
586
  type DocHandleEvent<T> =
614
- | CreateEvent
615
- | FindEvent
616
- | RequestEvent
617
- | RequestCompleteEvent
618
- | UpdateEvent<T>
619
- | TimeoutEvent
620
- | DeleteEvent
621
- | MarkUnavailableEvent
622
- | AwaitNetworkEvent
623
- | NetworkReadyEvent
624
-
625
- type DocHandleXstateMachine<T> = Interpreter<
626
- DocHandleContext<T>,
627
- DocHandleMachineState,
628
- DocHandleEvent<T>,
629
- {
630
- value: StateValue // Should this be unknown or T?
631
- context: DocHandleContext<T>
632
- },
633
- ResolveTypegenMeta<
634
- TypegenDisabled,
635
- DocHandleEvent<T>,
636
- BaseActionObject,
637
- ServiceMap
638
- >
639
- >
640
-
641
- // CONSTANTS
642
- export const {
643
- IDLE,
644
- LOADING,
645
- AWAITING_NETWORK,
646
- REQUESTING,
647
- READY,
648
- DELETED,
649
- UNAVAILABLE,
650
- } = HandleState
651
- const {
652
- CREATE,
653
- FIND,
654
- REQUEST,
655
- UPDATE,
656
- TIMEOUT,
657
- DELETE,
658
- REQUEST_COMPLETE,
659
- MARK_UNAVAILABLE,
660
- AWAIT_NETWORK,
661
- NETWORK_READY,
662
- } = Event
587
+ | { type: typeof CREATE }
588
+ | { type: typeof FIND }
589
+ | { type: typeof REQUEST }
590
+ | { type: typeof DOC_READY }
591
+ | {
592
+ type: typeof UPDATE
593
+ payload: { callback: (doc: A.Doc<T>) => A.Doc<T> }
594
+ }
595
+ | { type: typeof TIMEOUT }
596
+ | { type: typeof DELETE }
597
+ | { type: typeof DOC_UNAVAILABLE }
598
+ | { type: typeof AWAIT_NETWORK }
599
+ | { type: typeof NETWORK_READY }
600
+
601
+ const CREATE = "CREATE"
602
+ const FIND = "FIND"
603
+ const REQUEST = "REQUEST"
604
+ const DOC_READY = "DOC_READY"
605
+ const AWAIT_NETWORK = "AWAIT_NETWORK"
606
+ const NETWORK_READY = "NETWORK_READY"
607
+ const UPDATE = "UPDATE"
608
+ const DELETE = "DELETE"
609
+ const TIMEOUT = "TIMEOUT"
610
+ const DOC_UNAVAILABLE = "DOC_UNAVAILABLE"