From cdce17c7730352232e54897393bd61b5486ce6a4 Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Mon, 3 Jun 2024 19:49:48 +0200 Subject: [PATCH] add onPendingChangesUpdated --- .../YjsPendingChangesCallbackExample.tsx | 105 ++++++++++++++++++ documentation/pages/docs/_meta.json | 1 + documentation/pages/docs/api/client.mdx | 10 ++ .../docs/other-examples/pending-changes.mdx | 17 +++ packages/secsync/src/createSyncMachine.ts | 92 ++++++++++----- packages/secsync/src/types.ts | 1 + 6 files changed, 196 insertions(+), 30 deletions(-) create mode 100644 documentation/components/YjsPendingChangesCallbackExample/YjsPendingChangesCallbackExample.tsx create mode 100644 documentation/pages/docs/other-examples/pending-changes.mdx diff --git a/documentation/components/YjsPendingChangesCallbackExample/YjsPendingChangesCallbackExample.tsx b/documentation/components/YjsPendingChangesCallbackExample/YjsPendingChangesCallbackExample.tsx new file mode 100644 index 0000000..ce62e02 --- /dev/null +++ b/documentation/components/YjsPendingChangesCallbackExample/YjsPendingChangesCallbackExample.tsx @@ -0,0 +1,105 @@ +import sodium, { KeyPair } from "libsodium-wrappers"; +import React, { useRef, useState } from "react"; +import { DevTool } from "secsync-react-devtool"; +import { useYjsSync } from "secsync-react-yjs"; +import * as Yjs from "yjs"; +import { useYArray } from "../../hooks/useYArray"; + +const websocketEndpoint = + process.env.NODE_ENV === "development" + ? "ws://localhost:4000" + : "wss://secsync.fly.dev"; + +type Props = { + documentId: string; + showDevTool: boolean; +}; + +export const YjsPendingChangesCallbackExample: React.FC = ({ + documentId, + showDevTool, +}) => { + const documentKey = sodium.from_base64( + "MTcyipWZ6Kiibd5fATw55i9wyEU7KbdDoTE_MRgDR98" + ); + + const [authorKeyPair] = useState(() => { + return sodium.crypto_sign_keypair(); + }); + + const yDocRef = useRef(new Yjs.Doc()); + const yTodos: Yjs.Array = yDocRef.current.getArray("todos"); + const todos = useYArray(yTodos); + const [newTodoText, setNewTodoText] = useState(""); + + const [state, send] = useYjsSync({ + onPendingChangesUpdated: (allChanges) => { + console.log("pending changes", allChanges); + }, + yDoc: yDocRef.current, + documentId, + signatureKeyPair: authorKeyPair, + websocketEndpoint, + websocketSessionKey: "your-secret-session-key", + getNewSnapshotData: async ({ id }) => { + return { + data: Yjs.encodeStateAsUpdateV2(yDocRef.current), + key: documentKey, + publicData: {}, + }; + }, + getSnapshotKey: async () => { + return documentKey; + }, + shouldSendSnapshot: ({ snapshotUpdatesCount }) => { + // create a new snapshot if the active snapshot has more than 100 updates + return snapshotUpdatesCount > 3; + }, + isValidClient: async (signingPublicKey: string) => { + return true; + }, + sodium, + logging: "error", + }); + + return ( + <> +
+
{ + event.preventDefault(); + yTodos.push([newTodoText]); + setNewTodoText(""); + }} + > + setNewTodoText(event.target.value)} + value={newTodoText} + className="new-todo" + /> + +
+ +
    + {todos.map((entry, index) => { + return ( +
  • +
    {entry}
    +
  • + ); + })} +
+
+ +
+ + + ); +}; diff --git a/documentation/pages/docs/_meta.json b/documentation/pages/docs/_meta.json index 0804d13..5a0dca7 100644 --- a/documentation/pages/docs/_meta.json +++ b/documentation/pages/docs/_meta.json @@ -1,6 +1,7 @@ { "getting-started": "Getting started", "integration-examples": "Integration Examples", + "other-examples": "Other Examples", "api": "API", "error-handling": "Error Handling", "security_and_privacy": "Security & Privacy", diff --git a/documentation/pages/docs/api/client.mdx b/documentation/pages/docs/api/client.mdx index af9682b..aebbf9e 100644 --- a/documentation/pages/docs/api/client.mdx +++ b/documentation/pages/docs/api/client.mdx @@ -229,6 +229,16 @@ The `knownSnapshotInfo` can be stored locally in order to provide it to `loadDoc }) => void | Promise ``` +#### onPendingChangesUpdated + +Required: `false` + +A callback that is invoked every time the pending changes of a document are updated. It combines all unsynced changes, changes from a snapshot in flight and changes from updates in flight. + +```ts +(pendingChanges: any[]) => void | Promise +``` + #### loadDocumentParams Required: `false` diff --git a/documentation/pages/docs/other-examples/pending-changes.mdx b/documentation/pages/docs/other-examples/pending-changes.mdx new file mode 100644 index 0000000..2492ebf --- /dev/null +++ b/documentation/pages/docs/other-examples/pending-changes.mdx @@ -0,0 +1,17 @@ +## Pending Changes Example + +This example shows how to use the `onPendingChangesUpdated` callback to get the unsynced changes. This is useful to update a local storage to avoid changes getting lost e.g. in a local-first setting. + +See the `console.log` output in the browser console to see the pending changes. + +import SimpleExampleWrapper from "../../../components/SimpleExampleWrapper"; +import { YjsPendingChangesCallbackExample } from "../../../components/YjsPendingChangesCallbackExample/YjsPendingChangesCallbackExample"; + + + +## Code + +The code for this example is located [here](https://github.com/serenity-kit/secsync/blob/main/documentation/components/YjsPendingChangesCallbackExample/YjsPendingChangesCallbackExample.tsx). diff --git a/packages/secsync/src/createSyncMachine.ts b/packages/secsync/src/createSyncMachine.ts index 5548d51..be605b9 100644 --- a/packages/secsync/src/createSyncMachine.ts +++ b/packages/secsync/src/createSyncMachine.ts @@ -1174,6 +1174,17 @@ export const createSyncMachine = () => { }; }, actions: { + invokePendingChangesUpdatedCallback: ({ context, event }) => { + console.log("event", event); + const allPendingChanges = [ + ...context._pendingChangesQueue, + ...(context._snapshotInFlight?.changes || []), + ...context._updatesInFlight.reduce((accumulator, updateInFlight) => { + return [...accumulator, ...updateInFlight.changes]; + }, [] as any[]), + ]; + context.onPendingChangesUpdated?.(allPendingChanges); + }, resetWebsocketRetries: assign({ _websocketRetries: 0, }), @@ -1293,10 +1304,10 @@ export const createSyncMachine = () => { if (castedEvent.output.handledQueue === "incoming") { return { _incomingQueue: context._incomingQueue.slice(1), - // because changes might have been add while processing the queue ans creating a - // snapshot or update we can't just overwrite the _pendingChangesQueue - // instead we need to track how many to remove from the beginning of the list - // and of some should be restored also add them to the list + // Because changes might have been added while processing the queue and therefor + // creating a snapshot or update we can't just overwrite the _pendingChangesQueue. + // Instead we need to track how many to remove from the beginning of the list + // and of some should be restored also add them to the list. _pendingChangesQueue: castedEvent.output.pendingChangesToPrepend.concat( context._pendingChangesQueue.slice( @@ -1321,10 +1332,10 @@ export const createSyncMachine = () => { } else if (castedEvent.output.handledQueue === "customMessage") { return { _customMessageQueue: context._customMessageQueue.slice(1), - // because changes might have been add while processing the queue ans creating a - // snapshot or update we can't just overwrite the _pendingChangesQueue - // instead we need to track how many to remove from the beginning of the list - // and of some should be restored also add them to the list + // Because changes might have been added while processing the queue and therefor + // creating a snapshot or update we can't just overwrite the _pendingChangesQueue. + // Instead we need to track how many to remove from the beginning of the list + // and of some should be restored also add them to the list. _pendingChangesQueue: castedEvent.output.pendingChangesToPrepend.concat( context._pendingChangesQueue.slice( @@ -1348,10 +1359,10 @@ export const createSyncMachine = () => { }; } else if (castedEvent.output.handledQueue === "pending") { return { - // because changes might have been add while processing the queue ans creating a - // snapshot or update we can't just overwrite the _pendingChangesQueue - // instead we need to track how many to remove from the beginning of the list - // and of some should be restored also add them to the list + // Because changes might have been added while processing the queue and therefor + // creating a snapshot or update we can't just overwrite the _pendingChangesQueue. + // Instead we need to track how many to remove from the beginning of the list + // and of some should be restored also add them to the list. _pendingChangesQueue: castedEvent.output.pendingChangesToPrepend.concat( context._pendingChangesQueue.slice( @@ -1510,7 +1521,10 @@ export const createSyncMachine = () => { target: "connected", }, ADD_CHANGES: { - actions: ["addToPendingUpdatesQueue"], + actions: [ + "addToPendingUpdatesQueue", + "invokePendingChangesUpdatedCallback", + ], }, }, }, @@ -1528,7 +1542,10 @@ export const createSyncMachine = () => { target: "processingQueues", }, ADD_CHANGES: { - actions: ["addToPendingUpdatesQueue"], + actions: [ + "addToPendingUpdatesQueue", + "invokePendingChangesUpdatedCallback", + ], target: "processingQueues", }, }, @@ -1542,7 +1559,10 @@ export const createSyncMachine = () => { actions: ["addToCustomMessageQueue"], }, ADD_CHANGES: { - actions: ["addToPendingUpdatesQueue"], + actions: [ + "addToPendingUpdatesQueue", + "invokePendingChangesUpdatedCallback", + ], }, }, invoke: { @@ -1574,27 +1594,36 @@ export const createSyncMachine = () => { ) ); }, - actions: ["removeOldestItemFromQueueAndUpdateContext"], + actions: [ + "removeOldestItemFromQueueAndUpdateContext", + "invokePendingChangesUpdatedCallback", + ], target: "checkingForMoreQueueItems", }, { - actions: ["removeOldestItemFromQueueAndUpdateContext"], + actions: [ + "removeOldestItemFromQueueAndUpdateContext", + "invokePendingChangesUpdatedCallback", + ], target: "#syncMachine.disconnected", }, ], onError: { - actions: assign(({ context, event }) => { - return { - _documentDecryptionState: - // @ts-expect-error documentDecryptionState is dynamically added to the error event - event.error?.documentDecryptionState || - context._documentDecryptionState, - _snapshotAndUpdateErrors: [ - event.error as Error, - ...context._snapshotAndUpdateErrors, - ], - }; - }), + actions: [ + assign(({ context, event }) => { + return { + _documentDecryptionState: + // @ts-expect-error documentDecryptionState is dynamically added to the error event + event.error?.documentDecryptionState || + context._documentDecryptionState, + _snapshotAndUpdateErrors: [ + event.error as Error, + ...context._snapshotAndUpdateErrors, + ], + }; + }), + "invokePendingChangesUpdatedCallback", + ], target: "#syncMachine.failed", }, }, @@ -1645,7 +1674,10 @@ export const createSyncMachine = () => { }, on: { ADD_CHANGES: { - actions: ["addToPendingUpdatesQueue"], + actions: [ + "addToPendingUpdatesQueue", + "invokePendingChangesUpdatedCallback", + ], }, CONNECT: { target: "connecting", diff --git a/packages/secsync/src/types.ts b/packages/secsync/src/types.ts index 91351a3..fe2e2fc 100644 --- a/packages/secsync/src/types.ts +++ b/packages/secsync/src/types.ts @@ -154,6 +154,7 @@ export type SyncMachineConfig = { type: OnDocumentUpdatedEventType; knownSnapshotInfo: SnapshotInfoWithUpdateClocks; }) => void | Promise; + onPendingChangesUpdated?: (allChanges: any[]) => void; onCustomMessage?: (message: any) => Promise | void; loadDocumentParams?: LoadDocumentParams; additionalAuthenticationDataValidations?: AdditionalAuthenticationDataValidations;