@comapeo/core 2.3.1 → 3.0.0-0

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 (73) hide show
  1. package/dist/blob-store/entries-stream.d.ts.map +1 -1
  2. package/dist/blob-store/index.d.ts +27 -15
  3. package/dist/blob-store/index.d.ts.map +1 -1
  4. package/dist/blob-store/live-download.d.ts +107 -0
  5. package/dist/blob-store/live-download.d.ts.map +1 -0
  6. package/dist/capabilities.d.ts +121 -0
  7. package/dist/capabilities.d.ts.map +1 -0
  8. package/dist/core-manager/compat.d.ts +4 -0
  9. package/dist/core-manager/compat.d.ts.map +1 -0
  10. package/dist/core-manager/index.d.ts +4 -4
  11. package/dist/core-manager/index.d.ts.map +1 -1
  12. package/dist/discovery/dns-sd.d.ts +54 -0
  13. package/dist/discovery/dns-sd.d.ts.map +1 -0
  14. package/dist/errors.d.ts +16 -0
  15. package/dist/errors.d.ts.map +1 -1
  16. package/dist/fastify-plugins/maps/index.d.ts +11 -0
  17. package/dist/fastify-plugins/maps/index.d.ts.map +1 -0
  18. package/dist/fastify-plugins/maps/offline-fallback-map.d.ts +12 -0
  19. package/dist/fastify-plugins/maps/offline-fallback-map.d.ts.map +1 -0
  20. package/dist/fastify-plugins/maps/static-maps.d.ts +11 -0
  21. package/dist/fastify-plugins/maps/static-maps.d.ts.map +1 -0
  22. package/dist/generated/extensions.d.ts +7 -0
  23. package/dist/generated/extensions.d.ts.map +1 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/invite/invite-api.d.ts +112 -0
  26. package/dist/invite/invite-api.d.ts.map +1 -0
  27. package/dist/invite/invite-state-machine.d.ts +510 -0
  28. package/dist/invite/invite-state-machine.d.ts.map +1 -0
  29. package/dist/lib/timing-safe-equal.d.ts +15 -0
  30. package/dist/lib/timing-safe-equal.d.ts.map +1 -0
  31. package/dist/local-peers.d.ts.map +1 -1
  32. package/dist/mapeo-manager.d.ts +1 -1
  33. package/dist/mapeo-manager.d.ts.map +1 -1
  34. package/dist/mapeo-project.d.ts.map +1 -1
  35. package/dist/media-server.d.ts +36 -0
  36. package/dist/media-server.d.ts.map +1 -0
  37. package/dist/member-api.d.ts.map +1 -1
  38. package/dist/server/ws-core-replicator.d.ts +6 -0
  39. package/dist/server/ws-core-replicator.d.ts.map +1 -0
  40. package/dist/sync/core-sync-state.d.ts +14 -6
  41. package/dist/sync/core-sync-state.d.ts.map +1 -1
  42. package/dist/sync/namespace-sync-state.d.ts +3 -13
  43. package/dist/sync/namespace-sync-state.d.ts.map +1 -1
  44. package/dist/sync/sync-api.d.ts +17 -25
  45. package/dist/sync/sync-api.d.ts.map +1 -1
  46. package/dist/sync/sync-state.d.ts +3 -13
  47. package/dist/sync/sync-state.d.ts.map +1 -1
  48. package/dist/types.d.ts +6 -0
  49. package/dist/types.d.ts.map +1 -1
  50. package/dist/utils.d.ts +2 -2
  51. package/dist/utils.d.ts.map +1 -1
  52. package/package.json +5 -3
  53. package/src/blob-store/entries-stream.js +43 -18
  54. package/src/blob-store/index.js +161 -19
  55. package/src/core-manager/index.js +14 -7
  56. package/src/errors.js +33 -0
  57. package/src/generated/extensions.d.ts +7 -0
  58. package/src/generated/extensions.js +12 -2
  59. package/src/generated/extensions.ts +19 -1
  60. package/src/invite/StateDiagram.md +47 -0
  61. package/src/invite/invite-api.js +387 -0
  62. package/src/invite/invite-state-machine.js +208 -0
  63. package/src/local-peers.js +12 -9
  64. package/src/mapeo-manager.js +1 -1
  65. package/src/mapeo-project.js +7 -76
  66. package/src/member-api.js +5 -4
  67. package/src/sync/core-sync-state.js +41 -14
  68. package/src/sync/namespace-sync-state.js +25 -22
  69. package/src/sync/sync-api.js +12 -43
  70. package/src/sync/sync-state.js +9 -19
  71. package/src/types.ts +7 -1
  72. package/src/utils.js +8 -3
  73. package/src/invite-api.js +0 -450
