@automerge/automerge-repo 1.1.4 → 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.
Files changed (37) hide show
  1. package/README.md +3 -22
  2. package/dist/DocHandle.d.ts +124 -100
  3. package/dist/DocHandle.d.ts.map +1 -1
  4. package/dist/DocHandle.js +239 -231
  5. package/dist/Repo.d.ts +10 -3
  6. package/dist/Repo.d.ts.map +1 -1
  7. package/dist/Repo.js +22 -1
  8. package/dist/helpers/arraysAreEqual.d.ts.map +1 -1
  9. package/dist/helpers/debounce.d.ts.map +1 -1
  10. package/dist/helpers/tests/network-adapter-tests.d.ts +1 -1
  11. package/dist/helpers/tests/network-adapter-tests.d.ts.map +1 -1
  12. package/dist/helpers/tests/network-adapter-tests.js +2 -2
  13. package/dist/helpers/tests/storage-adapter-tests.d.ts +7 -0
  14. package/dist/helpers/tests/storage-adapter-tests.d.ts.map +1 -0
  15. package/dist/helpers/tests/storage-adapter-tests.js +128 -0
  16. package/dist/helpers/throttle.d.ts.map +1 -1
  17. package/dist/helpers/withTimeout.d.ts.map +1 -1
  18. package/dist/index.d.ts +4 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +7 -0
  21. package/dist/synchronizer/DocSynchronizer.js +1 -1
  22. package/package.json +4 -4
  23. package/src/DocHandle.ts +325 -375
  24. package/src/Repo.ts +35 -8
  25. package/src/helpers/tests/network-adapter-tests.ts +4 -2
  26. package/src/helpers/tests/storage-adapter-tests.ts +193 -0
  27. package/src/index.ts +43 -0
  28. package/src/synchronizer/DocSynchronizer.ts +1 -1
  29. package/test/CollectionSynchronizer.test.ts +1 -3
  30. package/test/DocHandle.test.ts +19 -1
  31. package/test/DocSynchronizer.test.ts +1 -4
  32. package/test/DummyStorageAdapter.test.ts +11 -0
  33. package/test/Repo.test.ts +179 -53
  34. package/test/helpers/DummyNetworkAdapter.ts +20 -18
  35. package/test/helpers/DummyStorageAdapter.ts +5 -1
  36. package/test/remoteHeads.test.ts +1 -1
  37. package/tsconfig.json +1 -0
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
@@ -244,64 +163,112 @@ export class DocHandle<T> //
244
163
 
245
164
  /** Returns a promise that resolves when the docHandle is in one of the given states */
246
165
  #statePromise(awaitStates: HandleState | HandleState[]) {
