Skip to content

Commit 934cbe9

Browse files
committed
feat(json-crdt): 🎸 improve the PatchLog class
1 parent 0b14ae0 commit 934cbe9

File tree

3 files changed

+81
-26
lines changed

3 files changed

+81
-26
lines changed

src/json-crdt/file/File.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {Model} from '../model';
2-
import {PatchLog} from './PatchLog';
2+
import {PatchLog} from '../history/PatchLog';
33
import {printTree} from '../../util/print/printTree';
44
import {decodeModel, decodeNdjsonComponents, decodePatch, decodeSeqCborComponents} from './util';
55
import {Patch} from '../../json-crdt-patch';
@@ -75,7 +75,7 @@ export class File implements Printable {
7575
}
7676

7777
public static fromModel(model: Model<any>, options: FileOptions = {}): File {
78-
return new File(model, PatchLog.fromModel(model), options);
78+
return new File(model, PatchLog.fromNewModel(model), options);
7979
}
8080

8181
constructor(

src/json-crdt/history/PatchLog.ts

Lines changed: 67 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import {FanOutUnsubscribe} from 'thingies/es2020/fanout';
12
import {ITimestampStruct, Patch, compare} from '../../json-crdt-patch';
23
import {printTree} from '../../util/print/printTree';
34
import {AvlMap} from '../../util/trees/avl/AvlMap';
@@ -6,32 +7,84 @@ import {first, next} from '../../util/trees/util';
67
import type {Printable} from '../../util/print/types';
78

89
export class PatchLog implements Printable {
10+
/**
11+
* Creates a `PatchLog` instance from a newly JSON CRDT model. Checks if
12+
* the model API buffer has any initial operations applied, if yes, it
13+
* uses them to create the initial state of the log.
14+
*
15+
* @param model A new JSON CRDT model, just created with
16+
* `Model.withLogicalClock()` or `Model.withServerClock()`.
17+
* @returns A new `PatchLog` instance.
18+
*/
919
public static fromNewModel(model: Model<any>): PatchLog {
10-
const start = new Model(model.clock.clone());
11-
const log = new PatchLog(start);
20+
const clock = model.clock.clone();
21+
const log = new PatchLog(() => new Model(clock));
1222
const api = model.api;
13-
if (api.builder.patch.ops.length) log.push(api.flush());
23+
if (api.builder.patch.ops.length) log.end.applyPatch(api.flush());
1424
return log;
1525
}
1626

27+
/**
28+
* Model factory function that creates a new JSON CRDT model instance, which
29+
* is used as the starting point of the log. It is called every time a new
30+
* model is needed to replay the log.
31+
*/
32+
public readonly start: () => Model;
33+
34+
/**
35+
* The end of the log, the current state of the document. It is the model
36+
* instance that is used to apply new patches to the log.
37+
*/
38+
public readonly end: Model;
39+
40+
/**
41+
* The patches in the log, stored in an AVL tree for efficient replaying. The
42+
* collection of patches which are applied to the `start()` model to reach
43+
* the `end` model.
44+
*/
1745
public readonly patches = new AvlMap<ITimestampStruct, Patch>(compare);
46+
private _patchesUnsub: FanOutUnsubscribe;
1847

19-
constructor(public readonly start: Model) {}
48+
constructor(start: () => Model) {
49+
this.start = start;
50+
this.end = start();
51+
this._patchesUnsub = this.end.api.onPatch.listen((patch) => {
52+
const id = patch.getId();
53+
if (!id) return;
54+
this.patches.set(id, patch);
55+
});
56+
}
2057

21-
public push(patch: Patch): void {
22-
const id = patch.getId();
23-
if (!id) return;
24-
this.patches.set(id, patch);
58+
/**
59+
* Call this method to destroy the `PatchLog` instance. It unsubscribes from
60+
* the model's `onPatch` event listener.
61+
*/
62+
public destroy() {
63+
this._patchesUnsub();
2564
}
2665

66+
/**
67+
* Creates a new model instance using the `start()` factory function and
68+
* replays all patches in the log to reach the current state of the document.
69+
*
70+
* @returns A new model instance with all patches replayed.
71+
*/
2772
public replayToEnd(): Model {
28-
const clone = this.start.clone();
73+
const clone = this.start().clone();
2974
for (let node = first(this.patches.root); node; node = next(node)) clone.applyPatch(node.v);
3075
return clone;
3176
}
3277

78+
/**
79+
* Replays the patch log until a specified timestamp, including the patch
80+
* at the given timestamp. The model returned is a new instance of `start()`
81+
* with patches replayed up to the given timestamp.
82+
*
83+
* @param ts Timestamp ID of the patch to replay to.
84+
* @returns A new model instance with patches replayed up to the given timestamp.
85+
*/
3386
public replayTo(ts: ITimestampStruct): Model {
34-
const clone = this.start.clone();
87+
const clone = this.start().clone();
3588
for (let node = first(this.patches.root); node && compare(ts, node.k) >= 0; node = next(node))
3689
clone.applyPatch(node.v);
3790
return clone;
@@ -45,14 +98,16 @@ export class PatchLog implements Printable {
4598
return (
4699
`log` +
47100
printTree(tab, [
48-
(tab) => this.start.toString(tab),
101+
(tab) => `start` + printTree(tab, [tab => this.start().toString(tab)]),
49102
() => '',
50103
(tab) =>
51-
'history' +
104+
'history' +
52105
printTree(
53106
tab,
54107
log.map((patch, i) => (tab) => `${i}: ${patch.toString(tab)}`),
55108
),
109+
() => '',
110+
(tab) => `end` + printTree(tab, [tab => this.end.toString(tab)]),
56111
])
57112
);
58113
}

src/json-crdt/file/__tests__/PatchLog.spec.ts renamed to src/json-crdt/history/__tests__/PatchLog.spec.ts

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,28 @@
11
import {Model} from '../../model';
2-
import {File} from '../File';
2+
import {PatchLog} from '../PatchLog';
33

44
const setup = (view: unknown) => {
55
const model = Model.withServerClock();
66
model.api.root(view);
7-
const file = File.fromModel(model);
8-
return {model, file};
7+
const log = PatchLog.fromNewModel(model);
8+
return {log};
99
};
1010

1111
test('can replay to specific patch', () => {
12-
const {file} = setup({foo: 'bar'});
13-
const model = file.model.clone();
12+
const {log} = setup({foo: 'bar'});
13+
const model = log.end.clone();
1414
model.api.obj([]).set({x: 1});
1515
const patch1 = model.api.flush();
1616
model.api.obj([]).set({y: 2});
1717
const patch2 = model.api.flush();
18-
file.apply(patch1);
19-
file.apply(patch2);
20-
const model2 = file.log.replayToEnd();
21-
const model3 = file.log.replayTo(patch1.getId()!);
22-
const model4 = file.log.replayTo(patch2.getId()!);
18+
log.end.applyPatch(patch1);
19+
log.end.applyPatch(patch2);
20+
const model2 = log.replayToEnd();
21+
const model3 = log.replayTo(patch1.getId()!);
22+
const model4 = log.replayTo(patch2.getId()!);
2323
expect(model.view()).toEqual({foo: 'bar', x: 1, y: 2});
24-
expect(file.model.view()).toEqual({foo: 'bar', x: 1, y: 2});
25-
expect(file.log.start.view()).toEqual(undefined);
24+
expect(log.end.view()).toEqual({foo: 'bar', x: 1, y: 2});
25+
expect(log.start().view()).toEqual(undefined);
2626
expect(model2.view()).toEqual({foo: 'bar', x: 1, y: 2});
2727
expect(model3.view()).toEqual({foo: 'bar', x: 1});
2828
expect(model4.view()).toEqual({foo: 'bar', x: 1, y: 2});

0 commit comments

Comments
 (0)