Skip to content

Commit

Permalink
Merge pull request #466 from streamich/events-2.0
Browse files Browse the repository at this point in the history
JSON CRDT Events 2.0
  • Loading branch information
streamich authored Nov 29, 2023
2 parents acdaff9 + 6a1768e commit b2ac900
Show file tree
Hide file tree
Showing 13 changed files with 426 additions and 287 deletions.
2 changes: 1 addition & 1 deletion src/json-crdt/__demos__/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ const model = Model.withLogicalClock(1234); // 1234 is the session ID

// DOM Level 2 node events
const root = model.api.r;
root.events.on('view', () => {
root.events.onViewChanges.listen(() => {
console.log('Root value changed');
});

Expand Down
62 changes: 39 additions & 23 deletions src/json-crdt/model/Model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,6 @@ import type {NodeApi} from './api/nodes';

export const UNDEFINED = new ConNode(ORIGIN, undefined);

export const enum ModelChangeType {
/** When operations are applied through `.applyPatch()` directly. */
REMOTE = 0,
/** When local operations are applied through the `ModelApi`. */
LOCAL = 1,
/** When model is reset using the `.reset()` method. */
RESET = 2,
}

/**
* In instance of Model class represents the underlying data structure,
* i.e. model, of the JSON CRDT document.
Expand Down Expand Up @@ -140,15 +131,6 @@ export class Model<N extends JsonNode = JsonNode> implements Printable {
*/
public tick: number = 0;

/**
* Callback called after every `applyPatch` call.
*
* When using the `.api` API, this property is set automatically by
* the {@link ModelApi} class. In that case use the `mode.api.evens.on('change')`
* to subscribe to changes.
*/
public onchange: undefined | ((type: ModelChangeType) => void) = undefined;

/**
* Applies a batch of patches to the document.
*
Expand All @@ -159,16 +141,27 @@ export class Model<N extends JsonNode = JsonNode> implements Printable {
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.onchange?.(ModelChangeType.REMOTE);
this.onpatch?.(patch);
}

/**
Expand All @@ -180,6 +173,7 @@ export class Model<N extends JsonNode = JsonNode> implements Printable {
*
* @param op Any JSON CRDT Patch operation
* @ignore
* @internal
*/
public applyOperation(op: JsonCrdtPatchOperation): void {
this.clock.observe(op.id, op.span());
Expand Down Expand Up @@ -293,7 +287,7 @@ export class Model<N extends JsonNode = JsonNode> implements Printable {
const node = this.index.get(value);
if (!node) return;
const api = node.api;
if (api) (api as NodeApi).events.onDelete();
if (api) (api as NodeApi).events.handleDelete();
node.children((child) => this.deleteNodeTree(child.id));
this.index.del(value);
}
Expand Down Expand Up @@ -322,18 +316,40 @@ export class Model<N extends JsonNode = JsonNode> implements Printable {
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();
const api = this._api;
if (api) api.flush();
this.onchange?.(ModelChangeType.RESET);
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.onreset?.();
}

/**
Expand Down
18 changes: 17 additions & 1 deletion src/json-crdt/model/__tests__/Model.cloning.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {until} from '../../../__tests__/util';
import {schema} from '../../../json-crdt-patch';
import {PatchBuilder} from '../../../json-crdt-patch/PatchBuilder';
import {Model} from '../Model';

Expand Down Expand Up @@ -214,9 +215,24 @@ describe('reset()', () => {
});
doc2.api.str(['text']).ins(5, ' world');
let cnt = 0;
doc2.api.events.on('change', () => cnt++);
doc2.api.onChanges.listen(() => cnt++);
doc2.reset(doc1);
await until(() => cnt > 0);
expect(cnt).toBe(1);
});

test('preserves API nodes when model is reset', async () => {
const doc1 = Model.withLogicalClock().setSchema(
schema.obj({
text: schema.str('hell'),
}),
);
const doc2 = doc1.fork();
doc2.s.text.toApi().ins(4, 'o');
const str = doc1.s.text.toApi();
expect(str === doc2.s.text.toApi()).toBe(false);
expect(str.view()).toBe('hell');
doc1.reset(doc2);
expect(str.view()).toBe('hello');
});
});
31 changes: 7 additions & 24 deletions src/json-crdt/model/__tests__/Model.events.spec.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import {PatchBuilder} from '../../../json-crdt-patch';
import {Model, ModelChangeType} from '../Model';
import {Model} from '../Model';

describe('DOM Level 0, .onchange event system', () => {
it('should trigger the onchange event when a value is set', () => {
const model = Model.withLogicalClock();
let cnt = 0;
model.onchange = () => {
model.onpatch = () => {
cnt++;
};
expect(cnt).toBe(0);
Expand All @@ -26,7 +26,7 @@ describe('DOM Level 0, .onchange event system', () => {
it('should trigger the onchange event when a value is set to the same value', () => {
const model = Model.withLogicalClock();
let cnt = 0;
model.onchange = () => {
model.onpatch = () => {
cnt++;
};
expect(cnt).toBe(0);
Expand All @@ -42,7 +42,7 @@ describe('DOM Level 0, .onchange event system', () => {
it('should trigger the onchange event when a value is deleted', () => {
const model = Model.withLogicalClock();
let cnt = 0;
model.onchange = () => {
model.onpatch = () => {
cnt++;
};
expect(cnt).toBe(0);
Expand All @@ -60,7 +60,7 @@ describe('DOM Level 0, .onchange event system', () => {
it('should trigger the onchange event when a non-existent value is deleted', () => {
const model = Model.withLogicalClock();
let cnt = 0;
model.onchange = () => {
model.onpatch = () => {
cnt++;
};
expect(cnt).toBe(0);
Expand All @@ -78,7 +78,7 @@ describe('DOM Level 0, .onchange event system', () => {
it('should trigger when root value is changed', () => {
const model = Model.withLogicalClock();
let cnt = 0;
model.onchange = () => {
model.onpatch = () => {
cnt++;
};
expect(cnt).toBe(0);
Expand All @@ -96,29 +96,12 @@ describe('DOM Level 0, .onchange event system', () => {
});

describe('event types', () => {
it('should trigger the onchange event with a REMOTE event type', () => {
const model = Model.withLogicalClock();
let cnt = 0;
model.onchange = (type) => {
expect(type).toBe(ModelChangeType.REMOTE);
cnt++;
};
const builder = new PatchBuilder(model.clock.clone());
builder.root(builder.json({foo: 123}));
const patch = builder.flush();
expect(cnt).toBe(0);
model.applyPatch(patch);
expect(cnt).toBe(1);
expect(model.view()).toStrictEqual({foo: 123});
});

it('should trigger the onchange event with a RESET event type', () => {
const model1 = Model.withLogicalClock();
const model2 = Model.withLogicalClock();
model2.api.root([1, 2, 3]);
let cnt = 0;
model1.onchange = (type) => {
expect(type).toBe(ModelChangeType.RESET);
model1.onreset = () => {
cnt++;
};
expect(cnt).toBe(0);
Expand Down
Loading

0 comments on commit b2ac900

Please sign in to comment.