From 059b20b9c06dcfc083c2893424d0ef1721c3a986 Mon Sep 17 00:00:00 2001 From: Yourim Cha <81357083+chacha912@users.noreply.github.com> Date: Tue, 11 Apr 2023 20:02:20 +0900 Subject: [PATCH] Allow specifying a topic when subscribing to a document (#487) * Modify `doc.subscribe` function to allow specifying a topic * Add document.getValueByPath * Specify the doc.subscribe event value for each operation * Use `waitStubCallCount` instead of `createEmitterAndSpy` and `waitFor` `waitFor` continuously registers the listener, which may result in missed event detections if an event occurs before the registration. Therefore, `waitFor` has been replaced with `waitStubCallCount` to verify the number of times the callback function is called and which events are executed. * Add test that `remote-change` event is properly detected in `doc.subscribe` * Modify the `selectPriv` method to return `TextChange` when setting the initial selectionMap --------- Co-authored-by: Youngteac Hong --- public/multi.html | 459 +++++++++++++++++++ public/style.css | 90 +++- src/document/change/change.ts | 12 +- src/document/crdt/root.ts | 4 +- src/document/crdt/text.ts | 24 +- src/document/document.ts | 149 +++++- src/document/json/array.ts | 2 +- src/document/json/text.ts | 2 +- src/document/operation/add_operation.ts | 32 +- src/document/operation/edit_operation.ts | 57 ++- src/document/operation/increase_operation.ts | 30 +- src/document/operation/move_operation.ts | 35 +- src/document/operation/operation.ts | 89 +++- src/document/operation/remove_operation.ts | 41 +- src/document/operation/select_operation.ts | 33 +- src/document/operation/set_operation.ts | 32 +- src/document/operation/style_operation.ts | 39 +- test/helper/helper.ts | 32 -- test/integration/client_test.ts | 79 +++- test/integration/document_test.ts | 412 ++++++++++++++++- test/integration/object_test.ts | 53 +-- test/unit/document/document_test.ts | 230 +++++++--- 22 files changed, 1608 insertions(+), 328 deletions(-) create mode 100644 public/multi.html diff --git a/public/multi.html b/public/multi.html new file mode 100644 index 000000000..2de14d59b --- /dev/null +++ b/public/multi.html @@ -0,0 +1,459 @@ + + + + + Multi Example + + + + + + + + +
+
status:
+
peers:
+
+
+

Counter

+ +
+

Todo List

+
+
    +
    + + +
    +
    +

    Quill Editor

    +
    +

    yorkie document

    +
    
    +    
    + + + + + diff --git a/public/style.css b/public/style.css index 5a15edf5d..fdc1ce0c5 100644 --- a/public/style.css +++ b/public/style.css @@ -2,6 +2,21 @@ body { background: white; } +h2 { + margin-top: 1.6em; +} + +li { + list-style: none; +} + +button { + display: inline-flex; + cursor: pointer; + justify-content: center; + align-items: center; +} + #network-status span { display: inline-block; height: 0.8rem; @@ -33,11 +48,11 @@ body { font-weight: bold; } -button { +#increaseButton, +#decreaseButton { margin-right: 1em; width: 30px; height: 30px; - cursor: pointer; } #network-status:before { @@ -76,3 +91,74 @@ button { overflow-y: auto; resize: vertical; } + +.increaseButton { + display: inline-block; + border: 1px solid #ddd; + border-radius: 10px; + padding: 0.6em 1.2em; + font-size: 1rem; + font-weight: bold; +} + +.todos { + width: 300px; + background: #efefef; + border-radius: 10px; + overflow: hidden; + border: 1px solid #ddd; +} +.todoList { + margin: 0; + padding: 8px 0; + height: 160px; + box-sizing: border-box; + overflow-y: auto; +} +.todoList li { + display: flex; + padding: 10px; + font-size: 0.9em; +} +.todoList li:hover, +.todoList li:focus { + background: #e2e2e2; +} +.todoList li .itemName { + padding: 0 10px; + flex: 1; + word-break: break-all; +} +.todoList .trash { + background: transparent; + border: none; +} +.todoList .moveUp, +.todoList .moveDown { + background: #fff; + border-radius: 10px; + border: none; + margin-right: 4px; + color: #666; +} +.todoList .trash:hover, +.todoList .moveUp:hover, +.todoList .moveDown:hover { + scale: 1.1; +} +.todoNew { + display: flex; + border-top: 1px solid #ddd; +} +.todoNew .addButton { + border: none; + padding: 0 10px; +} +.todoInput { + display: inline-block; + flex: 1; + padding: 0 20px; + height: 30px; + border: none; + outline: none; +} diff --git a/src/document/change/change.ts b/src/document/change/change.ts index 8a5469269..663d4787a 100644 --- a/src/document/change/change.ts +++ b/src/document/change/change.ts @@ -15,7 +15,10 @@ */ import { ActorID } from '@yorkie-js-sdk/src/document/time/actor_id'; -import { Operation } from '@yorkie-js-sdk/src/document/operation/operation'; +import { + Operation, + InternalOpInfo, +} from '@yorkie-js-sdk/src/document/operation/operation'; import { CRDTRoot } from '@yorkie-js-sdk/src/document/crdt/root'; import { ChangeID } from '@yorkie-js-sdk/src/document/change/change_id'; @@ -83,10 +86,13 @@ export class Change { /** * `execute` executes the operations of this change to the given root. */ - public execute(root: CRDTRoot): void { + public execute(root: CRDTRoot): Array { + const opInfos: Array = []; for (const operation of this.operations) { - operation.execute(root); + const infos = operation.execute(root); + opInfos.push(...infos); } + return opInfos; } /** diff --git a/src/document/crdt/root.ts b/src/document/crdt/root.ts index 7b014a3e8..2ba08f23e 100644 --- a/src/document/crdt/root.ts +++ b/src/document/crdt/root.ts @@ -95,11 +95,9 @@ export class CRDTRoot { const subPaths: Array = []; while (pair.parent) { const createdAt = pair.element.getCreatedAt(); - let subPath = pair.parent.subPathOf(createdAt); + const subPath = pair.parent.subPathOf(createdAt); if (subPath === undefined) { logger.fatal(`cant find the given element: ${createdAt.toIDString()}`); - } else { - subPath = subPath.replace(/[$.]/g, '\\$&'); } subPaths.unshift(subPath!); diff --git a/src/document/crdt/text.ts b/src/document/crdt/text.ts index 937198386..135009530 100644 --- a/src/document/crdt/text.ts +++ b/src/document/crdt/text.ts @@ -197,7 +197,7 @@ export class CRDTText extends CRDTTextElement { editedAt: TimeTicket, attributes?: Record, latestCreatedAtMapByActor?: Map, - ): Map { + ): [Map, Array>] { const crdtTextValue = content ? CRDTTextValue.create(content) : undefined; if (crdtTextValue && attributes) { for (const [k, v] of Object.entries(attributes)) { @@ -237,7 +237,7 @@ export class CRDTText extends CRDTTextElement { this.remoteChangeLock = false; } - return latestCreatedAtMap; + return [latestCreatedAtMap, changes]; } /** @@ -254,7 +254,7 @@ export class CRDTText extends CRDTTextElement { range: RGATreeSplitNodeRange, attributes: Record, editedAt: TimeTicket, - ): void { + ): Array> { // 01. split nodes with from and to const [, toRight] = this.rgaTreeSplit.findNodeWithSplit(range[1], editedAt); const [, fromRight] = this.rgaTreeSplit.findNodeWithSplit( @@ -280,7 +280,6 @@ export class CRDTText extends CRDTTextElement { to: toIdx, value: { attributes: this.parseAttributes(attributes) as A, - content: undefined, }, }); @@ -294,6 +293,7 @@ export class CRDTText extends CRDTTextElement { this.onChangesHandler(changes); this.remoteChangeLock = false; } + return changes; } /** @@ -301,7 +301,10 @@ export class CRDTText extends CRDTTextElement { * * @internal */ - public select(range: RGATreeSplitNodeRange, updatedAt: TimeTicket): void { + public select( + range: RGATreeSplitNodeRange, + updatedAt: TimeTicket, + ): TextChange | undefined { if (this.remoteChangeLock) { return; } @@ -312,6 +315,7 @@ export class CRDTText extends CRDTTextElement { this.onChangesHandler([change]); this.remoteChangeLock = false; } + return change; } /** @@ -445,16 +449,8 @@ export class CRDTText extends CRDTTextElement { range: RGATreeSplitNodeRange, updatedAt: TimeTicket, ): TextChange | undefined { - if (!this.selectionMap.has(updatedAt.getActorID()!)) { - this.selectionMap.set( - updatedAt.getActorID()!, - Selection.of(range, updatedAt), - ); - return; - } - const prevSelection = this.selectionMap.get(updatedAt.getActorID()!); - if (updatedAt.after(prevSelection!.getUpdatedAt())) { + if (!prevSelection || updatedAt.after(prevSelection!.getUpdatedAt())) { this.selectionMap.set( updatedAt.getActorID()!, Selection.of(range, updatedAt), diff --git a/src/document/document.ts b/src/document/document.ts index fe671de41..25008c914 100644 --- a/src/document/document.ts +++ b/src/document/document.ts @@ -36,12 +36,19 @@ import { converter } from '@yorkie-js-sdk/src/api/converter'; import { ChangePack } from '@yorkie-js-sdk/src/document/change/change_pack'; import { CRDTRoot } from '@yorkie-js-sdk/src/document/crdt/root'; import { CRDTObject } from '@yorkie-js-sdk/src/document/crdt/object'; -import { createJSON } from '@yorkie-js-sdk/src/document/json/element'; +import { + createJSON, + JSONElement, +} from '@yorkie-js-sdk/src/document/json/element'; import { Checkpoint, InitialCheckpoint, } from '@yorkie-js-sdk/src/document/change/checkpoint'; import { TimeTicket } from '@yorkie-js-sdk/src/document/time/ticket'; +import { + InternalOpInfo, + OperationInfo, +} from '@yorkie-js-sdk/src/document/operation/operation'; import { JSONObject } from './json/object'; import { Trie } from '../util/trie'; @@ -119,12 +126,12 @@ export interface SnapshotEvent extends BaseDocEvent { } /** - * `ChangeInfo` represents a pair of `Change` and the JsonPath of the changed - * element. + * `ChangeInfo` represents the modifications made during a document update + * and the message passed. */ export interface ChangeInfo { - change: Change; - paths: Array; + message: string; + operations: Array; } /** @@ -179,7 +186,7 @@ export type DocumentKey = string; * * @public */ -export class Document implements Observable { +export class Document { private key: DocumentKey; private status: DocumentStatus; private root: CRDTRoot; @@ -243,7 +250,7 @@ export class Document implements Observable { } const change = context.getChange(); - change.execute(this.root); + const internalOpInfos = change.execute(this.root); this.localChanges.push(change); this.changeID = change.getID(); @@ -252,8 +259,10 @@ export class Document implements Observable { type: DocEventType.LocalChange, value: [ { - change, - paths: this.createPaths(change), + message: change.getMessage() || '', + operations: internalOpInfos.map((internalOpInfo) => + this.toOperationInfo(internalOpInfo), + ), }, ], }); @@ -266,14 +275,86 @@ export class Document implements Observable { } /** - * `subscribe` adds the given observer to the fan-out list. + * `subscribe` registers a callback to subscribe to events on the document. + * The callback will be called when the document is changed. */ public subscribe( nextOrObserver: Observer | NextFn, error?: ErrorFn, complete?: CompleteFn, + ): Unsubscribe; + /** + * `subscribe` registers a callback to subscribe to events on the document. + * The callback will be called when the targetPath or any of its nested values change. + */ + public subscribe( + targetPath: string, + next: NextFn, + error?: ErrorFn, + complete?: CompleteFn, + ): Unsubscribe; + /** + * `subscribe` registers a callback to subscribe to events on the document. + */ + public subscribe( + arg1: string | Observer | NextFn, + arg2?: NextFn | ErrorFn, + arg3?: ErrorFn | CompleteFn, + arg4?: CompleteFn, ): Unsubscribe { - return this.eventStream.subscribe(nextOrObserver, error, complete); + if (typeof arg1 === 'string') { + if (typeof arg2 !== 'function') { + throw new Error('Second argument must be a callback function'); + } + const target = arg1; + const callback = arg2 as NextFn; + return this.eventStream.subscribe( + (event) => { + if (event.type === DocEventType.Snapshot) { + target === '$' && callback(event); + return; + } + + const changeInfos: Array = []; + for (const { message, operations } of event.value) { + const targetOps: Array = []; + for (const op of operations) { + if (this.isSameElementOrChildOf(op.path, target)) { + targetOps.push(op); + } + } + targetOps.length && + changeInfos.push({ + message, + operations: targetOps, + }); + } + changeInfos.length && + callback({ + type: event.type, + value: changeInfos, + }); + }, + arg3, + arg4, + ); + } + if (typeof arg1 === 'function') { + const error = arg2 as ErrorFn; + const complete = arg3 as CompleteFn; + return this.eventStream.subscribe(arg1, error, complete); + } + throw new Error(`"${arg1}" is not a valid`); + } + + private isSameElementOrChildOf(elem: string, parent: string): boolean { + if (parent === elem) { + return true; + } + + const nodePath = elem.split('.'); + const targetPath = parent.split('.'); + return targetPath.every((path, index) => path === nodePath[index]); } /** @@ -520,20 +601,22 @@ export class Document implements Observable { change.execute(this.clone!); } + const changeInfos: Array = []; for (const change of changes) { - change.execute(this.root); + const inernalOpInfos = change.execute(this.root); + changeInfos.push({ + message: change.getMessage() || '', + operations: inernalOpInfos.map((opInfo) => + this.toOperationInfo(opInfo), + ), + }); this.changeID = this.changeID.syncLamport(change.getID().getLamport()); } if (changes.length && this.eventStreamObserver) { this.eventStreamObserver.next({ type: DocEventType.RemoteChange, - value: changes.map((change) => { - return { - change, - paths: this.createPaths(change), - }; - }), + value: changeInfos, }); } @@ -546,6 +629,23 @@ export class Document implements Observable { } } + /** + * `getValueByPath` returns the JSONElement corresponding to the given path. + */ + public getValueByPath(path: string): JSONElement | undefined { + if (!path.startsWith('$')) { + throw new Error('The path must start with "$"'); + } + const pathArr = path.split('.'); + pathArr.shift(); + let value: JSONObject = this.getRoot(); + for (const key of pathArr) { + value = value[key]; + if (value === undefined) return undefined; + } + return value; + } + private createPaths(change: Change): Array { const pathTrie = new Trie('$'); for (const op of change.getOperations()) { @@ -558,4 +658,17 @@ export class Document implements Observable { } return pathTrie.findPrefixes().map((element) => element.join('.')); } + + private toOperationInfo(internalOpInfo: InternalOpInfo): OperationInfo { + const opInfo = {} as OperationInfo; + for (const key of Object.keys(internalOpInfo)) { + if (key === 'element') { + opInfo.path = this.root.createSubPaths(internalOpInfo[key])!.join('.'); + } else { + const k = key as keyof Omit; + opInfo[k] = internalOpInfo[k]; + } + } + return opInfo; + } } diff --git a/src/document/json/array.ts b/src/document/json/array.ts index 9a25063ae..d8e3710ee 100644 --- a/src/document/json/array.ts +++ b/src/document/json/array.ts @@ -405,7 +405,7 @@ export class ArrayProxy { } /** - * `moveAfterInternal` moves the given `createdAt` element + * `moveLastInternal` moves the given `createdAt` element * at the last of array. */ public static moveLastInternal( diff --git a/src/document/json/text.ts b/src/document/json/text.ts index dd82941ff..dbe92033b 100644 --- a/src/document/json/text.ts +++ b/src/document/json/text.ts @@ -85,7 +85,7 @@ export class Text { ? this.text.stringifyAttributes(attributes) : undefined; const ticket = this.context.issueTimeTicket(); - const maxCreatedAtMapByActor = this.text.edit( + const [maxCreatedAtMapByActor] = this.text.edit( range, content, ticket, diff --git a/src/document/operation/add_operation.ts b/src/document/operation/add_operation.ts index 35238e349..9a79b56a1 100644 --- a/src/document/operation/add_operation.ts +++ b/src/document/operation/add_operation.ts @@ -19,7 +19,10 @@ import { TimeTicket } from '@yorkie-js-sdk/src/document/time/ticket'; import { CRDTElement } from '@yorkie-js-sdk/src/document/crdt/element'; import { CRDTRoot } from '@yorkie-js-sdk/src/document/crdt/root'; import { CRDTArray } from '@yorkie-js-sdk/src/document/crdt/array'; -import { Operation } from '@yorkie-js-sdk/src/document/operation/operation'; +import { + Operation, + InternalOpInfo, +} from '@yorkie-js-sdk/src/document/operation/operation'; /** * `AddOperation` is an operation representing adding an element to an Array. @@ -54,20 +57,25 @@ export class AddOperation extends Operation { /** * `execute` executes this operation on the given `CRDTRoot`. */ - public execute(root: CRDTRoot): void { + public execute(root: CRDTRoot): Array { const parentObject = root.findByCreatedAt(this.getParentCreatedAt()); - if (parentObject instanceof CRDTArray) { - const array = parentObject as CRDTArray; - const value = this.value.deepcopy(); - array.insertAfter(this.prevCreatedAt, value); - root.registerElement(value, array); - } else { - if (!parentObject) { - logger.fatal(`fail to find ${this.getParentCreatedAt()}`); - } - + if (!parentObject) { + logger.fatal(`fail to find ${this.getParentCreatedAt()}`); + } + if (!(parentObject instanceof CRDTArray)) { logger.fatal(`fail to execute, only array can execute add`); } + const array = parentObject as CRDTArray; + const value = this.value.deepcopy(); + array.insertAfter(this.prevCreatedAt, value); + root.registerElement(value, array); + return [ + { + type: 'add', + element: this.getParentCreatedAt(), + index: Number(array.subPathOf(this.getEffectedCreatedAt())), + }, + ]; } /** diff --git a/src/document/operation/edit_operation.ts b/src/document/operation/edit_operation.ts index ebb61b13f..e3c52af4f 100644 --- a/src/document/operation/edit_operation.ts +++ b/src/document/operation/edit_operation.ts @@ -19,7 +19,10 @@ import { TimeTicket } from '@yorkie-js-sdk/src/document/time/ticket'; import { CRDTRoot } from '@yorkie-js-sdk/src/document/crdt/root'; import { RGATreeSplitNodePos } from '@yorkie-js-sdk/src/document/crdt/rga_tree_split'; import { CRDTText } from '@yorkie-js-sdk/src/document/crdt/text'; -import { Operation } from '@yorkie-js-sdk/src/document/operation/operation'; +import { + Operation, + InternalOpInfo, +} from '@yorkie-js-sdk/src/document/operation/operation'; import { Indexable } from '../document'; /** @@ -76,27 +79,43 @@ export class EditOperation extends Operation { /** * `execute` executes this operation on the given `CRDTRoot`. */ - public execute(root: CRDTRoot): void { + public execute(root: CRDTRoot): Array { const parentObject = root.findByCreatedAt(this.getParentCreatedAt()); - if (parentObject instanceof CRDTText) { - const text = parentObject as CRDTText; - text.edit( - [this.fromPos, this.toPos], - this.content, - this.getExecutedAt(), - Object.fromEntries(this.attributes), - this.maxCreatedAtMapByActor, - ); - if (!this.fromPos.equals(this.toPos)) { - root.registerTextWithGarbage(text); - } - } else { - if (!parentObject) { - logger.fatal(`fail to find ${this.getParentCreatedAt()}`); - } - + if (!parentObject) { + logger.fatal(`fail to find ${this.getParentCreatedAt()}`); + } + if (!(parentObject instanceof CRDTText)) { logger.fatal(`fail to execute, only Text can execute edit`); } + const text = parentObject as CRDTText; + const changes = text.edit( + [this.fromPos, this.toPos], + this.content, + this.getExecutedAt(), + Object.fromEntries(this.attributes), + this.maxCreatedAtMapByActor, + )[1]; + if (!this.fromPos.equals(this.toPos)) { + root.registerTextWithGarbage(text); + } + return changes.map(({ type, actor, from, to, value }) => { + return type === 'content' + ? { + type: 'edit', + actor, + from, + to, + value, + element: this.getParentCreatedAt(), + } + : { + type: 'select', + actor, + from, + to, + element: this.getParentCreatedAt(), + }; + }) as Array; } /** diff --git a/src/document/operation/increase_operation.ts b/src/document/operation/increase_operation.ts index a788d9e35..f38a1b49b 100644 --- a/src/document/operation/increase_operation.ts +++ b/src/document/operation/increase_operation.ts @@ -14,7 +14,10 @@ * limitations under the License. */ -import { Operation } from '@yorkie-js-sdk/src/document/operation/operation'; +import { + Operation, + InternalOpInfo, +} from '@yorkie-js-sdk/src/document/operation/operation'; import { TimeTicket } from '@yorkie-js-sdk/src/document/time/ticket'; import { CRDTElement } from '@yorkie-js-sdk/src/document/crdt/element'; import { CRDTRoot } from '@yorkie-js-sdk/src/document/crdt/root'; @@ -52,19 +55,24 @@ export class IncreaseOperation extends Operation { /** * `execute` executes this operation on the given `CRDTRoot`. */ - public execute(root: CRDTRoot): void { + public execute(root: CRDTRoot): Array { const parentObject = root.findByCreatedAt(this.getParentCreatedAt()); - if (parentObject instanceof CRDTCounter) { - const counter = parentObject as CRDTCounter; - const value = this.value.deepcopy() as Primitive; - counter.increase(value); - } else { - if (!parentObject) { - logger.fatal(`fail to find ${this.getParentCreatedAt()}`); - } - + if (!parentObject) { + logger.fatal(`fail to find ${this.getParentCreatedAt()}`); + } + if (!(parentObject instanceof CRDTCounter)) { logger.fatal(`fail to execute, only Counter can execute increase`); } + const counter = parentObject as CRDTCounter; + const value = this.value.deepcopy() as Primitive; + counter.increase(value); + return [ + { + type: 'increase', + element: this.getEffectedCreatedAt(), + value: value.getValue() as number, + }, + ]; } /** diff --git a/src/document/operation/move_operation.ts b/src/document/operation/move_operation.ts index 2f07db9e0..c5b1b57d7 100644 --- a/src/document/operation/move_operation.ts +++ b/src/document/operation/move_operation.ts @@ -18,7 +18,10 @@ import { logger } from '@yorkie-js-sdk/src/util/logger'; import { TimeTicket } from '@yorkie-js-sdk/src/document/time/ticket'; import { CRDTRoot } from '@yorkie-js-sdk/src/document/crdt/root'; import { CRDTArray } from '@yorkie-js-sdk/src/document/crdt/array'; -import { Operation } from '@yorkie-js-sdk/src/document/operation/operation'; +import { + Operation, + InternalOpInfo, +} from '@yorkie-js-sdk/src/document/operation/operation'; /** * `MoveOperation` is an operation representing moving an element to an Array. @@ -58,22 +61,26 @@ export class MoveOperation extends Operation { /** * `execute` executes this operation on the given `CRDTRoot`. */ - public execute(root: CRDTRoot): void { + public execute(root: CRDTRoot): Array { const parentObject = root.findByCreatedAt(this.getParentCreatedAt()); - if (parentObject instanceof CRDTArray) { - const array = parentObject as CRDTArray; - array.moveAfter( - this.prevCreatedAt!, - this.createdAt, - this.getExecutedAt(), - ); - } else { - if (!parentObject) { - logger.fatal(`fail to find ${this.getParentCreatedAt()}`); - } - + if (!parentObject) { + logger.fatal(`fail to find ${this.getParentCreatedAt()}`); + } + if (!(parentObject instanceof CRDTArray)) { logger.fatal(`fail to execute, only array can execute move`); } + const array = parentObject as CRDTArray; + const previousIndex = Number(array.subPathOf(this.createdAt)); + array.moveAfter(this.prevCreatedAt, this.createdAt, this.getExecutedAt()); + const index = Number(array.subPathOf(this.createdAt)); + return [ + { + type: 'move', + element: this.getParentCreatedAt(), + index, + previousIndex, + }, + ]; } /** diff --git a/src/document/operation/operation.ts b/src/document/operation/operation.ts index 2429cada1..ae78fce36 100644 --- a/src/document/operation/operation.ts +++ b/src/document/operation/operation.ts @@ -17,6 +17,93 @@ import { ActorID } from '@yorkie-js-sdk/src/document/time/actor_id'; import { TimeTicket } from '@yorkie-js-sdk/src/document/time/ticket'; import { CRDTRoot } from '@yorkie-js-sdk/src/document/crdt/root'; +import { Indexable } from '@yorkie-js-sdk/src/document/document'; + +/** + * `OperationInfo` represents the information of an operation. + * It is used to inform to the user what kind of operation was executed. + */ +export type OperationInfo = + | AddOpInfo + | IncreaseOpInfo + | RemoveOpInfo + | SetOpInfo + | MoveOpInfo + | EditOpInfo + | StyleOpInfo + | SelectOpInfo; +export type AddOpInfo = { + type: 'add'; + path: string; + index: number; +}; +export type MoveOpInfo = { + type: 'move'; + path: string; + previousIndex: number; + index: number; +}; +export type SetOpInfo = { + type: 'set'; + path: string; + key: string; +}; +export type RemoveOpInfo = { + type: 'remove'; + path: string; + key?: string; + index?: number; +}; +export type IncreaseOpInfo = { + type: 'increase'; + path: string; + value: number; +}; +export type EditOpInfo = { + type: 'edit'; + actor: ActorID; + from: number; + to: number; + path: string; + value: { + attributes: Indexable; + content: string; + }; +}; +export type StyleOpInfo = { + type: 'style'; + actor: ActorID; + from: number; + to: number; + path: string; + value: { + attributes: Indexable; + }; +}; +export type SelectOpInfo = { + type: 'select'; + actor: ActorID; + from: number; + to: number; + path: string; +}; + +/** + * `InternalOpInfo` represents the information of the operation. It is used to + * internally and can be converted to `OperationInfo` to inform to the user. + */ +export type InternalOpInfo = + | ToInternalOpInfo + | ToInternalOpInfo + | ToInternalOpInfo + | ToInternalOpInfo + | ToInternalOpInfo + | ToInternalOpInfo + | ToInternalOpInfo + | ToInternalOpInfo; +type ToInternalOpInfo = Omit & { + element: TimeTicket; +}; /** * `Operation` represents an operation to be executed on a document. @@ -65,5 +152,5 @@ export abstract class Operation { /** * `execute` executes this operation on the given `CRDTRoot`. */ - public abstract execute(root: CRDTRoot): void; + public abstract execute(root: CRDTRoot): Array; } diff --git a/src/document/operation/remove_operation.ts b/src/document/operation/remove_operation.ts index 59b4e3921..3f53757c4 100644 --- a/src/document/operation/remove_operation.ts +++ b/src/document/operation/remove_operation.ts @@ -17,8 +17,12 @@ import { logger } from '@yorkie-js-sdk/src/util/logger'; import { TimeTicket } from '@yorkie-js-sdk/src/document/time/ticket'; import { CRDTRoot } from '@yorkie-js-sdk/src/document/crdt/root'; -import { Operation } from '@yorkie-js-sdk/src/document/operation/operation'; +import { + Operation, + InternalOpInfo, +} from '@yorkie-js-sdk/src/document/operation/operation'; import { CRDTContainer } from '@yorkie-js-sdk/src/document/crdt/element'; +import { CRDTArray } from '@yorkie-js-sdk/src/document/crdt/array'; /** * `RemoveOperation` is an operation that removes an element from `CRDTContainer`. @@ -49,19 +53,34 @@ export class RemoveOperation extends Operation { /** * `execute` executes this operation on the given `CRDTRoot`. */ - public execute(root: CRDTRoot): void { + public execute(root: CRDTRoot): Array { const parentObject = root.findByCreatedAt(this.getParentCreatedAt()); - if (parentObject instanceof CRDTContainer) { - const obj = parentObject; - const elem = obj.delete(this.createdAt, this.getExecutedAt()); - root.registerRemovedElement(elem); - } else { - if (!parentObject) { - logger.fatal(`fail to find ${this.getParentCreatedAt()}`); - } - + if (!parentObject) { + logger.fatal(`fail to find ${this.getParentCreatedAt()}`); + } + if (!(parentObject instanceof CRDTContainer)) { logger.fatal(`only object and array can execute remove: ${parentObject}`); } + const obj = parentObject as CRDTContainer; + const key = obj.subPathOf(this.createdAt); + const elem = obj.delete(this.createdAt, this.getExecutedAt()); + root.registerRemovedElement(elem); + + return parentObject instanceof CRDTArray + ? [ + { + type: 'remove', + element: this.getEffectedCreatedAt(), + index: Number(key), + }, + ] + : [ + { + type: 'remove', + element: this.getEffectedCreatedAt(), + key, + }, + ]; } /** diff --git a/src/document/operation/select_operation.ts b/src/document/operation/select_operation.ts index b42ba1738..cd9fc0bd9 100644 --- a/src/document/operation/select_operation.ts +++ b/src/document/operation/select_operation.ts @@ -19,7 +19,10 @@ import { TimeTicket } from '@yorkie-js-sdk/src/document/time/ticket'; import { CRDTRoot } from '@yorkie-js-sdk/src/document/crdt/root'; import { RGATreeSplitNodePos } from '@yorkie-js-sdk/src/document/crdt/rga_tree_split'; import { CRDTText } from '@yorkie-js-sdk/src/document/crdt/text'; -import { Operation } from '@yorkie-js-sdk/src/document/operation/operation'; +import { + Operation, + InternalOpInfo, +} from '@yorkie-js-sdk/src/document/operation/operation'; import { Indexable } from '../document'; /** @@ -55,18 +58,28 @@ export class SelectOperation extends Operation { /** * `execute` executes this operation on the given `CRDTRoot`. */ - public execute(root: CRDTRoot): void { + public execute(root: CRDTRoot): Array { const parentObject = root.findByCreatedAt(this.getParentCreatedAt()); - if (parentObject instanceof CRDTText) { - const text = parentObject as CRDTText; - text.select([this.fromPos, this.toPos], this.getExecutedAt()); - } else { - if (!parentObject) { - logger.fatal(`fail to find ${this.getParentCreatedAt()}`); - } - + if (!parentObject) { + logger.fatal(`fail to find ${this.getParentCreatedAt()}`); + } + if (!(parentObject instanceof CRDTText)) { logger.fatal(`fail to execute, only Text can execute select`); } + const text = parentObject as CRDTText; + const change = text.select( + [this.fromPos, this.toPos], + this.getExecutedAt(), + ); + return change + ? [ + { + ...change, + type: 'select', + element: this.getParentCreatedAt(), + }, + ] + : []; } /** diff --git a/src/document/operation/set_operation.ts b/src/document/operation/set_operation.ts index dbcdd0218..3120f6f49 100644 --- a/src/document/operation/set_operation.ts +++ b/src/document/operation/set_operation.ts @@ -19,7 +19,10 @@ import { TimeTicket } from '@yorkie-js-sdk/src/document/time/ticket'; import { CRDTElement } from '@yorkie-js-sdk/src/document/crdt/element'; import { CRDTRoot } from '@yorkie-js-sdk/src/document/crdt/root'; import { CRDTObject } from '@yorkie-js-sdk/src/document/crdt/object'; -import { Operation } from '@yorkie-js-sdk/src/document/operation/operation'; +import { + Operation, + InternalOpInfo, +} from '@yorkie-js-sdk/src/document/operation/operation'; /** * `SetOperation` represents an operation that stores the value corresponding to the @@ -55,20 +58,25 @@ export class SetOperation extends Operation { /** * `execute` executes this operation on the given `CRDTRoot`. */ - public execute(root: CRDTRoot): void { + public execute(root: CRDTRoot): Array { const parentObject = root.findByCreatedAt(this.getParentCreatedAt()); - if (parentObject instanceof CRDTObject) { - const obj = parentObject as CRDTObject; - const value = this.value.deepcopy(); - obj.set(this.key, value); - root.registerElement(value, obj); - } else { - if (!parentObject) { - logger.fatal(`fail to find ${this.getParentCreatedAt()}`); - } - + if (!parentObject) { + logger.fatal(`fail to find ${this.getParentCreatedAt()}`); + } + if (!(parentObject instanceof CRDTObject)) { logger.fatal(`fail to execute, only object can execute set`); } + const obj = parentObject as CRDTObject; + const value = this.value.deepcopy(); + obj.set(this.key, value); + root.registerElement(value, obj); + return [ + { + type: 'set', + element: this.getParentCreatedAt(), + key: this.key, + }, + ]; } /** diff --git a/src/document/operation/style_operation.ts b/src/document/operation/style_operation.ts index bd913b752..76819df4c 100644 --- a/src/document/operation/style_operation.ts +++ b/src/document/operation/style_operation.ts @@ -19,7 +19,10 @@ import { TimeTicket } from '@yorkie-js-sdk/src/document/time/ticket'; import { CRDTRoot } from '@yorkie-js-sdk/src/document/crdt/root'; import { RGATreeSplitNodePos } from '@yorkie-js-sdk/src/document/crdt/rga_tree_split'; import { CRDTText } from '@yorkie-js-sdk/src/document/crdt/text'; -import { Operation } from '@yorkie-js-sdk/src/document/operation/operation'; +import { + Operation, + InternalOpInfo, +} from '@yorkie-js-sdk/src/document/operation/operation'; import { Indexable } from '../document'; /** @@ -65,22 +68,30 @@ export class StyleOperation extends Operation { /** * `execute` executes this operation on the given `CRDTRoot`. */ - public execute(root: CRDTRoot): void { + public execute(root: CRDTRoot): Array { const parentObject = root.findByCreatedAt(this.getParentCreatedAt()); - if (parentObject instanceof CRDTText) { - const text = parentObject as CRDTText; - text.setStyle( - [this.fromPos, this.toPos], - this.attributes ? Object.fromEntries(this.attributes) : {}, - this.getExecutedAt(), - ); - } else { - if (!parentObject) { - logger.fatal(`fail to find ${this.getParentCreatedAt()}`); - } - + if (!parentObject) { + logger.fatal(`fail to find ${this.getParentCreatedAt()}`); + } + if (!(parentObject instanceof CRDTText)) { logger.fatal(`fail to execute, only Text can execute edit`); } + const text = parentObject as CRDTText; + const changes = text.setStyle( + [this.fromPos, this.toPos], + this.attributes ? Object.fromEntries(this.attributes) : {}, + this.getExecutedAt(), + ); + return changes.map(({ actor, from, to, value }) => { + return { + type: 'style', + actor, + from, + to, + value, + element: this.getParentCreatedAt(), + }; + }) as Array; } /** diff --git a/test/helper/helper.ts b/test/helper/helper.ts index d14f11a2b..d8f2f703c 100644 --- a/test/helper/helper.ts +++ b/test/helper/helper.ts @@ -15,46 +15,14 @@ */ import { assert } from 'chai'; -import { EventEmitter } from 'events'; -import { NextFn } from '@yorkie-js-sdk/src/util/observable'; -import { ClientEvent } from '@yorkie-js-sdk/src/client/client'; -import { DocEvent } from '@yorkie-js-sdk/src/document/document'; import { TextChange, TextChangeType, } from '@yorkie-js-sdk/src/document/crdt/text'; -export function range(from: number, to: number): Array { - const list = []; - for (let idx = from; idx < to; idx++) { - list.push(idx); - } - return list; -} - export type Indexable = Record; -export function waitFor( - eventName: string, - listener: EventEmitter, -): Promise { - return new Promise((resolve) => listener.on(eventName, resolve)); -} - -export function delay(timeout: number): Promise { - return new Promise((resolve) => { - setTimeout(resolve, timeout); - }); -} - -export function createEmitterAndSpy< - E extends { type: any } = ClientEvent | DocEvent, ->(fn?: (event: E) => string): [EventEmitter, NextFn] { - const emitter = new EventEmitter(); - return [emitter, (event: E) => emitter.emit(fn ? fn(event) : event.type)]; -} - export async function waitStubCallCount( stub: sinon.SinonStub, callCount: number, diff --git a/test/integration/client_test.ts b/test/integration/client_test.ts index 0b7db42b2..38fe8b6a6 100644 --- a/test/integration/client_test.ts +++ b/test/integration/client_test.ts @@ -8,12 +8,7 @@ import yorkie, { DocEventType, ClientEventType, } from '@yorkie-js-sdk/src/yorkie'; -import { - createEmitterAndSpy, - waitFor, - waitStubCallCount, - deepSort, -} from '@yorkie-js-sdk/test/helper/helper'; +import { waitStubCallCount, deepSort } from '@yorkie-js-sdk/test/helper/helper'; import { toDocKey, testRPCAddr, @@ -136,20 +131,39 @@ describe('Client', function () { await c1.attach(d1); await c2.attach(d2); - const [emitter1, spy1] = createEmitterAndSpy((event) => - event.type === ClientEventType.DocumentSynced ? event.value : event.type, - ); - const [emitter2, spy2] = createEmitterAndSpy((event) => - event.type === ClientEventType.DocumentSynced ? event.value : event.type, - ); + const c1Events: Array = []; + const c2Events: Array = []; + const d1Events: Array = []; + const d2Events: Array = []; + + const stubC1 = sinon.stub().callsFake((event) => { + c1Events.push( + event.type === ClientEventType.DocumentSynced + ? event.value + : event.type, + ); + }); + const stubC2 = sinon.stub().callsFake((event) => { + c2Events.push( + event.type === ClientEventType.DocumentSynced + ? event.value + : event.type, + ); + }); + const stubD1 = sinon.stub().callsFake((event) => { + d1Events.push(event.type); + }); + const stubD2 = sinon.stub().callsFake((event) => { + d2Events.push(event.type); + }); const unsub1 = { - client: c1.subscribe(spy1), - doc: d1.subscribe(spy1), + client: c1.subscribe(stubC1), + doc: d1.subscribe(stubD1), }; const unsub2 = { - client: c2.subscribe(spy2), - doc: d2.subscribe(spy2), + client: c2.subscribe(stubC2), + doc: d2.subscribe(stubD2), }; // Normal Condition @@ -157,8 +171,10 @@ describe('Client', function () { root['k1'] = 'undefined'; }); - await waitFor(DocEventType.LocalChange, emitter2); // d2 should be able to update - await waitFor(DocEventType.RemoteChange, emitter1); // d1 should be able to receive d2's update + await waitStubCallCount(stubD2, 1); // d2 should be able to update + assert.equal(d2Events.pop(), DocEventType.LocalChange); + await waitStubCallCount(stubD1, 1); // d1 should be able to receive d2's update + assert.equal(d1Events.pop(), DocEventType.RemoteChange); assert.equal(d1.toSortedJSON(), d2.toSortedJSON()); // Simulate network error @@ -177,17 +193,26 @@ describe('Client', function () { root['k1'] = 'v1'; }); - await waitFor(DocEventType.LocalChange, emitter2); // d2 should be able to update - await waitFor(DocumentSyncResultType.SyncFailed, emitter2); // c2 should fail to sync + await waitStubCallCount(stubD2, 2); // d2 should be able to update + assert.equal(d2Events.pop(), DocEventType.LocalChange); + await waitStubCallCount(stubC2, 1); // c2 should fail to sync + assert.equal(c2Events.pop(), DocumentSyncResultType.SyncFailed); + c1.sync(); - await waitFor(DocumentSyncResultType.SyncFailed, emitter1); // c1 should also fail to sync + await waitStubCallCount(stubC1, 1); // c1 should also fail to sync + assert.equal(c1Events.pop(), DocumentSyncResultType.SyncFailed); assert.equal(d1.toSortedJSON(), '{"k1":"undefined"}'); assert.equal(d2.toSortedJSON(), '{"k1":"v1"}'); // Back to normal condition xhr.restore(); - await waitFor(DocEventType.RemoteChange, emitter1); // d1 should be able to receive d2's update + await waitStubCallCount(stubC2, 2); + assert.equal(c2Events.pop(), DocumentSyncResultType.Synced); + await waitStubCallCount(stubC1, 2); + assert.equal(c1Events.pop(), DocumentSyncResultType.Synced); + await waitStubCallCount(stubD1, 2); + assert.equal(d1Events.pop(), DocEventType.RemoteChange); // d1 should be able to receive d2's update assert.equal(d1.toSortedJSON(), d2.toSortedJSON()); unsub1.client(); @@ -641,14 +666,18 @@ describe('Client', function () { assert.equal(d1.toSortedJSON(), d2.toSortedJSON()); // 02. c2 changes the sync mode to realtime sync mode. - const [emitter2, spy2] = createEmitterAndSpy((event) => event.type); - const unsub1 = c2.subscribe(spy2); + const c2Events: Array = []; + const stubC2 = sinon.stub().callsFake((event) => { + c2Events.push(event.type); + }); + const unsub1 = c2.subscribe(stubC2); await c2.resume(d2); d1.update((root) => { root.version = 'v2'; }); await c1.sync(); - await waitFor(ClientEventType.DocumentSynced, emitter2); + await waitStubCallCount(stubC2, 1); + assert.equal(c2Events.pop(), ClientEventType.DocumentSynced); assert.equal(d1.toSortedJSON(), d2.toSortedJSON()); unsub1(); diff --git a/test/integration/document_test.ts b/test/integration/document_test.ts index 7fb064b3c..e2efca1f2 100644 --- a/test/integration/document_test.ts +++ b/test/integration/document_test.ts @@ -1,16 +1,21 @@ import { assert } from 'chai'; -import yorkie, { DocEventType } from '@yorkie-js-sdk/src/yorkie'; +import * as sinon from 'sinon'; +import yorkie, { Counter, Text, JSONArray } from '@yorkie-js-sdk/src/yorkie'; import { testRPCAddr, toDocKey, } from '@yorkie-js-sdk/test/integration/integration_helper'; import { - createEmitterAndSpy, - waitFor, + waitStubCallCount, assertThrowsAsync, } from '@yorkie-js-sdk/test/helper/helper'; import type { CRDTElement } from '@yorkie-js-sdk/src/document/crdt/element'; -import { DocumentStatus } from '@yorkie-js-sdk/src/document/document'; +import { + DocumentStatus, + DocEvent, + DocEventType, +} from '@yorkie-js-sdk/src/document/document'; +import { OperationInfo } from '@yorkie-js-sdk/src/document/operation/operation'; import { YorkieError } from '@yorkie-js-sdk/src/util/error'; describe('Document', function () { @@ -60,18 +65,25 @@ describe('Document', function () { const d2 = new yorkie.Document<{ k1: string }>(docKey); await c1.attach(d1); await c2.attach(d2); - - const [emitter1, spy1] = createEmitterAndSpy(); - const [emitter2, spy2] = createEmitterAndSpy(); - const unsub1 = d1.subscribe(spy1); - const unsub2 = d2.subscribe(spy2); + const d1Events: Array = []; + const d2Events: Array = []; + const stub1 = sinon.stub().callsFake((event) => { + d1Events.push(event.type); + }); + const stub2 = sinon.stub().callsFake((event) => { + d2Events.push(event.type); + }); + const unsub1 = d1.subscribe(stub1); + const unsub2 = d2.subscribe(stub2); d2.update((root) => { root['k1'] = 'v1'; }); - await waitFor(DocEventType.LocalChange, emitter2); - await waitFor(DocEventType.RemoteChange, emitter1); + await waitStubCallCount(stub2, 1); + assert.equal(d2Events.pop(), DocEventType.LocalChange); + await waitStubCallCount(stub1, 1); + assert.equal(d1Events.pop(), DocEventType.RemoteChange); assert.equal(d1.toSortedJSON(), d2.toSortedJSON()); unsub1(); @@ -83,6 +95,384 @@ describe('Document', function () { await c2.deactivate(); }); + it('detects the events from doc.subscribe', async function () { + const c1 = new yorkie.Client(testRPCAddr); + const c2 = new yorkie.Client(testRPCAddr); + await c1.activate(); + await c2.activate(); + const c1ID = c1.getID()!; + const c2ID = c2.getID()!; + + const docKey = toDocKey(`${this.test!.title}-${new Date().getTime()}`); + type TestDoc = { + counter: Counter; + todos: JSONArray; // specify type as `JSONArray` to use the `moveAfter` method + content: Text; + obj: { + name: string; + age: number; + food?: Array; + score: Record; + }; + }; + const d1 = new yorkie.Document(docKey); + const d2 = new yorkie.Document(docKey); + await c1.attach(d1); + await c2.attach(d2); + const events1: Array = []; + let expectedEvents1: Array = []; + const events2: Array = []; + let expectedEvents2: Array = []; + const pushEvent = (event: DocEvent, events: Array) => { + if (event.type !== DocEventType.RemoteChange) return; + for (const { operations } of event.value) { + events.push(...operations); + } + }; + const stub1 = sinon.stub().callsFake((event) => pushEvent(event, events1)); + const stub2 = sinon.stub().callsFake((event) => pushEvent(event, events2)); + const unsub1 = d1.subscribe(stub1); + const unsub2 = d2.subscribe(stub2); + + d1.update((root) => { + root.counter = new yorkie.Counter(yorkie.IntType, 100); + root.todos = ['todo1', 'todo2', 'todo3']; + root.content = new yorkie.Text(); + root.content.edit(0, 0, 'hello world', { italic: true }); + root.obj = { + name: 'josh', + age: 14, + food: ['🍏', '🍇'], + score: { + english: 80, + math: 90, + }, + }; + root.obj.score = { science: 100 }; + delete root.obj.food; + expectedEvents2 = [ + { type: 'set', path: '$', key: 'counter' }, + { type: 'set', path: '$', key: 'todos' }, + { type: 'add', path: '$.todos', index: 0 }, + { type: 'add', path: '$.todos', index: 1 }, + { type: 'add', path: '$.todos', index: 2 }, + { type: 'set', path: '$', key: 'content' }, + { + type: 'edit', + actor: c1ID, + from: 0, + to: 0, + value: { attributes: { italic: 'true' }, content: 'hello world' }, + path: '$.content', + }, + { + type: 'select', + actor: c1ID, + from: 11, + to: 11, + path: '$.content', + }, + { type: 'set', path: '$', key: 'obj' }, + { type: 'set', path: '$.obj', key: 'name' }, + { type: 'set', path: '$.obj', key: 'age' }, + { type: 'set', path: '$.obj', key: 'food' }, + { type: 'add', path: '$.obj.food', index: 0 }, + { type: 'add', path: '$.obj.food', index: 1 }, + { type: 'set', path: '$.obj', key: 'score' }, + { type: 'set', path: '$.obj.score', key: 'english' }, + { type: 'set', path: '$.obj.score', key: 'math' }, + { type: 'set', path: '$.obj', key: 'score' }, + { type: 'set', path: '$.obj.score', key: 'science' }, + { type: 'remove', path: '$.obj', key: 'food' }, + ]; + }); + + await waitStubCallCount(stub1, 1); + await waitStubCallCount(stub2, 1); + + d2.update((root) => { + root.counter.increase(1); + root.todos.push('todo4'); + const prevItem = root.todos.getElementByIndex!(1); + const currItem = root.todos.getElementByIndex!(0); + root.todos.moveAfter!(prevItem.getID!(), currItem.getID!()); + root.content.select(0, 5); + root.content.setStyle(0, 5, { bold: true }); + expectedEvents1 = [ + { type: 'increase', path: '$.counter', value: 1 }, + { type: 'add', path: '$.todos', index: 3 }, + { + type: 'move', + path: '$.todos', + index: 1, + previousIndex: 0, + }, + { + type: 'select', + actor: c2ID, + from: 0, + to: 5, + path: '$.content', + }, + { + type: 'style', + actor: c2ID, + from: 0, + to: 5, + value: { attributes: { bold: true } }, + path: '$.content', + }, + ]; + }); + await waitStubCallCount(stub1, 2); + await waitStubCallCount(stub2, 2); + assert.equal(d1.toSortedJSON(), d2.toSortedJSON()); + assert.deepEqual( + events1, + expectedEvents1, + `d1 event actual: ${JSON.stringify( + events1, + )} \n expected: ${JSON.stringify(expectedEvents1)}`, + ); + assert.deepEqual( + events2, + expectedEvents2, + `d2 event actual: ${JSON.stringify( + events2, + )} \n expected: ${JSON.stringify(expectedEvents2)}`, + ); + unsub1(); + unsub2(); + + await c1.detach(d1); + await c2.detach(d2); + await c1.deactivate(); + await c2.deactivate(); + }); + + it('specify the topic to subscribe to', async function () { + const c1 = new yorkie.Client(testRPCAddr); + const c2 = new yorkie.Client(testRPCAddr); + await c1.activate(); + await c2.activate(); + + const docKey = toDocKey(`${this.test!.title}-${new Date().getTime()}`); + type TestDoc = { + counter: Counter; + todos: JSONArray; + }; + const d1 = new yorkie.Document(docKey); + const d2 = new yorkie.Document(docKey); + await c1.attach(d1); + await c2.attach(d2); + let events: Array = []; + let todoEvents: Array = []; + let counterEvents: Array = []; + const pushEvent = (event: DocEvent, events: Array) => { + if (event.type !== DocEventType.RemoteChange) return; + for (const { operations } of event.value) { + events.push(...operations); + } + }; + const stub = sinon.stub().callsFake((event) => pushEvent(event, events)); + const stubTodo = sinon + .stub() + .callsFake((event) => pushEvent(event, todoEvents)); + const stubCounter = sinon + .stub() + .callsFake((event) => pushEvent(event, counterEvents)); + const unsub = d1.subscribe(stub); + const unsubTodo = d1.subscribe('$.todos', stubTodo); + const unsubCounter = d1.subscribe('$.counter', stubCounter); + + d2.update((root) => { + root.counter = new yorkie.Counter(yorkie.IntType, 0); + root.todos = ['todo1', 'todo2']; + }); + await waitStubCallCount(stub, 1); + await waitStubCallCount(stubTodo, 1); + assert.deepEqual(events, [ + { type: 'set', path: '$', key: 'counter' }, + { type: 'set', path: '$', key: 'todos' }, + { type: 'add', path: '$.todos', index: 0 }, + { type: 'add', path: '$.todos', index: 1 }, + ]); + assert.deepEqual(todoEvents, [ + { type: 'add', path: '$.todos', index: 0 }, + { type: 'add', path: '$.todos', index: 1 }, + ]); + events = []; + todoEvents = []; + + d2.update((root) => { + root.counter.increase(10); + }); + await waitStubCallCount(stub, 2); + await waitStubCallCount(stubCounter, 1); + assert.deepEqual(events, [ + { type: 'increase', path: '$.counter', value: 10 }, + ]); + assert.deepEqual(counterEvents, [ + { type: 'increase', path: '$.counter', value: 10 }, + ]); + events = []; + counterEvents = []; + + d2.update((root) => { + root.todos.push('todo3'); + }); + await waitStubCallCount(stub, 3); + await waitStubCallCount(stubTodo, 2); + assert.deepEqual(events, [{ type: 'add', path: '$.todos', index: 2 }]); + assert.deepEqual(todoEvents, [{ type: 'add', path: '$.todos', index: 2 }]); + events = []; + todoEvents = []; + + unsubTodo(); + d2.update((root) => { + root.todos.push('todo4'); + }); + await waitStubCallCount(stub, 4); + assert.deepEqual(events, [{ type: 'add', path: '$.todos', index: 3 }]); + assert.deepEqual(todoEvents, []); + events = []; + + unsubCounter(); + d2.update((root) => { + root.counter.increase(10); + }); + await waitStubCallCount(stub, 5); + assert.deepEqual(events, [ + { type: 'increase', path: '$.counter', value: 10 }, + ]); + assert.deepEqual(counterEvents, []); + + unsub(); + await c1.detach(d1); + await c2.detach(d2); + await c1.deactivate(); + await c2.deactivate(); + }); + + it('specify the nested topic to subscribe to', async function () { + const c1 = new yorkie.Client(testRPCAddr); + const c2 = new yorkie.Client(testRPCAddr); + await c1.activate(); + await c2.activate(); + + const docKey = toDocKey(`${this.test!.title}-${new Date().getTime()}`); + type TestDoc = { + todos: Array<{ + text: string; + completed: boolean; + }>; + obj: Record; + }; + const d1 = new yorkie.Document(docKey); + const d2 = new yorkie.Document(docKey); + await c1.attach(d1); + await c2.attach(d2); + let events: Array = []; + let todoEvents: Array = []; + let objEvents: Array = []; + const pushEvent = (event: DocEvent, events: Array) => { + if (event.type !== DocEventType.RemoteChange) return; + for (const { operations } of event.value) { + events.push(...operations); + } + }; + const stub = sinon.stub().callsFake((event) => pushEvent(event, events)); + const stubTodo = sinon + .stub() + .callsFake((event) => pushEvent(event, todoEvents)); + const stubObj = sinon + .stub() + .callsFake((event) => pushEvent(event, objEvents)); + const unsub = d1.subscribe(stub); + const unsubTodo = d1.subscribe('$.todos.0', stubTodo); + const unsubObj = d1.subscribe('$.obj.c1', stubObj); + + d2.update((root) => { + root.todos = [{ text: 'todo1', completed: false }]; + root.obj = { + c1: { name: 'josh', age: 14 }, + }; + }); + await waitStubCallCount(stub, 1); + await waitStubCallCount(stubTodo, 1); + await waitStubCallCount(stubObj, 1); + assert.deepEqual(events, [ + { type: 'set', path: '$', key: 'todos' }, + { type: 'add', path: '$.todos', index: 0 }, + { type: 'set', path: '$.todos.0', key: 'text' }, + { type: 'set', path: '$.todos.0', key: 'completed' }, + { type: 'set', path: '$', key: 'obj' }, + { type: 'set', path: '$.obj', key: 'c1' }, + { type: 'set', path: '$.obj.c1', key: 'name' }, + { type: 'set', path: '$.obj.c1', key: 'age' }, + ]); + assert.deepEqual(todoEvents, [ + { type: 'set', path: '$.todos.0', key: 'text' }, + { type: 'set', path: '$.todos.0', key: 'completed' }, + ]); + assert.deepEqual(objEvents, [ + { type: 'set', path: '$.obj.c1', key: 'name' }, + { type: 'set', path: '$.obj.c1', key: 'age' }, + ]); + events = []; + todoEvents = []; + objEvents = []; + + d2.update((root) => { + root.obj.c1.name = 'john'; + }); + await waitStubCallCount(stub, 2); + await waitStubCallCount(stubObj, 1); + assert.deepEqual(events, [{ type: 'set', path: '$.obj.c1', key: 'name' }]); + assert.deepEqual(objEvents, [ + { type: 'set', path: '$.obj.c1', key: 'name' }, + ]); + events = []; + objEvents = []; + + d2.update((root) => { + root.todos[0].completed = true; + }); + await waitStubCallCount(stub, 3); + await waitStubCallCount(stubTodo, 2); + assert.deepEqual(events, [ + { type: 'set', path: '$.todos.0', key: 'completed' }, + ]); + assert.deepEqual(todoEvents, [ + { type: 'set', path: '$.todos.0', key: 'completed' }, + ]); + events = []; + todoEvents = []; + + unsubTodo(); + d2.update((root) => { + root.todos[0].text = 'todo_1'; + }); + await waitStubCallCount(stub, 4); + assert.deepEqual(events, [{ type: 'set', path: '$.todos.0', key: 'text' }]); + assert.deepEqual(todoEvents, []); + events = []; + + unsubObj(); + d2.update((root) => { + root.obj.c1.age = 15; + }); + await waitStubCallCount(stub, 5); + assert.deepEqual(events, [{ type: 'set', path: '$.obj.c1', key: 'age' }]); + assert.deepEqual(objEvents, []); + + unsub(); + await c1.detach(d1); + await c2.detach(d2); + await c1.deactivate(); + await c2.deactivate(); + }); + it('Can handle tombstone', async function () { type TestDoc = { k1: Array }; const docKey = toDocKey(`${this.test!.title}-${new Date().getTime()}`); diff --git a/test/integration/object_test.ts b/test/integration/object_test.ts index c7f7d704c..e578729c9 100644 --- a/test/integration/object_test.ts +++ b/test/integration/object_test.ts @@ -1,6 +1,6 @@ import { assert } from 'chai'; import { JSONObject } from '@yorkie-js-sdk/src/yorkie'; -import { DocEventType, Document } from '@yorkie-js-sdk/src/document/document'; +import { Document } from '@yorkie-js-sdk/src/document/document'; import { withTwoClientsAndDocuments } from '@yorkie-js-sdk/test/integration/integration_helper'; describe('Object', function () { @@ -169,55 +169,4 @@ describe('Object', function () { assert.equal(d1.toSortedJSON(), d2.toSortedJSON()); }, this.test!.title); }); - - it('should show a reduced version of paths in cases when a nested object is instantiated', async function () { - await withTwoClientsAndDocuments<{ - k1: { - id: string; - selected: boolean; - test: string; - layers: Array<{ a: string; b: string }>; - }; - k2: number; - }>(async (c1, d1, c2, d2) => { - // TODO(hackerwins): consider replacing the below code with `createEmitterAndSpy`. - d2.subscribe((event) => { - if (event.type === DocEventType.RemoteChange) { - assert.deepEqual(event.value[0].paths.sort(), ['$.k1', '$.k2']); - } - }); - d1.subscribe((event) => { - if (event.type === DocEventType.RemoteChange) { - assert.deepEqual(event.value[0].paths, [ - '$.k1.selected', - '$.k1.layers.0.a', - '$.k2', - ]); - } - }); - d1.update((root) => { - root['k1'] = { - id: 'id7fb51ad', - selected: false, - test: 'hi', - layers: [{ a: 'hi', b: 'bhi' }], - }; - root['k1']['selected'] = true; - root['k1']['test'] = 'change'; - root['k2'] = 5; - }); - - await c1.sync(); - await c2.sync(); - await c1.sync(); - d2.update((root) => { - root['k1']['selected'] = false; - root['k1']['layers'][0]['a'] = 'hi2'; - root['k2']++; - }); - await c1.sync(); - await c2.sync(); - await c1.sync(); - }, this.test!.title); - }); }); diff --git a/test/unit/document/document_test.ts b/test/unit/document/document_test.ts index 7af6bea04..7004c6a09 100644 --- a/test/unit/document/document_test.ts +++ b/test/unit/document/document_test.ts @@ -15,8 +15,16 @@ */ import { assert } from 'chai'; +import * as sinon from 'sinon'; +import { waitStubCallCount } from '@yorkie-js-sdk/test/helper/helper'; + import { MaxTimeTicket } from '@yorkie-js-sdk/src/document/time/ticket'; -import { Document, DocEventType } from '@yorkie-js-sdk/src/document/document'; +import { + Document, + DocEvent, + DocEventType, +} from '@yorkie-js-sdk/src/document/document'; +import { OperationInfo } from '@yorkie-js-sdk/src/document/operation/operation'; import { JSONArray, Text, Counter } from '@yorkie-js-sdk/src/yorkie'; import { CounterType } from '@yorkie-js-sdk/src/document/crdt/counter'; @@ -932,86 +940,121 @@ describe('Document', function () { assert.equal(4, doc.getRoot().data.length); }); - it('change paths test for object', async function () { + it('changeInfo test for object', async function () { const doc = Document.create('test-doc'); await new Promise((resolve) => setTimeout(resolve, 0)); - const paths: Array = []; - - doc.subscribe((event) => { - assert.equal(event.type, DocEventType.LocalChange); - if (event.type === DocEventType.LocalChange) { - assert.deepEqual(event.value[0].paths, paths); + const expectedOps: Array = []; + const ops: Array = []; + const stub1 = sinon.stub().callsFake((event: DocEvent) => { + if (event.type !== DocEventType.LocalChange) return; + for (const { operations } of event.value) { + ops.push(...operations); } }); + const unsub1 = doc.subscribe(stub1); - // NOTE(hackerwins): We skip nested paths after introducing the trie. doc.update((root) => { root[''] = {}; - paths.push('$.'); - + expectedOps.push({ type: 'set', path: '$', key: '' }); root.obj = {}; - paths.push('$.obj'); + expectedOps.push({ type: 'set', path: '$', key: 'obj' }); root.obj.a = 1; - // paths.push('$.obj.a'); + expectedOps.push({ type: 'set', path: '$.obj', key: 'a' }); delete root.obj.a; - // paths.push('$.obj'); + expectedOps.push({ type: 'remove', path: '$.obj', key: 'a' }); root.obj['$.hello'] = 1; - // paths.push('$.obj.\\$\\.hello'); + expectedOps.push({ type: 'set', path: '$.obj', key: '$.hello' }); delete root.obj['$.hello']; - // paths.push('$.obj'); + expectedOps.push({ type: 'remove', path: '$.obj', key: '$.hello' }); delete root.obj; - // paths.push('$'); - }); + expectedOps.push({ type: 'remove', path: '$', key: 'obj' }); + }); + await waitStubCallCount(stub1, 1); + assert.deepEqual( + ops, + expectedOps, + `actual: ${JSON.stringify(ops)} \n expected: ${JSON.stringify( + expectedOps, + )}`, + ); + + unsub1(); }); - it('change paths test for array', async function () { + it('changeInfo test for array', async function () { const doc = Document.create('test-doc'); await new Promise((resolve) => setTimeout(resolve, 0)); - const paths: Array = []; - - doc.subscribe((event) => { - assert.equal(event.type, DocEventType.LocalChange); - if (event.type === DocEventType.LocalChange) { - assert.deepEqual(event.value[0].paths, paths); + const expectedOps: Array = []; + const ops: Array = []; + const stub1 = sinon.stub().callsFake((event: DocEvent) => { + if (event.type !== DocEventType.LocalChange) return; + for (const { operations } of event.value) { + ops.push(...operations); } }); + const unsub1 = doc.subscribe(stub1); - // NOTE(hackerwins): We skip nested paths after introducing the trie. doc.update((root) => { root.arr = []; - paths.push('$.arr'); + expectedOps.push({ type: 'set', path: '$', key: 'arr' }); root.arr.push(0); - // paths.push('$.arr.0'); + expectedOps.push({ type: 'add', path: '$.arr', index: 0 }); root.arr.push(1); - // paths.push('$.arr.1'); + expectedOps.push({ type: 'add', path: '$.arr', index: 1 }); delete root.arr[1]; - // paths.push('$.arr'); + expectedOps.push({ type: 'remove', path: '$.arr', index: 1 }); root['$$...hello'] = []; - paths.push('$.\\$\\$\\.\\.\\.hello'); + expectedOps.push({ type: 'set', path: '$', key: '$$...hello' }); root['$$...hello'].push(0); - // paths.push('$.\\$\\$\\.\\.\\.hello.0'); - }); + expectedOps.push({ type: 'add', path: '$.$$...hello', index: 0 }); + }); + await waitStubCallCount(stub1, 1); + assert.deepEqual( + ops, + expectedOps, + `actual: ${JSON.stringify(ops)} \n expected: ${JSON.stringify( + expectedOps, + )}`, + ); + + unsub1(); }); - it('change paths test for counter', async function () { + it('changeInfo test for counter', async function () { type TestDoc = { cnt: Counter }; const doc = Document.create('test-doc'); await new Promise((resolve) => setTimeout(resolve, 0)); - const paths: Array = []; + const expectedOps: Array = []; + const ops: Array = []; - doc.subscribe((event) => { - assert.equal(event.type, DocEventType.LocalChange); - if (event.type === DocEventType.LocalChange) { - assert.deepEqual(event.value[0].paths, paths); + const stub1 = sinon.stub().callsFake((event: DocEvent) => { + if (event.type !== DocEventType.LocalChange) return; + for (const { operations } of event.value) { + ops.push(...operations); } }); + const unsub1 = doc.subscribe(stub1); doc.update((root) => { root.cnt = new Counter(CounterType.IntegerCnt, 0); - paths.push('$.cnt'); + expectedOps.push({ type: 'set', path: '$', key: 'cnt' }); root.cnt.increase(1); - paths.push('$.cnt'); - }); + expectedOps.push({ type: 'increase', path: '$.cnt', value: 1 }); + root.cnt.increase(10); + expectedOps.push({ type: 'increase', path: '$.cnt', value: 10 }); + root.cnt.increase(-3); + expectedOps.push({ type: 'increase', path: '$.cnt', value: -3 }); + }); + await waitStubCallCount(stub1, 1); + assert.deepEqual( + ops, + expectedOps, + `actual: ${JSON.stringify(ops)} \n expected: ${JSON.stringify( + expectedOps, + )}`, + ); + + unsub1(); }); it('support TypeScript', function () { @@ -1028,51 +1071,114 @@ describe('Document', function () { }); }); - it('change paths test for text', async function () { + it('changeInfo test for text', async function () { type TestDoc = { text: Text }; const doc = Document.create('test-doc'); await new Promise((resolve) => setTimeout(resolve, 0)); - const paths: Array = []; - - doc.subscribe((event) => { - assert.equal(event.type, DocEventType.LocalChange); - if (event.type === DocEventType.LocalChange) { - assert.deepEqual(event.value[0].paths, paths); + const expectedOps: Array = []; + const ops: Array = []; + const stub1 = sinon.stub().callsFake((event: DocEvent) => { + if (event.type !== DocEventType.LocalChange) return; + for (const { operations } of event.value) { + ops.push(...operations); } }); + const unsub1 = doc.subscribe(stub1); doc.update((root) => { root.text = new Text(); - paths.push('$.text'); + expectedOps.push({ type: 'set', path: '$', key: 'text' }); root.text.edit(0, 0, 'hello world'); - paths.push('$.text'); + expectedOps.push({ + type: 'edit', + path: '$.text', + actor: '000000000000000000000000', + from: 0, + to: 0, + value: { attributes: {}, content: 'hello world' }, + }); + expectedOps.push({ + type: 'select', + actor: '000000000000000000000000', + from: 11, + to: 11, + path: '$.text', + }); root.text.select(0, 2); - paths.push('$.text'); + expectedOps.push({ + type: 'select', + path: '$.text', + actor: '000000000000000000000000', + from: 0, + to: 2, + }); }); + await waitStubCallCount(stub1, 1); + assert.deepEqual( + ops, + expectedOps, + `actual: ${JSON.stringify(ops)} \n expected: ${JSON.stringify( + expectedOps, + )}`, + ); + + unsub1(); }); - it('change paths test for text with attributes', async function () { + it('changeInfo test for text with attributes', async function () { type TestDoc = { textWithAttr: Text }; const doc = Document.create('test-doc'); await new Promise((resolve) => setTimeout(resolve, 0)); - const paths: Array = []; - - doc.subscribe((event) => { - assert.equal(event.type, DocEventType.LocalChange); - if (event.type === DocEventType.LocalChange) { - assert.deepEqual(event.value[0].paths, paths); + const expectedOps: Array = []; + const ops: Array = []; + const stub1 = sinon.stub().callsFake((event: DocEvent) => { + if (event.type !== DocEventType.LocalChange) return; + for (const { operations } of event.value) { + ops.push(...operations); } }); + const unsub1 = doc.subscribe(stub1); doc.update((root) => { root.textWithAttr = new Text(); - paths.push('$.textWithAttr'); + expectedOps.push({ type: 'set', path: '$', key: 'textWithAttr' }); root.textWithAttr.edit(0, 0, 'hello world'); - paths.push('$.textWithAttr'); + expectedOps.push({ + type: 'edit', + path: '$.textWithAttr', + actor: '000000000000000000000000', + from: 0, + to: 0, + value: { attributes: {}, content: 'hello world' }, + }); + expectedOps.push({ + type: 'select', + actor: '000000000000000000000000', + from: 11, + to: 11, + path: '$.textWithAttr', + }); root.textWithAttr.setStyle(0, 1, { bold: 'true' }); - paths.push('$.textWithAttr'); + expectedOps.push({ + type: 'style', + path: '$.textWithAttr', + actor: '000000000000000000000000', + from: 0, + to: 1, + value: { attributes: { bold: 'true' } }, + }); }); + await waitStubCallCount(stub1, 1); + assert.deepEqual( + ops, + expectedOps, + `actual: ${JSON.stringify(ops)} \n expected: ${JSON.stringify( + expectedOps, + )}`, + ); + + unsub1(); }); it('insert elements before a specific node of array', function () {