From 6300c0e77b369ace8c4d11c3312c0eb9959220f8 Mon Sep 17 00:00:00 2001 From: streamich Date: Fri, 24 Nov 2023 19:05:54 +0100 Subject: [PATCH 01/10] =?UTF-8?q?feat(util):=20=F0=9F=8E=B8=20add=20extern?= =?UTF-8?q?al=20sync=20store=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/util/events/types.ts | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 src/util/events/types.ts diff --git a/src/util/events/types.ts b/src/util/events/types.ts new file mode 100644 index 0000000000..ce8c04bca4 --- /dev/null +++ b/src/util/events/types.ts @@ -0,0 +1,7 @@ +export interface SyncExternalStore { + subscribe: SyncExternalStoreSubscribe; + getSnapshot: () => T; +} + +export type SyncExternalStoreSubscribe = (callback: () => void) => SyncExternalStoreUnsubscribe; +export type SyncExternalStoreUnsubscribe = () => void; From 9e9513553a68f3f8ea07b5c2e0ca7e5523ba0d1c Mon Sep 17 00:00:00 2001 From: streamich Date: Fri, 24 Nov 2023 19:07:17 +0100 Subject: [PATCH 02/10] =?UTF-8?q?feat(json-crdt):=20=F0=9F=8E=B8=20support?= =?UTF-8?q?=20SyncExternalStore=20interface=20in=20ModelApi?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt/model/api/ModelApi.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/json-crdt/model/api/ModelApi.ts b/src/json-crdt/model/api/ModelApi.ts index a5180efbb1..e327e5bbb9 100644 --- a/src/json-crdt/model/api/ModelApi.ts +++ b/src/json-crdt/model/api/ModelApi.ts @@ -4,7 +4,8 @@ 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 {SyncExternalStore} from '../../../util/events/types'; +import type {JsonNode, JsonNodeView} from '../../nodes'; export interface ModelApiEvents { /** @@ -26,7 +27,7 @@ export interface ModelApiEvents { * * @category Local API */ -export class ModelApi { +export class ModelApi implements SyncExternalStore> { /** * Patch builder for the local changes. */ @@ -277,4 +278,15 @@ export class ModelApi { this.events.emit(event); return patch; } + + + // -------------------------------------------------------- SyncExternalStore + + 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; } From b3767349385a20878ec81e4db7afd8f86c1bc3db Mon Sep 17 00:00:00 2001 From: streamich Date: Fri, 24 Nov 2023 21:30:38 +0100 Subject: [PATCH 03/10] =?UTF-8?q?refactor(util):=20=F0=9F=92=A1=20create?= =?UTF-8?q?=20dedicateed=20external=20store=20interfaces=20file?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt/model/api/ModelApi.ts | 6 +++--- src/util/events/sync-store.ts | 7 +++++++ src/util/events/types.ts | 7 ------- 3 files changed, 10 insertions(+), 10 deletions(-) create mode 100644 src/util/events/sync-store.ts delete mode 100644 src/util/events/types.ts diff --git a/src/json-crdt/model/api/ModelApi.ts b/src/json-crdt/model/api/ModelApi.ts index e327e5bbb9..4fef30be85 100644 --- a/src/json-crdt/model/api/ModelApi.ts +++ b/src/json-crdt/model/api/ModelApi.ts @@ -4,7 +4,7 @@ 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 {SyncExternalStore} from '../../../util/events/types'; +import {SyncStore} from '../../../util/events/sync-store'; import type {JsonNode, JsonNodeView} from '../../nodes'; export interface ModelApiEvents { @@ -27,7 +27,7 @@ export interface ModelApiEvents { * * @category Local API */ -export class ModelApi implements SyncExternalStore> { +export class ModelApi implements SyncStore> { /** * Patch builder for the local changes. */ @@ -280,7 +280,7 @@ export class ModelApi implements SyncExternal } - // -------------------------------------------------------- SyncExternalStore + // ---------------------------------------------------------------- SyncStore public readonly subscribe = (callback: () => void) => { const listener = () => callback(); diff --git a/src/util/events/sync-store.ts b/src/util/events/sync-store.ts new file mode 100644 index 0000000000..e339587f02 --- /dev/null +++ b/src/util/events/sync-store.ts @@ -0,0 +1,7 @@ +export interface SyncStore { + subscribe: SyncStoreSubscribe; + getSnapshot: () => T; +} + +export type SyncStoreSubscribe = (callback: () => void) => SyncStoreUnsubscribe; +export type SyncStoreUnsubscribe = () => void; diff --git a/src/util/events/types.ts b/src/util/events/types.ts deleted file mode 100644 index ce8c04bca4..0000000000 --- a/src/util/events/types.ts +++ /dev/null @@ -1,7 +0,0 @@ -export interface SyncExternalStore { - subscribe: SyncExternalStoreSubscribe; - getSnapshot: () => T; -} - -export type SyncExternalStoreSubscribe = (callback: () => void) => SyncExternalStoreUnsubscribe; -export type SyncExternalStoreUnsubscribe = () => void; From b90fafbea17c59dfa60adeacd268aedca8479f83 Mon Sep 17 00:00:00 2001 From: streamich Date: Fri, 24 Nov 2023 21:55:30 +0100 Subject: [PATCH 04/10] =?UTF-8?q?chore:=20=F0=9F=A4=96=20bump=20thingies?= =?UTF-8?q?=20dependency?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- yarn.lock | 11 ++++------- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index 1eddeecb9a..5159a446c1 100644 --- a/package.json +++ b/package.json @@ -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.0", "tinybench": "^2.4.0", "ts-jest": "^29.1.0", "ts-loader": "^9.4.3", diff --git a/yarn.lock b/yarn.lock index 4dd0dd9c0f..2ce69c58a2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2243,7 +2243,6 @@ eastasianwidth@^0.2.0: "editing-traces@https://github.com/streamich/editing-traces#6494020428530a6e382378b98d1d7e31334e2d7b": version "0.0.0" - uid "6494020428530a6e382378b98d1d7e31334e2d7b" resolved "https://github.com/streamich/editing-traces#6494020428530a6e382378b98d1d7e31334e2d7b" ee-first@1.1.1: @@ -3667,7 +3666,6 @@ jsesc@^2.5.1: "json-crdt-traces@https://github.com/streamich/json-crdt-traces#02718a7a5d09e0dc6c31ea7d45a9ce3cbb0bf085": version "0.0.1" - uid "02718a7a5d09e0dc6c31ea7d45a9ce3cbb0bf085" resolved "https://github.com/streamich/json-crdt-traces#02718a7a5d09e0dc6c31ea7d45a9ce3cbb0bf085" json-logic-js@^2.0.1: @@ -5244,10 +5242,10 @@ test-exclude@^6.0.0: glob "^7.1.4" minimatch "^3.0.4" -thingies@^1.11.1: - version "1.12.0" - resolved "https://registry.yarnpkg.com/thingies/-/thingies-1.12.0.tgz#a815c224482d607aa70f563d3cbb351a338e4710" - integrity sha512-AiGqfYC1jLmJagbzQGuoZRM48JPsr9yB734a7K6wzr34NMhjUPrWSQrkF7ZBybf3yCerCL2Gcr02kMv4NmaZfA== +thingies@^1.14.0: + version "1.14.0" + resolved "https://registry.yarnpkg.com/thingies/-/thingies-1.14.0.tgz#247c8d375b2204c2f4ff704d1169a17ec03f5dc3" + integrity sha512-KMcHMvDkxtO0D1OizQcyZxsbVGgIYZsJyV6Y3B6GrWcyCHRdVDUoSlK3EOHrDizCZs9p7x2PdUKcSFOzrd3pog== thunky@^1.0.2: version "1.1.0" @@ -5442,7 +5440,6 @@ typescript@^5.2.2: uWebSockets.js@uNetworking/uWebSockets.js#v20.23.0: version "20.23.0" - uid "49f8f1eb54435e4c3e3e436571e93d1f06aaabbb" resolved "https://codeload.github.com/uNetworking/uWebSockets.js/tar.gz/49f8f1eb54435e4c3e3e436571e93d1f06aaabbb" uc.micro@^1.0.1, uc.micro@^1.0.5: From 23ada4726b8b9007ffcc0c93a117b08a99db5153 Mon Sep 17 00:00:00 2001 From: streamich Date: Fri, 24 Nov 2023 22:44:43 +0100 Subject: [PATCH 05/10] =?UTF-8?q?feat(json-crdt):=20=F0=9F=8E=B8=20add=20M?= =?UTF-8?q?odelApi.changes=20fanout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../model/__tests__/Model.events.spec.ts | 70 +++++++++++-------- src/json-crdt/model/api/ModelApi.ts | 20 +++--- 2 files changed, 52 insertions(+), 38 deletions(-) diff --git a/src/json-crdt/model/__tests__/Model.events.spec.ts b/src/json-crdt/model/__tests__/Model.events.spec.ts index f7bcf4e6d6..83df7bb7c2 100644 --- a/src/json-crdt/model/__tests__/Model.events.spec.ts +++ b/src/json-crdt/model/__tests__/Model.events.spec.ts @@ -1,3 +1,4 @@ +import {PatchBuilder} from '../../../json-crdt-patch'; import {Model, ModelChangeType} from '../Model'; describe('DOM Level 0, .onchange event system', () => { @@ -8,13 +9,19 @@ 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, }); }); @@ -25,9 +32,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); }); @@ -38,9 +48,15 @@ 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({}); }); @@ -52,9 +68,15 @@ 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}); }); @@ -66,30 +88,20 @@ 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; @@ -97,7 +109,7 @@ describe('DOM Level 0, .onchange event system', () => { 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); diff --git a/src/json-crdt/model/api/ModelApi.ts b/src/json-crdt/model/api/ModelApi.ts index 4fef30be85..7ab352fa30 100644 --- a/src/json-crdt/model/api/ModelApi.ts +++ b/src/json-crdt/model/api/ModelApi.ts @@ -1,3 +1,4 @@ +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'; @@ -45,13 +46,17 @@ export class ModelApi implements SyncStore) { this.builder = new PatchBuilder(this.model.clock); + this.model.onchange = this.queueChange; } + public readonly changes = new FanOut(); + /** @ignore */ private queuedChanges: undefined | Set = undefined; - /** @ignore */ + /** @ignore @deprecated */ private readonly queueChange = (changeType: ModelChangeType): void => { + this.changes.emit(changeType); let changesQueued = this.queuedChanges; if (changesQueued) { changesQueued.add(changeType); @@ -68,19 +73,16 @@ export class ModelApi implements SyncStore = undefined; + /** @ignore @deprecated */ + private et: Emitter = new Emitter(); /** * Event target for listening to {@link Model} changes. + * + * @deprecated */ public get events(): Emitter { - let et = this.et; - if (!et) { - this.et = et = new Emitter(); - this.model.onchange = this.queueChange; - } - return et; + return this.et; } /** From 5f919ba65c838c53ecf4b25009a629cda0e907e6 Mon Sep 17 00:00:00 2001 From: streamich Date: Fri, 24 Nov 2023 22:45:18 +0100 Subject: [PATCH 06/10] =?UTF-8?q?style(json-crdt):=20=F0=9F=92=84=20run=20?= =?UTF-8?q?Prettier?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt/model/__tests__/Model.events.spec.ts | 12 +++--------- src/json-crdt/model/api/ModelApi.ts | 1 - 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/src/json-crdt/model/__tests__/Model.events.spec.ts b/src/json-crdt/model/__tests__/Model.events.spec.ts index 83df7bb7c2..939e8bcb13 100644 --- a/src/json-crdt/model/__tests__/Model.events.spec.ts +++ b/src/json-crdt/model/__tests__/Model.events.spec.ts @@ -14,9 +14,7 @@ describe('DOM Level 0, .onchange event system', () => { builder.root(objId); model.applyPatch(builder.flush()); expect(cnt).toBe(1); - builder.insObj(objId, [ - ['hello', builder.const(456)], - ]); + builder.insObj(objId, [['hello', builder.const(456)]]); model.applyPatch(builder.flush()); expect(cnt).toBe(2); expect(model.view()).toStrictEqual({ @@ -53,9 +51,7 @@ describe('DOM Level 0, .onchange event system', () => { builder.root(objId); model.applyPatch(builder.flush()); expect(cnt).toBe(1); - builder.insObj(objId, [ - ['foo', builder.const(undefined)], - ]); + builder.insObj(objId, [['foo', builder.const(undefined)]]); model.applyPatch(builder.flush()); expect(cnt).toBe(2); expect(model.view()).toStrictEqual({}); @@ -73,9 +69,7 @@ describe('DOM Level 0, .onchange event system', () => { builder.root(objId); model.applyPatch(builder.flush()); expect(cnt).toBe(1); - builder.insObj(objId, [ - ['bar', builder.const(undefined)], - ]); + builder.insObj(objId, [['bar', builder.const(undefined)]]); model.applyPatch(builder.flush()); expect(cnt).toBe(2); expect(model.view()).toStrictEqual({foo: 123}); diff --git a/src/json-crdt/model/api/ModelApi.ts b/src/json-crdt/model/api/ModelApi.ts index 7ab352fa30..7cb27e4ffe 100644 --- a/src/json-crdt/model/api/ModelApi.ts +++ b/src/json-crdt/model/api/ModelApi.ts @@ -281,7 +281,6 @@ export class ModelApi implements SyncStore void) => { From 3e2697ad64169e17808b388808519edf111c718e Mon Sep 17 00:00:00 2001 From: streamich Date: Fri, 24 Nov 2023 23:38:16 +0100 Subject: [PATCH 07/10] =?UTF-8?q?feat(json-crdt):=20=F0=9F=8E=B8=20add=20n?= =?UTF-8?q?ode=20changes=20fanout=20api?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- src/json-crdt/__demos__/getting-started.ts | 2 +- src/json-crdt/__demos__/type-safety.ts | 2 +- src/json-crdt/codec/indexed/binary/Encoder.ts | 2 +- .../codec/structural/compact/Encoder.ts | 2 +- .../codec/structural/verbose/Encoder.ts | 2 +- src/json-crdt/model/Model.ts | 25 ++++++------ .../model/__tests__/Model.types.spec.ts | 2 +- src/json-crdt/model/api/ModelApi.ts | 4 +- .../api/__tests__/ModelApi.proxy.spec.ts | 6 +-- src/json-crdt/model/api/events/NodeEvents.ts | 40 ++++++++++++++++++- src/json-crdt/model/api/nodes.ts | 6 +-- yarn.lock | 8 ++-- 13 files changed, 69 insertions(+), 34 deletions(-) diff --git a/package.json b/package.json index 5159a446c1..f905a612f2 100644 --- a/package.json +++ b/package.json @@ -144,7 +144,7 @@ "safe-stable-stringify": "^2.3.1", "secure-json-parse": "^2.4.0", "sorted-btree": "^1.8.1", - "thingies": "^1.14.0", + "thingies": "^1.14.1", "tinybench": "^2.4.0", "ts-jest": "^29.1.0", "ts-loader": "^9.4.3", diff --git a/src/json-crdt/__demos__/getting-started.ts b/src/json-crdt/__demos__/getting-started.ts index 5f57e081a1..fea94c14d8 100644 --- a/src/json-crdt/__demos__/getting-started.ts +++ b/src/json-crdt/__demos__/getting-started.ts @@ -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>; text: n.str; diff --git a/src/json-crdt/__demos__/type-safety.ts b/src/json-crdt/__demos__/type-safety.ts index 31981dac2d..a75346d9d4 100644 --- a/src/json-crdt/__demos__/type-safety.ts +++ b/src/json-crdt/__demos__/type-safety.ts @@ -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; text: n.str; diff --git a/src/json-crdt/codec/indexed/binary/Encoder.ts b/src/json-crdt/codec/indexed/binary/Encoder.ts index e9cf90a6eb..01b158f1dd 100644 --- a/src/json-crdt/codec/indexed/binary/Encoder.ts +++ b/src/json-crdt/codec/indexed/binary/Encoder.ts @@ -15,7 +15,7 @@ export class Encoder { this.enc = new CborEncoder(writer || new CrdtWriter()); } - public encode(doc: Model, clockTable: ClockTable = ClockTable.from(doc.clock)): IndexedFields { + public encode(doc: Model, clockTable: ClockTable = ClockTable.from(doc.clock)): IndexedFields { this.clockTable = clockTable; const writer = this.enc.writer; writer.reset(); diff --git a/src/json-crdt/codec/structural/compact/Encoder.ts b/src/json-crdt/codec/structural/compact/Encoder.ts index cd0219b230..7ce90b1daa 100644 --- a/src/json-crdt/codec/structural/compact/Encoder.ts +++ b/src/json-crdt/codec/structural/compact/Encoder.ts @@ -11,7 +11,7 @@ export class Encoder { protected clock?: ClockEncoder; protected model!: Model; - public encode(model: Model): t.JsonCrdtCompactDocument { + public encode(model: Model): t.JsonCrdtCompactDocument { this.model = model; const isServerTime = model.clock.sid === SESSION.SERVER; const clock = model.clock; diff --git a/src/json-crdt/codec/structural/verbose/Encoder.ts b/src/json-crdt/codec/structural/verbose/Encoder.ts index ae3b911a5d..34231ad036 100644 --- a/src/json-crdt/codec/structural/verbose/Encoder.ts +++ b/src/json-crdt/codec/structural/verbose/Encoder.ts @@ -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): types.JsonCrdtVerboseDocument { this.model = model; const clock = model.clock; const isServerClock = clock.sid === SESSION.SERVER; diff --git a/src/json-crdt/model/Model.ts b/src/json-crdt/model/Model.ts index aeb134dcaa..034f013f91 100644 --- a/src/json-crdt/model/Model.ts +++ b/src/json-crdt/model/Model.ts @@ -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 implements Printable { +export class Model implements Printable { /** * Create a CRDT model which uses logical clock. Logical clock assigns a * logical timestamp to every node and operation. Logical timestamp consists @@ -85,7 +85,7 @@ export class Model 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 = new RootNode(this, ORIGIN); + public root: RootNode = new RootNode(this, ORIGIN); /** * Clock that keeps track of logical timestamps of the current editing session @@ -115,13 +115,13 @@ export class Model implements Printabl } /** @ignore */ - private _api?: ModelApi; + private _api?: ModelApi; /** * API for applying local changes to the current document. */ - public get api(): ModelApi { - if (!this._api) this._api = new ModelApi(this); + public get api(): ModelApi { + if (!this._api) this._api = new ModelApi(this); return this._api; } @@ -302,11 +302,11 @@ export class Model 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 { - const copy = Model.fromBinary(this.toBinary()); + public fork(sessionId: number = randomSessionId()): Model { + const copy = Model.fromBinary(this.toBinary()) as unknown as Model; if (copy.clock.sid !== sessionId && copy.clock instanceof ClockVector) copy.clock = copy.clock.fork(sessionId); copy.ext = this.ext; - return copy as Model; + return copy; } /** @@ -314,17 +314,17 @@ export class Model implements Printabl * * @returns A copy of this model with the same session ID. */ - public clone(): Model { + public clone(): Model { return this.fork(this.clock.sid); } /** * Resets the model to equivalent state of another model. */ - public reset(to: Model): void { + public reset(to: Model): void { this.index = new AvlMap(compare); const blob = to.toBinary(); - decoder.decode(blob, this); + decoder.decode(blob, this); this.clock = to.clock.clone(); this.ext = to.ext.clone(); const api = this._api; @@ -337,7 +337,7 @@ export class Model implements Printabl * * @returns JSON/CBOR of the model. */ - public view(): Readonly> { + public view(): Readonly> { return this.root.view(); } @@ -386,7 +386,6 @@ export class Model 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, diff --git a/src/json-crdt/model/__tests__/Model.types.spec.ts b/src/json-crdt/model/__tests__/Model.types.spec.ts index 5d9ce0170a..7bda69de02 100644 --- a/src/json-crdt/model/__tests__/Model.types.spec.ts +++ b/src/json-crdt/model/__tests__/Model.types.spec.ts @@ -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; diff --git a/src/json-crdt/model/api/ModelApi.ts b/src/json-crdt/model/api/ModelApi.ts index 7cb27e4ffe..dba1b8baed 100644 --- a/src/json-crdt/model/api/ModelApi.ts +++ b/src/json-crdt/model/api/ModelApi.ts @@ -28,7 +28,7 @@ export interface ModelApiEvents { * * @category Local API */ -export class ModelApi implements SyncStore> { +export class ModelApi implements SyncStore> { /** * Patch builder for the local changes. */ @@ -44,7 +44,7 @@ export class ModelApi implements SyncStore) { + constructor(public readonly model: Model) { this.builder = new PatchBuilder(this.model.clock); this.model.onchange = this.queueChange; } diff --git a/src/json-crdt/model/api/__tests__/ModelApi.proxy.spec.ts b/src/json-crdt/model/api/__tests__/ModelApi.proxy.spec.ts index 8e85f0870f..053647d147 100644 --- a/src/json-crdt/model/api/__tests__/ModelApi.proxy.spec.ts +++ b/src/json-crdt/model/api/__tests__/ModelApi.proxy.spec.ts @@ -4,7 +4,7 @@ import {ConNode, RootNode, VecNode, ObjNode, StrNode} from '../../../nodes'; import {vec} from '../../../../json-crdt-patch'; test('proxy API supports object types', () => { - const model = Model.withLogicalClock() as Model< + const model = Model.withLogicalClock() as any as Model< ObjNode<{ foo: StrNode; bar: ConNode; @@ -15,7 +15,7 @@ test('proxy API supports object types', () => { bar: 1234, }); const root = model.api.r.proxy(); - const rootApi: ValApi = root.toApi(); + const rootApi = root.toApi(); expect(rootApi).toBeInstanceOf(ValApi); expect(rootApi.node).toBeInstanceOf(RootNode); expect(rootApi.view()).toStrictEqual({ @@ -50,7 +50,7 @@ describe('supports all node types', () => { }>; vec: VecNode<[StrNode]>; }>; - const model = Model.withLogicalClock() as Model; + const model = Model.withLogicalClock() as any as Model; const data = { obj: { str: 'asdf', diff --git a/src/json-crdt/model/api/events/NodeEvents.ts b/src/json-crdt/model/api/events/NodeEvents.ts index fa9b48d6a4..2c331ff745 100644 --- a/src/json-crdt/model/api/events/NodeEvents.ts +++ b/src/json-crdt/model/api/events/NodeEvents.ts @@ -1,5 +1,7 @@ +import {FanOut, FanOutListener, FanOutUnsubscribe} from 'thingies/es2020/fanout'; import {Emitter} from '../../../../util/events/Emitter'; import type {NodeApi} from '../nodes'; +import type {JsonNode, JsonNodeView} from '../../../nodes'; export interface NodeEventMap { /** @@ -17,9 +19,43 @@ export interface NodeEventMap { view: CustomEvent; } -export class NodeEvents extends Emitter { - constructor(private readonly api: NodeApi) { +class ChangesFanOut extends FanOut> { + private _v: JsonNodeView | undefined = undefined; + private _u: FanOutUnsubscribe | undefined = undefined; + + constructor(private readonly api: NodeApi) { + super(); + } + + public listen(listener: FanOutListener>) { + if (!this.listeners.size) { + const api = this.api; + this._v = api.view(); + this._u = api.api.changes.listen(() => { + const view = api.view(); + if (view !== this._v) { + this._v = view; + this.emit(view); + } + }); + } + const unsubscribe = super.listen(listener); + return () => { + unsubscribe(); + if (!this.listeners.size) { + this._u?.(); + // this._unsub = this._view = undefined; + } + }; + } +} + +export class NodeEvents extends Emitter { + public readonly changes: ChangesFanOut; + + constructor(private readonly api: NodeApi) { super(); + this.changes = new ChangesFanOut(api); } private viewSubs: Set<(ev: NodeEventMap['view']) => any> = new Set(); diff --git a/src/json-crdt/model/api/nodes.ts b/src/json-crdt/model/api/nodes.ts index b24b091373..fb4d39abec 100644 --- a/src/json-crdt/model/api/nodes.ts +++ b/src/json-crdt/model/api/nodes.ts @@ -22,7 +22,7 @@ export class NodeApi implements Printable { constructor(public readonly node: N, public readonly api: ModelApi) {} /** @ignore */ - private ev: undefined | NodeEvents = undefined; + private ev: undefined | NodeEvents = undefined; /** * Event target for listening to node changes. You can subscribe to `"view"` @@ -34,9 +34,9 @@ export class NodeApi implements Printable { * }); * ``` */ - public get events(): NodeEvents { + public get events(): NodeEvents { const et = this.ev; - return et || (this.ev = new NodeEvents(this)); + return et || (this.ev = new NodeEvents(this)); } /** diff --git a/yarn.lock b/yarn.lock index 2ce69c58a2..cad24e5ea5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5242,10 +5242,10 @@ test-exclude@^6.0.0: glob "^7.1.4" minimatch "^3.0.4" -thingies@^1.14.0: - version "1.14.0" - resolved "https://registry.yarnpkg.com/thingies/-/thingies-1.14.0.tgz#247c8d375b2204c2f4ff704d1169a17ec03f5dc3" - integrity sha512-KMcHMvDkxtO0D1OizQcyZxsbVGgIYZsJyV6Y3B6GrWcyCHRdVDUoSlK3EOHrDizCZs9p7x2PdUKcSFOzrd3pog== +thingies@^1.14.1: + version "1.14.1" + resolved "https://registry.yarnpkg.com/thingies/-/thingies-1.14.1.tgz#2456b15db2a4113b434874381b5691dbc4ce2f0f" + integrity sha512-ktiTHLvVsS5Cifx8zbvg7x4clgf3AS+52gMSGlW+fvk8xYzog8f9qHvivjpQF56NHCKKpj4O1wI6tH92V4a0rQ== thunky@^1.0.2: version "1.1.0" From 9897f402f596c1be7885e0cdbfb2b6debfc0a68a Mon Sep 17 00:00:00 2001 From: streamich Date: Sat, 25 Nov 2023 00:12:57 +0100 Subject: [PATCH 08/10] =?UTF-8?q?feat(json-crdt):=20=F0=9F=8E=B8=20expose?= =?UTF-8?q?=20sync=20store=20API=20for=20all=20nodes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt/model/api/events/NodeEvents.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/json-crdt/model/api/events/NodeEvents.ts b/src/json-crdt/model/api/events/NodeEvents.ts index 2c331ff745..44d21d06bd 100644 --- a/src/json-crdt/model/api/events/NodeEvents.ts +++ b/src/json-crdt/model/api/events/NodeEvents.ts @@ -2,6 +2,7 @@ import {FanOut, FanOutListener, FanOutUnsubscribe} from 'thingies/es2020/fanout' import {Emitter} from '../../../../util/events/Emitter'; import type {NodeApi} from '../nodes'; import type {JsonNode, JsonNodeView} from '../../../nodes'; +import type {SyncStore, SyncStoreUnsubscribe} from '../../../../util/events/sync-store'; export interface NodeEventMap { /** @@ -50,7 +51,7 @@ class ChangesFanOut extends FanOut extends Emitter { +export class NodeEvents extends Emitter implements SyncStore> { public readonly changes: ChangesFanOut; constructor(private readonly api: NodeApi) { @@ -96,4 +97,12 @@ export class NodeEvents extends Emitter void): SyncStoreUnsubscribe => { + return this.changes.listen(() => callback()); + }; + + public readonly getSnapshot = () => this.api.view(); } From 011c3caa7cfd2b87eeb388dacc5288d0b0a100ba Mon Sep 17 00:00:00 2001 From: streamich Date: Sat, 25 Nov 2023 00:22:38 +0100 Subject: [PATCH 09/10] =?UTF-8?q?test(json-crdt):=20=F0=9F=92=8D=20add=20t?= =?UTF-8?q?ests=20for=20new=20eventing=20methods?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/__tests__/ModelApi.events.spec.ts | 39 ++++++++++++++ .../model/api/__tests__/StringApi.spec.ts | 51 +++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/src/json-crdt/model/api/__tests__/ModelApi.events.spec.ts b/src/json-crdt/model/api/__tests__/ModelApi.events.spec.ts index d6ec2fba9d..066867e27d 100644 --- a/src/json-crdt/model/api/__tests__/ModelApi.events.spec.ts +++ b/src/json-crdt/model/api/__tests__/ModelApi.events.spec.ts @@ -196,3 +196,42 @@ describe('DOM Level 2 events, .et.addEventListener()', () => { expect(set!.has(ModelChangeType.RESET)).toBe(true); }); }); + +describe('fanout', () => { + describe('changes', () => { + test('emits events on document change', async () => { + const doc = Model.withLogicalClock(); + const api = doc.api; + let cnt = 0; + api.root({a: {}}); + expect(cnt).toBe(0); + api.changes.listen(() => { + cnt++; + }); + api.obj([]).set({gg: true}); + await Promise.resolve(); + expect(cnt).toBe(1); + api.obj(['a']).set({1: 1, 2: 2}); + await Promise.resolve(); + expect(cnt).toBe(2); + }); + + test('can have multiple subscribers', async () => { + const doc = Model.withLogicalClock(); + const api = doc.api; + let cnt = 0; + api.root({a: {}}); + expect(cnt).toBe(0); + api.changes.listen(() => { + cnt++; + }); + api.changes.listen(() => { + cnt++; + }); + expect(cnt).toBe(0); + api.obj([]).set({gg: true}); + await Promise.resolve(); + expect(cnt).toBe(2); + }); + }); +}); diff --git a/src/json-crdt/model/api/__tests__/StringApi.spec.ts b/src/json-crdt/model/api/__tests__/StringApi.spec.ts index 3aa2dfb351..16722b0844 100644 --- a/src/json-crdt/model/api/__tests__/StringApi.spec.ts +++ b/src/json-crdt/model/api/__tests__/StringApi.spec.ts @@ -63,4 +63,55 @@ describe('events', () => { await Promise.resolve(); expect(cnt).toEqual(1); }); + + describe('.changes', () => { + test('can listen to events', async () => { + const doc = Model.withLogicalClock(); + const api = doc.api; + api.root(''); + const str = api.str([]); + let cnt = 0; + const onView = () => cnt++; + str.ins(0, 'aaa'); + expect(cnt).toEqual(0); + const unsubscribe = str.events.changes.listen(onView); + str.ins(0, 'bbb'); + await Promise.resolve(); + expect(cnt).toEqual(1); + str.ins(0, 'ccc'); + await Promise.resolve(); + expect(cnt).toEqual(2); + unsubscribe(); + str.del(1, 7); + expect(cnt).toEqual(2); + }); + }); + + describe('SyncStore', () => { + test('can listen to events', async () => { + const doc = Model.withLogicalClock(); + const api = doc.api; + api.root(''); + const str = api.str([]); + let cnt = 0; + const onView = () => cnt++; + str.ins(0, 'aaa'); + expect(cnt).toEqual(0); + expect(str.events.getSnapshot()).toEqual('aaa'); + const unsubscribe = str.events.subscribe(onView); + str.ins(0, 'bbb'); + await Promise.resolve(); + expect(str.events.getSnapshot()).toEqual('bbbaaa'); + expect(cnt).toEqual(1); + str.ins(0, 'ccc'); + await Promise.resolve(); + expect(str.events.getSnapshot()).toEqual('cccbbbaaa'); + expect(cnt).toEqual(2); + unsubscribe(); + str.del(1, 7); + expect(cnt).toEqual(2); + }); + }); }); + + From 9661fb73cc919f5bbfd86a363ae58c052cc3ab16 Mon Sep 17 00:00:00 2001 From: streamich Date: Sat, 25 Nov 2023 00:23:07 +0100 Subject: [PATCH 10/10] =?UTF-8?q?style(json-crdt):=20=F0=9F=92=84=20run=20?= =?UTF-8?q?Prettier?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt/model/api/__tests__/StringApi.spec.ts | 2 -- src/json-crdt/model/api/events/NodeEvents.ts | 5 ++++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/json-crdt/model/api/__tests__/StringApi.spec.ts b/src/json-crdt/model/api/__tests__/StringApi.spec.ts index 16722b0844..fab97a677c 100644 --- a/src/json-crdt/model/api/__tests__/StringApi.spec.ts +++ b/src/json-crdt/model/api/__tests__/StringApi.spec.ts @@ -113,5 +113,3 @@ describe('events', () => { }); }); }); - - diff --git a/src/json-crdt/model/api/events/NodeEvents.ts b/src/json-crdt/model/api/events/NodeEvents.ts index 44d21d06bd..190161c4e1 100644 --- a/src/json-crdt/model/api/events/NodeEvents.ts +++ b/src/json-crdt/model/api/events/NodeEvents.ts @@ -51,7 +51,10 @@ class ChangesFanOut extends FanOut extends Emitter implements SyncStore> { +export class NodeEvents + extends Emitter + implements SyncStore> +{ public readonly changes: ChangesFanOut; constructor(private readonly api: NodeApi) {