Skip to content

Commit 49005be

Browse files
authored
Merge pull request #909 from streamich/node-api
High-level JSON CRDT node API
2 parents 76910e2 + b97a842 commit 49005be

File tree

16 files changed

+979
-40
lines changed

16 files changed

+979
-40
lines changed

biome.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@
3535
"noAssignInExpressions": "off",
3636
"noConfusingLabels": "off",
3737
"noConfusingVoidType": "off",
38-
"noConstEnum": "off"
38+
"noConstEnum": "off",
39+
"noSelfCompare": "off"
3940
},
4041
"complexity": {
4142
"noStaticOnlyClass": "off",

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@
8787
"@jsonjoy.com/base64": "^1.1.2",
8888
"@jsonjoy.com/json-expression": "^1.0.0",
8989
"@jsonjoy.com/json-pack": "^1.1.0",
90-
"@jsonjoy.com/json-pointer": "^1.0.0",
90+
"@jsonjoy.com/json-pointer": "^1.0.1",
9191
"@jsonjoy.com/json-type": "^1.0.0",
9292
"@jsonjoy.com/util": "^1.6.0",
9393
"arg": "^5.0.2",

src/json-crdt/__demos__/type-safety.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ console.log(model.view().text);
5858
console.log(model.view().flags[0]);
5959
// true
6060

61-
console.log(model.find.val.toApi() + '');
61+
console.log(model.s.toApi() + '');
6262
// ObjectApi
6363
// └─ ObjNode 1234.1
6464
// ├─ "num"
@@ -74,7 +74,7 @@ console.log(model.find.val.toApi() + '');
7474
// └─ [1]: ValNode 1234.13
7575
// └─ ConNode 1234.12 { false }
7676

77-
console.log(model.find.val.flags.toApi() + '');
77+
console.log(model.s.flags.toApi() + '');
7878
// ArrApi
7979
// └─ ArrNode 1234.9
8080
// └─ ArrChunk 1234.14!2 len:2
@@ -83,20 +83,20 @@ console.log(model.find.val.flags.toApi() + '');
8383
// └─ [1]: ValNode 1234.13
8484
// └─ ConNode 1234.12 { false }
8585

86-
console.log(model.find.val.flags[1].toApi() + '');
86+
console.log(model.s.flags[1].toApi() + '');
8787
// ValApi
8888
// └─ ValNode 1234.13
8989
// └─ ConNode 1234.12 { false }
9090

91-
console.log(model.find.val.flags[1].val.toApi() + '');
91+
console.log(model.s.flags[1].val.toApi() + '');
9292
// ConApi
9393
// └─ ConNode 1234.12 { false }
9494

95-
console.log(model.find.val.num.toApi() + '');
95+
console.log(model.s.num.toApi() + '');
9696
// ConApi
9797
// └─ ConNode 1234.2 { 123 }
9898

99-
console.log(model.find.val.text.toApi() + '');
99+
console.log(model.s.text.toApi() + '');
100100
// StrApi
101101
// └─ StrNode 1234.3 { "hello" }
102102
// └─ StrChunk 1234.4!5 len:5 { "hello" }

src/json-crdt/model/Model.ts

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@ import {ModelApi} from './api/ModelApi';
66
import {ORIGIN, SESSION, SYSTEM_SESSION_TIME} from '../../json-crdt-patch/constants';
77
import {randomSessionId} from './util';
88
import {RootNode, ValNode, VecNode, ObjNode, StrNode, BinNode, ArrNode} from '../nodes';
9-
import type {SchemaToJsonNode} from '../schema/types';
109
import {printTree} from 'tree-dump/lib/printTree';
1110
import {Extensions} from '../extensions/Extensions';
1211
import {AvlMap} from 'sonic-forest/lib/avl/AvlMap';
12+
import type {SchemaToJsonNode} from '../schema/types';
1313
import type {JsonCrdtPatchOperation, Patch} from '../../json-crdt-patch/Patch';
1414
import type {JsonNode, JsonNodeView} from '../nodes/types';
1515
import type {Printable} from 'tree-dump/lib/types';
@@ -269,23 +269,22 @@ export class Model<N extends JsonNode = JsonNode<any>> implements Printable {
269269
return this._api;
270270
}
271271

272-
/**
273-
* Experimental node retrieval API using proxy objects.
274-
*/
275-
public get find() {
276-
return this.api.r.proxy();
277-
}
278-
279272
/**
280273
* Experimental node retrieval API using proxy objects. Returns a strictly
281274
* typed proxy wrapper around the value of the root node.
282-
*
283-
* @todo consider renaming this to `_`.
284275
*/
285276
public get s() {
286277
return this.api.r.proxy().val;
287278
}
288279

280+
/**
281+
* Experimental strictly typed node retrieval API using proxy objects.
282+
* Automatically resolves nested "val" nodes.
283+
*/
284+
public get $() {
285+
return this.api.$;
286+
}
287+
289288
/**
290289
* Tracks number of times the `applyPatch` was called.
291290
*

src/json-crdt/model/api/ModelApi.ts

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import {FanOut} from 'thingies/lib/fanout';
22
import {VecNode, ConNode, ObjNode, ArrNode, BinNode, StrNode, ValNode} from '../../nodes';
33
import {type ApiPath, ArrApi, BinApi, ConApi, type NodeApi, ObjApi, StrApi, VecApi, ValApi} from './nodes';
4-
import type {Patch} from '../../../json-crdt-patch/Patch';
54
import {PatchBuilder} from '../../../json-crdt-patch/PatchBuilder';
6-
import type {SyncStore} from '../../../util/events/sync-store';
75
import {MergeFanOut, MicrotaskBufferFanOut} from './fanout';
6+
import {type JsonNodeToProxyPathNode, proxy$} from './proxy';
87
import {ExtNode} from '../../extensions/ExtNode';
8+
import type {Patch} from '../../../json-crdt-patch/Patch';
9+
import type {SyncStore} from '../../../util/events/sync-store';
910
import type {Model} from '../Model';
1011
import type {JsonNode, JsonNodeView} from '../../nodes';
1112

@@ -106,11 +107,25 @@ export class ModelApi<N extends JsonNode = JsonNode> implements SyncStore<JsonNo
106107
return new ValApi(this.model.root, this);
107108
}
108109

109-
/** @ignore */
110+
/**
111+
* @ignore
112+
*
113+
* @todo Remove this getter?
114+
*/
110115
public get node() {
111116
return this.r.get();
112117
}
113118

119+
public get $(): JsonNodeToProxyPathNode<N> {
120+
return proxy$((path) => {
121+
try {
122+
return this.wrap(this.find(path));
123+
} catch {
124+
return;
125+
}
126+
}, '$') as any;
127+
}
128+
114129
/**
115130
* Traverses the model starting from the root node and returns a local
116131
* changes API for a node at the given path.
@@ -266,6 +281,34 @@ export class ModelApi<N extends JsonNode = JsonNode> implements SyncStore<JsonNo
266281
return this.model.view();
267282
}
268283

284+
public select(path?: ApiPath, leaf?: boolean) {
285+
return this.r.select(path, leaf);
286+
}
287+
288+
/**
289+
* Reads the value at the given path in the model. If no path is provided,
290+
* returns the root node's view.
291+
*
292+
* @param path Path at which to read the value.
293+
* @returns The value at the given path, or the root node's view if no path
294+
* is provided.
295+
*/
296+
public read(path?: ApiPath): unknown {
297+
return this.r.read(path);
298+
}
299+
300+
public add(path: ApiPath, value: unknown): boolean {
301+
return this.r.add(path, value);
302+
}
303+
304+
public replace(path: ApiPath, value: unknown): boolean {
305+
return this.r.replace(path, value);
306+
}
307+
308+
public remove(path: ApiPath, length?: number): boolean {
309+
return this.r.remove(path, length);
310+
}
311+
269312
private inTx = false;
270313
public transaction(callback: () => void) {
271314
if (this.inTx) callback();

src/json-crdt/model/api/__tests__/BinaryApi.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,5 +33,5 @@ test('.length()', () => {
3333
bin: s.bin(new Uint8Array([1, 2, 3])),
3434
}),
3535
);
36-
expect(doc.find.val.bin.toApi().length()).toBe(3);
36+
expect(doc.api.r.proxy().val.bin.toApi().length()).toBe(3);
3737
});

