diff --git a/documentation/components/AuthorizedAuthorsExample/AuthorizedAuthorsExample.tsx b/documentation/components/AuthorizedAuthorsExample/AuthorizedAuthorsExample.tsx new file mode 100644 index 0000000..f8b0447 --- /dev/null +++ b/documentation/components/AuthorizedAuthorsExample/AuthorizedAuthorsExample.tsx @@ -0,0 +1,213 @@ +import sodium from "libsodium-wrappers"; +import React, { useId, 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; +}; + +const authorizedAuthors = [ + { + privateKey: + "90gI4rbA8cApZe72j3oJ0f31ymuleLuZdsaKm64jEUwlcH4y5KshGsNcNaWzQaBJKp3cHdUEnnP3bgXloyOytA", + publicKey: "JXB-MuSrIRrDXDWls0GgSSqd3B3VBJ5z924F5aMjsrQ", + }, + { + privateKey: + "dko_5CR064h36HQmOuYPcBQIS-xdM7wSQJAJjCnIO9SvQ-tKBL_BvFFw-AhkHPSKRPb3F6kw5kfTV3GGJ4awYg", + publicKey: "r0PrSgS_wbxRcPgIZBz0ikT29xepMOZH01dxhieGsGI", + }, + { + privateKey: + "rTzxM6i7bVjH3eG3jqvyI1E6ZFzJhNYsvUwiJxAfTlOC5jWTkIieCdRxpG3mXySar6HS1z5bL0Rdx1azVTr3jw", + publicKey: "guY1k5CIngnUcaRt5l8kmq-h0tc-Wy9EXcdWs1U6948", + }, + { + privateKey: + "MH_NDZ4aUfCT0cpCr-tZ8Hhvh4MYpw56IZkdbwVmUQn0wCzxJ_IYw_IzyZ3kxdOXIQFtg-UxykGK8hjiA-x1hg", + publicKey: "9MAs8SfyGMPyM8md5MXTlyEBbYPlMcpBivIY4gPsdYY", + }, + { + privateKey: + "ZcknsT8b9HdksFl9SxQbs0soLsWfNoGIGP52E4-8FcIQSgu83cXNvcqcYPAFO0wUatx_h19GM34sz-8u5RfejA", + publicKey: "EEoLvN3Fzb3KnGDwBTtMFGrcf4dfRjN-LM_vLuUX3ow", + }, +]; + +const nonAuthorizedAuthors = [ + { + privateKey: + "wjNC0kxBFJzaBN02Gr3a87pJlGUj6LTAM4PNMT2hFTxuGHbOc48VvXd4lhLJELmV4q7ahne0H3nCs271MSx_mA", + publicKey: "bhh2znOPFb13eJYSyRC5leKu2oZ3tB95wrNu9TEsf5g", + }, +]; + +export const AuthorizedAuthorsExample: React.FC = ({ + documentId, + showDevTool, +}) => { + const [authorKeyPair, setAuthorKeyPair] = useState( + null + ); + + const selectId = useId(); + + if (authorKeyPair === null) { + return ( +
+
+ + + +

+ NOTE: Choosing the same author in multiple clients will result in + errors when trying to create update in parallel. +

+
+
+ ); + } + + return ; +}; + +type TodosProps = { + documentId: string; + authorKeyPair: sodium.KeyPair; +}; + +export const Todos: React.FC = ({ documentId, authorKeyPair }) => { + const documentKey = sodium.from_base64( + "MTcyipWZ6Kiibd5fATw55i9wyEU7KbdDoTE_MRgDR98" + ); + + 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({ + 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 > 100; + }, + isValidClient: async (signingPublicKey: string) => { + return authorizedAuthors.some((author) => { + console.log( + author.publicKey, + signingPublicKey, + author.publicKey === signingPublicKey + ); + return author.publicKey === signingPublicKey; + }); + }, + sodium, + logging: "debug", + }); + + 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/components/Logo.tsx b/documentation/components/Logo.tsx index 73cacde..018cbd2 100644 --- a/documentation/components/Logo.tsx +++ b/documentation/components/Logo.tsx @@ -4,10 +4,12 @@ export type LogoProps = { hoverEffect?: boolean; }; +import LogoSvg from "../public/secsync-logo.svg"; + export const Logo = ({ color = "currentColor", height = 20 }: LogoProps) => { return (
- Secsync +
); }; diff --git a/documentation/components/YjsLocalFirstExample/YjsLocalFirstExample.tsx b/documentation/components/YjsLocalFirstExample/YjsLocalFirstExample.tsx new file mode 100644 index 0000000..0184d8f --- /dev/null +++ b/documentation/components/YjsLocalFirstExample/YjsLocalFirstExample.tsx @@ -0,0 +1,142 @@ +import sodium, { KeyPair } from "libsodium-wrappers"; +import React, { useEffect, 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"; +import { deserialize } from "./deserialize"; +import { serialize } from "./serialize"; + +const websocketEndpoint = + process.env.NODE_ENV === "development" + ? "ws://localhost:4000" + : "wss://secsync.fly.dev"; + +type Props = { + documentId: string; + showDevTool: boolean; +}; + +export const YjsLocalFirstExample: React.FC = ({ + documentId, + showDevTool, +}) => { + const documentKey = sodium.from_base64( + "MTcyipWZ6Kiibd5fATw55i9wyEU7KbdDoTE_MRgDR98" + ); + const [newTodoText, setNewTodoText] = useState(""); + const [authorKeyPair] = useState(() => { + return sodium.crypto_sign_keypair(); + }); + + // load initial data from localStorage + const [initialData] = useState(() => { + const yDoc = new Yjs.Doc(); + // load full document + const serializedDoc = localStorage.getItem(`doc:state:${documentId}`); + if (serializedDoc) { + Yjs.applyUpdateV2(yDoc, deserialize(serializedDoc)); + } + + // loads the pendingChanges from localStorage + const pendingChanges = localStorage.getItem(`doc:pending:${documentId}`); + + return { + yDoc, + pendingChanges: pendingChanges ? deserialize(pendingChanges) : [], + }; + }); + + // create the yDocRef + const yDocRef = useRef(initialData.yDoc); + + // update the document in localStorage after every change (could be debounced) + useEffect(() => { + const onUpdate = (update: any) => { + const fullYDoc = Yjs.encodeStateAsUpdateV2(yDocRef.current); + localStorage.setItem(`doc:state:${documentId}`, serialize(fullYDoc)); + }; + yDocRef.current.on("updateV2", onUpdate); + + return () => { + yDocRef.current.off("updateV2", onUpdate); + }; + }, []); + + const [state, send] = useYjsSync({ + // pass in the pending changes + pendingChanges: initialData.pendingChanges, + // callback to store the pending changes in + onPendingChangesUpdated: (allChanges) => { + localStorage.setItem(`doc:pending:${documentId}`, serialize(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 > 10; + }, + isValidClient: async (signingPublicKey: string) => { + return true; + }, + sodium, + logging: "debug", + }); + + const yTodos: Yjs.Array = yDocRef.current.getArray("todos"); + const todos = useYArray(yTodos); + + 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/components/YjsLocalFirstExample/deserialize.ts b/documentation/components/YjsLocalFirstExample/deserialize.ts new file mode 100644 index 0000000..5a50270 --- /dev/null +++ b/documentation/components/YjsLocalFirstExample/deserialize.ts @@ -0,0 +1,12 @@ +export const deserialize = (data: string) => { + return JSON.parse(data, (key, value) => { + if ( + typeof value === "object" && + value !== null && + value.type === "Uint8Array" + ) { + return new Uint8Array(value.data); + } + return value; + }); +}; diff --git a/documentation/components/YjsLocalFirstExample/serialize.ts b/documentation/components/YjsLocalFirstExample/serialize.ts new file mode 100644 index 0000000..8f9053e --- /dev/null +++ b/documentation/components/YjsLocalFirstExample/serialize.ts @@ -0,0 +1,8 @@ +export const serialize = (data: any) => { + return JSON.stringify(data, (key, value) => { + if (value instanceof Uint8Array) { + return { type: "Uint8Array", data: Array.from(value) }; + } + return value; + }); +}; diff --git a/documentation/pages/_meta.json b/documentation/pages/_meta.json index 5a0f328..f0e5d35 100644 --- a/documentation/pages/_meta.json +++ b/documentation/pages/_meta.json @@ -2,6 +2,9 @@ "index": { "title": "Secsync", "type": "page", + "theme": { + "typesetting": "article" + }, "display": "hidden" }, "docs": { 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..e145d34 100644 --- a/documentation/pages/docs/api/client.mdx +++ b/documentation/pages/docs/api/client.mdx @@ -229,6 +229,23 @@ 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 +``` + +#### pendingChanges + +Required: `false` + +When initializing secsync `pendingChanges` can be passed in. These changes will be synced as soon as a connection is established. The pendingChanges can be +received via the `onPendingChangesUpdated` callback. + #### loadDocumentParams Required: `false` diff --git a/documentation/pages/docs/other-examples/authorized-authors.mdx b/documentation/pages/docs/other-examples/authorized-authors.mdx new file mode 100644 index 0000000..e1ac6b5 --- /dev/null +++ b/documentation/pages/docs/other-examples/authorized-authors.mdx @@ -0,0 +1,24 @@ +## Authorized Authors Example + +This example demonstrates how to only allow a fixed set of authors. It leverages the `isValidClient` callback which will be invoked vor every update. In case all `snapshots`, `updates` and `ephemeralMessages` pass the `isValidClient` callback and `true` is returned all changes are accepted. In case `isValidClient` returns `false` or an error is thrown the change is rejected. Depending on the case it's simply ignored or the document ends up in a broken stage. More details can be found at [Error Handling](/docs/error-handling). + +Example: + +1. Select the first authorized author and add an item in the list +2. Open another tab and select the first non-authorized author and add an item in the list + +The entry will not be synced to the first tab and in the `DevTool` box below you can see the errors. You can also refresh and see how the document is reconstructed (except the last update was a snapshot). In this case the previous snapshot could be loaded, but that case is not handled in this example. + +NOTE: Choosing the same author in multiple clients will result in errors when trying to create update in parallel. + +import SimpleExampleWrapper from "../../../components/SimpleExampleWrapper"; +import { AuthorizedAuthorsExample } from "../../../components/AuthorizedAuthorsExample/AuthorizedAuthorsExample"; + + + +## Code + +The code for this example is located [here](https://github.com/serenity-kit/secsync/blob/main/documentation/components/AuthorizedAuthorsExample/AuthorizedAuthorsExample.tsx). diff --git a/documentation/pages/docs/other-examples/yjs-local-first.mdx b/documentation/pages/docs/other-examples/yjs-local-first.mdx new file mode 100644 index 0000000..3fd412f --- /dev/null +++ b/documentation/pages/docs/other-examples/yjs-local-first.mdx @@ -0,0 +1,17 @@ +## Yjs Local-first Example + +This example shows how to use secsync in a local-first setup. In this example the full document and the `pendingChanges` are stored in localStorage. In case the API is not available the changes won't be lost. Once re-connected to the API, Secsync will send the pending changes. + +This makes use of the `pendingChanges` parameter and `onPendingChangesUpdated` callback parameter. + +import SimpleExampleWrapper from "../../../components/SimpleExampleWrapper"; +import { YjsLocalFirstExample } from "../../../components/YjsLocalFirstExample/YjsLocalFirstExample"; + + + +## Code + +The code for this example is located [here](https://github.com/serenity-kit/secsync/blob/main/documentation/components/YjsLocalFirstExample/YjsLocalFirstExample.tsx). diff --git a/documentation/pages/docs/security_and_privacy/considerations.mdx b/documentation/pages/docs/security_and_privacy/considerations.mdx index c3b38dc..08c31e3 100644 --- a/documentation/pages/docs/security_and_privacy/considerations.mdx +++ b/documentation/pages/docs/security_and_privacy/considerations.mdx @@ -52,7 +52,7 @@ Secsync does not provide any metadata protection guarantees. ## Cryptographic Analysis -Security guarantees have been analyzed using [Verifpal](https://verifpal.com/) and can be found at [https://github.com/serenity-kit/secsync/tree/main/verifpal](https://github.com/serenity-kit/secsync/tree/main/verifpal). The proofs show the confidentiality and freshness between clients in two scenarios: +Security guarantees have been analyzed using [Verifpal](https://verifpal.com/) and can be found at [https://github.com/serenity-kit/secsync/tree/main/verifpal_and_threat_library](https://github.com/serenity-kit/secsync/tree/main/verifpal_and_threat_library). The proofs show the confidentiality and freshness between clients in two scenarios: 1. Client A sends a message to client B and the public key of each message is NOT verified to belong to a known client with access to the document. 1. Client A sends a message to client B and the public key of each message is verified to belong to a known client with access to the document. diff --git a/documentation/pages/docs/specification.mdx b/documentation/pages/docs/specification.mdx index e4d6e8e..8bfbba0 100644 --- a/documentation/pages/docs/specification.mdx +++ b/documentation/pages/docs/specification.mdx @@ -9,11 +9,8 @@ The public data of a message always contains the document ID in order to be able ## Encryption All messages use the AEAD xchacha20poly1305_ietf construction. The nonce is always public and must be included along with the ciphertext. -In order to commit to one (plaintext, AAD) pair matching only one (ciphertext, authentication tag) pair every message to be encrypted is prefixed with 4 NUL bytes. After every decryption the prefix is verified and an error thrown in case it's not valid. - -**TODO**: still needs to verified with a cryptographer if this technique and especially if 4 NUL bytes are sufficient -read more here https://soatok.blog/2023/04/03/asymmetric-cryptographic-commitments/#what-is-commitment and -here https://eprint.iacr.org/2019/016 +In order to commit to one (plaintext, AAD) pair matching only one (ciphertext, authentication tag) pair every message is prefixed with a commitment tag. +It is generated from an `HMAC(nonce + ciphertext + AAD, key)`. Before every decryption the commitment tag is verified and an error thrown in case it's not valid. While the AEAD construction already includes a MAC we additionally sign the data to verify its author. This is redundant and would not be necessary, but has been chosen to be able to rely on established constructions instead of implementing our own Signcryption like https://github.com/jedisct1/libsodium-signcryption. diff --git a/documentation/public/favicon.png b/documentation/public/favicon.png index 7600090..b711c96 100644 Binary files a/documentation/public/favicon.png and b/documentation/public/favicon.png differ diff --git a/documentation/public/favicon.svg b/documentation/public/favicon.svg index 4db38bd..8970b1e 100644 --- a/documentation/public/favicon.svg +++ b/documentation/public/favicon.svg @@ -1,4 +1,8 @@ - - - + + + + + + + diff --git a/documentation/public/secsync-logo.svg b/documentation/public/secsync-logo.svg new file mode 100644 index 0000000..397c0ae --- /dev/null +++ b/documentation/public/secsync-logo.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/documentation/theme.config.tsx b/documentation/theme.config.tsx index bb49978..01c4a4d 100644 --- a/documentation/theme.config.tsx +++ b/documentation/theme.config.tsx @@ -17,7 +17,7 @@ const config: DocsThemeConfig = { ), logo: ( // wrapper needed so it looks vertically centered in header -
+
), diff --git a/packages/secsync/CHANGELOG.md b/packages/secsync/CHANGELOG.md index 7e2537a..87dd2d1 100644 --- a/packages/secsync/CHANGELOG.md +++ b/packages/secsync/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- added `onPendingChangesUpdated` callback +- added `pendingChanges` config + +### Fixed + +- fix sync crashing due sending events to remove websocket actor + ## [0.4.0] - 2024-06-01 ### Changed diff --git a/packages/secsync/src/createSyncMachine.ts b/packages/secsync/src/createSyncMachine.ts index 5548d51..0146e49 100644 --- a/packages/secsync/src/createSyncMachine.ts +++ b/packages/secsync/src/createSyncMachine.ts @@ -1174,6 +1174,16 @@ export const createSyncMachine = () => { }; }, actions: { + invokePendingChangesUpdatedCallback: ({ context, 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 +1303,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 +1331,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 +1358,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( @@ -1406,6 +1416,9 @@ export const createSyncMachine = () => { shouldReconnect: ({ context }) => { return context._websocketShouldReconnect; }, + hasActiveWebsocket: ({ context }) => { + return Boolean(context._websocketActor); + }, }, }).createMachine({ context: ({ input }) => { @@ -1438,7 +1451,7 @@ export const createSyncMachine = () => { _snapshotInFlight: null, // it is needed so the the snapshotInFlight can be applied as the activeSnapshot once the server confirmed that it has been saved _incomingQueue: [], _customMessageQueue: [], - _pendingChangesQueue: [], + _pendingChangesQueue: input.pendingChanges || [], _snapshotInfosWithUpdateClocks: [], _websocketShouldReconnect: false, _websocketRetries: 0, @@ -1457,9 +1470,11 @@ export const createSyncMachine = () => { initial: "connecting", on: { SEND: { + guard: "hasActiveWebsocket", actions: forwardTo("websocketActor"), }, ADD_EPHEMERAL_MESSAGE: { + guard: "hasActiveWebsocket", actions: sendTo("websocketActor", ({ context, event }) => { return { type: "SEND_EPHEMERAL_MESSAGE", @@ -1510,7 +1525,10 @@ export const createSyncMachine = () => { target: "connected", }, ADD_CHANGES: { - actions: ["addToPendingUpdatesQueue"], + actions: [ + "addToPendingUpdatesQueue", + "invokePendingChangesUpdatedCallback", + ], }, }, }, @@ -1528,7 +1546,10 @@ export const createSyncMachine = () => { target: "processingQueues", }, ADD_CHANGES: { - actions: ["addToPendingUpdatesQueue"], + actions: [ + "addToPendingUpdatesQueue", + "invokePendingChangesUpdatedCallback", + ], target: "processingQueues", }, }, @@ -1542,7 +1563,10 @@ export const createSyncMachine = () => { actions: ["addToCustomMessageQueue"], }, ADD_CHANGES: { - actions: ["addToPendingUpdatesQueue"], + actions: [ + "addToPendingUpdatesQueue", + "invokePendingChangesUpdatedCallback", + ], }, }, invoke: { @@ -1574,27 +1598,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 +1678,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..1b4f793 100644 --- a/packages/secsync/src/types.ts +++ b/packages/secsync/src/types.ts @@ -154,9 +154,11 @@ export type SyncMachineConfig = { type: OnDocumentUpdatedEventType; knownSnapshotInfo: SnapshotInfoWithUpdateClocks; }) => void | Promise; + onPendingChangesUpdated?: (allChanges: any[]) => void; onCustomMessage?: (message: any) => Promise | void; loadDocumentParams?: LoadDocumentParams; additionalAuthenticationDataValidations?: AdditionalAuthenticationDataValidations; + pendingChanges?: any[]; /** default: "off" */ logging?: "off" | "error" | "debug"; };