@@ -6,12 +6,26 @@ import { FilterEntriesStream } from './utils.js'
6
6
  import { noop } from '../utils.js'
7
7
  import { TypedEmitter } from 'tiny-typed-emitter'
8
8
  import { HyperdriveIndexImpl as HyperdriveIndex } from './hyperdrive-index.js'
9
+ import { Logger } from '../logger.js'
10
+ import { getErrorCode, getErrorMessage } from '../lib/error.js'
9
11
 
10
12
  /** @import Hyperdrive from 'hyperdrive' */
11
13
  /** @import { JsonObject } from 'type-fest' */
12
14
  /** @import { Readable as NodeReadable } from 'node:stream' */
13
15
  /** @import { Readable as StreamxReadable, Writable } from 'streamx' */
14
- /** @import { BlobFilter, BlobId, BlobStoreEntriesStream } from '../types.js' */
16
+ /** @import { GenericBlobFilter, BlobFilter, BlobId, BlobStoreEntriesStream } from '../types.js' */
17
+
18
+ /**
19
+ * @typedef {object} BlobStoreEvents
20
+ * @prop {(peerId: string, blobFilter: GenericBlobFilter | null) => void} blob-filter
21
+ * @prop {(opts: {
22
+ * peerId: string
23
+ * start: number
24
+ * length: number
25
+ * blobCoreId: string
26
+ * }) => void} want-blob-range
27
+ * @prop {(error: Error) => void} error
28
+ */
15
29
 
