@encorejs/saaz 1.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.
- package/LICENSE +203 -0
- package/README.md +1 -0
- package/dist/back/BackMemoryAdapter.d.ts +18 -0
- package/dist/back/BackMemoryAdapter.d.ts.map +1 -0
- package/dist/back/BackMemoryAdapter.js +69 -0
- package/dist/back/BackStorage.d.ts +20 -0
- package/dist/back/BackStorage.d.ts.map +1 -0
- package/dist/back/BackStorage.js +22 -0
- package/dist/back/SaazBack.d.ts +60 -0
- package/dist/back/SaazBack.d.ts.map +1 -0
- package/dist/back/SaazBack.js +188 -0
- package/dist/front/FrontIdbAdapter.d.ts +15 -0
- package/dist/front/FrontIdbAdapter.d.ts.map +1 -0
- package/dist/front/FrontIdbAdapter.js +159 -0
- package/dist/front/FrontMemoryAdapter.d.ts +16 -0
- package/dist/front/FrontMemoryAdapter.d.ts.map +1 -0
- package/dist/front/FrontMemoryAdapter.js +156 -0
- package/dist/front/FrontStorage.d.ts +23 -0
- package/dist/front/FrontStorage.d.ts.map +1 -0
- package/dist/front/FrontStorage.js +85 -0
- package/dist/front/SaazFront.d.ts +113 -0
- package/dist/front/SaazFront.d.ts.map +1 -0
- package/dist/front/SaazFront.js +756 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9295 -0
- package/dist/index.js.map +7 -0
- package/dist/index.test.d.ts +2 -0
- package/dist/index.test.d.ts.map +1 -0
- package/dist/index.test.js +166 -0
- package/dist/rogue.d.ts +63 -0
- package/dist/rogue.d.ts.map +1 -0
- package/dist/rogue.js +548 -0
- package/dist/rogue.test.d.ts +2 -0
- package/dist/rogue.test.d.ts.map +1 -0
- package/dist/rogue.test.js +248 -0
- package/dist/shared/GeneratorSpy.d.ts +4 -0
- package/dist/shared/GeneratorSpy.d.ts.map +1 -0
- package/dist/shared/GeneratorSpy.js +41 -0
- package/dist/shared/transactions.d.ts +4 -0
- package/dist/shared/transactions.d.ts.map +1 -0
- package/dist/shared/transactions.js +131 -0
- package/dist/shared/utils.d.ts +5 -0
- package/dist/shared/utils.d.ts.map +1 -0
- package/dist/shared/utils.js +21 -0
- package/dist/tsdoc-metadata.json +11 -0
- package/dist/types.d.ts +240 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/package.json +54 -0
|
@@ -0,0 +1,756 @@
|
|
|
1
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
2
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
3
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
4
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
5
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
6
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
7
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
8
|
+
});
|
|
9
|
+
};
|
|
10
|
+
import { applyOptimisticUpdateToState, recordInvokations, } from '../shared/transactions';
|
|
11
|
+
import { FrontStorage } from './FrontStorage';
|
|
12
|
+
import { ensureStateIsUptodate } from '../shared/utils';
|
|
13
|
+
import { debounce } from 'lodash-es';
|
|
14
|
+
import { Atom, prism, val } from '@encorejs/dataverse';
|
|
15
|
+
import waitForPrism from '@encorejs/utils/waitForPrism';
|
|
16
|
+
import { subscribeDebounced } from '@encorejs/utils/subscribeDebounced';
|
|
17
|
+
import fastDeepEqual from 'fast-deep-equal';
|
|
18
|
+
import { diff } from 'jest-diff';
|
|
19
|
+
import { jsonFromCell, makeDraft } from '../rogue';
|
|
20
|
+
import memoizeFn from '@encorejs/utils/memoizeFn';
|
|
21
|
+
import { nanoid } from 'nanoid';
|
|
22
|
+
import { defer } from '@encorejs/utils/defer';
|
|
23
|
+
const emptyObject = {};
|
|
24
|
+
const MAX_UNDO_STACK_SIZE = 1000;
|
|
25
|
+
const PEERID_LENGTH = 32;
|
|
26
|
+
const LOCK_PREFIX = 'theatrejs-saaz-closed-session-lock/';
|
|
27
|
+
export class SaazFront {
|
|
28
|
+
constructor(opts) {
|
|
29
|
+
var _a;
|
|
30
|
+
/**
|
|
31
|
+
* A counter that is used to generate unique ids for temp transactions.
|
|
32
|
+
*/
|
|
33
|
+
this._tempTransactionCounter = 0;
|
|
34
|
+
/**
|
|
35
|
+
* A list of functions that will be called when the frontend is destroyed.
|
|
36
|
+
*/
|
|
37
|
+
this._teardownCallbacks = [];
|
|
38
|
+
/**
|
|
39
|
+
* We use dataverse prisms to derive several values from the atom. This makes the reactive parts of the code easier to maintain.
|
|
40
|
+
*/
|
|
41
|
+
this._prisms = {
|
|
42
|
+
base: prism(() => {
|
|
43
|
+
var _a;
|
|
44
|
+
return ((_a = val(this._atom.pointer.sessionState.snapshot)) !== null && _a !== void 0 ? _a : val(this._atom.pointer.emptySnapshot));
|
|
45
|
+
}),
|
|
46
|
+
optimisticState: prism(() => {
|
|
47
|
+
var _a;
|
|
48
|
+
const base = this._prisms.base.getValue();
|
|
49
|
+
let stateSoFar = base;
|
|
50
|
+
// this may become a bottleneck
|
|
51
|
+
const closedSessions = val(this._atom.pointer.closedSessions);
|
|
52
|
+
for (const session of closedSessions) {
|
|
53
|
+
for (const update of session.optimisticUpdates) {
|
|
54
|
+
stateSoFar = this._cachedApplyTransactionToState(update, base, stateSoFar);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const optimisticUpdates = val(this._atom.pointer.optimisticUpdatesQueue);
|
|
58
|
+
const lastIncorporatedPeerClock = (_a = val(this._atom.pointer.sessionState.lastIncorporatedPeerClock)) !== null && _a !== void 0 ? _a : -1;
|
|
59
|
+
for (const update of optimisticUpdates) {
|
|
60
|
+
// if the update has already been incorporated into the backend state, skip it (it'll be garbage collected soon)
|
|
61
|
+
if (update.peerClock <= lastIncorporatedPeerClock)
|
|
62
|
+
continue;
|
|
63
|
+
stateSoFar = this._cachedApplyTransactionToState(update, base, stateSoFar);
|
|
64
|
+
}
|
|
65
|
+
return stateSoFar;
|
|
66
|
+
}),
|
|
67
|
+
withTemps: prism(() => {
|
|
68
|
+
let currentState = this._prisms.optimisticState.getValue();
|
|
69
|
+
const temps = val(this._atom.pointer.tempTransactions);
|
|
70
|
+
for (const temp of temps) {
|
|
71
|
+
;
|
|
72
|
+
[currentState] = applyOptimisticUpdateToState(temp, currentState, this._schema, true);
|
|
73
|
+
}
|
|
74
|
+
return currentState;
|
|
75
|
+
}),
|
|
76
|
+
withPeers: prism(() => {
|
|
77
|
+
let currentState = this._prisms.withTemps.getValue();
|
|
78
|
+
for (const [peerId, presence] of Object.entries(val(this._atom.pointer.allPeersPresenceState))) {
|
|
79
|
+
if (peerId === this._peerId)
|
|
80
|
+
continue;
|
|
81
|
+
if (!presence)
|
|
82
|
+
continue;
|
|
83
|
+
for (const temp of presence.tempTransactions) {
|
|
84
|
+
;
|
|
85
|
+
[currentState] = applyOptimisticUpdateToState(temp, currentState, this._schema, true);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return currentState;
|
|
89
|
+
}),
|
|
90
|
+
countOfUnpushedUpdatesToBackend: prism(() => {
|
|
91
|
+
return val(this._atom.pointer.optimisticUpdatesQueue).length;
|
|
92
|
+
}),
|
|
93
|
+
allSyncedToFrontStorage: prism(() => {
|
|
94
|
+
var _a, _b;
|
|
95
|
+
// if not initialized, then we're not synced
|
|
96
|
+
if (!val(this._atom.pointer.initialized))
|
|
97
|
+
return false;
|
|
98
|
+
// backendClock as of the last time the front communicated with the backend
|
|
99
|
+
const backendClock = (_a = val(this._atom.pointer.sessionState.backendClock)) !== null && _a !== void 0 ? _a : -1;
|
|
100
|
+
// backendClock as of the last time we stored the backend's state in the front storage
|
|
101
|
+
const lastStoredBackendClock = (_b = val(this._atom.pointer.frontStorageStateMirror.backendState.backendClock)) !== null && _b !== void 0 ? _b : -1;
|
|
102
|
+
// if we haven't yet stored the backend's state in the front storage, return false
|
|
103
|
+
if (backendClock !== lastStoredBackendClock)
|
|
104
|
+
return false;
|
|
105
|
+
// TODO: how about closed sessions?
|
|
106
|
+
const outstandingUpdates = val(this._atom.pointer.optimisticUpdatesQueue);
|
|
107
|
+
const storedUpdates = val(this._atom.pointer.frontStorageStateMirror.optimisticUpdatesQueue);
|
|
108
|
+
if (outstandingUpdates.length === 0) {
|
|
109
|
+
// there are no outstanding optimistic updates (they're all incorporated into the backend state),
|
|
110
|
+
// so we can assume that the front storage is up to date. Note that front storage may not have
|
|
111
|
+
// gartbage collected the old updates yet, but that's ok. they'll be garbage collected eventually.
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
const lastOutstandingUpdate = outstandingUpdates[outstandingUpdates.length - 1];
|
|
115
|
+
const lastStoredUpdate = storedUpdates[storedUpdates.length - 1];
|
|
116
|
+
if (lastStoredUpdate &&
|
|
117
|
+
lastStoredUpdate.peerClock === lastOutstandingUpdate.peerClock) {
|
|
118
|
+
// the last outstanding update is the same as the last stored update, so we can assume that the front storage is up to date.
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
// the front storage has yet to catch up
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
}),
|
|
126
|
+
};
|
|
127
|
+
this._caches = {
|
|
128
|
+
transactionToState: new WeakMap(),
|
|
129
|
+
};
|
|
130
|
+
if (opts.diskSnapshot) {
|
|
131
|
+
this._diskSnapshot = Object.assign(Object.assign({}, opts.diskSnapshot), { snapshot: ensureStateIsUptodate(opts.diskSnapshot.snapshot, opts.schema) });
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
this._diskSnapshot = null;
|
|
135
|
+
}
|
|
136
|
+
this._atom = new Atom({
|
|
137
|
+
optimisticUpdatesQueue: [],
|
|
138
|
+
emptySnapshot: ensureStateIsUptodate(null, opts.schema),
|
|
139
|
+
sessionState: null,
|
|
140
|
+
tempTransactions: [],
|
|
141
|
+
allPeersPresenceState: emptyObject,
|
|
142
|
+
initialized: false,
|
|
143
|
+
frontStorageStateMirror: {
|
|
144
|
+
backendState: null,
|
|
145
|
+
optimisticUpdatesQueue: [],
|
|
146
|
+
},
|
|
147
|
+
peerClock: -1,
|
|
148
|
+
closedSessions: [],
|
|
149
|
+
undoRedo: {
|
|
150
|
+
stack: [],
|
|
151
|
+
cursor: 0,
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
this._initializedPromise = waitForPrism(prism(() => val(this._atom.pointer.initialized)), (v) => v === true);
|
|
155
|
+
if (opts.keepPrismsHot !== false) {
|
|
156
|
+
this._teardownCallbacks.push(this._prisms.withPeers.keepHot());
|
|
157
|
+
}
|
|
158
|
+
this._schema = opts.schema;
|
|
159
|
+
this._peerId = (_a = opts.peerId) !== null && _a !== void 0 ? _a : nanoid(PEERID_LENGTH);
|
|
160
|
+
this._backend = opts.backend;
|
|
161
|
+
this._dbName = opts.dbName;
|
|
162
|
+
this._storage = new FrontStorage(this._dbName, opts.storageAdapter);
|
|
163
|
+
void this._init();
|
|
164
|
+
}
|
|
165
|
+
_init() {
|
|
166
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
167
|
+
yield this._loadClosedSessions();
|
|
168
|
+
// The most recent backend state that one of the closed sessions has cached.
|
|
169
|
+
// We can use this until we get the first update from the backend
|
|
170
|
+
const cachedBackendState = yield this._storage.transaction((t) => __awaiter(this, void 0, void 0, function* () { return t.getMostRecentlySyncedSessionState(); }));
|
|
171
|
+
// this will create the database and start a transaction
|
|
172
|
+
yield this._storage.transaction((t) => __awaiter(this, void 0, void 0, function* () {
|
|
173
|
+
var _a, _b, _c;
|
|
174
|
+
const initialSnapshot = this._diskSnapshot;
|
|
175
|
+
if (!cachedBackendState) {
|
|
176
|
+
// there are no closed/crashed sessions that have a backend state.
|
|
177
|
+
this._setSessionState({
|
|
178
|
+
backendClock: (_a = initialSnapshot === null || initialSnapshot === void 0 ? void 0 : initialSnapshot.clock) !== null && _a !== void 0 ? _a : null,
|
|
179
|
+
lastIncorporatedPeerClock: null,
|
|
180
|
+
lastSyncTime: null,
|
|
181
|
+
snapshot: (_b = initialSnapshot === null || initialSnapshot === void 0 ? void 0 : initialSnapshot.snapshot) !== null && _b !== void 0 ? _b : null,
|
|
182
|
+
peerId: this._peerId,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
if (initialSnapshot) {
|
|
187
|
+
// TODO
|
|
188
|
+
throw new Error(`Not implemented`);
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
this._atom.setByPointer((p) => p.sessionState, {
|
|
192
|
+
lastSyncTime: null,
|
|
193
|
+
backendClock: (_c = cachedBackendState.backendClock) !== null && _c !== void 0 ? _c : null,
|
|
194
|
+
lastIncorporatedPeerClock: null,
|
|
195
|
+
peerId: this._peerId,
|
|
196
|
+
snapshot: ensureStateIsUptodate(cachedBackendState.snapshot, this._schema),
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
this._atom.setByPointer((p) => p.initialized, true);
|
|
201
|
+
}));
|
|
202
|
+
this._teardownCallbacks.push(this._subscribeToBackend(), this._reflectBackendStateToStorage(), this._reflectOptimisticUpdatesToStorage(), this._reflectPresenceToBackend(), this._removeStalePeerPresenceStates(), this._reflectUpdatesToBackend(), this._gcClosedSessions());
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
_loadClosedSessions() {
|
|
206
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
207
|
+
// in case there are crashed/closed sesions that haven't synced
|
|
208
|
+
// with backend yet, let's take them over
|
|
209
|
+
const closedSessionsLocks = yield this._acquireLocksOnClosedSessions();
|
|
210
|
+
const closedSessions = yield this._storage.transaction((t) => __awaiter(this, void 0, void 0, function* () {
|
|
211
|
+
return yield Promise.all(closedSessionsLocks.map((closedSessionLock) => __awaiter(this, void 0, void 0, function* () {
|
|
212
|
+
return {
|
|
213
|
+
optimisticUpdates: yield t.getOptimisticUpdates(closedSessionLock.peerId),
|
|
214
|
+
peerId: closedSessionLock.peerId,
|
|
215
|
+
lastIncorporatedPeerClock: null,
|
|
216
|
+
};
|
|
217
|
+
})));
|
|
218
|
+
}));
|
|
219
|
+
this._atom.setByPointer((p) => p.closedSessions, closedSessions);
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
_acquireLocksOnClosedSessions() {
|
|
223
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
224
|
+
const allPeerIds = yield this._storage.transaction((t) => __awaiter(this, void 0, void 0, function* () { return t.getAllExistingSessionIds(); }));
|
|
225
|
+
const all = yield Promise.all(allPeerIds.map((peerId) => __awaiter(this, void 0, void 0, function* () {
|
|
226
|
+
try {
|
|
227
|
+
const d = defer();
|
|
228
|
+
void navigator.locks.request(LOCK_PREFIX + peerId, {
|
|
229
|
+
mode: 'exclusive',
|
|
230
|
+
ifAvailable: true,
|
|
231
|
+
steal: false,
|
|
232
|
+
}, (lock) => __awaiter(this, void 0, void 0, function* () {
|
|
233
|
+
if (!lock) {
|
|
234
|
+
d.resolve(undefined);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
const unlockDeferred = defer();
|
|
238
|
+
let locked = true;
|
|
239
|
+
d.resolve({
|
|
240
|
+
peerId,
|
|
241
|
+
unlock: () => {
|
|
242
|
+
if (!locked)
|
|
243
|
+
return;
|
|
244
|
+
locked = false;
|
|
245
|
+
unlockDeferred.resolve();
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
yield unlockDeferred.promise;
|
|
249
|
+
}));
|
|
250
|
+
return d.promise;
|
|
251
|
+
}
|
|
252
|
+
catch (error) {
|
|
253
|
+
return undefined;
|
|
254
|
+
}
|
|
255
|
+
})));
|
|
256
|
+
return all.filter((v) => !!v);
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
teardown() {
|
|
260
|
+
for (const cb of this._teardownCallbacks) {
|
|
261
|
+
cb();
|
|
262
|
+
}
|
|
263
|
+
this._teardownCallbacks.length = 0;
|
|
264
|
+
}
|
|
265
|
+
_removeStalePeerPresenceStates() {
|
|
266
|
+
const schedule = debounce(() => {
|
|
267
|
+
this._atom.setByPointer((p) => p.allPeersPresenceState, emptyObject);
|
|
268
|
+
}, 1000 * 15);
|
|
269
|
+
return this._atom.onChangeByPointer((p) => p.allPeersPresenceState, schedule);
|
|
270
|
+
}
|
|
271
|
+
_reflectPresenceToBackend() {
|
|
272
|
+
let lastTempTransactions = [];
|
|
273
|
+
let lastUpdateSent = 0;
|
|
274
|
+
return subscribeDebounced(this._atom.pointer.tempTransactions, (tempTransactions) => __awaiter(this, void 0, void 0, function* () {
|
|
275
|
+
const now = Date.now();
|
|
276
|
+
if (!fastDeepEqual(tempTransactions, lastTempTransactions) ||
|
|
277
|
+
now - lastUpdateSent > 1000 * 5) {
|
|
278
|
+
lastTempTransactions = tempTransactions;
|
|
279
|
+
lastUpdateSent = now;
|
|
280
|
+
void this._backend.updatePresence({
|
|
281
|
+
peerId: this._peerId,
|
|
282
|
+
presence: { tempTransactions },
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
yield new Promise((resolve) => setTimeout(resolve, 30));
|
|
286
|
+
}));
|
|
287
|
+
}
|
|
288
|
+
_reflectUpdatesToBackend() {
|
|
289
|
+
const p = prism(() => {
|
|
290
|
+
var _a, _b;
|
|
291
|
+
const closedSessions = val(this._atom.pointer.closedSessions);
|
|
292
|
+
for (const closedSession of closedSessions) {
|
|
293
|
+
const updatesLeftToPush = closedSession.optimisticUpdates.filter((update) => { var _a; return update.peerClock > ((_a = closedSession.lastIncorporatedPeerClock) !== null && _a !== void 0 ? _a : -1); });
|
|
294
|
+
if (updatesLeftToPush.length > 0) {
|
|
295
|
+
return {
|
|
296
|
+
peerId: closedSession.peerId,
|
|
297
|
+
lastIncorporatedPeerClock: (_a = closedSession.lastIncorporatedPeerClock) !== null && _a !== void 0 ? _a : -1,
|
|
298
|
+
updates: updatesLeftToPush,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
// no closed sessions left to sync. let's sync the current session
|
|
303
|
+
const lastIncorporatedPeerClock = (_b = val(this._atom.pointer.sessionState.lastIncorporatedPeerClock)) !== null && _b !== void 0 ? _b : -1;
|
|
304
|
+
const updatesLeftToPush = val(this._atom.pointer.optimisticUpdatesQueue).filter((update) => update.peerClock > lastIncorporatedPeerClock);
|
|
305
|
+
return {
|
|
306
|
+
updates: updatesLeftToPush,
|
|
307
|
+
lastIncorporatedPeerClock,
|
|
308
|
+
peerId: this._peerId,
|
|
309
|
+
};
|
|
310
|
+
});
|
|
311
|
+
return subscribeDebounced(p, ({ updates, peerId }) => __awaiter(this, void 0, void 0, function* () {
|
|
312
|
+
if (updates.length === 0)
|
|
313
|
+
return;
|
|
314
|
+
const backendClock = val(this._atom.pointer.sessionState.backendClock);
|
|
315
|
+
try {
|
|
316
|
+
const res = yield this._backend.applyUpdates({
|
|
317
|
+
backendClock,
|
|
318
|
+
peerId,
|
|
319
|
+
updates,
|
|
320
|
+
});
|
|
321
|
+
if (res.ok) {
|
|
322
|
+
this._processBackendUpdate(res);
|
|
323
|
+
}
|
|
324
|
+
else {
|
|
325
|
+
console.error(res.error);
|
|
326
|
+
throw new Error('Backend rejected optimistic update');
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
catch (errs) {
|
|
330
|
+
console.error(errs);
|
|
331
|
+
}
|
|
332
|
+
}));
|
|
333
|
+
}
|
|
334
|
+
_processBackendUpdate(s) {
|
|
335
|
+
const originalBackendState = this._atom.get().sessionState;
|
|
336
|
+
if (!originalBackendState) {
|
|
337
|
+
throw new Error('backend state not initialized');
|
|
338
|
+
}
|
|
339
|
+
if (!s.hasUpdates) {
|
|
340
|
+
this._setSessionState(Object.assign(Object.assign({}, originalBackendState), { lastSyncTime: Date.now(), peerId: s.peerId, lastIncorporatedPeerClock: s.lastIncorporatedPeerClock }));
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
else {
|
|
344
|
+
const { snapshot } = s;
|
|
345
|
+
if (snapshot.type !== 'Snapshot') {
|
|
346
|
+
throw new Error('Non-snapshot updates not implemented');
|
|
347
|
+
}
|
|
348
|
+
this._setSessionState({
|
|
349
|
+
backendClock: s.clock,
|
|
350
|
+
lastIncorporatedPeerClock: s.lastIncorporatedPeerClock,
|
|
351
|
+
lastSyncTime: Date.now(),
|
|
352
|
+
snapshot: snapshot.value,
|
|
353
|
+
peerId: s.peerId,
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
_reflectBackendStateToStorage() {
|
|
358
|
+
return subscribeDebounced(this._atom.pointer.sessionState, (backendState) => __awaiter(this, void 0, void 0, function* () {
|
|
359
|
+
if (!backendState)
|
|
360
|
+
return;
|
|
361
|
+
try {
|
|
362
|
+
yield this._storage.transaction((t) => __awaiter(this, void 0, void 0, function* () {
|
|
363
|
+
yield t.setSessionState(backendState, this._peerId);
|
|
364
|
+
}));
|
|
365
|
+
this._atom.setByPointer((p) => p.frontStorageStateMirror.backendState, backendState);
|
|
366
|
+
}
|
|
367
|
+
catch (error) {
|
|
368
|
+
console.error(error);
|
|
369
|
+
}
|
|
370
|
+
}));
|
|
371
|
+
}
|
|
372
|
+
_gcClosedSessions() {
|
|
373
|
+
return subscribeDebounced(this._atom.pointer.closedSessions, (closedSessions) => __awaiter(this, void 0, void 0, function* () {
|
|
374
|
+
const sessionsWithNoUpdates = closedSessions.filter((s) => s.optimisticUpdates.length === 0);
|
|
375
|
+
for (const emptySession of sessionsWithNoUpdates) {
|
|
376
|
+
this._atom.reduceByPointer((p) => p.closedSessions, (a) => a.filter((c) => c.peerId !== emptySession.peerId));
|
|
377
|
+
yield this._storage.transaction((t) => __awaiter(this, void 0, void 0, function* () {
|
|
378
|
+
yield t.deleteSession(emptySession.peerId);
|
|
379
|
+
}));
|
|
380
|
+
void this._backend.closePeer({ peerId: emptySession.peerId });
|
|
381
|
+
}
|
|
382
|
+
}));
|
|
383
|
+
}
|
|
384
|
+
_reflectOptimisticUpdatesToStorage() {
|
|
385
|
+
return subscribeDebounced(this._atom.pointer.optimisticUpdatesQueue, (memory) => __awaiter(this, void 0, void 0, function* () {
|
|
386
|
+
const last = this._atom.get().frontStorageStateMirror.optimisticUpdatesQueue;
|
|
387
|
+
if (memory.length === 0 && last.length === 0)
|
|
388
|
+
return;
|
|
389
|
+
const toPush = [];
|
|
390
|
+
const toPluck = [];
|
|
391
|
+
for (const transacion of memory) {
|
|
392
|
+
const existing = last.find((t) => t.peerClock === transacion.peerClock);
|
|
393
|
+
if (!existing) {
|
|
394
|
+
toPush.push(transacion);
|
|
395
|
+
}
|
|
396
|
+
else {
|
|
397
|
+
toPluck.push(existing);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
try {
|
|
401
|
+
yield this._storage.transaction((t) => __awaiter(this, void 0, void 0, function* () {
|
|
402
|
+
yield t.pushOptimisticUpdates(toPush, this._peerId);
|
|
403
|
+
yield t.pluckOptimisticUpdates(toPluck, this._peerId);
|
|
404
|
+
}));
|
|
405
|
+
this._atom.setByPointer((p) => p.frontStorageStateMirror.optimisticUpdatesQueue, memory);
|
|
406
|
+
}
|
|
407
|
+
catch (error) {
|
|
408
|
+
console.error(error);
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
}));
|
|
412
|
+
}
|
|
413
|
+
_cachedApplyTransactionToState(transaction, base, before) {
|
|
414
|
+
let cache = this._caches.transactionToState.get(transaction);
|
|
415
|
+
if (cache) {
|
|
416
|
+
if (cache.before === before) {
|
|
417
|
+
return cache.after;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
const [after] = applyOptimisticUpdateToState(transaction, before, this._schema, true);
|
|
421
|
+
if (!cache) {
|
|
422
|
+
cache = { before: before, after: after, base };
|
|
423
|
+
this._caches.transactionToState.set(transaction, cache);
|
|
424
|
+
}
|
|
425
|
+
else {
|
|
426
|
+
cache.before = before;
|
|
427
|
+
cache.after = after;
|
|
428
|
+
cache.base = base;
|
|
429
|
+
}
|
|
430
|
+
return after;
|
|
431
|
+
}
|
|
432
|
+
_setSessionState(opts) {
|
|
433
|
+
var _a, _b;
|
|
434
|
+
const s = Object.assign(Object.assign({}, opts), { value: ensureStateIsUptodate(opts.snapshot, this._schema) });
|
|
435
|
+
if (s.peerId === this._peerId) {
|
|
436
|
+
this._atom.setByPointer((p) => p.sessionState, s);
|
|
437
|
+
// let's GC the updates the backend has incorporated
|
|
438
|
+
const lastAcknowledgedPeerClock = (_a = s.lastIncorporatedPeerClock) !== null && _a !== void 0 ? _a : -1;
|
|
439
|
+
const existingQueue = this._atom.get().optimisticUpdatesQueue;
|
|
440
|
+
if (existingQueue.length > 0 &&
|
|
441
|
+
existingQueue[0].peerClock <= lastAcknowledgedPeerClock) {
|
|
442
|
+
const newQueue = existingQueue.filter((update) => update.peerClock > lastAcknowledgedPeerClock);
|
|
443
|
+
this._atom.setByPointer((p) => p.optimisticUpdatesQueue, newQueue);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
else {
|
|
447
|
+
// the session state comes from an update belonging to a different session. let's update the
|
|
448
|
+
// snapshot/backendClock and other relevant bits, but keep peerId and lastIncorporatedPeerClock unchanged
|
|
449
|
+
this._atom.reduceByPointer((p) => p.sessionState, (oldSessionstate) => {
|
|
450
|
+
var _a;
|
|
451
|
+
return {
|
|
452
|
+
backendClock: s.backendClock,
|
|
453
|
+
lastSyncTime: s.lastSyncTime,
|
|
454
|
+
peerId: this._peerId,
|
|
455
|
+
lastIncorporatedPeerClock: (_a = oldSessionstate === null || oldSessionstate === void 0 ? void 0 : oldSessionstate.lastIncorporatedPeerClock) !== null && _a !== void 0 ? _a : null,
|
|
456
|
+
snapshot: s.snapshot,
|
|
457
|
+
};
|
|
458
|
+
});
|
|
459
|
+
const lastAcknowledgedPeerClock = (_b = s.lastIncorporatedPeerClock) !== null && _b !== void 0 ? _b : -1;
|
|
460
|
+
const index = this._atom
|
|
461
|
+
.get()
|
|
462
|
+
.closedSessions.findIndex((s) => s.peerId === this._peerId);
|
|
463
|
+
if (index === -1)
|
|
464
|
+
return;
|
|
465
|
+
const closedSession = this._atom.get().closedSessions[index];
|
|
466
|
+
if (!closedSession) {
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
const existingQueue = closedSession.optimisticUpdates;
|
|
470
|
+
if (existingQueue.length > 0 &&
|
|
471
|
+
existingQueue[0].peerClock <= lastAcknowledgedPeerClock) {
|
|
472
|
+
const newQueue = existingQueue.filter((update) => update.peerClock > lastAcknowledgedPeerClock);
|
|
473
|
+
this._atom.setByPointer((p) => p.closedSessions[index], {
|
|
474
|
+
optimisticUpdates: newQueue,
|
|
475
|
+
lastIncorporatedPeerClock: s.lastIncorporatedPeerClock,
|
|
476
|
+
peerId: s.peerId,
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
_pullUpdatesFromBackend() {
|
|
482
|
+
var _a;
|
|
483
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
484
|
+
const originalBackendState = this._atom.get().sessionState;
|
|
485
|
+
if (!originalBackendState) {
|
|
486
|
+
throw new Error('backend state not initialized');
|
|
487
|
+
}
|
|
488
|
+
const s = yield this._backend.getUpdatesSinceClock({
|
|
489
|
+
clock: (_a = originalBackendState.backendClock) !== null && _a !== void 0 ? _a : null,
|
|
490
|
+
peerId: this._peerId,
|
|
491
|
+
});
|
|
492
|
+
this._processBackendUpdate(s);
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
_subscribeToBackend() {
|
|
496
|
+
void this._pullUpdatesFromBackend();
|
|
497
|
+
const stop = this._backend.subscribe({
|
|
498
|
+
peerId: this._peerId,
|
|
499
|
+
}, (s) => __awaiter(this, void 0, void 0, function* () {
|
|
500
|
+
if (s.shouldCheckForUpdates) {
|
|
501
|
+
void this._pullUpdatesFromBackend().catch((err) => {
|
|
502
|
+
console.error(err);
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
this._atom.setByPointer((p) => p.allPeersPresenceState, s.presence);
|
|
506
|
+
}));
|
|
507
|
+
const unsub = () => {
|
|
508
|
+
void stop.then((s) => stop);
|
|
509
|
+
};
|
|
510
|
+
return unsub;
|
|
511
|
+
}
|
|
512
|
+
get state() {
|
|
513
|
+
return finalState(this._prisms.withPeers.getValue());
|
|
514
|
+
}
|
|
515
|
+
get isReady() {
|
|
516
|
+
return this._atom.get().initialized === true;
|
|
517
|
+
}
|
|
518
|
+
get ready() {
|
|
519
|
+
return this._initializedPromise;
|
|
520
|
+
}
|
|
521
|
+
tx(editorFn, draftFn, undoable = true) {
|
|
522
|
+
const [update, isEmpty, backwardOps] = this._createTransaction(this._prisms.optimisticState.getValue(), editorFn, draftFn);
|
|
523
|
+
if (isEmpty)
|
|
524
|
+
return;
|
|
525
|
+
this._pushOptimisticUpdate(update, undoable ? backwardOps : []);
|
|
526
|
+
}
|
|
527
|
+
tempTx(editorFn, draftFn, existingTempTransaction) {
|
|
528
|
+
if (existingTempTransaction) {
|
|
529
|
+
existingTempTransaction.recapture(editorFn, draftFn);
|
|
530
|
+
return existingTempTransaction;
|
|
531
|
+
}
|
|
532
|
+
const [o, originalIsEmpty, originalBackwardOps] = this._createTransaction(this._prisms.optimisticState.getValue(), editorFn, draftFn);
|
|
533
|
+
const originalTransaction = Object.assign(Object.assign({}, o), { tempId: this._tempTransactionCounter++, backwardOps: originalBackwardOps });
|
|
534
|
+
this._setTempTransaction(originalTransaction.tempId, originalTransaction);
|
|
535
|
+
let currentTransaction = originalTransaction;
|
|
536
|
+
let currentIsEmpty = originalIsEmpty;
|
|
537
|
+
let transactionState = 'alive';
|
|
538
|
+
const commit = (undoable = true) => {
|
|
539
|
+
if (transactionState !== 'alive') {
|
|
540
|
+
throw new Error('Transaction is already ' + transactionState);
|
|
541
|
+
}
|
|
542
|
+
transactionState = 'committed';
|
|
543
|
+
this._setTempTransaction(originalTransaction.tempId, undefined);
|
|
544
|
+
if (currentIsEmpty)
|
|
545
|
+
return;
|
|
546
|
+
const finalUpdate = Object.assign({}, currentTransaction);
|
|
547
|
+
this._pushOptimisticUpdate(finalUpdate, undoable ? currentTransaction.backwardOps : []);
|
|
548
|
+
};
|
|
549
|
+
const discard = () => {
|
|
550
|
+
if (transactionState !== 'alive') {
|
|
551
|
+
throw new Error('Transaction is already ' + transactionState);
|
|
552
|
+
}
|
|
553
|
+
transactionState = 'discarded';
|
|
554
|
+
this._setTempTransaction(originalTransaction.tempId, undefined);
|
|
555
|
+
};
|
|
556
|
+
const recapture = (editorFn, draftFn) => {
|
|
557
|
+
if (transactionState !== 'alive') {
|
|
558
|
+
throw new Error('Transaction is already ' + transactionState);
|
|
559
|
+
}
|
|
560
|
+
const [update, newIsEmpty, backwardOps] = this._createTransaction(this._prisms.optimisticState.getValue(), editorFn, draftFn);
|
|
561
|
+
const newTransaction = Object.assign(Object.assign({}, update), { tempId: originalTransaction.tempId, backwardOps });
|
|
562
|
+
currentTransaction = newTransaction;
|
|
563
|
+
currentIsEmpty = newIsEmpty;
|
|
564
|
+
this._setTempTransaction(originalTransaction.tempId, newTransaction);
|
|
565
|
+
};
|
|
566
|
+
const reset = () => {
|
|
567
|
+
if (transactionState !== 'alive') {
|
|
568
|
+
throw new Error('Transaction is already ' + transactionState);
|
|
569
|
+
}
|
|
570
|
+
this._setTempTransaction(originalTransaction.tempId, undefined);
|
|
571
|
+
};
|
|
572
|
+
return { commit, discard: discard, recapture, reset };
|
|
573
|
+
}
|
|
574
|
+
_setTempTransaction(id, transaction) {
|
|
575
|
+
const prev = this._atom.get().tempTransactions;
|
|
576
|
+
const existingIndex = prev.findIndex((t) => t.tempId === id);
|
|
577
|
+
const next = [...prev];
|
|
578
|
+
let changed = false;
|
|
579
|
+
if (existingIndex > -1) {
|
|
580
|
+
next.splice(existingIndex, 1);
|
|
581
|
+
changed = true;
|
|
582
|
+
}
|
|
583
|
+
if (transaction) {
|
|
584
|
+
if (existingIndex > -1) {
|
|
585
|
+
next.splice(existingIndex, 0, transaction);
|
|
586
|
+
changed = true;
|
|
587
|
+
}
|
|
588
|
+
else {
|
|
589
|
+
next.push(transaction);
|
|
590
|
+
changed = true;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
if (changed) {
|
|
594
|
+
this._atom.setByPointer((p) => p.tempTransactions, next);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
_createTransaction(fullSnapshot, editorFn, draftFn, warnIfNoInvokations = false) {
|
|
598
|
+
const invokations = editorFn
|
|
599
|
+
? recordInvokations(this._schema.editors, editorFn)
|
|
600
|
+
: [];
|
|
601
|
+
if (invokations.length === 0) {
|
|
602
|
+
if (warnIfNoInvokations && editorFn)
|
|
603
|
+
console.info(`Transaction didn't invoke any editors. It's a no-op.`);
|
|
604
|
+
}
|
|
605
|
+
let backwardOps = [];
|
|
606
|
+
let draftOps = [];
|
|
607
|
+
if (typeof draftFn === 'function') {
|
|
608
|
+
const [draft, fin] = makeDraft(fullSnapshot.cell);
|
|
609
|
+
draftFn(draft);
|
|
610
|
+
const [_, forwardOps, _backwardOps] = fin();
|
|
611
|
+
if (forwardOps.length > 0) {
|
|
612
|
+
draftOps = forwardOps;
|
|
613
|
+
backwardOps = _backwardOps;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
const [producedSnapshot, generatorRecordings] = applyOptimisticUpdateToState({ invokations, generatorRecordings: {}, draftOps }, fullSnapshot, this._schema, false);
|
|
617
|
+
const transaction = {
|
|
618
|
+
invokations,
|
|
619
|
+
generatorRecordings: generatorRecordings,
|
|
620
|
+
draftOps: draftOps,
|
|
621
|
+
peerId: this._peerId,
|
|
622
|
+
};
|
|
623
|
+
if (process.env.NODE_ENV !== 'production' && editorFn) {
|
|
624
|
+
if (!fastDeepEqual(invokations, recordInvokations(this._schema.editors, editorFn))) {
|
|
625
|
+
throw new Error(`Transaction function seems to invoke different editors each time it is called. This means it is not deterministic, and running it several times will create different states. To fix this, make sure the transaction calls exactly the same editors, in the same order, with the same arguments`);
|
|
626
|
+
}
|
|
627
|
+
const [secondSnapshot] = applyOptimisticUpdateToState(transaction, fullSnapshot, this._schema, true);
|
|
628
|
+
if (!fastDeepEqual(secondSnapshot, producedSnapshot)) {
|
|
629
|
+
// at least one editor is not deterministic
|
|
630
|
+
// let's see if we can find which one it is, to help the user debug
|
|
631
|
+
let invokationsSoFar = [];
|
|
632
|
+
for (const invokation of invokations) {
|
|
633
|
+
// run each invokation one-by-one, and see which one produces a different snapshot
|
|
634
|
+
invokationsSoFar = [...invokationsSoFar, invokation];
|
|
635
|
+
// first call
|
|
636
|
+
const [newSnapshot1] = applyOptimisticUpdateToState({
|
|
637
|
+
invokations: invokationsSoFar,
|
|
638
|
+
generatorRecordings: transaction.generatorRecordings,
|
|
639
|
+
draftOps: transaction.draftOps,
|
|
640
|
+
}, fullSnapshot, this._schema, true);
|
|
641
|
+
// second call
|
|
642
|
+
const [newSnapshot2] = applyOptimisticUpdateToState({
|
|
643
|
+
invokations: invokationsSoFar,
|
|
644
|
+
generatorRecordings: transaction.generatorRecordings,
|
|
645
|
+
draftOps: transaction.draftOps,
|
|
646
|
+
}, fullSnapshot, this._schema, true);
|
|
647
|
+
if (!fastDeepEqual(newSnapshot1, newSnapshot2)) {
|
|
648
|
+
// found the culprit
|
|
649
|
+
throw new Error(`Transaction is not deterministic, because editor ${invokation[0]}(${JSON.stringify(invokation[1])}) is not deterministic. It produces different results when called twice. \n${diff(newSnapshot1, newSnapshot2)}`);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
// couldn't find which editor is not deterministic. let's just throw a generic error
|
|
653
|
+
const diffString = diff(producedSnapshot, secondSnapshot);
|
|
654
|
+
throw new Error(`The second invocation of the transaction produced a different state than the first invocation. \n${diffString}`);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
return [
|
|
658
|
+
transaction,
|
|
659
|
+
invokations.length === 0 && transaction.draftOps.length === 0,
|
|
660
|
+
backwardOps,
|
|
661
|
+
];
|
|
662
|
+
}
|
|
663
|
+
waitForStorageSync() {
|
|
664
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
665
|
+
yield waitForPrism(this._prisms.allSyncedToFrontStorage, (v) => v === true);
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
_pushOptimisticUpdate(updateWithoutPeerClock,
|
|
669
|
+
// if defined, then it'll constitute an undo-able operation
|
|
670
|
+
backwardOps) {
|
|
671
|
+
const clockBefore = this._atom.get().peerClock;
|
|
672
|
+
const newClock = clockBefore + 1;
|
|
673
|
+
const transaction = {
|
|
674
|
+
generatorRecordings: updateWithoutPeerClock.generatorRecordings,
|
|
675
|
+
invokations: updateWithoutPeerClock.invokations,
|
|
676
|
+
peerId: updateWithoutPeerClock.peerId,
|
|
677
|
+
peerClock: newClock,
|
|
678
|
+
draftOps: updateWithoutPeerClock.draftOps,
|
|
679
|
+
};
|
|
680
|
+
this._atom.reduce((state) => (Object.assign(Object.assign({}, state), { peerClock: newClock, optimisticUpdatesQueue: [...state.optimisticUpdatesQueue, transaction] })));
|
|
681
|
+
if ((backwardOps === null || backwardOps === void 0 ? void 0 : backwardOps.length) === 0) {
|
|
682
|
+
// console.log('no backward ops', transaction.draftOps)
|
|
683
|
+
}
|
|
684
|
+
if (backwardOps && backwardOps.length > 0)
|
|
685
|
+
this._addToUndoStack({ backwardOps, forwardOps: transaction.draftOps });
|
|
686
|
+
}
|
|
687
|
+
waitForBackendSync() {
|
|
688
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
689
|
+
yield this.ready;
|
|
690
|
+
yield waitForPrism(this._prisms.countOfUnpushedUpdatesToBackend, (v) => v === 0);
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
_addToUndoStack(op) {
|
|
694
|
+
this._atom.reduceByPointer((p) => p.undoRedo, (o) => {
|
|
695
|
+
let stack =
|
|
696
|
+
// copy the stack
|
|
697
|
+
[...o.stack]
|
|
698
|
+
// and only keep the items that are before the cursor (so if the user has undone, and then does a new operation, we'll discard the redo stack)
|
|
699
|
+
.slice(o.cursor);
|
|
700
|
+
stack.unshift(op);
|
|
701
|
+
if (stack.length > MAX_UNDO_STACK_SIZE)
|
|
702
|
+
stack.length = MAX_UNDO_STACK_SIZE;
|
|
703
|
+
return {
|
|
704
|
+
cursor: 0,
|
|
705
|
+
stack,
|
|
706
|
+
};
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
undo() {
|
|
710
|
+
const undoRedo = this._atom.get().undoRedo;
|
|
711
|
+
if (undoRedo.cursor >= undoRedo.stack.length)
|
|
712
|
+
return;
|
|
713
|
+
const item = undoRedo.stack[undoRedo.cursor];
|
|
714
|
+
this._atom.reduceByPointer((p) => p.undoRedo, (o) => {
|
|
715
|
+
return Object.assign(Object.assign({}, o), { cursor: o.cursor + 1 });
|
|
716
|
+
});
|
|
717
|
+
this._pushOptimisticUpdate({
|
|
718
|
+
draftOps: item.backwardOps,
|
|
719
|
+
generatorRecordings: {},
|
|
720
|
+
invokations: [],
|
|
721
|
+
peerId: this._peerId,
|
|
722
|
+
}, undefined);
|
|
723
|
+
}
|
|
724
|
+
redo() {
|
|
725
|
+
const undoRedo = this._atom.get().undoRedo;
|
|
726
|
+
if (undoRedo.cursor === 0)
|
|
727
|
+
return;
|
|
728
|
+
const item = undoRedo.stack[undoRedo.cursor - 1];
|
|
729
|
+
this._atom.reduceByPointer((p) => p.undoRedo, (o) => {
|
|
730
|
+
return Object.assign(Object.assign({}, o), { cursor: o.cursor - 1 });
|
|
731
|
+
});
|
|
732
|
+
this._pushOptimisticUpdate({
|
|
733
|
+
draftOps: item.forwardOps,
|
|
734
|
+
generatorRecordings: {},
|
|
735
|
+
invokations: [],
|
|
736
|
+
peerId: this._peerId,
|
|
737
|
+
}, undefined);
|
|
738
|
+
}
|
|
739
|
+
subscribe(fn) {
|
|
740
|
+
const withPeers = this._prisms.withPeers;
|
|
741
|
+
let oldState = withPeers.getValue();
|
|
742
|
+
return withPeers.onStale(() => {
|
|
743
|
+
const newState = withPeers.getValue();
|
|
744
|
+
if (newState !== oldState) {
|
|
745
|
+
oldState = newState;
|
|
746
|
+
fn(finalState(newState));
|
|
747
|
+
}
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
const finalState = memoizeFn((s) => {
|
|
752
|
+
return {
|
|
753
|
+
op: s.op,
|
|
754
|
+
cell: jsonFromCell(s.cell),
|
|
755
|
+
};
|
|
756
|
+
});
|