Skip to content

Commit b3b7c8a

Browse files
authored
Merge pull request #972 from streamich/json-crdt-deep-clone
Deep clone for `Model`
2 parents c951317 + 446a761 commit b3b7c8a

10 files changed

Lines changed: 493 additions & 74 deletions

File tree

packages/json-joy/src/json-crdt/model/Model.ts

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -476,14 +476,36 @@ export class Model<N extends JsonNode = JsonNode<any>> implements Printable {
476476
* Creates a copy of this model with a new session ID. If the session ID is
477477
* not provided, a random session ID is generated.
478478
*
479+
* This performs a deep clone of all model state without serialization,
480+
* which allows sharing of immutable data like strings, binary buffers, and IDs
481+
* between the original and the clone for memory efficiency.
482+
*
479483
* @param sessionId Session ID to use for the new model.
480484
* @returns A copy of this model with a new session ID.
481485
*/
482486
public fork(sessionId: number = this.rndSid()): Model<N> {
483-
const copy = Model.fromBinary(this.toBinary()) as unknown as Model<N>;
484-
if (copy.clock.sid !== sessionId && copy.clock instanceof clock.ClockVector)
485-
copy.clock = copy.clock.fork(sessionId);
486-
copy.ext = this.ext;
487+
const clonedClock =
488+
this.clock instanceof clock.ClockVector
489+
? this.clock.fork(sessionId)
490+
: (this.clock as clock.ServerClockVector).clone();
491+
const copy = new Model<N>(clonedClock);
492+
copy.ext = this.ext.clone();
493+
const index = this.index;
494+
const copyIndex = copy.index;
495+
index.forEach(({v: node}) => {
496+
let cloned: JsonNode;
497+
if (node instanceof ConNode) cloned = node.clone();
498+
else if (node instanceof ValNode) cloned = node.clone(copy);
499+
else if (node instanceof ObjNode) cloned = node.clone(copy);
500+
else if (node instanceof VecNode) cloned = node.clone(copy);
501+
else if (node instanceof StrNode) cloned = node.clone();
502+
else if (node instanceof BinNode) cloned = node.clone();
503+
else if (node instanceof ArrNode) cloned = node.clone(copy);
504+
else throw new Error('UNKNOWN_NODE');
505+
copyIndex.set(cloned.id, cloned);
506+
});
507+
copy.root = this.root.clone(copy) as RootNode<N>;
508+
copy.tick = this.tick;
487509
return copy;
488510
}
489511

packages/json-joy/src/json-crdt/model/__tests__/Model.cloning.spec.ts

Lines changed: 345 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,3 +268,348 @@ describe('reset()', () => {
268268
expect(doc2.clock).toBe(doc2.api.builder.clock);
269269
});
270270
});
271+
272+
describe('deep clone state verification', () => {
273+
describe('string sharing (memory efficiency)', () => {
274+
test('StrChunk data strings are shared between original and clone', () => {
275+
const doc1 = Model.create();
276+
doc1.api.set({text: 'hello world'});
277+
doc1.api.flush();
278+
const doc2 = doc1.clone();
279+
const str1 = doc1.api.str(['text']).node;
280+
const str2 = doc2.api.str(['text']).node;
281+
expect(str1.view()).toBe('hello world');
282+
expect(str2.view()).toBe('hello world');
283+
const chunk1 = str1.first()!;
284+
const chunk2 = str2.first()!;
285+
expect(chunk1.data).toBe(chunk2.data);
286+
});
287+
288+
test('object keys are shared between original and clone', () => {
289+
const doc1 = Model.create();
290+
doc1.api.set({myLongKeyName: 123, anotherKey: 'test'});
291+
doc1.api.flush();
292+
const doc2 = doc1.clone();
293+
const obj1 = doc1.api.obj([]).node;
294+
const obj2 = doc2.api.obj([]).node;
295+
const keys1 = Array.from(obj1.keys.keys()).sort();
296+
const keys2 = Array.from(obj2.keys.keys()).sort();
297+
expect(keys1).toEqual(['anotherKey', 'myLongKeyName']);
298+
expect(keys2).toEqual(['anotherKey', 'myLongKeyName']);
299+
});
300+
});
301+
302+
describe('binary data sharing', () => {
303+
test('BinChunk Uint8Array data is shared between original and clone', () => {
304+
const doc1 = Model.create();
305+
const data = new Uint8Array([1, 2, 3, 4, 5]);
306+
doc1.api.set({bin: schema.bin(data)});
307+
doc1.api.flush();
308+
const doc2 = doc1.clone();
309+
const bin1 = doc1.api.bin(['bin']).node;
310+
const bin2 = doc2.api.bin(['bin']).node;
311+
expect(bin1.view()).toEqual(data);
312+
expect(bin2.view()).toEqual(data);
313+
314+
// Verify the underlying Uint8Array data is shared (same reference)
315+
const chunk1 = bin1.first()!;
316+
const chunk2 = bin2.first()!;
317+
expect(chunk1.data).toBe(chunk2.data);
318+
});
319+
});
320+
321+
describe('clock state', () => {
322+
test('clone has same clock time', () => {
323+
const doc1 = Model.create();
324+
doc1.api.set({a: 1, b: 2});
325+
doc1.api.flush();
326+
const doc2 = doc1.clone();
327+
expect(doc2.clock.time).toBe(doc1.clock.time);
328+
expect(doc2.clock.sid).toBe(doc1.clock.sid);
329+
});
330+
331+
test('fork has same clock time but different session ID', () => {
332+
const doc1 = Model.create();
333+
doc1.api.set({a: 1, b: 2});
334+
doc1.api.flush();
335+
const doc2 = doc1.fork();
336+
expect(doc2.clock.time).toBe(doc1.clock.time);
337+
expect(doc2.clock.sid).not.toBe(doc1.clock.sid);
338+
});
339+
340+
test('clone preserves peer information in clock vector', () => {
341+
const doc1 = Model.create();
342+
doc1.api.set({a: 1});
343+
doc1.api.flush();
344+
// Simulate receiving changes from another peer
345+
const doc2 = doc1.fork();
346+
doc2.api.obj([]).set({b: 2});
347+
const patch = doc2.api.flush();
348+
doc1.applyPatch(patch);
349+
// Clone should preserve peer info
350+
const doc3 = doc1.clone();
351+
expect(doc3.clock.peers.size).toBe(doc1.clock.peers.size);
352+
doc1.clock.peers.forEach((peerClock, sid) => {
353+
const clonedPeerClock = doc3.clock.peers.get(sid);
354+
expect(clonedPeerClock).toBeDefined();
355+
expect(clonedPeerClock!.time).toBe(peerClock.time);
356+
});
357+
});
358+
});
359+
360+
describe('model tick', () => {
361+
test('clone preserves model tick', () => {
362+
const doc1 = Model.create();
363+
doc1.api.set({a: 1});
364+
doc1.api.flush();
365+
const doc2 = doc1.fork();
366+
expect(doc1.tick).toBe(doc2.tick);
367+
doc2.api.obj([]).set({b: 2});
368+
doc1.applyPatch(doc2.api.flush());
369+
expect(doc1.tick).toBeGreaterThan(0);
370+
const doc3 = doc1.clone();
371+
expect(doc3.tick).toBe(doc1.tick);
372+
});
373+
});
374+
375+
describe('index completeness', () => {
376+
test('clone has all nodes in index', () => {
377+
const doc1 = Model.create();
378+
doc1.api.set({
379+
str: 'hello',
380+
num: 42,
381+
bool: true,
382+
nil: null,
383+
arr: [1, 2, 3],
384+
obj: {nested: 'value'},
385+
});
386+
doc1.api.flush();
387+
const doc2 = doc1.clone();
388+
let count1 = 0;
389+
let count2 = 0;
390+
doc1.index.forEach(() => count1++);
391+
doc2.index.forEach(() => count2++);
392+
expect(count2).toBe(count1);
393+
});
394+
395+
test('clone has independent index', () => {
396+
const doc1 = Model.create();
397+
doc1.api.set({a: 1});
398+
doc1.api.flush();
399+
const doc2 = doc1.clone();
400+
// Indexes should be different objects
401+
expect(doc2.index).not.toBe(doc1.index);
402+
// Adding to doc1 should not affect doc2
403+
doc1.api.obj([]).set({b: 2});
404+
doc1.api.flush();
405+
let count1 = 0;
406+
let count2 = 0;
407+
doc1.index.forEach(() => count1++);
408+
doc2.index.forEach(() => count2++);
409+
expect(count1).toBeGreaterThan(count2);
410+
});
411+
});
412+
413+
describe('extensions', () => {
414+
test('clone has cloned extensions', () => {
415+
const doc1 = Model.create();
416+
doc1.ext.register({} as any);
417+
doc1.api.set({a: 1});
418+
doc1.api.flush();
419+
const doc2 = doc1.clone();
420+
expect(doc2.ext).not.toBe(doc1.ext);
421+
expect(doc2.ext.size()).toBe(doc1.ext.size());
422+
});
423+
});
424+
425+
describe('API independence', () => {
426+
test('clone does not have API instance until accessed', () => {
427+
const doc1 = Model.create();
428+
doc1.api.set({a: 1});
429+
doc1.api.flush();
430+
// Access api on doc1
431+
expect(doc1.api).toBeDefined();
432+
const doc2 = doc1.clone();
433+
expect((doc2 as any)._api).toBeUndefined();
434+
// doc2 should have its own API when accessed
435+
expect(doc2.api).toBeDefined();
436+
expect(doc2.api).not.toBe(doc1.api);
437+
});
438+
439+
test('node APIs are not shared between clones', () => {
440+
const doc1 = Model.create();
441+
doc1.api.set({str: 'hello'});
442+
doc1.api.flush();
443+
// Access node API on doc1
444+
const strApi1 = doc1.api.str(['str']);
445+
expect(strApi1).toBeDefined();
446+
const doc2 = doc1.clone();
447+
// doc2's node API should be different
448+
const strApi2 = doc2.api.str(['str']);
449+
expect(strApi2).not.toBe(strApi1);
450+
});
451+
});
452+
453+
describe('RGA structure', () => {
454+
test('StrNode clone preserves RGA structure with splits', () => {
455+
const doc1 = Model.create();
456+
doc1.api.set({text: 'abc'});
457+
doc1.api.str(['text']).ins(3, 'def');
458+
doc1.api.str(['text']).del(1, 2); // Creates tombstones
459+
doc1.api.flush();
460+
461+
const doc2 = doc1.clone();
462+
463+
// Verify views match
464+
expect(doc2.view()).toEqual(doc1.view());
465+
466+
// Verify chunk counts match
467+
const str1 = doc1.api.str(['text']).node;
468+
const str2 = doc2.api.str(['text']).node;
469+
expect(str2.count).toBe(str1.count);
470+
expect(str2.length()).toBe(str1.length());
471+
});
472+
473+
test('ArrNode clone preserves RGA structure', () => {
474+
const doc1 = Model.create();
475+
doc1.api.set({arr: [1, 2, 3]});
476+
doc1.api.arr(['arr']).ins(3, [4, 5]);
477+
doc1.api.arr(['arr']).del(1, 2); // Delete some elements
478+
doc1.api.flush();
479+
480+
const doc2 = doc1.clone();
481+
482+
// Verify views match
483+
expect(doc2.view()).toEqual(doc1.view());
484+
485+
// Verify chunk counts match
486+
const arr1 = doc1.api.arr(['arr']).node;
487+
const arr2 = doc2.api.arr(['arr']).node;
488+
expect(arr2.count).toBe(arr1.count);
489+
expect(arr2.length()).toBe(arr1.length());
490+
});
491+
492+
test('BinNode clone preserves RGA structure', () => {
493+
const doc1 = Model.create();
494+
doc1.api.set({bin: schema.bin(new Uint8Array([1, 2, 3]))});
495+
doc1.api.bin(['bin']).ins(3, new Uint8Array([4, 5]));
496+
doc1.api.bin(['bin']).del(1, 2);
497+
doc1.api.flush();
498+
499+
const doc2 = doc1.clone();
500+
501+
// Verify views match
502+
const view1 = (doc1.view() as any).bin;
503+
const view2 = (doc2.view() as any).bin;
504+
expect(view2).toEqual(view1);
505+
506+
// Verify chunk counts match
507+
const bin1 = doc1.api.bin(['bin']).node;
508+
const bin2 = doc2.api.bin(['bin']).node;
509+
expect(bin2.count).toBe(bin1.count);
510+
expect(bin2.length()).toBe(bin1.length());
511+
});
512+
});
513+
514+
describe('mutation isolation', () => {
515+
test('modifying clone does not affect original', () => {
516+
const doc1 = Model.create();
517+
doc1.api.set({text: 'hello'});
518+
doc1.api.flush();
519+
520+
const doc2 = doc1.clone();
521+
doc2.api.str(['text']).ins(5, ' world');
522+
doc2.api.flush();
523+
524+
expect(doc1.view()).toEqual({text: 'hello'});
525+
expect(doc2.view()).toEqual({text: 'hello world'});
526+
});
527+
528+
test('modifying original does not affect clone', () => {
529+
const doc1 = Model.create();
530+
doc1.api.set({text: 'hello'});
531+
doc1.api.flush();
532+
533+
const doc2 = doc1.clone();
534+
535+
doc1.api.str(['text']).ins(5, ' world');
536+
doc1.api.flush();
537+
538+
expect(doc1.view()).toEqual({text: 'hello world'});
539+
expect(doc2.view()).toEqual({text: 'hello'});
540+
});
541+
542+
test('mutations to object in clone are isolated', () => {
543+
const doc1 = Model.create();
544+
doc1.api.set({
545+
obj: {a: 1, b: 2},
546+
});
547+
doc1.api.flush();
548+
549+
const doc2 = doc1.clone();
550+
doc2.api.obj(['obj']).set({c: 3});
551+
doc2.api.obj(['obj']).del(['a']);
552+
doc2.api.flush();
553+
554+
expect(doc1.view()).toEqual({obj: {a: 1, b: 2}});
555+
expect(doc2.view()).toEqual({obj: {b: 2, c: 3}});
556+
});
557+
558+
test('mutations to array in clone are isolated', () => {
559+
const doc1 = Model.create();
560+
doc1.api.set({arr: [1, 2, 3]});
561+
doc1.api.flush();
562+
563+
const doc2 = doc1.clone();
564+
doc2.api.arr(['arr']).ins(0, [0]);
565+
doc2.api.arr(['arr']).del(3, 1); // Delete index 3 (which is value 3)
566+
doc2.api.flush();
567+
568+
expect(doc1.view()).toEqual({arr: [1, 2, 3]});
569+
expect(doc2.view()).toEqual({arr: [0, 1, 2]});
570+
});
571+
});
572+
573+
describe('complex documents', () => {
574+
test('clone of deeply nested document', () => {
575+
const doc1 = Model.create();
576+
doc1.api.set({
577+
level1: {
578+
level2: {
579+
level3: {
580+
value: 'deep',
581+
arr: [1, [2, [3]]],
582+
},
583+
},
584+
},
585+
});
586+
doc1.api.flush();
587+
588+
const doc2 = doc1.clone();
589+
590+
expect(doc2.view()).toEqual(doc1.view());
591+
592+
// Modify deep value in clone
593+
doc2.api.obj(['level1', 'level2', 'level3']).set({value: 'modified'});
594+
doc2.api.flush();
595+
596+
expect((doc1.view() as any).level1.level2.level3.value).toBe('deep');
597+
expect((doc2.view() as any).level1.level2.level3.value).toBe('modified');
598+
});
599+
600+
test('clone with vectors', () => {
601+
const doc1 = Model.create();
602+
doc1.api.set({
603+
vec: schema.vec(schema.con(1), schema.con(2), schema.con(3)),
604+
});
605+
doc1.api.flush();
606+
607+
const doc2 = doc1.clone();
608+
609+
const view1 = doc1.view() as any;
610+
const view2 = doc2.view() as any;
611+
612+
expect(view2.vec).toEqual(view1.vec);
613+
});
614+
});
615+
});

0 commit comments

Comments
 (0)