247
- const awaitStatesArray = Array.isArray(awaitStates) ? awaitStates : [awaitStates]
166
+ const awaitStatesArray = Array.isArray(awaitStates)
167
+ ? awaitStates
168
+ : [awaitStates]
248
169
  return waitFor(
249
170
  this.#machine,
250
- s => awaitStatesArray.some((state) => s.matches(state)),
171
+ s => awaitStatesArray.some(state => s.matches(state)),
251
172
  // use a longer delay here so as not to race with other delays
252
- {timeout: this.#timeoutDelay * 2}
173
+ { timeout: this.#timeoutDelay * 2 }
253
174
  )
254
175
  }
255
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
+
256
204
  // PUBLIC
257
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
+
258
220
  /**
259
- * Checks if the document is ready for accessing or changes.
260
- * Note that for documents already stored locally this occurs before synchronization
261
- * 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.
262
225
  */
263
- isReady = () => this.inState([HandleState.READY])
226
+ isDeleted = () => this.inState(["deleted"])
227
+
264
228
  /**
265
- * Checks if this document has been marked as deleted.
266
- * Deleted documents are removed from local storage and the sync process.
267
- * It's not currently possible at runtime to undelete a document.
268
- * @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.
269
237
  */
270
- isDeleted = () => this.inState([HandleState.DELETED])
271
- isUnavailable = () => this.inState([HandleState.UNAVAILABLE])
272
238
  inState = (states: HandleState[]) =>
273
- states.some(this.#machine?.getSnapshot().matches)
239
+ states.some(s => this.#machine.getSnapshot().matches(s))
274
240
 
275
241
  /** @hidden */
276
242
  get state() {
277
- return this.#machine?.getSnapshot().value
243
+ return this.#machine.getSnapshot().value
278
244
  }
279
245
 
280
246
  /**
281
- * Use this to block until the document handle has finished loading.
282
- * The async equivalent to checking `inState()`.
283
- * @param awaitStates = [READY]
284
- * @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()`.
285
252
  */
286
- async whenReady(awaitStates: HandleState[] = [READY]): Promise<void> {
253
+ async whenReady(awaitStates: HandleState[] = ["ready"]) {
287
254
  await withTimeout(this.#statePromise(awaitStates), this.#timeoutDelay)
288
255
  }
289
256
 
290
257
  /**
291
- * Returns the current state of the Automerge document this handle manages.
292
- * Note that this waits for the handle to be ready if necessary, and currently, if
293
- * loading (or synchronization) fails, will never resolve.
258
+ * @returns the current state of this handle's Automerge document.
294
259
  *
295
- * @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.
296
262
  */
297
263
  async doc(
298
- awaitStates: HandleState[] = [READY, UNAVAILABLE]
299
- ): Promise<A.Doc<T> | undefined> {
264
+ /** states to wait for, such as "LOADING". mostly for internal use. */
265
+ awaitStates: HandleState[] = ["ready", "unavailable"]
266
+ ) {
300
267
  try {
301
268
  // wait for the document to enter one of the desired states
302
269
  await this.#statePromise(awaitStates)
303
270
  } catch (error) {
304
- // if we timed out (or have determined the document is currently unavailable), return undefined
271
+ // if we timed out, return undefined
305
272
  return undefined
306
273
  }
307
274
  // Return the document
@@ -309,33 +276,46 @@ export class DocHandle<T> //
309
276
  }
310
277
 
311
278
  /**
312
- * Returns the current state of the Automerge document this handle manages, or undefined.
313
- * Useful in a synchronous context. Consider using `await handle.doc()` instead, check `isReady()`,
314
- * 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.
315
282
  *
316
- * 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.
317
285
  *
318
- * Note that `undefined` is not a valid Automerge document so the return from this function is unambigous.
319
- * @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
320
300
  */
321
- docSync(): A.Doc<T> | undefined {
301
+ heads(): A.Heads | undefined {
322
302
  if (!this.isReady()) {
323
303
  return undefined
324
304
  }
325
-
326
- return this.#doc
305
+ return A.getHeads(this.#doc)
327
306
  }
328
307
 
329
- /** `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.
330
311
  * @hidden
331
- * */
312
+ */
332
313
  update(callback: (doc: A.Doc<T>) => A.Doc<T>) {
333
- this.#machine.send(UPDATE, {
334
- payload: { callback },
335
- })
314
+ this.#machine.send({ type: UPDATE, payload: { callback } })
336
315
  }
337
316
 
338
- /** `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.
339
319
  * @hidden
340
320
  */
341
321
  setRemoteHeads(storageId: StorageId, heads: A.Heads) {
@@ -343,28 +323,39 @@ export class DocHandle<T> //
343
323
  this.emit("remote-heads", { storageId, heads })
344
324
  }
345
325
 
346
- /** Returns the heads of the storageId */
326
+ /** Returns the heads of the storageId. */
347
327
  getRemoteHeads(storageId: StorageId): A.Heads | undefined {
348
328
  return this.#remoteHeads[storageId]
349
329
  }
350
330
 
351
- /** `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
+ */
352
346
  change(callback: A.ChangeFn<T>, options: A.ChangeOptions<T> = {}) {
353
347
  if (!this.isReady()) {
354
348
  throw new Error(
355
349
  `DocHandle#${this.documentId} is not ready. Check \`handle.isReady()\` before accessing the document.`
356
350
  )
357
351
  }
358
- this.#machine.send(UPDATE, {
359
- payload: {
360
- callback: (doc: A.Doc<T>) => {
361
- return A.change(doc, options, callback)
362
- },
363
- },
352
+ this.#machine.send({
353
+ type: UPDATE,
354
+ payload: { callback: doc => A.change(doc, options, callback) },
364
355
  })
365
356
  }
366
-
367
- /** Make a change as if the document were at `heads`
357
+ /**
358
+ * Makes a change as if the document were at `heads`.
368
359
  *
369
360
  * @returns A set of heads representing the concurrent change that was made.
370
361
  */
@@ -379,37 +370,39 @@ export class DocHandle<T> //
379
370
  )
380
371
  }
381
372
  let resultHeads: string[] | undefined = undefined
382
- this.#machine.send(UPDATE, {
373
+ this.#machine.send({
374
+ type: UPDATE,
383
375
  payload: {
384
- callback: (doc: A.Doc<T>) => {
376
+ callback: doc => {
385
377
  const result = A.changeAt(doc, heads, options, callback)
386
378
  resultHeads = result.newHeads || undefined
387
379
  return result.newDoc
388
380
  },
389
381
  },
390
382
  })
