From 1f5bf6d46df8e7fd861b5c27878caabf04a99e07 Mon Sep 17 00:00:00 2001 From: cloneot Date: Fri, 4 Oct 2024 00:02:29 +0900 Subject: [PATCH 1/5] Add localHistory, remoteHistory to Document for tracking change history --- packages/sdk/public/multi.html | 169 ++++++++++++-- packages/sdk/src/document/change/change_id.ts | 13 +- packages/sdk/src/document/document.ts | 212 ++++++++++++------ 3 files changed, 308 insertions(+), 86 deletions(-) diff --git a/packages/sdk/public/multi.html b/packages/sdk/public/multi.html index 3fb36ce45..b5e08edd3 100644 --- a/packages/sdk/public/multi.html +++ b/packages/sdk/public/multi.html @@ -8,6 +8,7 @@ rel="stylesheet" /> + @@ -17,28 +18,94 @@
-
-

Counter

- -
-

Todo List

-
-
    -
    - - +
    + Client ( id: ) +
    + SyncMode: +
    + Realtime Sync +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    + + + +
    +
    + +
    +

    History

    +
    +
    + +
    + +

    version:

    +
    +
    +
    + +
    +

    Counter

    + +
    + +

    Todo List

    +
    +
      +
      + + +
      + +

      Quill Editor

      +
      + +

      yorkie document

      +
      
             
      -

      Quill Editor

      -
      -

      yorkie document

      -
      
           
      diff --git a/packages/sdk/src/document/change/change_id.ts b/packages/sdk/src/document/change/change_id.ts index d4c5fc894..b63808cf4 100644 --- a/packages/sdk/src/document/change/change_id.ts +++ b/packages/sdk/src/document/change/change_id.ts @@ -100,7 +100,7 @@ export class ChangeID { } /** - * `getServerSeq` returns the server sequence of this ID to string. + * `getServerSeq` returns the server sequence of this ID as string. */ public getServerSeq(): string { if (this.serverSeq) { @@ -110,15 +110,22 @@ export class ChangeID { } /** - * `getServerSeqLong` returns the server sequence of this ID. + * `getServerSeqAsLong` returns the server sequence of this ID as Long. */ - public getServerSeqLong(): Long { + public getServerSeqAsLong(): Long { if (this.serverSeq) { return this.serverSeq; } return InitialCheckpoint.getServerSeq(); } + /** + * `hasServerSeq` returns whether the server sequence is set. + */ + public hasServerSeq(): boolean { + return this.serverSeq !== undefined; + } + /** * `getLamport` returns the lamport clock of this ID. */ @@ -148,6 +155,13 @@ export class ChangeID { this.clientSeq }`; } + + /** + * `toUniqueString` returns a unique string for the changes. + */ + public toUniqueString(): string { + return `c-${this.clientSeq}-${this.actor}`; + } } /** diff --git a/packages/sdk/src/document/document.ts b/packages/sdk/src/document/document.ts index b9dcf05f2..5fc95de8c 100644 --- a/packages/sdk/src/document/document.ts +++ b/packages/sdk/src/document/document.ts @@ -594,6 +594,79 @@ type PathOf = PathOfInternal< Depth >; +/** + * `DocumentVersion` represents the version information of a document. + * + * @public + */ +export class DocumentVersion { + private serverSeq: Long; + private clientSeq: number; + private actorID: ActorID; + + constructor(serverSeq?: Long, clientSeq?: number, actorID?: ActorID) { + this.serverSeq = serverSeq || InitialChangeID.getServerSeqAsLong(); + this.clientSeq = clientSeq || InitialChangeID.getClientSeq(); + this.actorID = actorID || InitialChangeID.getActorID(); + } + + /** + * `getServerSeq` returns the server sequence number of this version. + */ + public getServerSeq(): Long { + return this.serverSeq; + } + + /** + * `getClientSeq` returns the client sequence number of this version. + */ + public getClientSeq(): number { + return this.clientSeq; + } + + /** + * `getActorID` returns the actor ID of this version. + */ + public getActorID(): ActorID { + return this.actorID; + } + + /** + * `coversChangeID` checks if the given change ID is covered by this version. + */ + public coversChangeID(changeID: ChangeID): boolean { + // for local changes + if (changeID.getActorID() === this.actorID) { + return changeID.getClientSeq() <= this.clientSeq; + } + + // for remote changes + return ( + changeID.hasServerSeq() && + changeID.getServerSeqAsLong().lessThanOrEqual(this.serverSeq) + ); + } + + /** + * `fromChangeID` creates a new DocumentVersion from the given change ID. + */ + static fromChangeID(changeID: ChangeID): DocumentVersion { + // NOTE(junseo): if change is not pushed, its serverSeq is considered as infinite. + return new DocumentVersion( + changeID.hasServerSeq() ? changeID.getServerSeqAsLong() : Long.MAX_VALUE, + changeID.getClientSeq(), + changeID.getActorID(), + ); + } + + /** + * `toTestString` returns a string for test. + */ + public toTestString(): string { + return `(${this.serverSeq.toString()}, ${this.clientSeq}@${this.actorID.slice(-4)})`; + } +} + /** * `Document` is a CRDT-based data type. We can represent the model * of the application and edit it even while offline. @@ -608,8 +681,9 @@ export class Document { private changeID: ChangeID; private checkpoint: Checkpoint; - public localHistory: Array>; - public remoteHistory: Array>; + private version: DocumentVersion; + private localHistory: Array>; + private remoteHistory: Array>; private localChanges: Array>; private root: CRDTRoot; @@ -656,6 +730,8 @@ export class Document { this.changeID = InitialChangeID; this.checkpoint = InitialCheckpoint; + + this.version = new DocumentVersion(); this.remoteHistory = []; this.localHistory = []; this.localChanges = []; @@ -679,6 +755,41 @@ export class Document { setupDevtools(this); } + /** + * `getVersion` returns the traveling version of this document. + */ + public getVersion(): DocumentVersion { + return this.version; + } + + /** + * `timeTravel` moves the document state to the given version. + */ + public timeTravel(version: DocumentVersion) { + // 01. get changes such that change <= version + const changes = this.getChangesCoveredBy(version); + + // 02. clear state informations (change is already validated when they are applied) + // TODO(junseo): old root GC? + this.root = CRDTRoot.create(); + // this.changeID = InitialChangeID; + // this.checkpoint = InitialCheckpoint; + this.presences = new Map(); + this.clone = undefined; + + // 03. apply changes to the empty root + for (const change of changes) { + // TOOD(junseo): filter change.presenceChange + // we only need to apply operations + this.applyChange(change, OpSource.Local, true); + } + + // 04. change document metadata about time travel, version, isLatest + this.version = version; + + // 05. publish time-travel set event for $.counter, $.todos, ... + } + /** * `mergeChangeLists` merges listA and listB ordered by serverSeq. */ @@ -697,8 +808,8 @@ export class Document { if ( changeA.value .getID() - .getServerSeqLong() - .lessThan(changeB.value.getID().getServerSeqLong()) + .getServerSeqAsLong() + .lessThan(changeB.value.getID().getServerSeqAsLong()) ) { merged.push(changeA.value); changeA = iteratorA.next(); @@ -720,11 +831,33 @@ export class Document { } /** - * `getMergedHistory` returns change history of this document ordred by serverSeq. + * `getChangeHistoryLen` returns the length of the change history. + */ + public getChangeHistoryLen(): number { + return ( + this.localHistory.length + + this.remoteHistory.length + + this.localChanges.length + ); + } + + /** + * `getVersionFromHistoryIndex` returns the version from the given history index. + */ + public getVersionFromHistoryIndex(idx: number): DocumentVersion { + const changes = this.getChangeHistory(); + if (idx < 0 || idx >= changes.length) { + throw new YorkieError(Code.ErrInvalidArgument, `Invalid index: ${idx}`); + } + return DocumentVersion.fromChangeID(changes[idx].getID()); + } + + /** + * `getChangeHistory` returns change history of this document ordred by serverSeq. * Unpushed changes are considered to have infinite serverSeq. * TODO(junseo): consider pushonly mode. */ - public getMergedHistory(): Array> { + public getChangeHistory(): Array> { const merged = this.mergeChangeLists(this.localHistory, this.remoteHistory); for (const change of this.localChanges) { merged.push(change); @@ -732,6 +865,16 @@ export class Document { return merged; } + /** + * `getChangesCoveredBy` returns changes that are covered by the given version. + */ + public getChangesCoveredBy(version: DocumentVersion) { + const changes = this.getChangeHistory(); + return changes.filter((change) => { + return version.coversChangeID(change.getID()); + }); + } + /** * `update` executes the given updater to update this document. */ @@ -1521,7 +1664,11 @@ export class Document { /** * `applyChange` applies the given change into this document. */ - public applyChange(change: Change

      , source: OpSource) { + public applyChange( + change: Change

      , + source: OpSource, + isTravel: boolean = false, + ) { this.ensureClone(); change.execute(this.clone!.root, this.clone!.presences, source); @@ -1577,8 +1724,10 @@ export class Document { } const { opInfos } = change.execute(this.root, this.presences, source); - this.changeID = this.changeID.syncLamport(change.getID().getLamport()); - if (opInfos.length > 0) { + if (!isTravel) { + this.changeID = this.changeID.syncLamport(change.getID().getLamport()); + } + if (!isTravel && opInfos.length > 0) { const rawChange = this.isEnableDevtools() ? change.toStruct() : undefined; event.push( source === OpSource.Remote @@ -1612,7 +1761,7 @@ export class Document { // This is because 3rd party model should be synced with the Document // after RemoteChange event is emitted. If the event is emitted // asynchronously, the model can be changed and breaking consistency. - if (event.length > 0) { + if (!isTravel && event.length > 0) { this.publish(event); } } From 42d77d9d2355ca2a92c5d2b362c97866e2e7f4af Mon Sep 17 00:00:00 2001 From: cloneot Date: Mon, 7 Oct 2024 09:58:28 +0900 Subject: [PATCH 4/5] Add time travel event mechanisms - Add OpSource.TimeTravel to SnapshotEvent - Add isLatestVersion method in Document - Modify time-travel example --- packages/sdk/public/multi.html | 234 +----------- packages/sdk/public/time-travel.css | 28 ++ packages/sdk/public/time-travel.html | 114 ++++++ packages/sdk/public/time-travel.js | 345 ++++++++++++++++++ packages/sdk/src/document/document.ts | 147 ++++++-- .../sdk/src/document/operation/operation.ts | 1 + 6 files changed, 622 insertions(+), 247 deletions(-) create mode 100644 packages/sdk/public/time-travel.css create mode 100644 packages/sdk/public/time-travel.html create mode 100644 packages/sdk/public/time-travel.js diff --git a/packages/sdk/public/multi.html b/packages/sdk/public/multi.html index e281e41c9..1bd6426a7 100644 --- a/packages/sdk/public/multi.html +++ b/packages/sdk/public/multi.html @@ -8,7 +8,6 @@ rel="stylesheet" /> - @@ -18,97 +17,28 @@

      -
      - Client ( id: ) -
      - SyncMode: -
      - Realtime Sync -
      - - -
      -
      - - -
      -
      - - -
      -
      -
      - - - -
      -
      - -
      -

      History

      -
      - auto - - -
      -
      - -

      slider version:

      -

      current version:

      -
      -
      -
      - -
      -

      Counter

      - -
      - -

      Todo List

      -
      -
        -
        - - -
        +
        +

        Counter

        + +
        +

        Todo List

        +
        +
          +
          + +
          - -

          Quill Editor

          -
          - -

          yorkie document

          -
          
                 
          +

          Quill Editor

          +
          +

          yorkie document

          +
          
               
          - + \ No newline at end of file diff --git a/packages/sdk/public/time-travel.css b/packages/sdk/public/time-travel.css new file mode 100644 index 000000000..889f62d6a --- /dev/null +++ b/packages/sdk/public/time-travel.css @@ -0,0 +1,28 @@ +#change-history-list { + display: flex; + flex-wrap: wrap; + overflow-x: hidden; + width: 100%; +} + +#change-history-list > span { + display: inline-block; + padding: 2px 5px; + margin: 0 2px; + border-radius: 3px; +} + +#change-history-list > span.local { + background-color: yellow; + color: black; +} + +#change-history-list > span.remote { + background-color: blue; + color: white; +} + +#change-history-list > span.covered { + font-weight: bold; + border: 1px solid red; +} \ No newline at end of file diff --git a/packages/sdk/public/time-travel.html b/packages/sdk/public/time-travel.html new file mode 100644 index 000000000..14ee33037 --- /dev/null +++ b/packages/sdk/public/time-travel.html @@ -0,0 +1,114 @@ + + + + + Time Travel Example + + + + + + + + + + +
          +
          +
          +
          + Client ( id: ) +
          + SyncMode: +
          + Realtime Sync +
          + + +
          +
          + + +
          +
          + + +
          +
          +
          + + + +
          +
          + +
          +

          History

          +
          + auto + + +
          +
          + +

          slider version:

          +

          current version:

          +
          +
          +
          + +
          +

          Counter

          + +
          + +

          Todo List

          +
          +
            +
            + + +
            +
            + +

            Quill Editor

            +
            + +

            yorkie document

            +
            
            +      
            +
            + + + + + + diff --git a/packages/sdk/public/time-travel.js b/packages/sdk/public/time-travel.js new file mode 100644 index 000000000..6eca533d6 --- /dev/null +++ b/packages/sdk/public/time-travel.js @@ -0,0 +1,345 @@ +const clientElem = document.getElementById('client'); +const documentKey = 'multi-example'; + +const statusHolder = document.getElementById('network-status'); +const placeholder = document.getElementById('placeholder'); +const onlineClientsHolder = document.getElementById('online-clients'); +const logHolder = document.getElementById('log-holder'); +const shortUniqueID = new ShortUniqueId(); +const colorHash = new ColorHash(); +const counter = document.querySelector('.count'); +const counterIncreaseButton = document.querySelector('.increaseButton'); +const todoList = document.querySelector('.todoList'); +const todoInput = document.querySelector('.todoInput'); +const addTodoButton = document.querySelector('.addButton'); + +const changeHistoryList = document.getElementById('change-history-list'); +const versionSlider = document.getElementById('version-slider'); +const sliderVersionText = document.getElementById('slider-version-text'); +const currentVersionText = document.getElementById('current-version-text'); +const autoTravelBtn = document.getElementById('auto-travel'); +const renewHistoryBtn = document.getElementById('renew-history'); +const timeTravelBtn = document.getElementById('time-travel'); + +function displayOnlineClients(presences, myClientID) { + const usernames = []; + for (const { clientID, presence } of presences) { + usernames.push( + myClientID === clientID + ? `${presence.username.slice(-4)}` + : presence.username.slice(-4), + ); + } + onlineClientsHolder.innerHTML = JSON.stringify(usernames); +} + + +async function main() { + try { + // 01-1. create client with RPCAddr. + const client = new yorkie.Client('http://localhost:8080'); + // 01-2. activate client + await client.activate(); + const clientID = client.getID().slice(-4); + clientElem.querySelector('.client-id').textContent = clientID; + + // 02. create a document then attach it into the client. + const doc = new yorkie.Document('time-travel-example', { + enableDevtools: true, + }); + doc.subscribe('connection', new Network(statusHolder).statusListener); + doc.subscribe('presence', (event) => { + if (event.type === 'presence-changed') return; + displayOnlineClients(doc.getPresences(), client.getID()); + }); + + + doc.subscribe((event) => { + console.log('🟢 doc event', event); + if (event.type === 'snapshot') { // remote snapshot or local travel + if (event.source !== 'timetravel') { + // update history content UI + // displayUI(); + } + // update UI components + // update history UI style + displayUI(); + } + displayHistory(); + displayVersion(); + displayLog(); + }); + + // 07. for every events that changes localChanges or localHistory or remoteHistory, + // update changeHistoryList + + function displayLog() { + logHolder.innerHTML = JSON.stringify(doc.getRoot().toJS(), null, 2); + } + + const displayCount = () => { + counter.textContent = doc.getValueByPath('$.counter').getValue(); + // you can also get the value as follows: + // doc.getRoot().counter.getValue(); + }; + + function displayTodos() { + todoList.innerHTML = ''; + doc.getValueByPath('$.todos').forEach((todo) => { + addTodo(todo); + }); + } + + function displayVersion() { + const sliderVersion = doc.getVersionFromHistoryIndex(versionSlider.value); + sliderVersionText.textContent = sliderVersion.toTestString(); + currentVersionText.textContent = doc.getVersion().toTestString(); + } + + function displayHistory() { + const changeHistory = doc.getChangeHistory(); + const wasLatest = (versionSlider.value === versionSlider.max); + versionSlider.min = 0; + versionSlider.max = changeHistory.length - 1; + // if(doc.isLatestVersion()) { + if(wasLatest) { + versionSlider.value = changeHistory.length - 1; + } + + changeHistoryList.innerHTML = ''; + for (const change of changeHistory) { + const id = change.getID().toUniqueString(); + if(changeHistoryList.querySelector(`#${id}`)) continue; + + const span = document.createElement('span'); + span.id = id; + changeHistoryList.appendChild(span); + } + updateHistoryStyle(doc.getVersion()); + } + + function displayUI() { + displayCount(); + displayTodos(); + displayVersion(); + } + + function addTodo(text) { + const newTodo = document.createElement('li'); + newTodo.classList.add('todoItem'); + newTodo.innerHTML = ` + + + ${text} + + `; + todoList.appendChild(newTodo); + } + + + await client.attach(doc, { + initialPresence: { username: `user-${client.getID()}` }, + }); + + initDoc(doc); + initCounter(doc, displayCount); + initTodos(doc, displayTodos, addTodo) + initSyncOption(doc, client); + + autoTravelBtn.addEventListener('click', (e) => { + timeTravelBtn.disabled = e.target.checked; + }); + renewHistoryBtn.addEventListener('click', () => { + displayHistory(); + displayVersion(); + }); + timeTravelBtn.addEventListener('click', () => { + const sliderVersion = doc.getVersionFromHistoryIndex(versionSlider.value); + doc.timeTravel(sliderVersion); + }); + + versionSlider.addEventListener('input', (e) => { + const historyIdx = e.target.value; + const version = doc.getVersionFromHistoryIndex(historyIdx); + console.debug('version:', version); + sliderVersionText.textContent = version.toTestString(); + + updateHistoryStyle(version); + if(autoTravelBtn.checked) { + doc.timeTravel(version); + } + }); + + function updateHistoryStyle(version) { + const changeHistory = doc.getChangeHistory(); + for (const change of changeHistory) { + const span = document.getElementById(change.getID().toUniqueString()); + if(!span) { + console.debug('span not found', change.getID().toUniqueString()); + } + const isLocal = change.getID().getActorID() === client.getID(); + const isCovered = version.coversChangeID(change.getID()); + const isPushed = change.getID().hasServerSeq(); + + span.textContent = `${isPushed ? change.getID().getServerSeq() : '#'+change.getID().getClientSeq()}`; + span.classList.remove('local', 'remote', 'covered', 'not-covered'); + span.classList.add(isLocal ? 'local' : 'remote'); + span.classList.add(isCovered ? 'covered' : 'not-covered'); + } + } + + displayUI(); + displayHistory(); + displayLog(); + + window.addEventListener('beforeunload', async () => { + await client.deactivate(); + }); + } catch (e) { + console.error(e); + } +} + +function initDoc(doc) { + doc.update((root) => { + if (!root.counter) { + root.counter = new yorkie.Counter(yorkie.IntType, 0); + root.todos = []; + root.content = new yorkie.Text(); + root.content.edit(0, 0, '\n'); + root.obj = { + name: 'josh', + age: 14, + food: ['🍇', '🍌', '🍏'], + score: { + english: 80, + math: 90, + }, + }; + root.obj.score = { science: 100 }; + delete root.obj.food; + } + }, 'initaialize doc'); +} + +function initCounter(doc, displayCount) { + // 03. Counter example + doc.subscribe('$.counter', (event) => { + console.log('🟣 counter event', event); + displayCount(); + }); + + counterIncreaseButton.onclick = () => { + doc.update((root) => { + root.counter.increase(1); + }); + }; +} + +function initTodos(doc, displayTodos, addTodo) { + // 04. Todo example + doc.subscribe('$.todos', (event) => { + console.log('🟡 todos event', event); + + const { message, operations } = event.value; + for (const op of operations) { + const { type, path, index } = op; + switch (type) { + case 'add': + const value = doc.getValueByPath(`${path}.${index}`); + addTodo(value); + break; + default: + displayTodos(); + break; + } + } + + if (event.type === 'local-change') { + todoInput.value = ''; + todoInput.focus(); + } + }); + + function handleAddTodo() { + const text = todoInput.value; + if (text === '') { + todoInput.focus(); + return; + } + doc.update((root) => { + root.todos.push(text); + }); + } + + addTodoButton.addEventListener('click', handleAddTodo); + todoInput.addEventListener('keypress', (event) => { + if (event.key === 'Enter') { + handleAddTodo(); + } + }); + todoList.addEventListener('click', function (e) { + if (e.target.classList.contains('trash')) { + const li = e.target.parentNode; + const idx = Array.from(li.parentNode.children).indexOf(li); + doc.update((root) => { + const todoID = root.todos.getElementByIndex(idx).getID(); + root.todos.deleteByID(todoID); + }); + return; + } + if (e.target.classList.contains('moveUp')) { + const li = e.target.parentNode; + const idx = Array.from(li.parentNode.children).indexOf(li); + if (idx === 0) return; + doc.update((root) => { + const nextItem = root.todos.getElementByIndex(idx - 1); + const currItem = root.todos.getElementByIndex(idx); + root.todos.moveBefore(nextItem.getID(), currItem.getID()); + }); + return; + } + if (e.target.classList.contains('moveDown')) { + const li = e.target.parentNode; + const idx = Array.from(li.parentNode.children).indexOf(li); + if (idx === doc.getRoot().todos.length - 1) return; + doc.update((root) => { + const prevItem = root.todos.getElementByIndex(idx + 1); + const currItem = root.todos.getElementByIndex(idx); + root.todos.moveAfter(prevItem.getID(), currItem.getID()); + }); + return; + } + }); +} + +function initSyncOption(doc, client) { + // 06. sync option + const option = clientElem.querySelector('.syncmode-option'); + option.addEventListener('change', async (e) => { + if (!e.target.matches('input[type="radio"]')) { + return; + } + const syncMode = e.target.value; + switch (syncMode) { + case 'pushpull': + await client.changeSyncMode(doc, 'realtime'); + break; + case 'pushonly': + await client.changeSyncMode(doc, 'realtime-pushonly'); + break; + case 'syncoff': + await client.changeSyncMode(doc, 'realtime-syncoff'); + break; + case 'manual': + await client.changeSyncMode(doc, 'manual'); + break; + default: + break; + } + }); + const syncButton = clientElem.querySelector('.manual-sync'); + syncButton.addEventListener('click', async () => { + await client.sync(doc); + }); +} \ No newline at end of file diff --git a/packages/sdk/src/document/document.ts b/packages/sdk/src/document/document.ts index 5fc95de8c..71c8b519e 100644 --- a/packages/sdk/src/document/document.ts +++ b/packages/sdk/src/document/document.ts @@ -321,7 +321,7 @@ export interface SnapshotEvent extends BaseDocEvent { * enum {@link DocEventType}.Snapshot */ type: DocEventType.Snapshot; - source: OpSource.Remote; + source: OpSource.Remote | OpSource.TimeTravel; value: { snapshot?: string; serverSeq: string }; } @@ -605,7 +605,7 @@ export class DocumentVersion { private actorID: ActorID; constructor(serverSeq?: Long, clientSeq?: number, actorID?: ActorID) { - this.serverSeq = serverSeq || InitialChangeID.getServerSeqAsLong(); + this.serverSeq = serverSeq || Long.MAX_VALUE; this.clientSeq = clientSeq || InitialChangeID.getClientSeq(); this.actorID = actorID || InitialChangeID.getActorID(); } @@ -624,6 +624,22 @@ export class DocumentVersion { return this.clientSeq; } + /** + * `forward` moves the version forward to the given change ID. + */ + public forward(changeID: ChangeID) { + if (this.actorID === changeID.getActorID()) { + this.clientSeq = Math.max(this.clientSeq, changeID.getClientSeq()); + } else { + if ( + changeID.hasServerSeq() && + changeID.getServerSeqAsLong().greaterThan(this.serverSeq) + ) { + this.serverSeq = changeID.getServerSeqAsLong(); + } + } + } + /** * `getActorID` returns the actor ID of this version. */ @@ -631,12 +647,19 @@ export class DocumentVersion { return this.actorID; } + /** + * `setActorID` sets the actor ID of this version. + */ + public setActorID(actorID: ActorID) { + this.actorID = actorID; + } + /** * `coversChangeID` checks if the given change ID is covered by this version. */ public coversChangeID(changeID: ChangeID): boolean { // for local changes - if (changeID.getActorID() === this.actorID) { + if (this.actorID === changeID.getActorID()) { return changeID.getClientSeq() <= this.clientSeq; } @@ -647,6 +670,17 @@ export class DocumentVersion { ); } + /** + * `coverChanges` checks if the array of changes is covered by this document version. + * array is given as ascending order. + */ + public coverChanges

            (changes: Array>): boolean { + if (changes.length === 0) { + return true; + } + return this.coversChangeID(changes[changes.length - 1].getID()); + } + /** * `fromChangeID` creates a new DocumentVersion from the given change ID. */ @@ -732,6 +766,7 @@ export class Document { this.checkpoint = InitialCheckpoint; this.version = new DocumentVersion(); + this.remoteHistory = []; this.localHistory = []; this.localChanges = []; @@ -762,6 +797,18 @@ export class Document { return this.version; } + /** + * `isLatestVersion` checks if current version of this document is latest. + * It's equivalent to `this.version.coverChanges(this.getChangeHistory())`. + */ + public isLatestVersion(): boolean { + return ( + this.version.coverChanges(this.localChanges) && + this.version.coverChanges(this.localHistory) && + this.version.coverChanges(this.remoteHistory) + ); + } + /** * `timeTravel` moves the document state to the given version. */ @@ -781,13 +828,22 @@ export class Document { for (const change of changes) { // TOOD(junseo): filter change.presenceChange // we only need to apply operations - this.applyChange(change, OpSource.Local, true); + this.applyChange(change, OpSource.TimeTravel); } // 04. change document metadata about time travel, version, isLatest this.version = version; // 05. publish time-travel set event for $.counter, $.todos, ... + this.publish([ + { + type: DocEventType.Snapshot, + source: OpSource.TimeTravel, + value: { + serverSeq: this.version.getServerSeq().toString(), + }, + }, + ]); } /** @@ -886,6 +942,14 @@ export class Document { throw new YorkieError(Code.ErrDocumentRemoved, `${this.key} is removed`); } + // If the document is traveling version, do nothing. + if (!this.isLatestVersion()) { + throw new YorkieError( + Code.ErrNotReady, + 'Only latest version can be updated', + ); + } + // 01. Update the clone object and create a change. this.ensureClone(); const actorID = this.changeID.getActorID(); @@ -943,6 +1007,7 @@ export class Document { } this.localChanges.push(change); + this.version.forward(change.getID()); if (reverseOps.length > 0) { this.internalHistory.pushUndo(reverseOps); } @@ -1356,25 +1421,14 @@ export class Document { * @internal */ public applyChangePack(pack: ChangePack

            ): void { - if (pack.hasSnapshot()) { - this.applySnapshot( - pack.getCheckpoint().getServerSeq(), - pack.getSnapshot(), - ); - } else if (pack.hasChanges()) { - this.applyChanges(pack.getChanges(), OpSource.Remote); - } - - // 02. Remove local changes applied to server. - // And push the changes to local history with appropriate ServerSeq. + // 01. Push local changes to local history. + // TODO(junseo): consider pushonly mode. const initServerSeq = pack .getCheckpoint() .getServerSeq() .subtract(Long.fromNumber(this.localChanges.length)); let counter = 0; - - while (this.localChanges.length) { - const change = this.localChanges[0]; + for (const change of this.localChanges) { if (change.getID().getClientSeq() > pack.getCheckpoint().getClientSeq()) { break; } @@ -1390,9 +1444,31 @@ export class Document { id: pushedID, }); this.localHistory.push(pushedChange); - this.localChanges.shift(); } + // 02. Apply snapshot or changes. + if (pack.hasSnapshot()) { + this.applySnapshot( + pack.getCheckpoint().getServerSeq(), + pack.getSnapshot(), + ); + } else if (pack.hasChanges()) { + this.applyChanges(pack.getChanges(), OpSource.Remote); + } + + // 03. Remove local changes applied to server. + this.localChanges = this.localChanges.filter( + (change) => + change.getID().getClientSeq() > pack.getCheckpoint().getClientSeq(), + ); + // while (this.localChanges.length) { + // const change = this.localChanges[0]; + // if (change.getID().getClientSeq() > pack.getCheckpoint().getClientSeq()) { + // break; + // } + // this.localChanges.shift(); + // } + // NOTE(hackerwins): If the document has local changes, we need to apply // them after applying the snapshot. We need to treat the local changes // as remote changes because the application should apply the local @@ -1401,13 +1477,13 @@ export class Document { this.applyChanges(this.localChanges, OpSource.Remote); } - // 03. Update the checkpoint. + // 04. Update the checkpoint. this.checkpoint = this.checkpoint.forward(pack.getCheckpoint()); - // 04. Do Garbage collection. + // 05. Do Garbage collection. this.garbageCollect(pack.getMinSyncedTicket()!); - // 05. Update the status. + // 06. Update the status. if (pack.getIsRemoved()) { this.applyStatus(DocumentStatus.Removed); } @@ -1481,6 +1557,7 @@ export class Document { change.setActor(actorID); } this.changeID = this.changeID.setActor(actorID); + this.version.setActorID(actorID); // TODO(hackerwins): If the given actorID is not IntialActorID, we need to // update InitialActor of the root and clone. @@ -1648,8 +1725,8 @@ export class Document { } for (const change of changes) { - this.applyChange(change, source); this.remoteHistory.push(change); + this.applyChange(change, source); } if (logger.isEnabled(LogLevel.Debug)) { @@ -1664,11 +1741,13 @@ export class Document { /** * `applyChange` applies the given change into this document. */ - public applyChange( - change: Change

            , - source: OpSource, - isTravel: boolean = false, - ) { + public applyChange(change: Change

            , source: OpSource) { + const isTravel = source === OpSource.TimeTravel; + const isLatest = this.isLatestVersion(); + if (!isTravel && !isLatest) { + return; + } + this.ensureClone(); change.execute(this.clone!.root, this.clone!.presences, source); @@ -1724,10 +1803,14 @@ export class Document { } const { opInfos } = change.execute(this.root, this.presences, source); - if (!isTravel) { - this.changeID = this.changeID.syncLamport(change.getID().getLamport()); + + if (isTravel) { + return; } - if (!isTravel && opInfos.length > 0) { + // NOTE(junseo): remote change when version is latest + this.changeID = this.changeID.syncLamport(change.getID().getLamport()); + this.version.forward(change.getID()); + if (opInfos.length > 0) { const rawChange = this.isEnableDevtools() ? change.toStruct() : undefined; event.push( source === OpSource.Remote @@ -1761,7 +1844,7 @@ export class Document { // This is because 3rd party model should be synced with the Document // after RemoteChange event is emitted. If the event is emitted // asynchronously, the model can be changed and breaking consistency. - if (!isTravel && event.length > 0) { + if (event.length > 0) { this.publish(event); } } diff --git a/packages/sdk/src/document/operation/operation.ts b/packages/sdk/src/document/operation/operation.ts index 18057b00a..63b1bbac2 100644 --- a/packages/sdk/src/document/operation/operation.ts +++ b/packages/sdk/src/document/operation/operation.ts @@ -30,6 +30,7 @@ export enum OpSource { Local = 'local', Remote = 'remote', UndoRedo = 'undoredo', + TimeTravel = 'timetravel', } /** From 166ecc9a708017fa5e915bfc4a5a86f16ce6f519 Mon Sep 17 00:00:00 2001 From: cloneot Date: Fri, 11 Oct 2024 14:19:15 +0900 Subject: [PATCH 5/5] Modify time travel example --- packages/sdk/public/time-travel.html | 15 +++++------- packages/sdk/src/document/document.ts | 34 +++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/packages/sdk/public/time-travel.html b/packages/sdk/public/time-travel.html index 14ee33037..8d04c9d04 100644 --- a/packages/sdk/public/time-travel.html +++ b/packages/sdk/public/time-travel.html @@ -81,13 +81,6 @@

            History

            -
            -

            Counter

            - -
            -

            Todo List

              @@ -97,8 +90,12 @@

              Todo List

              -

              Quill Editor

              -
              +
              +

              Counter

              + +

              yorkie document

              
              diff --git a/packages/sdk/src/document/document.ts b/packages/sdk/src/document/document.ts
              index 71c8b519e..f7eae4cde 100644
              --- a/packages/sdk/src/document/document.ts
              +++ b/packages/sdk/src/document/document.ts
              @@ -654,6 +654,10 @@ export class DocumentVersion {
                   this.actorID = actorID;
                 }
               
              +  // public getStableID(): string {
              +  //   return `${this.clientSeq}-${this.actorID}`;
              +  // }
              +
                 /**
                  * `coversChangeID` checks if the given change ID is covered by this version.
                  */
              @@ -720,6 +724,8 @@ export class Document {
                 private remoteHistory: Array>;
                 private localChanges: Array>;
               
              +  private snapshots: Map;
              +
                 private root: CRDTRoot;
                 private clone?: {
                   root: CRDTRoot;
              @@ -771,6 +777,8 @@ export class Document {
                   this.localHistory = [];
                   this.localChanges = [];
               
              +    this.snapshots = new Map();
              +
                   this.eventStream = createObservable>((observer) => {
                     this.eventStreamObserver = observer;
                   });
              @@ -809,6 +817,32 @@ export class Document {
                   );
                 }
               
              +  // public getSnapshot(version: DocumentVersion) {
              +  //   const versionID = version.getStableID();
              +  //   if (!this.snapshots.has(versionID)) {
              +  //     throw new YorkieError(
              +  //       Code.ErrInvalidArgument,
              +  //       `Snapshot not found: ${versionID}`,
              +  //     );
              +  //   }
              +  //   return this.snapshots.get(versionID)!;
              +  // }
              +
              +  // public createSnapshot(version: DocumentVersion) {
              +  //   const versionID = version.getStableID();
              +  //   if (this.snapshots.has(versionID)) {
              +  //     return;
              +  //   }
              +  //   const snapshot = this.root.deepcopy();
              +  //   this.snapshots.set(versionID, snapshot);
              +  // }
              +
              +  // public gotoSnapshot(version: DocumentVersion) {
              +  //   const snapshot = this.getSnapshot(version);
              +  //   this.root = snapshot.deepcopy();
              +  //   this.version = version;
              +  // }
              +
                 /**
                  * `timeTravel` moves the document state to the given version.
                  */