src/json-crdt/model/api/__tests__/ModelApi.proxy.spec.ts

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {Model} from '../../Model';
2-
import {ConApi, ObjApi, StrApi, VecApi, ValApi, ArrApi, BinApi} from '../nodes';
2+
import {ConApi, ObjApi, StrApi, VecApi, ValApi, ArrApi, BinApi, NodeApi} from '../nodes';
33
import {ConNode, RootNode, ObjNode, StrNode, type ValNode} from '../../../nodes';
44
import {s} from '../../../../json-crdt-patch';
55
import type {ProxyNodeVal} from '../proxy';
@@ -114,3 +114,82 @@ describe('supports all node types', () => {
114114
expect(model.s.obj.num.toView()).toStrictEqual(1234);
115115
});
116116
});
117+
118+
describe('$ proxy', () => {
119+
const schema = s.obj({
120+
obj: s.obj({
121+
str: s.str('asdf'),
122+
num: s.con(1234),
123+
address: s.obj({
124+
city: s.str<string>('New York'),
125+
zip: s.con(10001),
126+
}),
127+
}),
128+
vec: s.vec(s.con('asdf'), s.con(1234), s.con(true), s.con(null)),
129+
arr: s.arr([s.con('asdf'), s.val(s.con(0))]),
130+
bin: s.bin(new Uint8Array([1, 2, 3])),
131+
});
132+
133+
test('returns NodeApi? for un-typed model', () => {
134+
const model1 = Model.create();
135+
const model2 = Model.create<any>() as Model<any>;
136+
model1.api.root(schema);
137+
model2.api.root(schema);
138+
const node1 = model1.api.$.obj.str.$;
139+
const node2 = model2.api.$.obj.str.$;
140+
const assertNodeApi = (node?: NodeApi) => {
141+
expect(node instanceof NodeApi).toBe(true);
142+
};
143+
assertNodeApi(node1);
144+
assertNodeApi(node2);
145+
expect(node1?.asStr().view()).toBe('asdf');
146+
expect(node2?.asStr().view()).toBe('asdf');
147+
});
148+
149+
test('returns StrApi? for typed model', () => {
150+
const model = Model.create(schema);
151+
const node = model.api.$.obj.str.$;
152+
const assertStrApi = (node?: StrApi) => {
153+
expect(node instanceof StrApi).toBe(true);
154+
};
155+
assertStrApi(node);
156+
expect(node?.view()).toBe('asdf');
157+
});
158+
159+
test('can access various node types', () => {
160+
const model = Model.create(schema);
161+
expect(model.api.$.obj.str.$?.view()).toBe('asdf');
162+
expect(model.api.$.obj.num.$?.view()).toBe(1234);
163+
expect(model.api.$.obj.address.city.$?.view()).toBe('New York');
164+
expect(model.api.$.obj.address.zip.$?.view()).toBe(10001);
165+
expect(model.api.$.vec[0].$?.view()).toBe('asdf');
166+
expect(model.api.$.vec[1].$?.view()).toBe(1234);
167+
expect(model.api.$.vec[2].$?.view()).toBe(true);
168+
expect(model.api.$.vec[3].$?.view()).toBe(null);
169+
expect(model.api.$.arr[0].$?.view()).toBe('asdf');
170+
expect(model.api.$.arr[1].$?.view()).toBe(0);
171+
expect(model.api.$.bin.$?.view()).toEqual(new Uint8Array([1, 2, 3]));
172+
expect(model.api.$.obj.address.$!.view()).toEqual({
173+
city: 'New York',
174+
zip: 10001,
175+
});
176+
expect(model.api.$.obj.address.$ instanceof ObjApi).toBe(true);
177+
});
178+
179+
test('returns undefined if node not found', () => {
180+
const model = Model.create(schema);
181+
expect(model.api.$.vec[10].$?.view()).toBe(undefined);
182+
expect(model.api.$.arr[111].$?.view()).toBe(undefined);
183+
expect((model.api.$.arr as any).asdf.$?.view()).toBe(undefined);
184+
});
185+
186+
test('returns undefined if node not found in un-typed model', () => {
187+
const model = Model.create();
188+
model.api.root(schema);
189+
expect(model.api.$.asdfasdfasdf.$?.view()).toBe(undefined);
190+
expect(model.api.$[0].$?.view()).toBe(undefined);
191+
expect(model.api.$.vec[10].$?.view()).toBe(undefined);
192+
expect(model.api.$.arr[111].$?.view()).toBe(undefined);
193+
expect(model.api.$.arr.asdf.$?.view()).toBe(undefined);
194+
});
195+
});