16
30
  /**
17
31
  * @internal
@@ -31,6 +45,13 @@ const SUPPORTED_BLOB_VARIANTS = /** @type {const} */ ({
31
45
  // the type with JSDoc
32
46
  export { SUPPORTED_BLOB_VARIANTS }
33
47
 
48
+ /** @type {import('../types.js').BlobFilter} */
49
+ const NON_ARCHIVE_DEVICE_DOWNLOAD_FILTER = {
50
+ photo: ['preview', 'thumbnail'],
51
+ // Don't download any audio of video files, since previews and
52
+ // thumbnails aren't supported yet.
53
+ }
54
+
34
55
  class ErrNotFound extends Error {
35
56
  constructor(message = 'NotFound') {
36
57
  super(message)
@@ -38,24 +59,117 @@ class ErrNotFound extends Error {
38
59
  }
39
60
  }
40
61
 
41
- /** @extends {TypedEmitter<{ error: (error: Error) => void }>} */
62
+ /** @extends {TypedEmitter<BlobStoreEvents>} */
42
63
  export class BlobStore extends TypedEmitter {
43
64
  #driveIndex
44
65
  /** @type {Downloader} */
45
66
  #downloader
67
+ /** @type {Map<string, GenericBlobFilter | null>} */
68
+ #blobFilters = new Map()
69
+ #l
70
+ /** @type {Map<string, BlobStoreEntriesStream>} */
71
+ #entriesStreams = new Map()
72
+ #isArchiveDevice
73
+ #deviceId
74
+
75
+ /**
76
+ * Bound function for handling download intents for both peers and self
77
+ * @param {GenericBlobFilter | null} filter
78
+ * @param {string} peerId
79
+ */
80
+ #handleDownloadIntent = async (filter, peerId) => {
81
+ this.#l.log('Download intent %o for peer %S', filter, peerId)
82
+ try {
83
+ this.#entriesStreams.get(peerId)?.destroy()
84
+ this.emit('blob-filter', peerId, filter)
85
+ this.#blobFilters.set(peerId, filter)
86
+
87
+ if (filter === null) return
88
+
89
+ const entriesReadStream = this.createEntriesReadStream({
90
+ live: true,
91
+ filter,
92
+ })
93
+ this.#entriesStreams.set(peerId, entriesReadStream)
94
+
95
+ entriesReadStream.once('close', () => {
96
+ if (this.#entriesStreams.get(peerId) === entriesReadStream) {
97
+ this.#entriesStreams.delete(peerId)
98
+ }
99
+ })
100
+
101
+ for await (const {
102
+ blobCoreId,
103
+ value: { blob },
104
+ } of entriesReadStream) {
105
+ const { blockOffset: start, blockLength: length } = blob
106
+ this.emit('want-blob-range', {
107
+ peerId,
108
+ start,
109
+ length,
110
+ blobCoreId,
111
+ })
112
+ }
113
+ } catch (err) {
114
+ if (getErrorCode(err) === 'ERR_STREAM_PREMATURE_CLOSE') return
115
+ this.#l.log(
116
+ 'Error getting blob entries stream for peer %h: %s',
117
+ peerId,
118
+ getErrorMessage(err)
119
+ )
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Bound to `this`
125
+ * This will be called whenever a peer is successfully added to the creatorcore
126
+ * @param {import('../types.js').HypercorePeer & { protomux: import('protomux')<import('../lib/noise-secret-stream-helpers.js').OpenedNoiseStream> }} peer
127
+ */
128
+ #handlePeerAdd = (peer) => {
129
+ const downloadFilter = getBlobDownloadFilter(this.#isArchiveDevice)
130
+ this.#coreManager.sendDownloadIntents(downloadFilter, peer)
131
+ }
132
+
133
+ /**
134
+ * Bound to `this`
135
+ * @param {import('../types.js').HypercorePeer & { protomux: import('protomux')<import('../lib/noise-secret-stream-helpers.js').OpenedNoiseStream> }} peer
136
+ */
137
+ #handlePeerRemove = (peer) => {
138
+ const peerKey = peer.protomux.stream.remotePublicKey
139
+ const peerId = peerKey.toString('hex')
140
+ this.#entriesStreams.get(peerId)?.destroy()
141
+ this.#entriesStreams.delete(peerId)
142
+ }
143
+
144
+ #coreManager
145
+ #logger
46
146
 
47
147
  /**
48
148
  * @param {object} options
49
149
  * @param {import('../core-manager/index.js').CoreManager} options.coreManager
50
- * @param {BlobFilter | null} options.downloadFilter - Filter blob types and/or variants to download. Set to `null` to download all blobs.
150
+ * @param {boolean} [options.isArchiveDevice] Set to `true` if this is an archive device which should download all blobs, or just a selection of blobs
151
+ * @param {import('../logger.js').Logger} [options.logger]
51
152
  */
52
- constructor({ coreManager, downloadFilter }) {
153
+ constructor({ coreManager, isArchiveDevice = true, logger }) {
53
154
  super()
155
+ this.#logger = logger
156
+ this.#l = Logger.create('blobStore', logger)
157
+ this.#isArchiveDevice = isArchiveDevice
54
158
  this.#driveIndex = new HyperdriveIndex(coreManager)
159
+ this.#coreManager = coreManager
160
+ this.#deviceId = coreManager.deviceId
161
+ const downloadFilter = getBlobDownloadFilter(isArchiveDevice)
162
+ if (downloadFilter) {
163
+ this.#handleDownloadIntent(downloadFilter, this.#deviceId)
164
+ }
55
165
  this.#downloader = new Downloader(this.#driveIndex, {
56
166
  filter: downloadFilter,
57
167
  })
58
168
  this.#downloader.on('error', (error) => this.emit('error', error))
169
+
170
+ coreManager.on('peer-download-intent', this.#handleDownloadIntent)
171
+ coreManager.creatorCore.on('peer-add', this.#handlePeerAdd)
172
+ coreManager.creatorCore.on('peer-remove', this.#handlePeerRemove)
59
173
  }
60
174
 
61
175
  /**
@@ -65,6 +179,38 @@ export class BlobStore extends TypedEmitter {
65
179
  return getDiscoveryId(this.#driveIndex.writerKey)
66
180
  }
67
181
 
182
+ get isArchiveDevice() {
183
+ return this.#isArchiveDevice
184
+ }
185
+
186
+ /**
187
+ * @param {string} peerId
188
+ * @returns {GenericBlobFilter | null}
189
+ */
190
+ getBlobFilter(peerId) {
191
+ return this.#blobFilters.get(peerId) ?? null
192
+ }
193
+
194
+ /** @param {boolean} isArchiveDevice */
195
+ async setIsArchiveDevice(isArchiveDevice) {
196
+ this.#l.log('Setting isArchiveDevice to %s', isArchiveDevice)
197
+ if (this.#isArchiveDevice === isArchiveDevice) return
198
+ this.#isArchiveDevice = isArchiveDevice
199
+ const blobDownloadFilter = getBlobDownloadFilter(isArchiveDevice)
200
+ this.#downloader.removeAllListeners()
201
+ this.#downloader.destroy()
202
+ this.#downloader = new Downloader(this.#driveIndex, {
203
+ filter: blobDownloadFilter,
204
+ })
205
+ this.#downloader.on('error', (error) => this.emit('error', error))
206
+ // Even if blobFilter is null, e.g. we plan to download everything, we still
207
+ // need to inform connected peers of the change.
208
+ for (const peer of this.#coreManager.creatorCore.peers) {
209
+ this.#coreManager.sendDownloadIntents(blobDownloadFilter, peer)
210
+ }
211
+ this.#handleDownloadIntent(blobDownloadFilter, this.#deviceId)
212
+ }
213
+
68
214
  /**
69
215
  * @param {string} driveId hex-encoded discovery key
70
216
  * @returns {Hyperdrive}
@@ -90,21 +236,6 @@ export class BlobStore extends TypedEmitter {
90
236
  return blob
91
237
  }
92
238
 
93
- /**
94
- * Set the filter for downloading blobs.
95
- *
96
- * @param {import('../types.js').BlobFilter | null} filter Filter blob types and/or variants to download. Filter is { [BlobType]: BlobVariants[] }. At least one blob variant must be specified for each blob type.
97
- * @returns {void}
98
- */
99
- setDownloadFilter(filter) {
100
- this.#downloader.removeAllListeners()
101
- this.#downloader.destroy()
102
- this.#downloader = new Downloader(this.#driveIndex, {
103
- filter,
104
- })
105
- this.#downloader.on('error', (error) => this.emit('error', error))
106
- }
107
-
108
239
  /**
109
240
  * @param {BlobId} blobId
110
241
  * @param {object} [options]
@@ -245,6 +376,9 @@ export class BlobStore extends TypedEmitter {
245
376
  close() {
246
377
  this.#downloader.removeAllListeners()
247
378
  this.#downloader.destroy()
379
+ this.#coreManager.off('peer-download-intent', this.#handleDownloadIntent)
380
+ this.#coreManager.creatorCore.off('peer-add', this.#handlePeerAdd)
381
+ this.#coreManager.creatorCore.off('peer-remove', this.#handlePeerRemove)
248
382
  }
249
383
  }
250
384
 
@@ -280,3 +414,11 @@ function makePath({ type, variant, name }) {
280
414
  function getDiscoveryId(key) {
281
415
  return discoveryKey(key).toString('hex')
282
416
  }
417
+
418
+ /**
419
+ * @param {boolean} isArchiveDevice
420
+ * @returns {null | BlobFilter}
421
+ */
422
+ function getBlobDownloadFilter(isArchiveDevice) {
423
+ return isArchiveDevice ? null : NON_ARCHIVE_DEVICE_DOWNLOAD_FILTER
424
+ }
@@ -30,7 +30,7 @@ export const kCoreManagerReplicate = Symbol('replicate core manager')
30
30
  * @typedef {Object} Events
31
31
  * @property {(coreRecord: CoreRecord) => void} add-core
32
32
  * @property {(namespace: Namespace, msg: { coreDiscoveryId: string, peerId: string, start: number, bitfield: Uint32Array }) => void} peer-have
33
- * @property {(blobFilter: GenericBlobFilter, peerId: string) => void} peer-download-intent
33
+ * @property {(blobFilter: GenericBlobFilter | null, peerId: string) => void} peer-download-intent
34
34
  */
35
35
 
36
36
  /**
@@ -412,7 +412,7 @@ export class CoreManager extends TypedEmitter {
412
412
  }
413
413
 
414
414
  /**
415
- * @param {GenericBlobFilter} blobFilter
415
+ * @param {GenericBlobFilter | null} blobFilter
416
416
  * @param {HypercorePeer} peer
417
417
  */
418
418
  #handleDownloadIntentMessage(blobFilter, peer) {
@@ -421,7 +421,7 @@ export class CoreManager extends TypedEmitter {
421
421
  }
422
422
 
423
423
  /**
424
- * @param {BlobFilter} blobFilter
424
+ * @param {BlobFilter | null} blobFilter
425
425
  * @param {HypercorePeer} peer
426
426
  */
427
427
  sendDownloadIntents(blobFilter, peer) {
@@ -540,20 +540,27 @@ const HaveExtensionCodec = {
540
540
  }
541
541
 
542
542
  const DownloadIntentCodec = {
543
- /** @param {BlobFilter} filter */
543
+ /** @param {BlobFilter | null} filter */
544
544
  encode(filter) {
545
- const downloadIntents = mapObject(filter, (key, value) => [
545
+ const downloadIntents = mapObject(filter || {}, (key, value) => [
546
546
  key,
547
547
  { variants: value || [] },
548
548
  ])
549
- return DownloadIntentExtension.encode({ downloadIntents }).finish()
549
+ // If filter is null, we want to download everything
550
+ const everything = !filter
551
+ return DownloadIntentExtension.encode({
552
+ downloadIntents,
553
+ everything,
554
+ }).finish()
550
555
  },
551
556
  /**
552
557
  * @param {Buffer | Uint8Array} buf
553
- * @returns {GenericBlobFilter}
558
+ * @returns {GenericBlobFilter | null}
554
559
  */
555
560
  decode(buf) {
556
561
  const msg = DownloadIntentExtension.decode(buf)
562
+ // If everything is true, we ignore the downloadIntents and return null, which means download everything
563
+ if (msg.everything) return null
557
564
  return mapObject(msg.downloadIntents, (key, value) => [
558
565
  key + '', // keep TS happy
559
566
  value.variants,
package/src/errors.js CHANGED
@@ -1,6 +1,39 @@
1
1
  export class NotFoundError extends Error {
2
2
  constructor(message = 'Not found') {
3
3
  super(message)
4
+ this.name = 'NotFoundError'
5
+ }
6
+ }
7
+
8
+ export class AlreadyJoinedError extends Error {
9
+ /** @param {string} [message] */
10
+ constructor(message = 'AlreadyJoinedError') {
11
+ super(message)
12
+ this.name = 'AlreadyJoinedError'
13
+ }
14
+ }
15
+
16
+ export class InviteSendError extends Error {
17
+ /** @param {string} [message] */
18
+ constructor(message = 'Invite Send Error') {
19
+ super(message)
20
+ this.name = 'InviteSendError'
21
+ }
22
+ }
23
+
24
+ export class InviteAbortedError extends Error {
25
+ /** @param {string} [message] */
26
+ constructor(message = 'Invite Aborted') {
27
+ super(message)
28
+ this.name = 'InviteAbortedError'
29
+ }
30
+ }
31
+
32
+ export class TimeoutError extends Error {
33
+ /** @param {string} [message] */
34
+ constructor(message = 'TimeoutError') {
35
+ super(message)
36
+ this.name = 'TimeoutError'
4
37
  }
5
38
  }
6
39
 
@@ -24,6 +24,13 @@ export interface DownloadIntentExtension {
24
24
  downloadIntents: {
25
25
  [key: string]: DownloadIntentExtension_DownloadIntent;
26
26
  };
27
+ /**
28
+ * If true, the peer intends to download all blobs - this is the default
29
+ * assumption when a peer has not sent a download intent, but if a peer
30
+ * changes their intent while connected, we need to send the new intent to
31
+ * download everything.
32
+ */
33
+ everything: boolean;
27
34
  }
28
35
  export interface DownloadIntentExtension_DownloadIntent {
29
36
  variants: string[];
@@ -170,7 +170,7 @@ export var HaveExtension = {
170
170
  },
171
171
  };
172
172
  function createBaseDownloadIntentExtension() {
173
- return { downloadIntents: {} };
173
+ return { downloadIntents: {}, everything: false };
174
174
  }
175
175
  export var DownloadIntentExtension = {
176
176
  encode: function (message, writer) {
@@ -180,6 +180,9 @@ export var DownloadIntentExtension = {
180
180
  DownloadIntentExtension_DownloadIntentsEntry.encode({ key: key, value: value }, writer.uint32(10).fork())
181
181
  .ldelim();
182
182
  });
183
+ if (message.everything === true) {
184
+ writer.uint32(16).bool(message.everything);
185
+ }
183
186
  return writer;
184
187
  },
185
188
  decode: function (input, length) {
@@ -198,6 +201,12 @@ export var DownloadIntentExtension = {
198
201
  message.downloadIntents[entry1.key] = entry1.value;
199
202
  }
200
203
  continue;
204
+ case 2:
205
+ if (tag !== 16) {
206
+ break;
207
+ }
208
+ message.everything = reader.bool();
209
+ continue;
201
210
  }
202
211
  if ((tag & 7) === 4 || tag === 0) {
203
212
  break;
@@ -210,7 +219,7 @@ export var DownloadIntentExtension = {
210
219
  return DownloadIntentExtension.fromPartial(base !== null && base !== void 0 ? base : {});
211
220
  },
212
221
  fromPartial: function (object) {
213
- var _a;
222
+ var _a, _b;
214
223
  var message = createBaseDownloadIntentExtension();
215
224
  message.downloadIntents = Object.entries((_a = object.downloadIntents) !== null && _a !== void 0 ? _a : {}).reduce(function (acc, _a) {
216
225
  var key = _a[0], value = _a[1];
@@ -219,6 +228,7 @@ export var DownloadIntentExtension = {
219
228
  }
220
229
  return acc;
221
230
  }, {});
231
+ message.everything = (_b = object.everything) !== null && _b !== void 0 ? _b : false;
222
232
  return message;
223
233
  },
224
234
  };
@@ -69,6 +69,13 @@ export function haveExtension_NamespaceToNumber(object: HaveExtension_Namespace)
69
69
  /** A map of blob types and variants that a peer intends to download */
70
70
  export interface DownloadIntentExtension {
71
71
  downloadIntents: { [key: string]: DownloadIntentExtension_DownloadIntent };
72
+ /**
73
+ * If true, the peer intends to download all blobs - this is the default
74
+ * assumption when a peer has not sent a download intent, but if a peer
75
+ * changes their intent while connected, we need to send the new intent to
76
+ * download everything.
77
+ */
78
+ everything: boolean;
72
79
  }
73
80
 
74
81
  export interface DownloadIntentExtension_DownloadIntent {
@@ -209,7 +216,7 @@ export const HaveExtension = {
209
216
  };
210
217
 
211
218
  function createBaseDownloadIntentExtension(): DownloadIntentExtension {
212
- return { downloadIntents: {} };
219
+ return { downloadIntents: {}, everything: false };
213
220
  }
214
221
 
215
222
  export const DownloadIntentExtension = {
@@ -218,6 +225,9 @@ export const DownloadIntentExtension = {
218
225
  DownloadIntentExtension_DownloadIntentsEntry.encode({ key: key as any, value }, writer.uint32(10).fork())
219
226
  .ldelim();
220
227
  });
228
+ if (message.everything === true) {
229
+ writer.uint32(16).bool(message.everything);
230
+ }
221
231
  return writer;
222
232
  },
223
233
 
@@ -238,6 +248,13 @@ export const DownloadIntentExtension = {
238
248
  message.downloadIntents[entry1.key] = entry1.value;
239
249
  }
240
250
  continue;
251
+ case 2:
252
+ if (tag !== 16) {
253
+ break;
254
+ }
255
+
256
+ message.everything = reader.bool();
257
+ continue;
241
258
  }
242
259
  if ((tag & 7) === 4 || tag === 0) {
243
260
  break;
@@ -260,6 +277,7 @@ export const DownloadIntentExtension = {
260
277
  }
261
278
  return acc;
262
279
  }, {});
280
+ message.everything = object.everything ?? false;
263
281
  return message;
264
282
  },
265
283
  };
@@ -0,0 +1,47 @@
1
+ ```mermaid
2
+ ---
3
+ title: Invite Receive State Diagram
4
+ ---
5
+ stateDiagram-v2
6
+ state "invite" as invite {
7
+ [*] --> invite.pending
8
+ invite.pending --> invite.canceled : CANCEL_INVITE
9
+ invite.pending --> invite.responding.accept : ACCEPT_INVITE
10
+ invite.pending --> invite.responding.already : ALREADY_IN_PROJECT
11
+ invite.pending --> invite.responding.reject : REJECT_INVITE
12
+ invite.responding.default --> invite.error
13
+ invite.responding.accept --> invite.joining.addingProject : RECEIVE_PROJECT_DETAILS
14
+ invite.responding.accept --> invite.joining : Responded Accept
15
+ invite.responding.accept --> invite.error : Error responding
16
+ invite.responding.reject --> invite.rejected : Responded Reject
17
+ invite.responding.reject --> invite.error : Error responding
18
+ invite.responding.already --> invite.respondedAlready : Responded Already
19
+ invite.responding.already --> invite.error : Error responding
20
+ invite.responding --> invite.canceled : CANCEL_INVITE
21
+ invite.joining.awaitingDetails --> invite.canceled : CANCEL_INVITE
22
+ invite.joining.awaitingDetails --> invite.error : projectDetailsTimeout
23
+ invite.joining.addingProject --> invite.error : addProject Timeout
24
+ invite.joining.addingProject --> invite.joined : Project Added
25
+ invite.joining.addingProject --> invite.error : addProject Error
26
+ state "Pending invite awaiting response" as invite.pending
27
+ state "Responding to invite" as invite.responding {
28
+ [*] --> invite.responding.default
29
+ state "default" as invite.responding.default
30
+ state "accept" as invite.responding.accept
31
+ state "reject" as invite.responding.reject
32
+ state "already" as invite.responding.already
33
+ }
34
+ state "Joining project from invite" as invite.joining {
35
+ [*] --> invite.joining.awaitingDetails
36
+ invite.joining.awaitingDetails --> invite.joining.addingProject : RECEIVE_PROJECT_DETAILS
37
+ state "Awaiting project details" as invite.joining.awaitingDetails
38
+ state "Adding project" as invite.joining.addingProject
39
+ }
40
+ state "Invite Canceled" as invite.canceled
41
+ state "Invite Rejected" as invite.rejected
42
+ state "Responded already in project" as invite.respondedAlready
43
+ state "Joined project" as invite.joined
44
+ state "Invite Error" as invite.error
45
+ }
46
+
47
+ ```