@automerge/automerge-repo 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintrc +28 -0
- package/.mocharc.json +5 -0
- package/README.md +298 -0
- package/TODO.md +54 -0
- package/dist/DocCollection.d.ts +44 -0
- package/dist/DocCollection.d.ts.map +1 -0
- package/dist/DocCollection.js +85 -0
- package/dist/DocHandle.d.ts +78 -0
- package/dist/DocHandle.d.ts.map +1 -0
- package/dist/DocHandle.js +227 -0
- package/dist/EphemeralData.d.ts +27 -0
- package/dist/EphemeralData.d.ts.map +1 -0
- package/dist/EphemeralData.js +28 -0
- package/dist/Repo.d.ts +30 -0
- package/dist/Repo.d.ts.map +1 -0
- package/dist/Repo.js +97 -0
- package/dist/helpers/arraysAreEqual.d.ts +2 -0
- package/dist/helpers/arraysAreEqual.d.ts.map +1 -0
- package/dist/helpers/arraysAreEqual.js +1 -0
- package/dist/helpers/eventPromise.d.ts +5 -0
- package/dist/helpers/eventPromise.d.ts.map +1 -0
- package/dist/helpers/eventPromise.js +6 -0
- package/dist/helpers/headsAreSame.d.ts +3 -0
- package/dist/helpers/headsAreSame.d.ts.map +1 -0
- package/dist/helpers/headsAreSame.js +7 -0
- package/dist/helpers/mergeArrays.d.ts +2 -0
- package/dist/helpers/mergeArrays.d.ts.map +1 -0
- package/dist/helpers/mergeArrays.js +15 -0
- package/dist/helpers/pause.d.ts +3 -0
- package/dist/helpers/pause.d.ts.map +1 -0
- package/dist/helpers/pause.js +7 -0
- package/dist/helpers/withTimeout.d.ts +9 -0
- package/dist/helpers/withTimeout.d.ts.map +1 -0
- package/dist/helpers/withTimeout.js +22 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/network/NetworkAdapter.d.ts +37 -0
- package/dist/network/NetworkAdapter.d.ts.map +1 -0
- package/dist/network/NetworkAdapter.js +4 -0
- package/dist/network/NetworkSubsystem.d.ts +23 -0
- package/dist/network/NetworkSubsystem.d.ts.map +1 -0
- package/dist/network/NetworkSubsystem.js +89 -0
- package/dist/storage/StorageAdapter.d.ts +6 -0
- package/dist/storage/StorageAdapter.d.ts.map +1 -0
- package/dist/storage/StorageAdapter.js +2 -0
- package/dist/storage/StorageSubsystem.d.ts +12 -0
- package/dist/storage/StorageSubsystem.d.ts.map +1 -0
- package/dist/storage/StorageSubsystem.js +65 -0
- package/dist/synchronizer/CollectionSynchronizer.d.ts +24 -0
- package/dist/synchronizer/CollectionSynchronizer.d.ts.map +1 -0
- package/dist/synchronizer/CollectionSynchronizer.js +92 -0
- package/dist/synchronizer/DocSynchronizer.d.ts +18 -0
- package/dist/synchronizer/DocSynchronizer.d.ts.map +1 -0
- package/dist/synchronizer/DocSynchronizer.js +136 -0
- package/dist/synchronizer/Synchronizer.d.ts +10 -0
- package/dist/synchronizer/Synchronizer.d.ts.map +1 -0
- package/dist/synchronizer/Synchronizer.js +3 -0
- package/dist/test-utilities/adapter-tests.d.ts +21 -0
- package/dist/test-utilities/adapter-tests.d.ts.map +1 -0
- package/dist/test-utilities/adapter-tests.js +117 -0
- package/dist/types.d.ts +10 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/fuzz/fuzz.ts +129 -0
- package/package.json +65 -0
- package/src/DocCollection.ts +123 -0
- package/src/DocHandle.ts +386 -0
- package/src/EphemeralData.ts +46 -0
- package/src/Repo.ts +155 -0
- package/src/helpers/arraysAreEqual.ts +2 -0
- package/src/helpers/eventPromise.ts +10 -0
- package/src/helpers/headsAreSame.ts +8 -0
- package/src/helpers/mergeArrays.ts +17 -0
- package/src/helpers/pause.ts +9 -0
- package/src/helpers/withTimeout.ts +28 -0
- package/src/index.ts +22 -0
- package/src/network/NetworkAdapter.ts +54 -0
- package/src/network/NetworkSubsystem.ts +130 -0
- package/src/storage/StorageAdapter.ts +5 -0
- package/src/storage/StorageSubsystem.ts +91 -0
- package/src/synchronizer/CollectionSynchronizer.ts +112 -0
- package/src/synchronizer/DocSynchronizer.ts +182 -0
- package/src/synchronizer/Synchronizer.ts +15 -0
- package/src/test-utilities/adapter-tests.ts +163 -0
- package/src/types.ts +3 -0
- package/test/CollectionSynchronizer.test.ts +73 -0
- package/test/DocCollection.test.ts +19 -0
- package/test/DocHandle.test.ts +281 -0
- package/test/DocSynchronizer.test.ts +68 -0
- package/test/EphemeralData.test.ts +44 -0
- package/test/Network.test.ts +13 -0
- package/test/Repo.test.ts +367 -0
- package/test/StorageSubsystem.test.ts +78 -0
- package/test/helpers/DummyNetworkAdapter.ts +8 -0
- package/test/helpers/DummyStorageAdapter.ts +23 -0
- package/test/helpers/getRandomItem.ts +4 -0
- package/test/types.ts +3 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import * as A from "@automerge/automerge";
|
|
2
|
+
import debug from "debug";
|
|
3
|
+
import EventEmitter from "eventemitter3";
|
|
4
|
+
import { assign, createMachine, interpret, } from "xstate";
|
|
5
|
+
import { waitFor } from "xstate/lib/waitFor.js";
|
|
6
|
+
import { headsAreSame } from "./helpers/headsAreSame.js";
|
|
7
|
+
import { pause } from "./helpers/pause.js";
|
|
8
|
+
import { withTimeout, TimeoutError } from "./helpers/withTimeout.js";
|
|
9
|
+
/** DocHandle is a wrapper around a single Automerge document that lets us listen for changes. */
|
|
10
|
+
export class DocHandle//
|
|
11
|
+
extends EventEmitter {
|
|
12
|
+
documentId;
|
|
13
|
+
#log;
|
|
14
|
+
#machine;
|
|
15
|
+
#timeoutDelay;
|
|
16
|
+
constructor(documentId, { isNew = false, timeoutDelay = 700000 } = {}) {
|
|
17
|
+
super();
|
|
18
|
+
this.documentId = documentId;
|
|
19
|
+
this.#timeoutDelay = timeoutDelay;
|
|
20
|
+
this.#log = debug(`automerge-repo:dochandle:${documentId.slice(0, 5)}`);
|
|
21
|
+
// initial doc
|
|
22
|
+
const doc = A.init({
|
|
23
|
+
patchCallback: (patches, { before, after }) => this.emit("patch", { handle: this, patches, before, after }),
|
|
24
|
+
});
|
|
25
|
+
/**
|
|
26
|
+
* Internally we use a state machine to orchestrate document loading and/or syncing, in order to
|
|
27
|
+
* avoid requesting data we already have, or surfacing intermediate values to the consumer.
|
|
28
|
+
*
|
|
29
|
+
* ┌─────────┐ ┌────────────┐
|
|
30
|
+
* ┌───────┐ ┌──FIND──┤ loading ├─REQUEST──►│ requesting ├─UPDATE──┐
|
|
31
|
+
* │ idle ├──┤ └───┬─────┘ └────────────┘ │
|
|
32
|
+
* └───────┘ │ │ └─►┌─────────┐
|
|
33
|
+
* │ └───────LOAD───────────────────────────────►│ ready │
|
|
34
|
+
* └──CREATE───────────────────────────────────────────────►└─────────┘
|
|
35
|
+
*/
|
|
36
|
+
this.#machine = interpret(createMachine({
|
|
37
|
+
predictableActionArguments: true,
|
|
38
|
+
id: "docHandle",
|
|
39
|
+
initial: IDLE,
|
|
40
|
+
context: { documentId, doc },
|
|
41
|
+
states: {
|
|
42
|
+
idle: {
|
|
43
|
+
on: {
|
|
44
|
+
// If we're creating a new document, we don't need to load anything
|
|
45
|
+
CREATE: { target: READY },
|
|
46
|
+
// If we're accessing an existing document, we need to request it from storage
|
|
47
|
+
// and/or the network
|
|
48
|
+
FIND: { target: LOADING },
|
|
49
|
+
DELETE: { actions: "onDelete", target: DELETED },
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
loading: {
|
|
53
|
+
on: {
|
|
54
|
+
// LOAD is called by the Repo if the document is found in storage
|
|
55
|
+
LOAD: { actions: "onLoad", target: READY },
|
|
56
|
+
// REQUEST is called by the Repo if the document is not found in storage
|
|
57
|
+
REQUEST: { target: REQUESTING },
|
|
58
|
+
DELETE: { actions: "onDelete", target: DELETED },
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
requesting: {
|
|
62
|
+
on: {
|
|
63
|
+
// UPDATE is called by the Repo when we receive changes from the network
|
|
64
|
+
UPDATE: { actions: "onUpdate" },
|
|
65
|
+
// REQUEST_COMPLETE is called from `onUpdate` when the doc has been fully loaded from the network
|
|
66
|
+
REQUEST_COMPLETE: { target: READY },
|
|
67
|
+
DELETE: { actions: "onDelete", target: DELETED },
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
ready: {
|
|
71
|
+
on: {
|
|
72
|
+
// UPDATE is called by the Repo when we receive changes from the network
|
|
73
|
+
UPDATE: { actions: "onUpdate", target: READY },
|
|
74
|
+
DELETE: { actions: "onDelete", target: DELETED },
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
error: {},
|
|
78
|
+
deleted: {},
|
|
79
|
+
},
|
|
80
|
+
}, {
|
|
81
|
+
actions: {
|
|
82
|
+
/** Apply the binary changes from storage and put the updated doc on context */
|
|
83
|
+
onLoad: assign((context, { payload }) => {
|
|
84
|
+
const { binary } = payload;
|
|
85
|
+
const { doc } = context;
|
|
86
|
+
const newDoc = A.loadIncremental(doc, binary);
|
|
87
|
+
return { doc: newDoc };
|
|
88
|
+
}),
|
|
89
|
+
/** Put the updated doc on context; if it's different, emit a `change` event */
|
|
90
|
+
onUpdate: assign((context, { payload }) => {
|
|
91
|
+
const { doc: oldDoc } = context;
|
|
92
|
+
const { callback } = payload;
|
|
93
|
+
const newDoc = callback(oldDoc);
|
|
94
|
+
const docChanged = !headsAreSame(newDoc, oldDoc);
|
|
95
|
+
if (docChanged) {
|
|
96
|
+
this.emit("change", { handle: this, doc: newDoc });
|
|
97
|
+
if (!this.isReady()) {
|
|
98
|
+
this.#machine.send(REQUEST_COMPLETE);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return { doc: newDoc };
|
|
102
|
+
}),
|
|
103
|
+
onDelete: assign(() => {
|
|
104
|
+
this.emit("delete", { handle: this });
|
|
105
|
+
return { doc: undefined };
|
|
106
|
+
}),
|
|
107
|
+
},
|
|
108
|
+
}))
|
|
109
|
+
.onTransition(({ value: state }, { type: event }) => this.#log(`${event} → ${state}`, this.#doc))
|
|
110
|
+
.start();
|
|
111
|
+
this.#machine.send(isNew ? CREATE : FIND);
|
|
112
|
+
}
|
|
113
|
+
get doc() {
|
|
114
|
+
if (!this.isReady()) {
|
|
115
|
+
throw new Error(`DocHandle#${this.documentId} is not ready. Check \`handle.isReady()\` before accessing the document.`);
|
|
116
|
+
}
|
|
117
|
+
return this.#doc;
|
|
118
|
+
}
|
|
119
|
+
// PRIVATE
|
|
120
|
+
/** Returns the current document */
|
|
121
|
+
get #doc() {
|
|
122
|
+
return this.#machine?.getSnapshot().context.doc;
|
|
123
|
+
}
|
|
124
|
+
/** Returns the docHandle's state (READY, etc.) */
|
|
125
|
+
get #state() {
|
|
126
|
+
return this.#machine?.getSnapshot().value;
|
|
127
|
+
}
|
|
128
|
+
/** Returns a promise that resolves when the docHandle is in one of the given states */
|
|
129
|
+
#statePromise(awaitStates) {
|
|
130
|
+
if (!Array.isArray(awaitStates))
|
|
131
|
+
awaitStates = [awaitStates];
|
|
132
|
+
return Promise.any(awaitStates.map(state => waitFor(this.#machine, s => s.matches(state))));
|
|
133
|
+
}
|
|
134
|
+
// PUBLIC
|
|
135
|
+
isReady = () => this.#state === READY;
|
|
136
|
+
isReadyOrRequesting = () => this.#state === READY || this.#state === REQUESTING;
|
|
137
|
+
isDeleted = () => this.#state === DELETED;
|
|
138
|
+
/**
|
|
139
|
+
* Returns the current document, waiting for the handle to be ready if necessary.
|
|
140
|
+
*/
|
|
141
|
+
async value(awaitStates = [READY]) {
|
|
142
|
+
await pause(); // yield one tick because reasons
|
|
143
|
+
try {
|
|
144
|
+
// wait for the document to enter one of the desired states
|
|
145
|
+
await withTimeout(this.#statePromise(awaitStates), this.#timeoutDelay);
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
if (error instanceof TimeoutError)
|
|
149
|
+
throw new Error(`DocHandle: timed out loading ${this.documentId}`);
|
|
150
|
+
else
|
|
151
|
+
throw error;
|
|
152
|
+
}
|
|
153
|
+
// Return the document
|
|
154
|
+
return this.#doc;
|
|
155
|
+
}
|
|
156
|
+
async loadAttemptedValue() {
|
|
157
|
+
return this.value([READY, REQUESTING]);
|
|
158
|
+
}
|
|
159
|
+
/** `load` is called by the repo when the document is found in storage */
|
|
160
|
+
load(binary) {
|
|
161
|
+
if (binary.length) {
|
|
162
|
+
this.#machine.send(LOAD, { payload: { binary } });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/** `update` is called by the repo when we receive changes from the network */
|
|
166
|
+
update(callback) {
|
|
167
|
+
this.#machine.send(UPDATE, { payload: { callback } });
|
|
168
|
+
}
|
|
169
|
+
/** `change` is called by the repo when the document is changed locally */
|
|
170
|
+
change(callback, options = {}) {
|
|
171
|
+
if (!this.isReady()) {
|
|
172
|
+
throw new Error(`DocHandle#${this.documentId} is not ready. Check \`handle.isReady()\` before accessing the document.`);
|
|
173
|
+
}
|
|
174
|
+
this.#machine.send(UPDATE, {
|
|
175
|
+
payload: {
|
|
176
|
+
callback: (doc) => {
|
|
177
|
+
return A.change(doc, options, callback);
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
changeAt(heads, callback, options = {}) {
|
|
183
|
+
if (!this.isReady()) {
|
|
184
|
+
throw new Error(`DocHandle#${this.documentId} is not ready. Check \`handle.isReady()\` before accessing the document.`);
|
|
185
|
+
}
|
|
186
|
+
this.#machine.send(UPDATE, {
|
|
187
|
+
payload: {
|
|
188
|
+
callback: (doc) => {
|
|
189
|
+
return A.changeAt(doc, heads, options, callback);
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
/** `request` is called by the repo when the document is not found in storage */
|
|
195
|
+
request() {
|
|
196
|
+
if (this.#state === LOADING)
|
|
197
|
+
this.#machine.send(REQUEST);
|
|
198
|
+
}
|
|
199
|
+
/** `delete` is called by the repo when the document is deleted */
|
|
200
|
+
delete() {
|
|
201
|
+
this.#machine.send(DELETE);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
// STATE MACHINE TYPES
|
|
205
|
+
// state
|
|
206
|
+
export const HandleState = {
|
|
207
|
+
IDLE: "idle",
|
|
208
|
+
LOADING: "loading",
|
|
209
|
+
REQUESTING: "requesting",
|
|
210
|
+
READY: "ready",
|
|
211
|
+
ERROR: "error",
|
|
212
|
+
DELETED: "deleted",
|
|
213
|
+
};
|
|
214
|
+
// events
|
|
215
|
+
export const Event = {
|
|
216
|
+
CREATE: "CREATE",
|
|
217
|
+
LOAD: "LOAD",
|
|
218
|
+
FIND: "FIND",
|
|
219
|
+
REQUEST: "REQUEST",
|
|
220
|
+
REQUEST_COMPLETE: "REQUEST_COMPLETE",
|
|
221
|
+
UPDATE: "UPDATE",
|
|
222
|
+
TIMEOUT: "TIMEOUT",
|
|
223
|
+
DELETE: "DELETE",
|
|
224
|
+
};
|
|
225
|
+
// CONSTANTS
|
|
226
|
+
const { IDLE, LOADING, REQUESTING, READY, ERROR, DELETED } = HandleState;
|
|
227
|
+
const { CREATE, LOAD, FIND, REQUEST, UPDATE, TIMEOUT, DELETE, REQUEST_COMPLETE, } = Event;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import EventEmitter from "eventemitter3";
|
|
2
|
+
import { ChannelId, PeerId } from "./index.js";
|
|
3
|
+
import { MessagePayload } from "./network/NetworkAdapter.js";
|
|
4
|
+
/**
|
|
5
|
+
* EphemeralData provides a mechanism to broadcast short-lived data — cursor positions, presence,
|
|
6
|
+
* heartbeats, etc. — that is useful in the moment but not worth persisting.
|
|
7
|
+
*/
|
|
8
|
+
export declare class EphemeralData extends EventEmitter<EphemeralDataMessageEvents> {
|
|
9
|
+
/** Broadcast an ephemeral message */
|
|
10
|
+
broadcast(channelId: ChannelId, message: unknown): void;
|
|
11
|
+
/** Receive an ephemeral message */
|
|
12
|
+
receive(senderId: PeerId, grossChannelId: ChannelId, message: Uint8Array): void;
|
|
13
|
+
}
|
|
14
|
+
export interface EphemeralDataPayload {
|
|
15
|
+
channelId: ChannelId;
|
|
16
|
+
peerId: PeerId;
|
|
17
|
+
data: {
|
|
18
|
+
peerId: PeerId;
|
|
19
|
+
channelId: ChannelId;
|
|
20
|
+
data: unknown;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
export type EphemeralDataMessageEvents = {
|
|
24
|
+
message: (event: MessagePayload) => void;
|
|
25
|
+
data: (event: EphemeralDataPayload) => void;
|
|
26
|
+
};
|
|
27
|
+
//# sourceMappingURL=EphemeralData.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"EphemeralData.d.ts","sourceRoot":"","sources":["../src/EphemeralData.ts"],"names":[],"mappings":"AACA,OAAO,YAAY,MAAM,eAAe,CAAA;AACxC,OAAO,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,YAAY,CAAA;AAC9C,OAAO,EAAE,cAAc,EAAE,MAAM,6BAA6B,CAAA;AAE5D;;;GAGG;AACH,qBAAa,aAAc,SAAQ,YAAY,CAAC,0BAA0B,CAAC;IACzE,qCAAqC;IACrC,SAAS,CAAC,SAAS,EAAE,SAAS,EAAE,OAAO,EAAE,OAAO;IAWhD,mCAAmC;IACnC,OAAO,CAAC,QAAQ,EAAE,MAAM,EAAE,cAAc,EAAE,SAAS,EAAE,OAAO,EAAE,UAAU;CASzE;AAID,MAAM,WAAW,oBAAoB;IACnC,SAAS,EAAE,SAAS,CAAA;IACpB,MAAM,EAAE,MAAM,CAAA;IACd,IAAI,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,SAAS,CAAC;QAAC,IAAI,EAAE,OAAO,CAAA;KAAE,CAAA;CAC9D;AAED,MAAM,MAAM,0BAA0B,GAAG;IACvC,OAAO,EAAE,CAAC,KAAK,EAAE,cAAc,KAAK,IAAI,CAAA;IACxC,IAAI,EAAE,CAAC,KAAK,EAAE,oBAAoB,KAAK,IAAI,CAAA;CAC5C,CAAA"}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { decode, encode } from "cbor-x";
|
|
2
|
+
import EventEmitter from "eventemitter3";
|
|
3
|
+
/**
|
|
4
|
+
* EphemeralData provides a mechanism to broadcast short-lived data — cursor positions, presence,
|
|
5
|
+
* heartbeats, etc. — that is useful in the moment but not worth persisting.
|
|
6
|
+
*/
|
|
7
|
+
export class EphemeralData extends EventEmitter {
|
|
8
|
+
/** Broadcast an ephemeral message */
|
|
9
|
+
broadcast(channelId, message) {
|
|
10
|
+
const messageBytes = encode(message);
|
|
11
|
+
this.emit("message", {
|
|
12
|
+
targetId: "*",
|
|
13
|
+
channelId: ("m/" + channelId),
|
|
14
|
+
message: messageBytes,
|
|
15
|
+
broadcast: true,
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
/** Receive an ephemeral message */
|
|
19
|
+
receive(senderId, grossChannelId, message) {
|
|
20
|
+
const data = decode(message);
|
|
21
|
+
const channelId = grossChannelId.slice(2);
|
|
22
|
+
this.emit("data", {
|
|
23
|
+
peerId: senderId,
|
|
24
|
+
channelId,
|
|
25
|
+
data,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
}
|
package/dist/Repo.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { DocCollection } from "./DocCollection.js";
|
|
2
|
+
import { EphemeralData } from "./EphemeralData.js";
|
|
3
|
+
import { NetworkAdapter } from "./network/NetworkAdapter.js";
|
|
4
|
+
import { NetworkSubsystem } from "./network/NetworkSubsystem.js";
|
|
5
|
+
import { StorageAdapter } from "./storage/StorageAdapter.js";
|
|
6
|
+
import { StorageSubsystem } from "./storage/StorageSubsystem.js";
|
|
7
|
+
import { DocumentId, PeerId } from "./types.js";
|
|
8
|
+
/** A Repo is a DocCollection with networking, syncing, and storage capabilities. */
|
|
9
|
+
export declare class Repo extends DocCollection {
|
|
10
|
+
#private;
|
|
11
|
+
networkSubsystem: NetworkSubsystem;
|
|
12
|
+
storageSubsystem?: StorageSubsystem;
|
|
13
|
+
ephemeralData: EphemeralData;
|
|
14
|
+
constructor({ storage, network, peerId, sharePolicy }: RepoConfig);
|
|
15
|
+
}
|
|
16
|
+
export interface RepoConfig {
|
|
17
|
+
/** Our unique identifier */
|
|
18
|
+
peerId?: PeerId;
|
|
19
|
+
/** A storage adapter can be provided, or not */
|
|
20
|
+
storage?: StorageAdapter;
|
|
21
|
+
/** One or more network adapters must be provided */
|
|
22
|
+
network: NetworkAdapter[];
|
|
23
|
+
/**
|
|
24
|
+
* Normal peers typically share generously with everyone (meaning we sync all our documents with
|
|
25
|
+
* all peers). A server only syncs documents that a peer explicitly requests by ID.
|
|
26
|
+
*/
|
|
27
|
+
sharePolicy?: SharePolicy;
|
|
28
|
+
}
|
|
29
|
+
export type SharePolicy = (peerId: PeerId, documentId?: DocumentId) => Promise<boolean>;
|
|
30
|
+
//# sourceMappingURL=Repo.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Repo.d.ts","sourceRoot":"","sources":["../src/Repo.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAA;AAClD,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAA;AAClD,OAAO,EAAE,cAAc,EAAE,MAAM,6BAA6B,CAAA;AAC5D,OAAO,EAAE,gBAAgB,EAAE,MAAM,+BAA+B,CAAA;AAChE,OAAO,EAAE,cAAc,EAAE,MAAM,6BAA6B,CAAA;AAC5D,OAAO,EAAE,gBAAgB,EAAE,MAAM,+BAA+B,CAAA;AAEhE,OAAO,EAAa,UAAU,EAAE,MAAM,EAAE,MAAM,YAAY,CAAA;AAM1D,oFAAoF;AACpF,qBAAa,IAAK,SAAQ,aAAa;;IAGrC,gBAAgB,EAAE,gBAAgB,CAAA;IAClC,gBAAgB,CAAC,EAAE,gBAAgB,CAAA;IACnC,aAAa,EAAE,aAAa,CAAA;gBAEhB,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,WAAW,EAAE,EAAE,UAAU;CA+GlE;AAED,MAAM,WAAW,UAAU;IACzB,4BAA4B;IAC5B,MAAM,CAAC,EAAE,MAAM,CAAA;IAEf,gDAAgD;IAChD,OAAO,CAAC,EAAE,cAAc,CAAA;IAExB,oDAAoD;IACpD,OAAO,EAAE,cAAc,EAAE,CAAA;IAEzB;;;OAGG;IACH,WAAW,CAAC,EAAE,WAAW,CAAA;CAC1B;AAED,MAAM,MAAM,WAAW,GAAG,CACxB,MAAM,EAAE,MAAM,EACd,UAAU,CAAC,EAAE,UAAU,KACpB,OAAO,CAAC,OAAO,CAAC,CAAA"}
|
package/dist/Repo.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { DocCollection } from "./DocCollection.js";
|
|
2
|
+
import { EphemeralData } from "./EphemeralData.js";
|
|
3
|
+
import { NetworkSubsystem } from "./network/NetworkSubsystem.js";
|
|
4
|
+
import { StorageSubsystem } from "./storage/StorageSubsystem.js";
|
|
5
|
+
import { CollectionSynchronizer } from "./synchronizer/CollectionSynchronizer.js";
|
|
6
|
+
import debug from "debug";
|
|
7
|
+
const SYNC_CHANNEL = "sync_channel";
|
|
8
|
+
/** A Repo is a DocCollection with networking, syncing, and storage capabilities. */
|
|
9
|
+
export class Repo extends DocCollection {
|
|
10
|
+
#log;
|
|
11
|
+
networkSubsystem;
|
|
12
|
+
storageSubsystem;
|
|
13
|
+
ephemeralData;
|
|
14
|
+
constructor({ storage, network, peerId, sharePolicy }) {
|
|
15
|
+
super();
|
|
16
|
+
this.#log = debug(`automerge-repo:repo`);
|
|
17
|
+
this.sharePolicy = sharePolicy ?? this.sharePolicy;
|
|
18
|
+
// DOC COLLECTION
|
|
19
|
+
// The `document` event is fired by the DocCollection any time we create a new document or look
|
|
20
|
+
// up a document by ID. We listen for it in order to wire up storage and network synchronization.
|
|
21
|
+
this.on("document", async ({ handle }) => {
|
|
22
|
+
if (storageSubsystem) {
|
|
23
|
+
// Save when the document changes
|
|
24
|
+
handle.on("change", async ({ handle }) => {
|
|
25
|
+
const doc = await handle.value();
|
|
26
|
+
storageSubsystem.save(handle.documentId, doc);
|
|
27
|
+
});
|
|
28
|
+
// Try to load from disk
|
|
29
|
+
const binary = await storageSubsystem.loadBinary(handle.documentId);
|
|
30
|
+
handle.load(binary);
|
|
31
|
+
}
|
|
32
|
+
handle.request();
|
|
33
|
+
// Register the document with the synchronizer. This advertises our interest in the document.
|
|
34
|
+
synchronizer.addDocument(handle.documentId);
|
|
35
|
+
});
|
|
36
|
+
this.on("delete-document", ({ documentId }) => {
|
|
37
|
+
// TODO Pass the delete on to the network
|
|
38
|
+
// synchronizer.removeDocument(documentId)
|
|
39
|
+
if (storageSubsystem) {
|
|
40
|
+
storageSubsystem.remove(documentId);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
// SYNCHRONIZER
|
|
44
|
+
// The synchronizer uses the network subsystem to keep documents in sync with peers.
|
|
45
|
+
const synchronizer = new CollectionSynchronizer(this);
|
|
46
|
+
// When the synchronizer emits sync messages, send them to peers
|
|
47
|
+
synchronizer.on("message", ({ targetId, channelId, message, broadcast }) => {
|
|
48
|
+
this.#log(`sending sync message to ${targetId}`);
|
|
49
|
+
networkSubsystem.sendMessage(targetId, channelId, message, broadcast);
|
|
50
|
+
});
|
|
51
|
+
// STORAGE
|
|
52
|
+
// The storage subsystem has access to some form of persistence, and deals with save and loading documents.
|
|
53
|
+
const storageSubsystem = storage ? new StorageSubsystem(storage) : undefined;
|
|
54
|
+
this.storageSubsystem = storageSubsystem;
|
|
55
|
+
// NETWORK
|
|
56
|
+
// The network subsystem deals with sending and receiving messages to and from peers.
|
|
57
|
+
const networkSubsystem = new NetworkSubsystem(network, peerId);
|
|
58
|
+
this.networkSubsystem = networkSubsystem;
|
|
59
|
+
// When we get a new peer, register it with the synchronizer
|
|
60
|
+
networkSubsystem.on("peer", async ({ peerId }) => {
|
|
61
|
+
this.#log("peer connected", { peerId });
|
|
62
|
+
synchronizer.addPeer(peerId);
|
|
63
|
+
});
|
|
64
|
+
// When a peer disconnects, remove it from the synchronizer
|
|
65
|
+
networkSubsystem.on("peer-disconnected", ({ peerId }) => {
|
|
66
|
+
synchronizer.removePeer(peerId);
|
|
67
|
+
});
|
|
68
|
+
// Handle incoming messages
|
|
69
|
+
networkSubsystem.on("message", async (msg) => {
|
|
70
|
+
const { senderId, channelId, message } = msg;
|
|
71
|
+
// TODO: this demands a more principled way of associating channels with recipients
|
|
72
|
+
// Ephemeral channel ids start with "m/"
|
|
73
|
+
if (channelId.startsWith("m/")) {
|
|
74
|
+
// Ephemeral message
|
|
75
|
+
this.#log(`receiving ephemeral message from ${senderId}`);
|
|
76
|
+
ephemeralData.receive(senderId, channelId, message);
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
// Sync message
|
|
80
|
+
this.#log(`receiving sync message from ${senderId}`);
|
|
81
|
+
await synchronizer.receiveSyncMessage(senderId, channelId, message);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
// We establish a special channel for sync messages
|
|
85
|
+
networkSubsystem.join(SYNC_CHANNEL);
|
|
86
|
+
// EPHEMERAL DATA
|
|
87
|
+
// The ephemeral data subsystem uses the network to send and receive messages that are not
|
|
88
|
+
// persisted to storage, e.g. cursor position, presence, etc.
|
|
89
|
+
const ephemeralData = new EphemeralData();
|
|
90
|
+
this.ephemeralData = ephemeralData;
|
|
91
|
+
// Send ephemeral messages to peers
|
|
92
|
+
ephemeralData.on("message", ({ targetId, channelId, message, broadcast }) => {
|
|
93
|
+
this.#log(`sending ephemeral message to ${targetId}`);
|
|
94
|
+
networkSubsystem.sendMessage(targetId, channelId, message, broadcast);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"arraysAreEqual.d.ts","sourceRoot":"","sources":["../../src/helpers/arraysAreEqual.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,cAAc,gCACiD,CAAA"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const arraysAreEqual = (a, b) => a.length === b.length && a.every((element, index) => element === b[index]);
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import EventEmitter from "eventemitter3";
|
|
2
|
+
/** Returns a promise that resolves when the given event is emitted on the given emitter. */
|
|
3
|
+
export declare const eventPromise: (emitter: EventEmitter, event: string) => Promise<any>;
|
|
4
|
+
export declare const eventPromises: (emitters: EventEmitter[], event: string) => Promise<any[]>;
|
|
5
|
+
//# sourceMappingURL=eventPromise.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"eventPromise.d.ts","sourceRoot":"","sources":["../../src/helpers/eventPromise.ts"],"names":[],"mappings":"AAAA,OAAO,YAAY,MAAM,eAAe,CAAA;AAExC,4FAA4F;AAC5F,eAAO,MAAM,YAAY,YAAa,YAAY,SAAS,MAAM,iBACE,CAAA;AAEnE,eAAO,MAAM,aAAa,aAAc,YAAY,EAAE,SAAS,MAAM,mBAGpE,CAAA"}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Returns a promise that resolves when the given event is emitted on the given emitter. */
|
|
2
|
+
export const eventPromise = (emitter, event) => new Promise(resolve => emitter.once(event, d => resolve(d)));
|
|
3
|
+
export const eventPromises = (emitters, event) => {
|
|
4
|
+
const promises = emitters.map(emitter => eventPromise(emitter, event));
|
|
5
|
+
return Promise.all(promises);
|
|
6
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"headsAreSame.d.ts","sourceRoot":"","sources":["../../src/helpers/headsAreSame.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,CAAC,MAAM,sBAAsB,CAAA;AAGzC,eAAO,MAAM,YAAY,4DAIxB,CAAA"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mergeArrays.d.ts","sourceRoot":"","sources":["../../src/helpers/mergeArrays.ts"],"names":[],"mappings":"AAAA,wBAAgB,WAAW,CAAC,QAAQ,EAAE,UAAU,EAAE,cAgBjD"}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export function mergeArrays(myArrays) {
|
|
2
|
+
// Get the total length of all arrays.
|
|
3
|
+
let length = 0;
|
|
4
|
+
myArrays.forEach(item => {
|
|
5
|
+
length += item.length;
|
|
6
|
+
});
|
|
7
|
+
// Create a new array with total length and merge all source arrays.
|
|
8
|
+
const mergedArray = new Uint8Array(length);
|
|
9
|
+
let offset = 0;
|
|
10
|
+
myArrays.forEach(item => {
|
|
11
|
+
mergedArray.set(item, offset);
|
|
12
|
+
offset += item.length;
|
|
13
|
+
});
|
|
14
|
+
return mergedArray;
|
|
15
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pause.d.ts","sourceRoot":"","sources":["../../src/helpers/pause.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,KAAK,+BAC4C,CAAA;AAE9D,wBAAgB,eAAe,CAAC,CAAC,EAAE,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,CAAC,CAKlF"}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* If `promise` is resolved before `t` ms elapse, the timeout is cleared and the result of the
|
|
3
|
+
* promise is returned. If the timeout ends first, a `TimeoutError` is thrown.
|
|
4
|
+
*/
|
|
5
|
+
export declare const withTimeout: <T>(promise: Promise<T>, t: number) => Promise<T>;
|
|
6
|
+
export declare class TimeoutError extends Error {
|
|
7
|
+
constructor(message: string);
|
|
8
|
+
}
|
|
9
|
+
//# sourceMappingURL=withTimeout.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"withTimeout.d.ts","sourceRoot":"","sources":["../../src/helpers/withTimeout.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,eAAO,MAAM,WAAW,8BAEnB,MAAM,eAcV,CAAA;AAED,qBAAa,YAAa,SAAQ,KAAK;gBACzB,OAAO,EAAE,MAAM;CAI5B"}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* If `promise` is resolved before `t` ms elapse, the timeout is cleared and the result of the
|
|
3
|
+
* promise is returned. If the timeout ends first, a `TimeoutError` is thrown.
|
|
4
|
+
*/
|
|
5
|
+
export const withTimeout = async (promise, t) => {
|
|
6
|
+
let timeoutId;
|
|
7
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
8
|
+
timeoutId = setTimeout(() => reject(new TimeoutError(`withTimeout: timed out after ${t}ms`)), t);
|
|
9
|
+
});
|
|
10
|
+
try {
|
|
11
|
+
return await Promise.race([promise, timeoutPromise]);
|
|
12
|
+
}
|
|
13
|
+
finally {
|
|
14
|
+
clearTimeout(timeoutId);
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
export class TimeoutError extends Error {
|
|
18
|
+
constructor(message) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = "TimeoutError";
|
|
21
|
+
}
|
|
22
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { DocCollection } from "./DocCollection.js";
|
|
2
|
+
export { DocHandle, HandleState } from "./DocHandle.js";
|
|
3
|
+
export type { DocHandleChangePayload, DocHandleMessagePayload, DocHandlePatchPayload, } from "./DocHandle.js";
|
|
4
|
+
export { NetworkAdapter } from "./network/NetworkAdapter.js";
|
|
5
|
+
export type { InboundMessagePayload, MessagePayload, OpenPayload, PeerCandidatePayload, PeerDisconnectedPayload, } from "./network/NetworkAdapter.js";
|
|
6
|
+
export { NetworkSubsystem } from "./network/NetworkSubsystem.js";
|
|
7
|
+
export { Repo, type SharePolicy } from "./Repo.js";
|
|
8
|
+
export { StorageAdapter } from "./storage/StorageAdapter.js";
|
|
9
|
+
export { StorageSubsystem } from "./storage/StorageSubsystem.js";
|
|
10
|
+
export { CollectionSynchronizer } from "./synchronizer/CollectionSynchronizer.js";
|
|
11
|
+
export * from "./types.js";
|
|
12
|
+
export * from "./test-utilities/adapter-tests.js";
|
|
13
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAA;AAClD,OAAO,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAA;AACvD,YAAY,EACV,sBAAsB,EACtB,uBAAuB,EACvB,qBAAqB,GACtB,MAAM,gBAAgB,CAAA;AACvB,OAAO,EAAE,cAAc,EAAE,MAAM,6BAA6B,CAAA;AAC5D,YAAY,EACV,qBAAqB,EACrB,cAAc,EACd,WAAW,EACX,oBAAoB,EACpB,uBAAuB,GACxB,MAAM,6BAA6B,CAAA;AACpC,OAAO,EAAE,gBAAgB,EAAE,MAAM,+BAA+B,CAAA;AAChE,OAAO,EAAE,IAAI,EAAE,KAAK,WAAW,EAAE,MAAM,WAAW,CAAA;AAClD,OAAO,EAAE,cAAc,EAAE,MAAM,6BAA6B,CAAA;AAC5D,OAAO,EAAE,gBAAgB,EAAE,MAAM,+BAA+B,CAAA;AAChE,OAAO,EAAE,sBAAsB,EAAE,MAAM,0CAA0C,CAAA;AACjF,cAAc,YAAY,CAAA;AAC1B,cAAc,mCAAmC,CAAA"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { DocCollection } from "./DocCollection.js";
|
|
2
|
+
export { DocHandle, HandleState } from "./DocHandle.js";
|
|
3
|
+
export { NetworkAdapter } from "./network/NetworkAdapter.js";
|
|
4
|
+
export { NetworkSubsystem } from "./network/NetworkSubsystem.js";
|
|
5
|
+
export { Repo } from "./Repo.js";
|
|
6
|
+
export { StorageAdapter } from "./storage/StorageAdapter.js";
|
|
7
|
+
export { StorageSubsystem } from "./storage/StorageSubsystem.js";
|
|
8
|
+
export { CollectionSynchronizer } from "./synchronizer/CollectionSynchronizer.js";
|
|
9
|
+
export * from "./types.js";
|
|
10
|
+
export * from "./test-utilities/adapter-tests.js";
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import EventEmitter from "eventemitter3";
|
|
2
|
+
import { PeerId, ChannelId } from "../types.js";
|
|
3
|
+
export declare abstract class NetworkAdapter extends EventEmitter<NetworkAdapterEvents> {
|
|
4
|
+
peerId?: PeerId;
|
|
5
|
+
abstract connect(url?: string): void;
|
|
6
|
+
abstract sendMessage(peerId: PeerId, channelId: ChannelId, message: Uint8Array, broadcast: boolean): void;
|
|
7
|
+
abstract join(channelId: ChannelId): void;
|
|
8
|
+
abstract leave(channelId: ChannelId): void;
|
|
9
|
+
}
|
|
10
|
+
export interface NetworkAdapterEvents {
|
|
11
|
+
open: (payload: OpenPayload) => void;
|
|
12
|
+
close: () => void;
|
|
13
|
+
"peer-candidate": (payload: PeerCandidatePayload) => void;
|
|
14
|
+
"peer-disconnected": (payload: PeerDisconnectedPayload) => void;
|
|
15
|
+
message: (payload: InboundMessagePayload) => void;
|
|
16
|
+
}
|
|
17
|
+
export interface OpenPayload {
|
|
18
|
+
network: NetworkAdapter;
|
|
19
|
+
}
|
|
20
|
+
export interface PeerCandidatePayload {
|
|
21
|
+
peerId: PeerId;
|
|
22
|
+
channelId: ChannelId;
|
|
23
|
+
}
|
|
24
|
+
export interface MessagePayload {
|
|
25
|
+
targetId: PeerId;
|
|
26
|
+
channelId: ChannelId;
|
|
27
|
+
message: Uint8Array;
|
|
28
|
+
broadcast: boolean;
|
|
29
|
+
}
|
|
30
|
+
export interface InboundMessagePayload extends MessagePayload {
|
|
31
|
+
type?: string;
|
|
32
|
+
senderId: PeerId;
|
|
33
|
+
}
|
|
34
|
+
export interface PeerDisconnectedPayload {
|
|
35
|
+
peerId: PeerId;
|
|
36
|
+
}
|
|
37
|
+
//# sourceMappingURL=NetworkAdapter.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"NetworkAdapter.d.ts","sourceRoot":"","sources":["../../src/network/NetworkAdapter.ts"],"names":[],"mappings":"AAAA,OAAO,YAAY,MAAM,eAAe,CAAA;AACxC,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AAE/C,8BAAsB,cAAe,SAAQ,YAAY,CAAC,oBAAoB,CAAC;IAC7E,MAAM,CAAC,EAAE,MAAM,CAAA;IAEf,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,EAAE,MAAM,GAAG,IAAI;IAEpC,QAAQ,CAAC,WAAW,CAClB,MAAM,EAAE,MAAM,EACd,SAAS,EAAE,SAAS,EACpB,OAAO,EAAE,UAAU,EACnB,SAAS,EAAE,OAAO,GACjB,IAAI;IAEP,QAAQ,CAAC,IAAI,CAAC,SAAS,EAAE,SAAS,GAAG,IAAI;IAEzC,QAAQ,CAAC,KAAK,CAAC,SAAS,EAAE,SAAS,GAAG,IAAI;CAC3C;AAID,MAAM,WAAW,oBAAoB;IACnC,IAAI,EAAE,CAAC,OAAO,EAAE,WAAW,KAAK,IAAI,CAAA;IACpC,KAAK,EAAE,MAAM,IAAI,CAAA;IACjB,gBAAgB,EAAE,CAAC,OAAO,EAAE,oBAAoB,KAAK,IAAI,CAAA;IACzD,mBAAmB,EAAE,CAAC,OAAO,EAAE,uBAAuB,KAAK,IAAI,CAAA;IAC/D,OAAO,EAAE,CAAC,OAAO,EAAE,qBAAqB,KAAK,IAAI,CAAA;CAClD;AAED,MAAM,WAAW,WAAW;IAC1B,OAAO,EAAE,cAAc,CAAA;CACxB;AAED,MAAM,WAAW,oBAAoB;IACnC,MAAM,EAAE,MAAM,CAAA;IACd,SAAS,EAAE,SAAS,CAAA;CACrB;AAED,MAAM,WAAW,cAAc;IAC7B,QAAQ,EAAE,MAAM,CAAA;IAChB,SAAS,EAAE,SAAS,CAAA;IACpB,OAAO,EAAE,UAAU,CAAA;IACnB,SAAS,EAAE,OAAO,CAAA;CACnB;AAED,MAAM,WAAW,qBAAsB,SAAQ,cAAc;IAC3D,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,QAAQ,EAAE,MAAM,CAAA;CACjB;AAED,MAAM,WAAW,uBAAuB;IACtC,MAAM,EAAE,MAAM,CAAA;CACf"}
|