src/json-crdt/model/api/__tests__/ModelApi.spec.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import {Model} from '../../Model';
22

33
describe('string manipulation', () => {
44
test('can edit strings', () => {
5-
const doc = Model.withLogicalClock();
5+
const doc = Model.create();
66
const api = doc.api;
77
api.root('');
88
api.str([]).ins(0, 'var foo = bar');
@@ -14,7 +14,7 @@ describe('string manipulation', () => {
1414
});
1515

1616
test('can edit strings - 2', () => {
17-
const doc = Model.withLogicalClock();
17+
const doc = Model.create();
1818
const api = doc.api;
1919
api.root({foo: [123, '', 5]});
2020
api.str(['foo', 1]).ins(0, 'var foo = bar');
@@ -30,7 +30,7 @@ describe('string manipulation', () => {
3030

3131
describe('number manipulation', () => {
3232
test('can edit numbers in object', () => {
33-
const doc = Model.withLogicalClock();
33+
const doc = Model.create();
3434
const api = doc.api;
3535
api.root({
3636
a: [
@@ -57,7 +57,7 @@ describe('number manipulation', () => {
5757
});
5858

5959
test('can edit numbers in arrays', () => {
60-
const doc = Model.withLogicalClock();
60+
const doc = Model.create();
6161
const api = doc.api;
6262
api.root({
6363
a: [123],
@@ -74,7 +74,7 @@ describe('number manipulation', () => {
7474

7575
describe('array manipulation', () => {
7676
test('can edit arrays', () => {
77-
const doc = Model.withLogicalClock();
77+
const doc = Model.create();
7878
const api = doc.api;
7979
api.root([]);
8080
expect(doc.view()).toEqual([]);
@@ -91,7 +91,7 @@ describe('array manipulation', () => {
9191

9292
describe('object manipulation', () => {
9393
test('can create objects', () => {
94-
const doc = Model.withLogicalClock();
94+
const doc = Model.create();
9595
const api = doc.api;
9696
api.root({a: {}});
9797
expect(doc.view()).toEqual({a: {}});
@@ -102,7 +102,7 @@ describe('object manipulation', () => {
102102
});
103103

104104
test('can delete object keys', () => {
105-
const doc = Model.withLogicalClock();
105+
const doc = Model.create();
106106
const api = doc.api;
107107
api.root({a: 'a'});
108108
expect(doc.view()).toEqual({a: 'a'});
@@ -121,7 +121,7 @@ describe('object manipulation', () => {
121121
});
122122

123123
test('can use ID to insert in object', () => {
124-
const doc = Model.withLogicalClock();
124+
const doc = Model.create();
125125
const api = doc.api;
126126
api.root({a: 'a'});
127127
expect(doc.view()).toEqual({a: 'a'});
@@ -133,7 +133,7 @@ describe('object manipulation', () => {
133133
});
134134

135135
test('can use ID to insert in array', () => {
136-
const doc = Model.withLogicalClock();
136+
const doc = Model.create();
137137
const api = doc.api;
138138
api.root([1, 2]);
139139
expect(doc.view()).toEqual([1, 2]);

0 commit comments

Comments
 (0)