383
+
384
+ // the callback above will always run before we get here, so this should always contain the new heads
391
385
  return resultHeads
392
386
  }
393
387
 
394
- /** Merge another document into this document
395
- *
396
- * @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.
397
391
  *
398
- * @remarks
399
- * This is a convenience method for
400
- * `handle.change(doc => A.merge(doc, otherHandle.docSync()))`. Any peers
401
- * whom we are sharing changes with will be notified of the changes resulting
402
- * from the merge.
392
+ * @returns the merged document.
403
393
  *
404
- * @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.
405
395
  */
406
- merge(otherHandle: DocHandle<T>) {
396
+ merge(
397
+ /** the handle of the document to merge into this one */
398
+ otherHandle: DocHandle<T>
399
+ ) {
407
400
  if (!this.isReady() || !otherHandle.isReady()) {
408
401
  throw new Error("Both handles must be ready to merge")
409
402
  }
410
403
  const mergingDoc = otherHandle.docSync()
411
404
  if (!mergingDoc) {
412
- 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.")
413
406
  }
414
407
 
415
408
  this.update(doc => {
@@ -417,37 +410,43 @@ export class DocHandle<T> //
417
410
  })
418
411
  }
419
412
 
413
+ /**
414
+ * Used in testing to mark this document as unavailable.
415
+ * @hidden
416
+ */
420
417
  unavailable() {
421
- this.#machine.send(MARK_UNAVAILABLE)
418
+ this.#machine.send({ type: DOC_UNAVAILABLE })
422
419
  }
423
420
 
424
- /** `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.
425
422
  * @hidden
426
423
  * */
427
424
  request() {
428
- if (this.#state === LOADING) this.#machine.send(REQUEST)
425
+ if (this.#state === "loading") this.#machine.send({ type: REQUEST })
429
426
  }
430
427
 
431
428
  /** @hidden */
432
429
  awaitNetwork() {
433
- if (this.#state === LOADING) this.#machine.send(AWAIT_NETWORK)
430
+ if (this.#state === "loading") this.#machine.send({ type: AWAIT_NETWORK })
434
431
  }
435
432
 
436
433
  /** @hidden */
437
434
  networkReady() {
438
- if (this.#state === AWAITING_NETWORK) this.#machine.send(NETWORK_READY)
435
+ if (this.#state === "awaitingNetwork")
436
+ this.#machine.send({ type: NETWORK_READY })
439
437
  }
440
438
 
441
- /** `delete` is called by the repo when the document is deleted */
439
+ /** Called by the repo when the document is deleted. */
442
440
  delete() {
443
- this.#machine.send(DELETE)
441
+ this.#machine.send({ type: DELETE })
444
442
  }
445
443
 
446
- /** `broadcast` sends an arbitrary ephemeral message out to all reachable peers who would receive sync messages from you
447
- * it has no guarantee of delivery, and is not persisted to the underlying automerge doc in any way.
448
- * messages will have a sending PeerId but this is *not* a useful user identifier.
449
- * a user could have multiple tabs open and would appear as multiple PeerIds.
450
- * 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.
451
450
  */
452
451
  broadcast(message: unknown) {
453
452
  this.emit("ephemeral-message-outbound", {
@@ -457,7 +456,7 @@ export class DocHandle<T> //
457
456
  }
458
457
  }
459
458
 
460
- // WRAPPER CLASS TYPES
459
+ // TYPES
461
460
 
462
461
  /** @hidden */
463
462
  export type DocHandleOptions<T> =
@@ -477,24 +476,30 @@ export type DocHandleOptions<T> =
477
476
  timeoutDelay?: number
478
477
  }
479
478
 
480
- export interface DocHandleMessagePayload {
481
- destinationId: PeerId
482
- documentId: DocumentId
483
- 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
484
492
  }
485
493
 
494
+ /** Emitted when this document's heads have changed */
486
495
  export interface DocHandleEncodedChangePayload<T> {
487
496
  handle: DocHandle<T>
488
497
  doc: A.Doc<T>
489
498
  }
490
499
 
491
- export interface DocHandleDeletePayload<T> {
492
- handle: DocHandle<T>
493
- }
494
-
495
- /** Emitted when a document has changed */
500
+ /** Emitted when this document has changed */
496
501
  export interface DocHandleChangePayload<T> {
497
- /** The hande which changed */
502
+ /** The handle that changed */
498
503
  handle: DocHandle<T>
499
504
  /** The value of the document after the change */
500
505
  doc: A.Doc<T>
@@ -504,47 +509,41 @@ export interface DocHandleChangePayload<T> {
504
509
  patchInfo: A.PatchInfo<T>
505
510
  }
506
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 */
507
523
  export interface DocHandleEphemeralMessagePayload<T> {
508
524
  handle: DocHandle<T>
509
525
  senderId: PeerId
510
526
  message: unknown
511
527
  }
512
528
 
529
+ /** Emitted when an ephemeral message is sent for this document */
513
530
  export interface DocHandleOutboundEphemeralMessagePayload<T> {
514
531
  handle: DocHandle<T>
515
532
  data: Uint8Array
516
533
  }
517
534
 
535
+ /** Emitted when we have new remote heads for this document */
518
536
  export interface DocHandleRemoteHeadsPayload {
519
537
  storageId: StorageId
520
538
  heads: A.Heads
521
539
  }
522
540
 
523
- export interface DocHandleSyncStatePayload {
524
- peerId: PeerId
525
- syncState: A.SyncState
526
- }
527
-
528
- export interface DocHandleEvents<T> {
529
- "heads-changed": (payload: DocHandleEncodedChangePayload<T>) => void
530
- change: (payload: DocHandleChangePayload<T>) => void
531
- delete: (payload: DocHandleDeletePayload<T>) => void
532
- unavailable: (payload: DocHandleDeletePayload<T>) => void
533
- "ephemeral-message": (payload: DocHandleEphemeralMessagePayload<T>) => void
534
- "ephemeral-message-outbound": (
535
- payload: DocHandleOutboundEphemeralMessagePayload<T>
536
- ) => void
537
- "remote-heads": (payload: DocHandleRemoteHeadsPayload) => void
538
- }
539
-
540
- // STATE MACHINE TYPES
541
+ // STATE MACHINE TYPES & CONSTANTS
541
542
 
542
543
  // state
543
544
 
544
545
  /**
545
- * The state of a document handle
546
- * @enum
547
- *
546
+ * Possible internal states for a DocHandle
548
547
  */
549
548
  export const HandleState = {
550
549
  /** The handle has been created but not yet loaded or requested */
@@ -564,12 +563,15 @@ export const HandleState = {
564
563
  } as const
565
564
  export type HandleState = (typeof HandleState)[keyof typeof HandleState]
566
565
 
567
- type DocHandleMachineState = {
568
- states: Record<
569
- (typeof HandleState)[keyof typeof HandleState],
570
- StateSchema<HandleState>
571
- >
572
- }
566
+ export const {
567
+ IDLE,
568
+ LOADING,
569
+ AWAITING_NETWORK,
570
+ REQUESTING,
571
+ READY,
572
+ DELETED,
573
+ UNAVAILABLE,
574
+ } = HandleState
573
575
 
574
576
  // context
575
577
 
@@ -580,81 +582,29 @@ interface DocHandleContext<T> {
580
582
 
581
583
  // events
582
584
 
583
- export const Event = {
584
- CREATE: "CREATE",
585
- FIND: "FIND",
586
- REQUEST: "REQUEST",
587
- REQUEST_COMPLETE: "REQUEST_COMPLETE",
588
- AWAIT_NETWORK: "AWAIT_NETWORK",
589
- NETWORK_READY: "NETWORK_READY",
590
- UPDATE: "UPDATE",
591
- TIMEOUT: "TIMEOUT",
592
- DELETE: "DELETE",
593
- MARK_UNAVAILABLE: "MARK_UNAVAILABLE",
594
- } as const
595
- type Event = (typeof Event)[keyof typeof Event]
596
-
597
- type CreateEvent = { type: typeof CREATE; payload: { documentId: string } }
598
- type FindEvent = { type: typeof FIND; payload: { documentId: string } }
599
- type RequestEvent = { type: typeof REQUEST }
600
- type RequestCompleteEvent = { type: typeof REQUEST_COMPLETE }
601
- type DeleteEvent = { type: typeof DELETE }
602
- type UpdateEvent<T> = {
603
- type: typeof UPDATE
604
- payload: { callback: (doc: A.Doc<T>) => A.Doc<T> }
605
- }
606
- type TimeoutEvent = { type: typeof TIMEOUT }
607
- type MarkUnavailableEvent = { type: typeof MARK_UNAVAILABLE }
608
- type AwaitNetworkEvent = { type: typeof AWAIT_NETWORK }
609
- type NetworkReadyEvent = { type: typeof NETWORK_READY }
610
-
585
+ /** These are the (internal) events that can be sent to the state machine */
611
586
  type DocHandleEvent<T> =
612
- | CreateEvent
613
- | FindEvent
614
- | RequestEvent
615
- | RequestCompleteEvent
616
- | UpdateEvent<T>
617
- | TimeoutEvent
618
- | DeleteEvent
619
- | MarkUnavailableEvent
620
- | AwaitNetworkEvent
621
- | NetworkReadyEvent
622
-
623
- type DocHandleXstateMachine<T> = Interpreter<
624
- DocHandleContext<T>,
625
- DocHandleMachineState,
626
- DocHandleEvent<T>,
627
- {
628
- value: StateValue // Should this be unknown or T?
629
- context: DocHandleContext<T>
630
- },
631
- ResolveTypegenMeta<
632
- TypegenDisabled,
633
- DocHandleEvent<T>,
634
- BaseActionObject,
635
- ServiceMap
636
- >
637
- >
638
-
639
- // CONSTANTS
640
- export const {
641
- IDLE,
642
- LOADING,
643
- AWAITING_NETWORK,
644
- REQUESTING,
645
- READY,
646
- DELETED,
647
- UNAVAILABLE,
648
- } = HandleState
649
- const {
650
- CREATE,
651
- FIND,
652
- REQUEST,
653
- UPDATE,
654
- TIMEOUT,
655
- DELETE,
656
- REQUEST_COMPLETE,
657
- MARK_UNAVAILABLE,
658
- AWAIT_NETWORK,
659
- NETWORK_READY,
660
- } = 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"