Skip to content
Merged
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@
"safe-stable-stringify": "^2.3.1",
"secure-json-parse": "^2.4.0",
"sorted-btree": "^1.8.1",
"thingies": "^1.11.1",
"thingies": "^1.14.1",
"tinybench": "^2.4.0",
"ts-jest": "^29.1.0",
"ts-loader": "^9.4.3",
Expand Down
2 changes: 1 addition & 1 deletion src/json-crdt/__demos__/getting-started.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {Model, n} from '..';
import {vec} from '../../json-crdt-patch';

// Create a new JSON CRDT document, 1234 is the session ID.
const model = Model.withLogicalClock(1234) as Model<
const model = Model.withLogicalClock(1234) as any as Model<
n.obj<{
counter: n.val<n.con<number>>;
text: n.str;
Expand Down
2 changes: 1 addition & 1 deletion src/json-crdt/__demos__/type-safety.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {Model, n} from '..';

console.clear();

const model = Model.withLogicalClock(1234) as Model<
const model = Model.withLogicalClock(1234) as any as Model<
n.obj<{
num: n.con<number>;
text: n.str;
Expand Down
2 changes: 1 addition & 1 deletion src/json-crdt/codec/indexed/binary/Encoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export class Encoder {
this.enc = new CborEncoder<CrdtWriter>(writer || new CrdtWriter());
}

public encode(doc: Model, clockTable: ClockTable = ClockTable.from(doc.clock)): IndexedFields {
public encode(doc: Model<any>, clockTable: ClockTable = ClockTable.from(doc.clock)): IndexedFields {
this.clockTable = clockTable;
const writer = this.enc.writer;
writer.reset();
Expand Down
2 changes: 1 addition & 1 deletion src/json-crdt/codec/structural/compact/Encoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export class Encoder {
protected clock?: ClockEncoder;
protected model!: Model;

public encode(model: Model): t.JsonCrdtCompactDocument {
public encode(model: Model<any>): t.JsonCrdtCompactDocument {
this.model = model;
const isServerTime = model.clock.sid === SESSION.SERVER;
const clock = model.clock;
Expand Down
2 changes: 1 addition & 1 deletion src/json-crdt/codec/structural/verbose/Encoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type * as types from './types';
export class Encoder {
protected model!: Model;

public encode(model: Model): types.JsonCrdtVerboseDocument {
public encode(model: Model<any>): types.JsonCrdtVerboseDocument {
this.model = model;
const clock = model.clock;
const isServerClock = clock.sid === SESSION.SERVER;
Expand Down
25 changes: 12 additions & 13 deletions src/json-crdt/model/Model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export const enum ModelChangeType {
* In instance of Model class represents the underlying data structure,
* i.e. model, of the JSON CRDT document.
*/
export class Model<RootJsonNode extends JsonNode = JsonNode> implements Printable {
export class Model<N extends JsonNode = JsonNode> implements Printable {
/**
* Create a CRDT model which uses logical clock. Logical clock assigns a
* logical timestamp to every node and operation. Logical timestamp consists
Expand Down Expand Up @@ -85,7 +85,7 @@ export class Model<RootJsonNode extends JsonNode = JsonNode> implements Printabl
* so that the JSON document does not necessarily need to be an object. The
* JSON document can be any JSON value.
*/
public root: RootNode<RootJsonNode> = new RootNode<RootJsonNode>(this, ORIGIN);
public root: RootNode<N> = new RootNode<N>(this, ORIGIN);

/**
* Clock that keeps track of logical timestamps of the current editing session
Expand Down Expand Up @@ -115,13 +115,13 @@ export class Model<RootJsonNode extends JsonNode = JsonNode> implements Printabl
}

/** @ignore */
private _api?: ModelApi<RootJsonNode>;
private _api?: ModelApi<N>;

/**
* API for applying local changes to the current document.
*/
public get api(): ModelApi<RootJsonNode> {
if (!this._api) this._api = new ModelApi<RootJsonNode>(this);
public get api(): ModelApi<N> {
if (!this._api) this._api = new ModelApi<N>(this);
return this._api;
}

Expand Down Expand Up @@ -302,29 +302,29 @@ export class Model<RootJsonNode extends JsonNode = JsonNode> implements Printabl
* @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 = randomSessionId()): Model<RootJsonNode> {
const copy = Model.fromBinary(this.toBinary());
public fork(sessionId: number = randomSessionId()): Model<N> {
const copy = Model.fromBinary(this.toBinary()) as unknown as Model<N>;
if (copy.clock.sid !== sessionId && copy.clock instanceof ClockVector) copy.clock = copy.clock.fork(sessionId);
copy.ext = this.ext;
return copy as Model<RootJsonNode>;
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<RootJsonNode> {
public clone(): Model<N> {
return this.fork(this.clock.sid);
}

/**
* Resets the model to equivalent state of another model.
*/
public reset(to: Model<RootJsonNode>): void {
public reset(to: Model<N>): void {
this.index = new AvlMap<ITimestampStruct, JsonNode>(compare);
const blob = to.toBinary();
decoder.decode(blob, this);
decoder.decode(blob, <any>this);
this.clock = to.clock.clone();
this.ext = to.ext.clone();
const api = this._api;
Expand All @@ -337,7 +337,7 @@ export class Model<RootJsonNode extends JsonNode = JsonNode> implements Printabl
*
* @returns JSON/CBOR of the model.
*/
public view(): Readonly<JsonNodeView<RootJsonNode>> {
public view(): Readonly<JsonNodeView<N>> {
return this.root.view();
}

Expand Down Expand Up @@ -386,7 +386,6 @@ export class Model<RootJsonNode extends JsonNode = JsonNode> implements Printabl
);
},
nl,
// (tab) => `View ${toTree(this.view(), tab)}`,
(tab) =>
`view${printTree(tab, [(tab) => String(JSON.stringify(this.view(), null, 2)).replace(/\n/g, '\n' + tab)])}`,
nl,
Expand Down
64 changes: 35 additions & 29 deletions src/json-crdt/model/__tests__/Model.events.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {PatchBuilder} from '../../../json-crdt-patch';
import {Model, ModelChangeType} from '../Model';

describe('DOM Level 0, .onchange event system', () => {
Expand All @@ -8,13 +9,17 @@ describe('DOM Level 0, .onchange event system', () => {
cnt++;
};
expect(cnt).toBe(0);
model.api.root({foo: 'bar'});
const builder = new PatchBuilder(model.clock.clone());
const objId = builder.json({foo: 123});
builder.root(objId);
model.applyPatch(builder.flush());
expect(cnt).toBe(1);
model.api.obj([]).set({hello: 123});
builder.insObj(objId, [['hello', builder.const(456)]]);
model.applyPatch(builder.flush());
expect(cnt).toBe(2);
expect(model.view()).toStrictEqual({
foo: 'bar',
hello: 123,
foo: 123,
hello: 456,
});
});

Expand All @@ -25,9 +30,12 @@ describe('DOM Level 0, .onchange event system', () => {
cnt++;
};
expect(cnt).toBe(0);
model.api.root({foo: 123});
const builder = new PatchBuilder(model.clock.clone());
builder.root(builder.json({foo: 123}));
model.applyPatch(builder.flush());
expect(cnt).toBe(1);
model.api.obj([]).set({foo: 123});
builder.root(builder.json({foo: 123}));
model.applyPatch(builder.flush());
expect(cnt).toBe(2);
});

Expand All @@ -38,9 +46,13 @@ describe('DOM Level 0, .onchange event system', () => {
cnt++;
};
expect(cnt).toBe(0);
model.api.root({foo: 123});
const builder = new PatchBuilder(model.clock.clone());
const objId = builder.json({foo: 123});
builder.root(objId);
model.applyPatch(builder.flush());
expect(cnt).toBe(1);
model.api.obj([]).set({foo: undefined});
builder.insObj(objId, [['foo', builder.const(undefined)]]);
model.applyPatch(builder.flush());
expect(cnt).toBe(2);
expect(model.view()).toStrictEqual({});
});
Expand All @@ -52,9 +64,13 @@ describe('DOM Level 0, .onchange event system', () => {
cnt++;
};
expect(cnt).toBe(0);
model.api.root({foo: 123});
const builder = new PatchBuilder(model.clock.clone());
const objId = builder.json({foo: 123});
builder.root(objId);
model.applyPatch(builder.flush());
expect(cnt).toBe(1);
model.api.obj([]).set({bar: undefined});
builder.insObj(objId, [['bar', builder.const(undefined)]]);
model.applyPatch(builder.flush());
expect(cnt).toBe(2);
expect(model.view()).toStrictEqual({foo: 123});
});
Expand All @@ -66,38 +82,28 @@ describe('DOM Level 0, .onchange event system', () => {
cnt++;
};
expect(cnt).toBe(0);
model.api.root({foo: 123});
const builder = new PatchBuilder(model.clock.clone());
const objId = builder.json({foo: 123});
builder.root(objId);
model.applyPatch(builder.flush());
expect(cnt).toBe(1);
model.api.root(123);
builder.root(builder.json(123));
model.applyPatch(builder.flush());
expect(cnt).toBe(2);
model.api.root('asdf');
builder.root(builder.json('asdf'));
model.applyPatch(builder.flush());
expect(cnt).toBe(3);
});

describe('event types', () => {
it('should trigger the onchange event with a LOCAL event type', () => {
const model = Model.withLogicalClock();
let cnt = 0;
model.onchange = (type) => {
expect(type).toBe(ModelChangeType.LOCAL);
cnt++;
};
expect(cnt).toBe(0);
model.api.root({foo: 123});
expect(cnt).toBe(1);
model.api.obj([]).set({foo: 55});
expect(cnt).toBe(2);
expect(model.view()).toStrictEqual({foo: 55});
});

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 = model.api.builder;
const builder = new PatchBuilder(model.clock.clone());
builder.root(builder.json({foo: 123}));
const patch = builder.flush();
expect(cnt).toBe(0);
Expand Down
2 changes: 1 addition & 1 deletion src/json-crdt/model/__tests__/Model.types.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {Model} from '../Model';
import type {ConNode, ObjNode, StrNode} from '../../nodes';

test('can add TypeScript types to Model view', () => {
const model = Model.withLogicalClock() as Model<
const model = Model.withLogicalClock() as any as Model<
ObjNode<{
foo: StrNode;
bar: ConNode<number>;
Expand Down
37 changes: 25 additions & 12 deletions src/json-crdt/model/api/ModelApi.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import {FanOut} from 'thingies/es2020/fanout';
import {VecNode, ConNode, ObjNode, ArrNode, BinNode, StrNode, ValNode} from '../../nodes';
import {ApiPath, ArrApi, BinApi, ConApi, NodeApi, ObjApi, StrApi, VecApi, ValApi} from './nodes';
import {Emitter} from '../../../util/events/Emitter';
import {Patch} from '../../../json-crdt-patch/Patch';
import {PatchBuilder} from '../../../json-crdt-patch/PatchBuilder';
import {ModelChangeType, type Model} from '../Model';
import type {JsonNode} from '../../nodes';
import {SyncStore} from '../../../util/events/sync-store';
import type {JsonNode, JsonNodeView} from '../../nodes';

export interface ModelApiEvents {
/**
Expand All @@ -26,7 +28,7 @@ export interface ModelApiEvents {
*
* @category Local API
*/
export class ModelApi<Value extends JsonNode = JsonNode> {
export class ModelApi<N extends JsonNode = JsonNode> implements SyncStore<JsonNodeView<N>> {
/**
* Patch builder for the local changes.
*/
Expand All @@ -42,15 +44,19 @@ export class ModelApi<Value extends JsonNode = JsonNode> {
/**
* @param model Model instance on which the API operates.
*/
constructor(public readonly model: Model<Value>) {
constructor(public readonly model: Model<N>) {
this.builder = new PatchBuilder(this.model.clock);
this.model.onchange = this.queueChange;
}

public readonly changes = new FanOut<ModelChangeType>();

/** @ignore */
private queuedChanges: undefined | Set<ModelChangeType> = undefined;

/** @ignore */
/** @ignore @deprecated */
private readonly queueChange = (changeType: ModelChangeType): void => {
this.changes.emit(changeType);
let changesQueued = this.queuedChanges;
if (changesQueued) {
changesQueued.add(changeType);
Expand All @@ -67,19 +73,16 @@ export class ModelApi<Value extends JsonNode = JsonNode> {
});
};

/** @ignore */
private et: undefined | Emitter<ModelApiEvents> = undefined;
/** @ignore @deprecated */
private et: Emitter<ModelApiEvents> = new Emitter();

/**
* Event target for listening to {@link Model} changes.
*
* @deprecated
*/
public get events(): Emitter<ModelApiEvents> {
let et = this.et;
if (!et) {
this.et = et = new Emitter();
this.model.onchange = this.queueChange;
}
return et;
return this.et;
}

/**
Expand Down Expand Up @@ -277,4 +280,14 @@ export class ModelApi<Value extends JsonNode = JsonNode> {
this.events.emit(event);
return patch;
}

// ---------------------------------------------------------------- SyncStore

public readonly subscribe = (callback: () => void) => {
const listener = () => callback();
this.events.on('change', listener);
return () => this.events.off('change', listener);
};

public readonly getSnapshot = () => this.view() as any;
}
Loading