-
-
Notifications
You must be signed in to change notification settings - Fork 11
/
Model.ts
587 lines (552 loc) · 19.4 KB
/
Model.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
import * as operations from '../../json-crdt-patch/operations';
import * as clock from '../../json-crdt-patch/clock';
import {ConNode} from '../nodes/const/ConNode';
import {encoder, decoder} from '../codec/structural/binary/shared';
import {JsonCrdtPatchOperation, Patch} from '../../json-crdt-patch/Patch';
import {ModelApi} from './api/ModelApi';
import {ORIGIN, SESSION, SYSTEM_SESSION_TIME} from '../../json-crdt-patch/constants';
import {randomSessionId} from './util';
import {RootNode, ValNode, VecNode, ObjNode, StrNode, BinNode, ArrNode} from '../nodes';
import {SchemaToJsonNode} from '../schema/types';
import {printTree} from 'tree-dump/lib/printTree';
import {Extensions} from '../extensions/Extensions';
import {AvlMap} from 'sonic-forest/lib/avl/AvlMap';
import type {JsonNode, JsonNodeView} from '../nodes/types';
import type {Printable} from 'tree-dump/lib/types';
import type {NodeBuilder} from '../../json-crdt-patch';
import type {NodeApi} from './api/nodes';
export const UNDEFINED = new ConNode(ORIGIN, undefined);
/**
* In instance of Model class represents the underlying data structure,
* i.e. model, of the JSON CRDT document.
*/
export class Model<N extends JsonNode = JsonNode<any>> implements Printable {
/**
* Generates a random session ID. Use this method to generate a session ID
* for a new user. Store the session ID in the user's browser or device once
* and reuse it for all editing sessions of that user.
*
* Generating a new session ID for each editing session will work, however,
* that is not recommended. If a user generates a new session ID for each
* editing session, the session clock table will grow indefinitely.
*/
public static readonly sid = randomSessionId;
/**
* Create a CRDT model which uses logical clock. Logical clock assigns a
* logical timestamp to every node and operation. Logical timestamp consists
* of a session ID and sequence number 2-tuple. Logical clocks allow to
* sync peer-to-peer.
*
* @param clockOrSessionId Logical clock to use.
* @returns CRDT model.
*
* @deprecated Use `Model.create()` instead.
*/
public static readonly withLogicalClock = (clockOrSessionId?: clock.ClockVector | number): Model => {
return Model.create(undefined, clockOrSessionId);
};
/**
* Create a CRDT model which uses server clock. In this model a central server
* timestamps each operation with a sequence number. Each timestamp consists
* simply of a sequence number, which was assigned by a server. In this model
* all operations are approved, persisted and re-distributed to all clients by
* a central server.
*
* @param time Latest known server sequence number.
* @returns CRDT model.
*
* @deprecated Use `Model.create()` instead: `Model.create(undefined, SESSION.SERVER)`.
*/
public static readonly withServerClock = (time: number = 1): Model => {
return Model.create(undefined, new clock.ServerClockVector(SESSION.SERVER, time));
};
/**
* Create a new JSON CRDT model. If a schema is provided, the model is
* strictly typed and the default value of the model is set to the default
* value of the schema.
*
* By default, the model is created with a random session ID and is using
* a logical clock. It is also possible to create a model which uses a server
* clock by providing the session ID `SESSION.SERVER` (1).
*
* ### Examples
*
* Create a basic model, without schema and default value:
*
* ```ts
* const model = Model.create();
* ```
*
* Create a strictly typed model with a schema and default value:
*
* ```ts
* const schema = s.obj({
* ticker: s.con<string>('BODEN'),
* name: s.str('Jeo Boden'),
* tags: s.arr(
* s.str('token'),
* ),
* });
* const model = Model.create(schema);
* ```
*
* Create a model with a custom session ID for your logical clock:
*
* ```ts
* const schema = s.str('');
* const sid = 123456789;
* const model = Model.create(schema, sid);
* ```
*
* The session ID must be at least 65,536 or higher, [see JSON CRDT Patch
* specification][json-crdt-patch].
*
* [json-crdt-patch]: https://jsonjoy.com/specs/json-crdt-patch/patch-document/logical-clock
*
* To create a model with a server clock, use the `SESSION.SERVER`, which is
* equal to 1:
*
* ```ts
* const model = Model.create(undefined, SESSION.SERVER);
* // or
* const model = Model.create(undefined, 1);
* ```
*
* Finally, you can create a model with your clock vector:
*
* ```ts
* const clock = new ClockVector(123456789, 1);
* const model = Model.create(undefined, clock);
* ```
*
* @param schema The schema (typing and default value) to set for this model.
* @param sidOrClock Session ID to use for local operations. Defaults to a random
* session ID generated by {@link Model.sid}.
* @returns A strictly typed model.
*/
public static readonly create = <S extends NodeBuilder>(
schema?: S,
sidOrClock: clock.ClockVector | number = Model.sid(),
): Model<SchemaToJsonNode<S>> => {
const cl =
typeof sidOrClock === 'number'
? sidOrClock === SESSION.SERVER
? new clock.ServerClockVector(SESSION.SERVER, 1)
: new clock.ClockVector(sidOrClock, 1)
: sidOrClock;
const model = new Model<SchemaToJsonNode<S>>(cl);
if (schema) model.setSchema(schema, true);
return model;
};
/**
* Decodes a model from a "binary" structural encoding.
*
* Use {@link Model.load} instead, if you want to set the session ID of the
* model and the right schema for the model, during the de-serialization.
*
* @param data Binary blob of a model encoded using "binary" structural
* encoding.
* @returns An instance of a model.
*/
public static readonly fromBinary = (data: Uint8Array): Model => {
return decoder.decode(data);
};
/**
* Un-serializes a model from "binary" structural encoding. The session ID of
* the model is set to the provided session ID `sid`, or the default session
* ID of the un-serialized model is used.
*
* @param data Binary blob of a model encoded using "binary" structural
* encoding.
* @param sid Session ID to set for the model.
* @returns An instance of a model.
*/
public static readonly load = <S extends NodeBuilder>(
data: Uint8Array,
sid?: number,
schema?: S,
): Model<SchemaToJsonNode<S>> => {
const model = decoder.decode(data) as unknown as Model<SchemaToJsonNode<S>>;
if (schema) model.setSchema(schema, true);
if (typeof sid === 'number') model.setSid(sid);
return model;
};
/**
* Instantiates a model from a collection of patches. The patches are applied
* to the model in the order they are provided. The session ID of the model is
* set to the session ID of the first patch.
*
* @param patches A collection of initial patches to apply to the model.
* @returns A model with the patches applied.
*/
public static fromPatches(patches: Patch[]): Model {
const length = patches.length;
if (!length) throw new Error('NO_PATCHES');
const first = patches[0];
const sid = first.getId()!.sid;
if (!sid) throw new Error('NO_SID');
const model = Model.withLogicalClock(sid);
model.applyBatch(patches);
return model;
}
/**
* Root of the JSON document is implemented as Last Write Wins Register,
* so that the JSON document does not necessarily need to be an object. The
* JSON document can be any JSON value.
*/
public root: RootNode<N> = new RootNode<N>(this, ORIGIN);
/**
* Clock that keeps track of logical timestamps of the current editing session
* and logical clocks of all known peers.
*/
public clock: clock.IClockVector;
/**
* Index of all known node objects (objects, array, strings, values)
* in this document.
*
* @ignore
*/
public index = new AvlMap<clock.ITimestampStruct, JsonNode>(clock.compare);
/**
* Extensions to the JSON CRDT protocol. Extensions are used to implement
* custom data types on top of the JSON CRDT protocol.
*
* @ignore
* @todo Allow this to be `undefined`.
*/
public ext: Extensions = new Extensions();
public constructor(clockVector: clock.IClockVector) {
this.clock = clockVector;
if (!clockVector.time) clockVector.time = 1;
}
/** @ignore */
private _api?: ModelApi<N>;
/**
* API for applying local changes to the current document.
*/
public get api(): ModelApi<N> {
if (!this._api) this._api = new ModelApi<N>(this);
return this._api;
}
/**
* Experimental node retrieval API using proxy objects.
*/
public get find() {
return this.api.r.proxy();
}
/**
* Experimental node retrieval API using proxy objects. Returns a strictly
* typed proxy wrapper around the value of the root node.
*
* @todo consider renaming this to `_`.
*/
public get s() {
return this.api.r.proxy().val;
}
/**
* Tracks number of times the `applyPatch` was called.
*
* @ignore
*/
public tick: number = 0;
/**
* Applies a batch of patches to the document.
*
* @param patches A batch, i.e. an array of patches.
*/
public applyBatch(patches: Patch[]) {
const length = patches.length;
for (let i = 0; i < length; i++) this.applyPatch(patches[i]);
}
/**
* Callback called before every `applyPatch` call.
*/
public onbeforepatch?: (patch: Patch) => void = undefined;
/**
* Callback called after every `applyPatch` call.
*/
public onpatch?: (patch: Patch) => void = undefined;
/**
* Applies a single patch to the document. All mutations to the model must go
* through this method.
*/
public applyPatch(patch: Patch) {
this.onbeforepatch?.(patch);
const ops = patch.ops;
const {length} = ops;
for (let i = 0; i < length; i++) this.applyOperation(ops[i]);
this.tick++;
this.onpatch?.(patch);
}
/**
* Applies a single operation to the model. All mutations to the model must go
* through this method.
*
* For advanced use only, better use `applyPatch` instead. You MUST increment
* the `tick` property and call the necessary event emitters manually.
*
* @param op Any JSON CRDT Patch operation
* @ignore
* @internal
*/
public applyOperation(op: JsonCrdtPatchOperation): void {
this.clock.observe(op.id, op.span());
const index = this.index;
if (op instanceof operations.InsStrOp) {
const node = index.get(op.obj);
if (node instanceof StrNode) node.ins(op.ref, op.id, op.data);
} else if (op instanceof operations.NewObjOp) {
const id = op.id;
if (!index.get(id)) index.set(id, new ObjNode(this, id));
} else if (op instanceof operations.NewArrOp) {
const id = op.id;
if (!index.get(id)) index.set(id, new ArrNode(this, id));
} else if (op instanceof operations.NewStrOp) {
const id = op.id;
if (!index.get(id)) index.set(id, new StrNode(id));
} else if (op instanceof operations.NewValOp) {
const id = op.id;
if (!index.get(id)) index.set(id, new ValNode(this, id, ORIGIN));
} else if (op instanceof operations.NewConOp) {
const id = op.id;
if (!index.get(id)) index.set(id, new ConNode(id, op.val));
} else if (op instanceof operations.InsObjOp) {
const node = index.get(op.obj);
const tuples = op.data;
const length = tuples.length;
if (node instanceof ObjNode) {
for (let i = 0; i < length; i++) {
const tuple = tuples[i];
const valueNode = index.get(tuple[1]);
if (!valueNode) continue;
if (node.id.time >= tuple[1].time) continue;
const old = node.put(tuple[0] + '', valueNode.id);
if (old) this.deleteNodeTree(old);
}
}
} else if (op instanceof operations.InsVecOp) {
const node = index.get(op.obj);
const tuples = op.data;
const length = tuples.length;
if (node instanceof VecNode) {
for (let i = 0; i < length; i++) {
const tuple = tuples[i];
const valueNode = index.get(tuple[1]);
if (!valueNode) continue;
if (node.id.time >= tuple[1].time) continue;
const old = node.put(Number(tuple[0]), valueNode.id);
if (old) this.deleteNodeTree(old);
}
}
} else if (op instanceof operations.InsValOp) {
const obj = op.obj;
const node = obj.sid === SESSION.SYSTEM && obj.time === SYSTEM_SESSION_TIME.ORIGIN ? this.root : index.get(obj);
if (node instanceof ValNode) {
const newValue = index.get(op.val);
if (newValue) {
const old = node.set(op.val);
if (old) this.deleteNodeTree(old);
}
}
} else if (op instanceof operations.InsArrOp) {
const node = index.get(op.obj);
if (node instanceof ArrNode) {
const nodes: clock.ITimestampStruct[] = [];
const data = op.data;
const length = data.length;
for (let i = 0; i < length; i++) {
const stamp = data[i];
const valueNode = index.get(stamp);
if (!valueNode) continue;
if (node.id.time >= stamp.time) continue;
nodes.push(stamp);
}
if (nodes.length) node.ins(op.ref, op.id, nodes);
}
} else if (op instanceof operations.DelOp) {
const node = index.get(op.obj);
if (node instanceof ArrNode) {
const length = op.what.length;
for (let i = 0; i < length; i++) {
const span = op.what[i];
for (let j = 0; j < span.span; j++) {
const id = node.getById(new clock.Timestamp(span.sid, span.time + j));
if (id) this.deleteNodeTree(id);
}
}
node.delete(op.what);
} else if (node instanceof StrNode) node.delete(op.what);
else if (node instanceof BinNode) node.delete(op.what);
} else if (op instanceof operations.NewBinOp) {
const id = op.id;
if (!index.get(id)) index.set(id, new BinNode(id));
} else if (op instanceof operations.InsBinOp) {
const node = index.get(op.obj);
if (node instanceof BinNode) node.ins(op.ref, op.id, op.data);
} else if (op instanceof operations.NewVecOp) {
const id = op.id;
if (!index.get(id)) index.set(id, new VecNode(this, id));
}
}
/**
* Recursively deletes a tree of nodes. Used when root node is overwritten or
* when object contents of container node (object or array) is removed.
*
* @ignore
*/
protected deleteNodeTree(value: clock.ITimestampStruct) {
const isSystemNode = value.sid === SESSION.SYSTEM;
if (isSystemNode) return;
const node = this.index.get(value);
if (!node) return;
const api = node.api;
if (api) (api as NodeApi).events.handleDelete();
node.children((child) => this.deleteNodeTree(child.id));
this.index.del(value);
}
/**
* Creates a copy of this model with a new session ID. If the session ID is
* not provided, a random session ID is generated.
*
* @param sessionId Session ID to use for the new model.
* @returns A copy of this model with a new session ID.
*/
public fork(sessionId: number = Model.sid()): Model<N> {
const copy = Model.fromBinary(this.toBinary()) as unknown as Model<N>;
if (copy.clock.sid !== sessionId && copy.clock instanceof clock.ClockVector)
copy.clock = copy.clock.fork(sessionId);
copy.ext = this.ext;
return copy;
}
/**
* Creates a copy of this model with the same session ID.
*
* @returns A copy of this model with the same session ID.
*/
public clone(): Model<N> {
return this.fork(this.clock.sid);
}
/**
* Callback called before model isi reset using the `.reset()` method.
*/
public onbeforereset?: () => void = undefined;
/**
* Callback called after model has been reset using the `.reset()` method.
*/
public onreset?: () => void = undefined;
/**
* Resets the model to equivalent state of another model.
*/
public reset(to: Model<N>): void {
this.onbeforereset?.();
const index = this.index;
this.index = new AvlMap<clock.ITimestampStruct, JsonNode>(clock.compare);
const blob = to.toBinary();
decoder.decode(blob, <any>this);
this.clock = to.clock.clone();
this.ext = to.ext.clone();
this._api?.flush();
index.forEach(({v: node}) => {
const api = node.api as NodeApi | undefined;
if (!api) return;
const newNode = this.index.get(node.id);
if (!newNode) {
api.events.handleDelete();
return;
}
api.node = newNode;
newNode.api = api;
});
this.tick++;
this.onreset?.();
}
/**
* Returns the view of the model.
*
* @returns JSON/CBOR of the model.
*/
public view(): Readonly<JsonNodeView<N>> {
return this.root.view();
}
/**
* Serialize this model using "binary" structural encoding.
*
* @returns This model encoded in octets.
*/
public toBinary(): Uint8Array {
return encoder.encode(this);
}
/**
* Strictly types the model and sets the default value of the model, if
* the document is empty.
*
* @param schema The schema to set for this model.
* @param sid Session ID to use for setting the default value of the document.
* Defaults to `SESSION.GLOBAL` (2), which is the default session ID
* for all operations operations that are not attributed to a specific
* session.
* @returns Strictly typed model.
*/
public setSchema<S extends NodeBuilder>(schema: S, useGlobalSession: boolean = true): Model<SchemaToJsonNode<S>> {
const c = this.clock;
const isNewDocument = c.time === 1;
if (isNewDocument) {
const oldSid = c.sid;
if (useGlobalSession) c.sid = SESSION.GLOBAL;
this.api.root(schema);
if (useGlobalSession) this.setSid(oldSid);
}
return <any>this;
}
/**
* Changes the session ID of the model. By modifying the attached clock vector
* of the model. Be careful when changing the session ID of the model, as this
* is an advanced operation.
*
* Use the {@link Model.load} method to load a model with the the right session
* ID, instead of changing the session ID of the model. When in doubt, use the
* {@link Model.fork} method to create a new model with the right session ID.
*
* @param sid The new session ID to set for the model.
*/
public setSid(sid: number): void {
const cl = this.clock;
const oldSid = cl.sid;
if (oldSid !== sid) {
cl.sid = sid;
cl.observe(new clock.Timestamp(oldSid, cl.time - 1), 1);
}
}
// ---------------------------------------------------------------- Printable
public toString(tab: string = ''): string {
const nl = () => '';
const hasExtensions = this.ext.size() > 0;
return (
'model' +
printTree(tab, [
(tab) => this.root.toString(tab),
nl,
(tab) => {
const nodes: JsonNode[] = [];
this.index.forEach((item) => nodes.push(item.v));
return (
`index (${nodes.length} nodes)` +
(nodes.length
? printTree(
tab,
nodes.map((node) => (tab) => `${node.name()} ${clock.printTs(node.id)}`),
)
: '')
);
},
nl,
(tab) =>
`view${printTree(tab, [(tab) => String(JSON.stringify(this.view(), null, 2)).replace(/\n/g, '\n' + tab)])}`,
nl,
(tab) => this.clock.toString(tab),
hasExtensions ? nl : null,
hasExtensions ? (tab) => this.ext.toString(tab) : null,
])
);
}
}