diff --git a/examples/document/.gitignore b/examples/document/.gitignore
new file mode 100644
index 000000000..8fce60300
--- /dev/null
+++ b/examples/document/.gitignore
@@ -0,0 +1 @@
+data/
diff --git a/examples/document/README.md b/examples/document/README.md
new file mode 100644
index 000000000..a8e358c81
--- /dev/null
+++ b/examples/document/README.md
@@ -0,0 +1,54 @@
+# Document Editor Example
+
+[Github](https://github.com/canvasxyz/canvas/tree/main/examples/document)
+
+This example app demonstrates collaborative document editing using the CRDT provided by [Yjs](https://github.com/yjs/yjs). It allows users to collaboratively edit a single text document with persistence over libp2p.
+
+```ts
+export const models = {
+ documents: {
+ id: "primary",
+ content: "yjs-doc",
+ },
+}
+
+export const actions = {
+ async applyDeltaToDoc(db, index, text) {
+ await db.ytext.applyDelta("documents", "0", index, text)
+ },
+}
+```
+
+## Server
+
+Run `npm run dev:server` to start a temporary in-memory server, or
+`npm run start:server` to persist data to a `.cache` directory.
+
+To deploy the replication server:
+
+```
+$ cd server
+$ fly deploy
+```
+
+If you are forking this example, you should change:
+
+- the Fly app name
+- the `ANNOUNCE` environment variable to match your Fly app name
+
+## Running the Docker container locally
+
+Mount a volume to `/data`. Set the `PORT`, `LISTEN`, `ANNOUNCE`, and
+`BOOTSTRAP_LIST` environment variables if appropriate.
+
+## Deploying to Railway
+
+Create a Railway space based on the root of this Github workspace (e.g. canvasxyz/canvas).
+
+- Custom build command: `npm run build && VITE_CANVAS_WS_URL=wss://chat-example.canvas.xyz npm run build --workspace=@canvas-js/example-chat`
+- Custom start command: `./install-prod.sh && canvas run /tmp/canvas-example-chat --port 8080 --static examples/chat/dist --topic chat-example.canvas.xyz --init examples/chat/src/contract.ts`
+- Watch paths: `/examples/chat/**`
+- Public networking:
+ - Add a service domain for port 8080.
+ - Add a service domain for port 4444.
+- Watch path: `/examples/chat/**`. (Only build when chat code is updated, or a chat package is updated.)
diff --git a/examples/document/index.html b/examples/document/index.html
new file mode 100644
index 000000000..0aa3300d3
--- /dev/null
+++ b/examples/document/index.html
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+ Canvas Document Editor
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/document/package.json b/examples/document/package.json
new file mode 100644
index 000000000..1769190b2
--- /dev/null
+++ b/examples/document/package.json
@@ -0,0 +1,51 @@
+{
+ "private": true,
+ "name": "@canvas-js/document",
+ "version": "0.14.0-next.1",
+ "type": "module",
+ "scripts": {
+ "build": "npx vite build",
+ "dev": "npx vite",
+ "dev:server": "canvas run --port 8080 --static dist --network-explorer --topic document-example.canvas.xyz src/contract.ts"
+ },
+ "dependencies": {
+ "@canvas-js/chain-atp": "0.14.0-next.1",
+ "@canvas-js/chain-cosmos": "0.14.0-next.1",
+ "@canvas-js/chain-ethereum": "0.14.0-next.1",
+ "@canvas-js/chain-ethereum-viem": "0.14.0-next.1",
+ "@canvas-js/chain-solana": "0.14.0-next.1",
+ "@canvas-js/chain-substrate": "0.14.0-next.1",
+ "@canvas-js/cli": "0.14.0-next.1",
+ "@canvas-js/core": "0.14.0-next.1",
+ "@canvas-js/gossiplog": "0.14.0-next.1",
+ "@canvas-js/hooks": "0.14.0-next.1",
+ "@canvas-js/interfaces": "0.14.0-next.1",
+ "@canvas-js/modeldb-sqlite-wasm": "0.14.0-next.1",
+ "@farcaster/auth-kit": "^0.6.0",
+ "@farcaster/frame-sdk": "^0.0.26",
+ "@libp2p/interface": "^2.7.0",
+ "@multiformats/multiaddr": "^12.3.5",
+ "@noble/hashes": "^1.7.1",
+ "@types/react": "^18.3.9",
+ "@types/react-dom": "^18.3.0",
+ "buffer": "^6.0.3",
+ "comlink": "^4.4.1",
+ "ethers": "^6.13.5",
+ "idb": "^8.0.2",
+ "multiformats": "^13.3.2",
+ "near-api-js": "^2.1.4",
+ "process": "^0.11.10",
+ "quill": "^2.0.3",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "vite-plugin-wasm": "^3.3.0"
+ },
+ "devDependencies": {
+ "autoprefixer": "^10.4.19",
+ "postcss": "^8.4.38",
+ "tailwindcss": "^3.4.4",
+ "typescript": "~5.6.0",
+ "vite": "^5.4.8",
+ "vite-plugin-node-polyfills": "^0.22.0"
+ }
+}
diff --git a/examples/document/postcss.config.js b/examples/document/postcss.config.js
new file mode 100644
index 000000000..1a5262473
--- /dev/null
+++ b/examples/document/postcss.config.js
@@ -0,0 +1,6 @@
+export default {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+}
diff --git a/examples/document/src/App.tsx b/examples/document/src/App.tsx
new file mode 100644
index 000000000..15e78d1d7
--- /dev/null
+++ b/examples/document/src/App.tsx
@@ -0,0 +1,99 @@
+import React, { useRef, useState } from "react"
+
+import type { SessionSigner } from "@canvas-js/interfaces"
+import { Eip712Signer, SIWESigner, SIWFSigner } from "@canvas-js/chain-ethereum"
+import { ATPSigner } from "@canvas-js/chain-atp"
+import { CosmosSigner } from "@canvas-js/chain-cosmos"
+import { SolanaSigner } from "@canvas-js/chain-solana"
+import { SubstrateSigner } from "@canvas-js/chain-substrate"
+
+import { useCanvas } from "@canvas-js/hooks"
+
+import { AuthKitProvider } from "@farcaster/auth-kit"
+import { JsonRpcProvider } from "ethers"
+
+import { AppContext } from "./AppContext.js"
+import { ControlPanel } from "./ControlPanel.js"
+import { SessionStatus } from "./SessionStatus.js"
+import { ConnectEIP712Burner } from "./connect/ConnectEIP712Burner.js"
+import { ConnectionStatus } from "./ConnectionStatus.js"
+import { LogStatus } from "./LogStatus.js"
+import * as contract from "./contract.js"
+import { Editor } from "./Editor.js"
+import { useQuill } from "./useQuill.js"
+
+export const topic = "document-example.canvas.xyz"
+
+const wsURL = import.meta.env.VITE_CANVAS_WS_URL ?? null
+console.log("websocket API URL:", wsURL)
+
+const config = {
+ // For a production app, replace this with an Optimism Mainnet
+ // RPC URL from a provider like Alchemy or Infura.
+ relay: "https://relay.farcaster.xyz",
+ rpcUrl: "https://mainnet.optimism.io",
+ domain: "document-example.canvas.xyz",
+ siweUri: "https://document-example.canvas.xyz",
+ provider: new JsonRpcProvider(undefined, 10),
+}
+
+export const App: React.FC<{}> = ({}) => {
+ const [sessionSigner, setSessionSigner] = useState(null)
+ const [address, setAddress] = useState(null)
+
+ const topicRef = useRef(topic)
+
+ const { app, ws } = useCanvas(wsURL, {
+ topic: topicRef.current,
+ contract,
+ signers: [
+ new SIWESigner(),
+ new Eip712Signer(),
+ new SIWFSigner(),
+ new ATPSigner(),
+ new CosmosSigner(),
+ new SubstrateSigner({}),
+ new SolanaSigner(),
+ ],
+ })
+
+ const quillRef = useQuill({ modelName: "documents", modelKey: "0", app })
+
+ return (
+
+
+ {app && ws ? (
+
+
+
+ {}}
+ onTextChange={async (delta, oldContents, source) => {
+ if (source === "user") {
+ await app.actions.applyDeltaToDoc(JSON.stringify(delta))
+ }
+ }}
+ />
+
+
+
+
+
+
+
+
+
+
+ ) : (
+ Connecting to {wsURL}...
+ )}
+
+
+ )
+}
diff --git a/examples/document/src/AppContext.ts b/examples/document/src/AppContext.ts
new file mode 100644
index 000000000..2e4ec4586
--- /dev/null
+++ b/examples/document/src/AppContext.ts
@@ -0,0 +1,28 @@
+import { createContext } from "react"
+
+import type { SessionSigner } from "@canvas-js/interfaces"
+import { Canvas } from "@canvas-js/core"
+
+export type AppContext = {
+ app: Canvas | null
+
+ address: string | null
+ setAddress: (address: string | null) => void
+
+ sessionSigner: SessionSigner | null
+ setSessionSigner: (signer: SessionSigner | null) => void
+}
+
+export const AppContext = createContext({
+ app: null,
+
+ address: null,
+ setAddress: (address: string | null) => {
+ throw new Error("AppContext.Provider not found")
+ },
+
+ sessionSigner: null,
+ setSessionSigner: (signer) => {
+ throw new Error("AppContext.Provider not found")
+ },
+})
diff --git a/examples/document/src/ConnectionStatus.tsx b/examples/document/src/ConnectionStatus.tsx
new file mode 100644
index 000000000..08aba6381
--- /dev/null
+++ b/examples/document/src/ConnectionStatus.tsx
@@ -0,0 +1,90 @@
+import React, { useContext, useEffect, useState } from "react"
+
+import type { Canvas, NetworkClient } from "@canvas-js/core"
+
+import { AppContext } from "./AppContext.js"
+
+export interface ConnectionStatusProps {
+ topic: string
+ ws: NetworkClient
+}
+
+export const ConnectionStatus: React.FC = ({ topic, ws }) => {
+ const { app } = useContext(AppContext)
+
+ const [, setTick] = useState(0)
+ useEffect(() => {
+ const timer = setInterval(() => {
+ setTick(t => t + 1)
+ }, 1000)
+ return () => clearInterval(timer)
+ }, [])
+
+ if (app === null) {
+ return null
+ }
+
+ return (
+
+
+ Topic
+
+
+ {topic}
+
+
+
+
+ Connection
+
+
+ {import.meta.env.VITE_CANVAS_WS_URL}
+ ({ws.isConnected() ? 'Connected' : 'Disconnected'})
+
+
+ )
+}
+
+interface ConnectionListProps {
+ app: Canvas
+}
+
+// const ConnectionList: React.FC = ({ app }) => {
+// const [peers, setPeers] = useState([])
+
+// useEffect(() => {
+// if (app === null) {
+// return
+// }
+
+// const handleConnectionOpen = ({ detail: { peer } }: CustomEvent<{ peer: string }>) =>
+// void setPeers((peers) => [...peers, peer])
+
+// const handleConnectionClose = ({ detail: { peer } }: CustomEvent<{ peer: string }>) =>
+// void setPeers((peers) => peers.filter((id) => id !== peer))
+
+// app.messageLog.addEventListener("connect", handleConnectionOpen)
+// app.messageLog.addEventListener("disconnect", handleConnectionClose)
+
+// return () => {
+// app.messageLog.removeEventListener("connect", handleConnectionOpen)
+// app.messageLog.removeEventListener("disconnect", handleConnectionClose)
+// }
+// }, [app])
+
+// if (peers.length === 0) {
+// return No connections
+// } else {
+// return (
+//
+// {peers.map((peer) => {
+// return (
+//
+// {peer}
+//
+// )
+// })}
+//
+// )
+// }
+// }
diff --git a/examples/document/src/ControlPanel.tsx b/examples/document/src/ControlPanel.tsx
new file mode 100644
index 000000000..4d2d8ecc9
--- /dev/null
+++ b/examples/document/src/ControlPanel.tsx
@@ -0,0 +1,120 @@
+import React, { useCallback, useContext, useState } from "react"
+import { deleteDB } from "idb"
+import { bytesToHex, randomBytes } from "@noble/hashes/utils"
+
+import { AppContext } from "./AppContext.js"
+
+export interface ControlPanelProps {}
+
+// this is used for debugging in development
+// when the database schema changes between page loads
+// @ts-ignore
+window.deleteDB = deleteDB
+
+export const ControlPanel: React.FC = ({}) => {
+ const { app, sessionSigner } = useContext(AppContext)
+
+ const [isStarted, setIsStarted] = useState(false)
+
+ const start = useCallback(async () => {
+ if (app === null) {
+ return
+ }
+
+ // try {
+ // await app.libp2p.start()
+ // setIsStarted(true)
+ // } catch (err) {
+ // console.error(err)
+ // }
+ }, [app])
+
+ const stop = useCallback(async () => {
+ if (app === null) {
+ return
+ }
+
+ // try {
+ // await app.libp2p.stop()
+ // setIsStarted(false)
+ // } catch (err) {
+ // console.error(err)
+ // }
+ }, [app])
+
+ const clear = useCallback(async () => {
+ if (app === null) {
+ return
+ }
+
+ await app.stop()
+
+ console.log("deleting database")
+ await deleteDB(`canvas/v1/${app.topic}`, {})
+
+ console.log("clearing session signer data", sessionSigner)
+ await sessionSigner?.clear?.(app.topic)
+
+ window.location.reload()
+ }, [app, sessionSigner])
+
+ const spam = useCallback(async () => {
+ if (app === null || sessionSigner === null) {
+ return
+ }
+
+ for (let i = 0; i < 100; i++) {
+ const content = bytesToHex(randomBytes(8))
+ await app.as(sessionSigner).createMessage(content)
+ }
+ }, [app, sessionSigner])
+
+ const button = `p-2 border rounded flex`
+ const disabled = `bg-gray-100 text-gray-500 hover:cursor-not-allowed`
+ const enabledGreen = `bg-green-100 active:bg-green-300 hover:cursor-pointer hover:bg-green-200`
+ const enabledRed = `bg-red-100 active:bg-red-300 hover:cursor-pointer hover:bg-red-200`
+ const enabledYellow = `bg-yellow-100 active:bg-yellow-300 hover:cursor-pointer hover:bg-yellow-200`
+
+ if (app === null) {
+ return (
+
+ {/*
+ Spam
+ */}
+
+ Clear data
+
+
+ )
+ } else if (isStarted) {
+ return (
+
+ {/* spam()}
+ className={`${button} ${sessionSigner === null ? disabled : enabledYellow}`}
+ >
+ Spam
+ */}
+
+ Clear data
+
+
+ )
+ } else {
+ return (
+
+ {/* spam()}
+ className={`${button} ${sessionSigner === null ? disabled : enabledYellow}`}
+ >
+ Spam
+ */}
+ clear()} className={`${button} ${enabledRed}`}>
+ Clear data
+
+
+ )
+ }
+}
diff --git a/examples/document/src/Editor.tsx b/examples/document/src/Editor.tsx
new file mode 100644
index 000000000..6cf9648be
--- /dev/null
+++ b/examples/document/src/Editor.tsx
@@ -0,0 +1,61 @@
+import { Canvas } from "@canvas-js/core"
+import Quill, { EmitterSource } from "quill"
+import "quill/dist/quill.snow.css"
+import React, { forwardRef, useEffect, useLayoutEffect, useRef } from "react"
+
+type EditorProps = {
+ readOnly: boolean
+ defaultValue: any
+ onTextChange: (delta: any, oldDelta: any, source: EmitterSource) => void
+ onSelectionChange: (delta: any, oldDelta: any, source: EmitterSource) => void
+}
+
+// Editor is an uncontrolled React component
+export const Editor = forwardRef(({ readOnly, defaultValue, onTextChange, onSelectionChange }: EditorProps, ref) => {
+ const containerRef = useRef(null)
+ const defaultValueRef = useRef(defaultValue)
+ const onTextChangeRef = useRef(onTextChange)
+ const onSelectionChangeRef = useRef(onSelectionChange)
+
+ useLayoutEffect(() => {
+ onTextChangeRef.current = onTextChange
+ onSelectionChangeRef.current = onSelectionChange
+ })
+
+ useEffect(() => {
+ // @ts-ignore
+ ref.current?.enable(!readOnly)
+ }, [ref, readOnly])
+
+ useEffect(() => {
+ const container = containerRef.current
+ if (!container || !ref) return
+ const editorContainer = container.appendChild(container.ownerDocument.createElement("div"))
+ const quill = new Quill(editorContainer, {
+ theme: "snow",
+ })
+
+ // @ts-ignore
+ ref.current = quill
+
+ if (defaultValueRef.current) {
+ quill.setContents(defaultValueRef.current)
+ }
+
+ quill.on(Quill.events.TEXT_CHANGE, (...args) => {
+ onTextChangeRef.current?.(...args)
+ })
+
+ quill.on(Quill.events.SELECTION_CHANGE, (...args) => {
+ onSelectionChangeRef.current?.(...args)
+ })
+
+ return () => {
+ // @ts-ignore
+ ref.current = undefined
+ container.innerHTML = ""
+ }
+ }, [ref])
+
+ return
+})
diff --git a/examples/document/src/LogStatus.tsx b/examples/document/src/LogStatus.tsx
new file mode 100644
index 000000000..811350d24
--- /dev/null
+++ b/examples/document/src/LogStatus.tsx
@@ -0,0 +1,67 @@
+import React, { useContext, useEffect, useState } from "react"
+import { bytesToHex } from "@noble/hashes/utils"
+
+import type { CanvasEvents } from "@canvas-js/core"
+import { MessageId } from "@canvas-js/gossiplog"
+
+import { AppContext } from "./AppContext.js"
+
+export interface LogStatusProps {}
+
+export const LogStatus: React.FC = ({}) => {
+ const { app } = useContext(AppContext)
+
+ const [root, setRoot] = useState(null)
+ const [heads, setHeads] = useState(null)
+ useEffect(() => {
+ if (app === null) {
+ return
+ }
+
+ app.messageLog.tree.read((txn) => txn.getRoot()).then((root) => setRoot(`${root.level}:${bytesToHex(root.hash)}`))
+ app.db
+ .getAll<{ id: string }>("$heads")
+ .then((records) => setHeads(records.map((record) => MessageId.encode(record.id))))
+
+ const handleCommit = ({ detail: { root, heads } }: CanvasEvents["commit"]) => {
+ const rootValue = `${root.level}:${bytesToHex(root.hash)}`
+ setRoot(rootValue)
+ setHeads(heads.map(MessageId.encode))
+ }
+
+ app.addEventListener("commit", handleCommit)
+ return () => app.removeEventListener("commit", handleCommit)
+ }, [app])
+
+ if (app === null) {
+ return null
+ }
+
+ return (
+
+
+ Merkle root
+
+
+ {root !== null ? {root}
: none }
+
+
+ Message heads
+
+
+ {heads !== null ? (
+
+ {heads.map((head) => (
+
+ {head.id}
+ (clock: {head.clock})
+
+ ))}
+
+ ) : (
+
none
+ )}
+
+
+ )
+}
diff --git a/examples/document/src/SessionStatus.tsx b/examples/document/src/SessionStatus.tsx
new file mode 100644
index 000000000..189484c19
--- /dev/null
+++ b/examples/document/src/SessionStatus.tsx
@@ -0,0 +1,85 @@
+import React, { useContext, useMemo } from "react"
+import { useLiveQuery } from "@canvas-js/hooks"
+import { DeriveModelTypes } from "@canvas-js/modeldb"
+
+import { AppContext } from "./AppContext.js"
+import { AddressView } from "./components/AddressView.js"
+
+const sessionSchema = {
+ $sessions: {
+ message_id: "primary",
+ did: "string",
+ public_key: "string",
+ address: "string",
+ expiration: "integer?",
+ // $indexes: [["did"], ["public_key"]],
+ },
+} as const
+
+export interface SessionStatusProps {}
+
+export const SessionStatus: React.FC = ({}) => {
+ const { address } = useContext(AppContext)
+ if (address === null) {
+ return null
+ }
+
+ return (
+
+
+ Address
+
+
+
+
+ Sessions
+
+
+
+ )
+}
+
+interface SessionListProps {
+ address: string
+}
+
+const SessionList: React.FC = ({ address }) => {
+ const { app } = useContext(AppContext)
+
+ const timestamp = useMemo(() => Date.now(), [])
+
+ const results = useLiveQuery(app, "$sessions", {
+ where: { address, expiration: { gt: timestamp } },
+ })
+
+ if (results === null) {
+ return null
+ } else if (results.length === 0) {
+ return No sessions
+ } else {
+ return (
+
+ )
+ }
+}
diff --git a/examples/document/src/components/AddressView.tsx b/examples/document/src/components/AddressView.tsx
new file mode 100644
index 000000000..e051a4a91
--- /dev/null
+++ b/examples/document/src/components/AddressView.tsx
@@ -0,0 +1,16 @@
+import React from "react"
+
+export interface AddressViewProps {
+ className?: string
+ address: string
+}
+
+export const AddressView: React.FC = (props) => {
+ const className = props.className ?? "text-sm"
+ return (
+
+ {/* {props.address.slice(0, 6)}…{props.address.slice(-4)} */}
+ {props.address}
+
+ )
+}
diff --git a/examples/document/src/components/MultiaddrView.tsx b/examples/document/src/components/MultiaddrView.tsx
new file mode 100644
index 000000000..30a7073ae
--- /dev/null
+++ b/examples/document/src/components/MultiaddrView.tsx
@@ -0,0 +1,22 @@
+import React from "react"
+
+import type { PeerId } from "@libp2p/interface"
+import type { Multiaddr } from "@multiformats/multiaddr"
+
+export interface MultiaddrViewProps {
+ addr: Multiaddr
+ peerId?: PeerId
+}
+
+export const MultiaddrView: React.FC = (props) => {
+ let address = props.addr.toString()
+ if (props.peerId && address.endsWith(`/p2p/${props.peerId}`)) {
+ address = address.slice(0, address.lastIndexOf("/p2p/"))
+ }
+
+ if (address.endsWith("/p2p-circuit/webrtc")) {
+ return /webrtc
+ } else {
+ return {address}
+ }
+}
diff --git a/examples/document/src/components/PeerIdView.tsx b/examples/document/src/components/PeerIdView.tsx
new file mode 100644
index 000000000..1d523e773
--- /dev/null
+++ b/examples/document/src/components/PeerIdView.tsx
@@ -0,0 +1,19 @@
+import React from "react"
+
+import type { PeerId } from "@libp2p/interface"
+
+export interface PeerIdViewProps {
+ className?: string
+ peerId: PeerId
+}
+
+export const PeerIdView: React.FC = (props) => {
+ const className = props.className ?? "text-sm"
+ const id = props.peerId.toString()
+ return (
+
+ {/* {id.slice(0, 12)}…{id.slice(-4)} */}
+ {id}
+
+ )
+}
diff --git a/examples/document/src/connect/ConnectEIP712Burner.tsx b/examples/document/src/connect/ConnectEIP712Burner.tsx
new file mode 100644
index 000000000..2e5cd987f
--- /dev/null
+++ b/examples/document/src/connect/ConnectEIP712Burner.tsx
@@ -0,0 +1,62 @@
+import React, { useCallback, useContext, useState } from "react"
+import { Eip1193Provider, EventEmitterable } from "ethers"
+
+import { Eip712Signer } from "@canvas-js/chain-ethereum"
+
+import { AppContext } from "../AppContext.js"
+
+declare global {
+ // eslint-disable-next-line no-var
+ var ethereum: undefined | null | (Eip1193Provider & EventEmitterable<"accountsChanged" | "chainChanged">)
+}
+
+export interface ConnectEIP712BurnerProps {}
+
+export const ConnectEIP712Burner: React.FC = ({}) => {
+ const { app, sessionSigner, setSessionSigner, address, setAddress } = useContext(AppContext)
+
+ const [error, setError] = useState(null)
+
+ const connect = useCallback(async () => {
+ if (app === null) {
+ setError(new Error("app not initialized"))
+ return
+ }
+
+ const signer = new Eip712Signer()
+ const address = await signer.getDid()
+ setAddress(address)
+ setSessionSigner(signer)
+ }, [app])
+
+ const disconnect = useCallback(async () => {
+ setAddress(null)
+ setSessionSigner(null)
+ }, [sessionSigner])
+
+ if (error !== null) {
+ return (
+
+ {error.message}
+
+ )
+ } else if (address !== null && sessionSigner instanceof Eip712Signer) {
+ return (
+ disconnect()}
+ className="p-2 border rounded hover:cursor-pointer hover:bg-gray-100 active:bg-gray-200"
+ >
+ Disconnect burner wallet
+
+ )
+ } else {
+ return (
+ connect()}
+ className="p-2 border rounded hover:cursor-pointer hover:bg-gray-100 active:bg-gray-200"
+ >
+ Connect burner wallet
+
+ )
+ }
+}
diff --git a/examples/document/src/contract.ts b/examples/document/src/contract.ts
new file mode 100644
index 000000000..5d63ed928
--- /dev/null
+++ b/examples/document/src/contract.ts
@@ -0,0 +1,14 @@
+import type { Actions, ModelSchema } from "@canvas-js/core"
+
+export const models = {
+ documents: {
+ id: "primary",
+ content: "yjs-doc",
+ },
+} satisfies ModelSchema
+
+export const actions = {
+ async applyDeltaToDoc(db, delta) {
+ await db.ytext.applyDelta("documents", "0", delta)
+ },
+} satisfies Actions
diff --git a/examples/document/src/index.tsx b/examples/document/src/index.tsx
new file mode 100644
index 000000000..a2a1b0c67
--- /dev/null
+++ b/examples/document/src/index.tsx
@@ -0,0 +1,14 @@
+import React from "react"
+import ReactDOM from "react-dom/client"
+
+import "../styles.css"
+
+import { App } from "./App.js"
+
+const root = ReactDOM.createRoot(document.getElementById("root")!)
+
+root.render(
+
+
+ ,
+)
diff --git a/examples/document/src/useQuill.ts b/examples/document/src/useQuill.ts
new file mode 100644
index 000000000..7fc72a4ae
--- /dev/null
+++ b/examples/document/src/useQuill.ts
@@ -0,0 +1,89 @@
+import { Canvas } from "@canvas-js/core"
+import { AbstractModelDB } from "@canvas-js/modeldb"
+import Quill from "quill"
+import { useEffect, useRef } from "react"
+
+export const useQuill = ({
+ modelName,
+ modelKey,
+ app,
+}: {
+ modelName: string
+ modelKey: string
+ app: Canvas | undefined
+}) => {
+ const db = app?.db
+ const dbRef = useRef(db ?? null)
+ const subscriptionRef = useRef(null)
+ const quillRef = useRef()
+
+ const cursorRef = useRef(-1)
+
+ useEffect(() => {
+ // Unsubscribe from the cached database handle, if necessary
+ if (
+ !app ||
+ db === null ||
+ modelName === null ||
+ db === undefined ||
+ modelName === undefined ||
+ modelKey === undefined
+ ) {
+ if (dbRef.current !== null) {
+ if (subscriptionRef.current !== null) {
+ dbRef.current.unsubscribe(subscriptionRef.current)
+ subscriptionRef.current = null
+ }
+ }
+
+ dbRef.current = db ?? null
+ return
+ }
+
+ if (dbRef.current === db && subscriptionRef.current !== null) {
+ return
+ }
+
+ if (dbRef.current !== null && subscriptionRef.current !== null) {
+ db.unsubscribe(subscriptionRef.current)
+ }
+
+ // set the initial value
+ const initialContents = app.getYDoc(modelName, modelKey).getText().toDelta()
+ quillRef.current?.updateContents(initialContents)
+
+ // get the initial value for cursorRef
+ const startId = app.getYDocId(modelName, modelKey)
+ const query = {
+ where: {
+ model: modelName,
+ key: modelKey,
+ isAppend: false,
+ id: { gt: startId },
+ },
+ limit: 1,
+ orderBy: { id: "desc" as const },
+ }
+
+ const { id } = db.subscribe("$document_updates", query, (results) => {
+ for (const result of results) {
+ const resultId = result.id as number
+ if (cursorRef.current < resultId) {
+ cursorRef.current = resultId
+ quillRef.current?.updateContents(result.data)
+ }
+ }
+ })
+ dbRef.current = db
+
+ subscriptionRef.current = id
+
+ return () => {
+ if (dbRef.current !== null && subscriptionRef.current !== null) dbRef.current.unsubscribe(subscriptionRef.current)
+ dbRef.current = null
+ subscriptionRef.current = null
+ }
+ }, [(db as any)?.isProxy ? null : db, modelKey, modelName])
+
+ return quillRef
+}
diff --git a/examples/document/styles.css b/examples/document/styles.css
new file mode 100644
index 000000000..fd0d1983e
--- /dev/null
+++ b/examples/document/styles.css
@@ -0,0 +1,17 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+html,
+body,
+#root {
+ margin: 0;
+ width: 100%;
+ height: 100%;
+}
+
+main {
+ height: 100%;
+ max-width: 960px;
+ padding: 2em;
+}
diff --git a/examples/document/tailwind.config.js b/examples/document/tailwind.config.js
new file mode 100644
index 000000000..719ea4b3d
--- /dev/null
+++ b/examples/document/tailwind.config.js
@@ -0,0 +1,4 @@
+export default {
+ content: ["./src/**/*.{html,js,tsx}"],
+ plugins: [],
+}
diff --git a/examples/document/tsconfig.json b/examples/document/tsconfig.json
new file mode 100644
index 000000000..d21902116
--- /dev/null
+++ b/examples/document/tsconfig.json
@@ -0,0 +1,19 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "include": ["src"],
+ "compilerOptions": {
+ "types": ["vite/client"],
+ "rootDir": "src",
+ "outDir": "lib",
+ "esModuleInterop": true,
+ "jsx": "react"
+ },
+ "references": [
+ { "path": "../../packages/core" },
+ { "path": "../../packages/chain-ethereum" },
+ { "path": "../../packages/gossiplog" },
+ { "path": "../../packages/hooks" },
+ { "path": "../../packages/interfaces" },
+ { "path": "../../packages/modeldb" }
+ ]
+}
diff --git a/examples/document/vite.config.js b/examples/document/vite.config.js
new file mode 100644
index 000000000..8cafc7b4d
--- /dev/null
+++ b/examples/document/vite.config.js
@@ -0,0 +1,28 @@
+import { defineConfig } from "vite"
+import wasm from "vite-plugin-wasm"
+import { nodePolyfills } from "vite-plugin-node-polyfills"
+
+export default defineConfig({
+ // ...other config settings
+ plugins: [nodePolyfills({ globals: { Buffer: true } }), wasm()],
+ server: {
+ headers: {
+ "Cross-Origin-Opener-Policy": "same-origin",
+ "Cross-Origin-Embedder-Policy": "require-corp",
+ },
+ },
+ build: {
+ minify: false,
+ target: "es2022",
+ },
+ optimizeDeps: {
+ exclude: ["@sqlite.org/sqlite-wasm", "quickjs-emscripten"],
+ esbuildOptions: {
+ // Node.js global to browser globalThis
+ define: {
+ global: "globalThis",
+ },
+ },
+ },
+ publicDir: 'public'
+})
diff --git a/package-lock.json b/package-lock.json
index 7096d18e2..c8b2696be 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -153,6 +153,61 @@
"url": "https://paulmillr.com/funding/"
}
},
+ "examples/document": {
+ "name": "@canvas-js/document",
+ "version": "0.14.0-next.1",
+ "dependencies": {
+ "@canvas-js/chain-atp": "0.14.0-next.1",
+ "@canvas-js/chain-cosmos": "0.14.0-next.1",
+ "@canvas-js/chain-ethereum": "0.14.0-next.1",
+ "@canvas-js/chain-ethereum-viem": "0.14.0-next.1",
+ "@canvas-js/chain-solana": "0.14.0-next.1",
+ "@canvas-js/chain-substrate": "0.14.0-next.1",
+ "@canvas-js/cli": "0.14.0-next.1",
+ "@canvas-js/core": "0.14.0-next.1",
+ "@canvas-js/gossiplog": "0.14.0-next.1",
+ "@canvas-js/hooks": "0.14.0-next.1",
+ "@canvas-js/interfaces": "0.14.0-next.1",
+ "@canvas-js/modeldb-sqlite-wasm": "0.14.0-next.1",
+ "@farcaster/auth-kit": "^0.6.0",
+ "@farcaster/frame-sdk": "^0.0.26",
+ "@libp2p/interface": "^2.7.0",
+ "@multiformats/multiaddr": "^12.3.5",
+ "@noble/hashes": "^1.7.1",
+ "@types/react": "^18.3.9",
+ "@types/react-dom": "^18.3.0",
+ "buffer": "^6.0.3",
+ "comlink": "^4.4.1",
+ "ethers": "^6.13.5",
+ "idb": "^8.0.2",
+ "multiformats": "^13.3.2",
+ "near-api-js": "^2.1.4",
+ "process": "^0.11.10",
+ "quill": "^2.0.3",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "vite-plugin-wasm": "^3.3.0"
+ },
+ "devDependencies": {
+ "autoprefixer": "^10.4.19",
+ "postcss": "^8.4.38",
+ "tailwindcss": "^3.4.4",
+ "typescript": "~5.6.0",
+ "vite": "^5.4.8",
+ "vite-plugin-node-polyfills": "^0.22.0"
+ }
+ },
+ "examples/document/node_modules/@noble/hashes": {
+ "version": "1.7.1",
+ "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.7.1.tgz",
+ "integrity": "sha512-B8XBPsn4vT/KJAGqDzbwztd+6Yte3P4V7iafm24bxgDe/mlRuK6xmWPuCNrKt2vDafZ8MfJLlchDG/vYafQEjQ==",
+ "engines": {
+ "node": "^14.21.3 || >=16"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
"examples/encrypted-chat": {
"name": "@canvas-js/example-chat-encrypted",
"version": "0.14.0-next.1",
@@ -2784,6 +2839,10 @@
"resolved": "packages/core",
"link": true
},
+ "node_modules/@canvas-js/document": {
+ "resolved": "examples/document",
+ "link": true
+ },
"node_modules/@canvas-js/ethereum-contracts": {
"resolved": "packages/ethereum-contracts",
"link": true
@@ -20094,7 +20153,6 @@
},
"node_modules/fast-diff": {
"version": "1.3.0",
- "dev": true,
"license": "Apache-2.0"
},
"node_modules/fast-fifo": {
@@ -22719,6 +22777,15 @@
"ws": "*"
}
},
+ "node_modules/isomorphic.js": {
+ "version": "0.2.5",
+ "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz",
+ "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==",
+ "funding": {
+ "type": "GitHub Sponsors ❤",
+ "url": "https://github.com/sponsors/dmonad"
+ }
+ },
"node_modules/isows": {
"version": "1.0.6",
"funding": [
@@ -24376,6 +24443,26 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/lib0": {
+ "version": "0.2.99",
+ "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.99.tgz",
+ "integrity": "sha512-vwztYuUf1uf/1zQxfzRfO5yzfNKhTtgOByCruuiQQxWQXnPb8Itaube5ylofcV0oM0aKal9Mv+S1s1Ky0UYP1w==",
+ "dependencies": {
+ "isomorphic.js": "^0.2.4"
+ },
+ "bin": {
+ "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js",
+ "0gentesthtml": "bin/gentesthtml.js",
+ "0serve": "bin/0serve.js"
+ },
+ "engines": {
+ "node": ">=16"
+ },
+ "funding": {
+ "type": "GitHub Sponsors ❤",
+ "url": "https://github.com/sponsors/dmonad"
+ }
+ },
"node_modules/libp2p": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/libp2p/-/libp2p-2.8.0.tgz",
@@ -24591,6 +24678,11 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/lodash.clonedeep": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
+ "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ=="
+ },
"node_modules/lodash.debounce": {
"version": "4.0.8",
"license": "MIT",
@@ -27440,6 +27532,11 @@
"dev": true,
"license": "(MIT AND Zlib)"
},
+ "node_modules/parchment": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz",
+ "integrity": "sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A=="
+ },
"node_modules/parent-module": {
"version": "1.0.1",
"license": "MIT",
@@ -28878,6 +28975,33 @@
"@jitl/quickjs-ffi-types": "0.31.0"
}
},
+ "node_modules/quill": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/quill/-/quill-2.0.3.tgz",
+ "integrity": "sha512-xEYQBqfYx/sfb33VJiKnSJp8ehloavImQ2A6564GAbqG55PGw1dAWUn1MUbQB62t0azawUS2CZZhWCjO8gRvTw==",
+ "dependencies": {
+ "eventemitter3": "^5.0.1",
+ "lodash-es": "^4.17.21",
+ "parchment": "^3.0.0",
+ "quill-delta": "^5.1.0"
+ },
+ "engines": {
+ "npm": ">=8.2.3"
+ }
+ },
+ "node_modules/quill-delta": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz",
+ "integrity": "sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==",
+ "dependencies": {
+ "fast-diff": "^1.3.0",
+ "lodash.clonedeep": "^4.5.0",
+ "lodash.isequal": "^4.5.0"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ }
+ },
"node_modules/race-event": {
"version": "1.3.0",
"license": "Apache-2.0 OR MIT"
@@ -36177,6 +36301,22 @@
"fd-slicer": "~1.1.0"
}
},
+ "node_modules/yjs": {
+ "version": "13.6.23",
+ "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.23.tgz",
+ "integrity": "sha512-ExtnT5WIOVpkL56bhLeisG/N5c4fmzKn4k0ROVfJa5TY2QHbH7F0Wu2T5ZhR7ErsFWQEFafyrnSI8TPKVF9Few==",
+ "dependencies": {
+ "lib0": "^0.2.99"
+ },
+ "engines": {
+ "node": ">=16.0.0",
+ "npm": ">=8.0.0"
+ },
+ "funding": {
+ "type": "GitHub Sponsors ❤",
+ "url": "https://github.com/sponsors/dmonad"
+ }
+ },
"node_modules/yn": {
"version": "3.1.1",
"devOptional": true,
@@ -36642,7 +36782,8 @@
"quickjs-emscripten": "^0.31.0",
"uint8arraylist": "^2.4.8",
"uint8arrays": "^5.1.0",
- "ws": "^8.18.0"
+ "ws": "^8.18.0",
+ "yjs": "^13.6.23"
},
"devDependencies": {
"@canvas-js/chain-cosmos": "0.14.0-next.1",
diff --git a/packages/chain-ethereum/src/eip712/Secp256k1DelegateSigner.ts b/packages/chain-ethereum/src/eip712/Secp256k1DelegateSigner.ts
index ff638b146..e8e6f76b7 100644
--- a/packages/chain-ethereum/src/eip712/Secp256k1DelegateSigner.ts
+++ b/packages/chain-ethereum/src/eip712/Secp256k1DelegateSigner.ts
@@ -128,6 +128,8 @@ export class Secp256k1DelegateSigner implements Signer(signers, messageLog, runtime)
// Check to see if the $actions table is empty and populate it if necessary
@@ -290,6 +292,12 @@ export class Canvas<
this.log("applied action %s", signedMessage.id)
+ for (const additionalUpdate of this.runtime.additionalUpdates.get(signedMessage.id) || []) {
+ await this.messageLog.append(additionalUpdate)
+ }
+ this.runtime.additionalUpdates.delete(signedMessage.id)
+ this.log("applied additional updates for action %s", signedMessage.id)
+
return signedMessage
}
@@ -478,6 +486,14 @@ export class Canvas<
}
}
+ public getYDoc(model: string, key: string) {
+ return this.runtime.getYDoc(model, key)
+ }
+
+ public getYDocId(model: string, key: string) {
+ return this.runtime.getYDocId(model, key)
+ }
+
public async createSnapshot(): Promise {
return createSnapshot(this)
}
diff --git a/packages/core/src/DocumentStore.ts b/packages/core/src/DocumentStore.ts
new file mode 100644
index 000000000..b78ff9c73
--- /dev/null
+++ b/packages/core/src/DocumentStore.ts
@@ -0,0 +1,158 @@
+import { Message, Updates } from "@canvas-js/interfaces"
+import { AbstractModelDB, ModelSchema } from "@canvas-js/modeldb"
+import * as Y from "yjs"
+import { YjsCall } from "./ExecutionContext.js"
+import { assert } from "@canvas-js/utils"
+
+type Delta = Y.YTextEvent["changes"]["delta"]
+
+function getDeltaForYText(ytext: Y.Text, fn: () => void): Delta {
+ let delta: Delta | null = null
+
+ const handler = (event: Y.YTextEvent) => {
+ delta = event.changes.delta
+ }
+
+ ytext.observe(handler)
+ fn()
+ ytext.unobserve(handler)
+ return delta || []
+}
+
+export type DocumentUpdateRecord = {
+ primary: string
+ model: string
+ key: string
+ id: number
+ data: Delta
+ diff: Uint8Array
+ isAppend: boolean
+}
+
+export class DocumentStore {
+ private documents: Record> = {}
+ private documentIds: Record> = {}
+
+ public static schema = {
+ $document_updates: {
+ primary: "primary",
+ model: "string",
+ key: "string",
+ id: "number",
+ // applyDelta, insert or delete
+ data: "json",
+ // yjs document diff
+ diff: "bytes",
+ isAppend: "boolean",
+ $indexes: ["id"],
+ },
+ } satisfies ModelSchema
+
+ #db: AbstractModelDB | null = null
+
+ public get db() {
+ assert(this.#db !== null, "internal error - expected this.#db !== null")
+ return this.#db
+ }
+
+ public set db(db: AbstractModelDB) {
+ this.#db = db
+ }
+
+ public getYDoc(model: string, key: string) {
+ this.documents[model] ||= {}
+ this.documents[model][key] ||= new Y.Doc()
+ return this.documents[model][key]
+ }
+
+ public async loadSavedDocuments() {
+ assert(this.#db !== null, "internal error - expected this.#db !== null")
+ // iterate over the past document operations
+ // and create the yjs documents
+ for await (const operation of this.#db.iterate("$document_updates")) {
+ const doc = this.getYDoc(operation.model, operation.key)
+ Y.applyUpdate(doc, operation.diff)
+ const existingId = this.getId(operation.model, operation.key)
+ if (operation.id > existingId) {
+ this.setId(operation.model, operation.key, operation.id)
+ }
+ }
+ }
+
+ public getId(model: string, key: string) {
+ this.documentIds[model] ||= {}
+ return this.documentIds[model][key] ?? -1
+ }
+
+ public setId(model: string, key: string, id: number) {
+ this.documentIds[model] ||= {}
+ this.documentIds[model][key] = id
+ }
+
+ public getNextId(model: string, key: string) {
+ return this.getId(model, key) + 1
+ }
+
+ public async applyYjsCalls(model: string, key: string, calls: YjsCall[]) {
+ assert(this.#db !== null, "internal error - expected this.#db !== null")
+
+ const doc = this.getYDoc(model, key)
+
+ // get the initial state of the document
+ const beforeState = Y.encodeStateAsUpdate(doc)
+
+ const delta = getDeltaForYText(doc.getText(), () => {
+ for (const call of calls) {
+ if (call.call === "insert") {
+ doc.getText().insert(call.index, call.content)
+ } else if (call.call === "delete") {
+ doc.getText().delete(call.index, call.length)
+ } else if (call.call === "applyDelta") {
+ // TODO: do we actually need to call sanitize here?
+ doc.getText().applyDelta(call.delta.ops, { sanitize: true })
+ } else {
+ throw new Error("unexpected call type")
+ }
+ }
+ })
+
+ // diff the document with the initial state
+ const afterState = Y.encodeStateAsUpdate(doc)
+ const diff = Y.diffUpdate(afterState, Y.encodeStateVectorFromUpdate(beforeState))
+
+ await this.writeDocumentUpdate(model, key, delta || [], diff, true)
+
+ return { model, key, diff }
+ }
+
+ public async consumeUpdatesMessage(message: Message) {
+ assert(this.#db !== null, "internal error - expected this.#db !== null")
+
+ for (const { model, key, diff } of message.payload.updates) {
+ // apply the diff to the doc
+ const doc = this.getYDoc(model, key)
+ const delta = getDeltaForYText(doc.getText(), () => {
+ Y.applyUpdate(doc, diff)
+ })
+
+ // save the observed update to the db
+ await this.writeDocumentUpdate(model, key, delta, diff, false)
+ }
+ }
+
+ private async writeDocumentUpdate(model: string, key: string, data: Delta, diff: Uint8Array, isAppend: boolean) {
+ assert(this.#db !== null, "internal error - expected this.#db !== null")
+ const id = this.getNextId(model, key)
+ this.setId(model, key, id)
+
+ await this.#db.set(`$document_updates`, {
+ primary: `${model}/${key}/${id}`,
+ model,
+ key,
+ id,
+ data,
+ diff,
+ isAppend,
+ })
+ }
+}
diff --git a/packages/core/src/ExecutionContext.ts b/packages/core/src/ExecutionContext.ts
index 7d2439d5e..289e0669d 100644
--- a/packages/core/src/ExecutionContext.ts
+++ b/packages/core/src/ExecutionContext.ts
@@ -1,14 +1,32 @@
import * as cbor from "@ipld/dag-cbor"
import { blake3 } from "@noble/hashes/blake3"
import { bytesToHex } from "@noble/hashes/utils"
+import * as Y from "yjs"
-import type { Action, MessageType } from "@canvas-js/interfaces"
+import type { Action, MessageType, Updates } from "@canvas-js/interfaces"
import { ModelValue, PropertyValue, validateModelValue, updateModelValue, mergeModelValue } from "@canvas-js/modeldb"
import { AbstractGossipLog, SignedMessage, MessageId } from "@canvas-js/gossiplog"
import { assert, mapValues } from "@canvas-js/utils"
export const getKeyHash = (key: string) => bytesToHex(blake3(key, { dkLen: 16 }))
+type YjsCallInsert = {
+ call: "insert"
+ index: number
+ content: string
+}
+type YjsCallDelete = {
+ call: "delete"
+ index: number
+ length: number
+}
+type YjsCallApplyDelta = {
+ call: "applyDelta"
+ // we don't have a declared type for Quill Deltas
+ delta: any
+}
+
+export type YjsCall = YjsCallInsert | YjsCallDelete | YjsCallApplyDelta
export class ExecutionContext {
// // recordId -> { version, value }
@@ -18,6 +36,7 @@ export class ExecutionContext {
// public readonly writes: Record = {}
public readonly modelEntries: Record>
+ public readonly yjsCalls: Record> = {}
public readonly root: MessageId[]
constructor(
@@ -137,4 +156,24 @@ export class ExecutionContext {
validateModelValue(this.db.models[model], result)
this.modelEntries[model][key] = result
}
+
+ public pushYjsCall(modelName: string, id: string, call: YjsCall) {
+ this.yjsCalls[modelName] ||= {}
+ this.yjsCalls[modelName][id] ||= []
+ this.yjsCalls[modelName][id].push(call)
+ }
+
+ public async getYDoc(modelName: string, id: string): Promise {
+ const existingStateEntries = await this.db.query<{ id: string; content: Uint8Array }>(`${modelName}:state`, {
+ where: { id: id },
+ limit: 1,
+ })
+ if (existingStateEntries.length > 0) {
+ const doc = new Y.Doc()
+ Y.applyUpdate(doc, existingStateEntries[0].content)
+ return doc
+ } else {
+ return null
+ }
+ }
}
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index ec6890370..326442227 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -1,6 +1,7 @@
export * from "./Canvas.js"
export * from "./CanvasLoadable.js"
export * from "./types.js"
+export * from "./DocumentStore.js"
export { hashContract } from "./snapshot.js"
export { Action, Session, Snapshot } from "@canvas-js/interfaces"
export { NetworkClient } from "@canvas-js/gossiplog"
diff --git a/packages/core/src/runtime/AbstractRuntime.ts b/packages/core/src/runtime/AbstractRuntime.ts
index 9bfef9076..701c0f94b 100644
--- a/packages/core/src/runtime/AbstractRuntime.ts
+++ b/packages/core/src/runtime/AbstractRuntime.ts
@@ -1,15 +1,25 @@
import * as cbor from "@ipld/dag-cbor"
import { logger } from "@libp2p/logger"
-import type { Action, Session, Snapshot, SignerCache, Awaitable, MessageType } from "@canvas-js/interfaces"
+import type {
+ Action,
+ Session,
+ Snapshot,
+ SignerCache,
+ Awaitable,
+ MessageType,
+ Updates,
+ Message,
+} from "@canvas-js/interfaces"
import { AbstractModelDB, Effect, ModelSchema } from "@canvas-js/modeldb"
import { GossipLogConsumer, MAX_MESSAGE_ID, AbstractGossipLog, SignedMessage } from "@canvas-js/gossiplog"
import { assert } from "@canvas-js/utils"
+import { DocumentStore } from "../DocumentStore.js"
import { ExecutionContext, getKeyHash } from "../ExecutionContext.js"
-import { isAction, isSession, isSnapshot } from "../utils.js"
import { Contract } from "../types.js"
+import { isAction, isSession, isSnapshot, isUpdates } from "../utils.js"
export type EffectRecord = { key: string; value: Uint8Array | null; branch: number; clock: number }
@@ -64,12 +74,30 @@ export abstract class AbstractRuntime {
} satisfies ModelSchema
protected static getModelSchema(schema: ModelSchema): ModelSchema {
+ const outputSchema: ModelSchema = {}
+ for (const [modelName, modelSchema] of Object.entries(schema)) {
+ // @ts-ignore
+ if (modelSchema.content === "yjs-doc") {
+ if (
+ Object.entries(modelSchema).length !== 2 &&
+ // @ts-ignore
+ modelSchema.id !== "primary"
+ ) {
+ // not valid
+ throw new Error("yjs-doc tables must have two columns, one of which is 'id'")
+ }
+ } else {
+ outputSchema[modelName] = modelSchema
+ }
+ }
+
return {
- ...schema,
+ ...outputSchema,
...AbstractRuntime.sessionsModel,
...AbstractRuntime.actionsModel,
...AbstractRuntime.effectsModel,
...AbstractRuntime.usersModel,
+ ...DocumentStore.schema,
}
}
@@ -79,8 +107,11 @@ export abstract class AbstractRuntime {
public abstract readonly actionNames: string[]
public abstract readonly contract: string | Contract
+ public readonly additionalUpdates = new Map()
+
protected readonly log = logger("canvas:runtime")
#db: AbstractModelDB | null = null
+ #documentStore = new DocumentStore()
protected constructor() {}
@@ -95,20 +126,24 @@ export abstract class AbstractRuntime {
public set db(db: AbstractModelDB) {
this.#db = db
+ this.#documentStore.db = db
}
public getConsumer(): GossipLogConsumer {
const handleSession = this.handleSession.bind(this)
const handleAction = this.handleAction.bind(this)
const handleSnapshot = this.handleSnapshot.bind(this)
+ const handleUpdates = this.handleUpdates.bind(this)
- return async function (this: AbstractGossipLog, signedMessage) {
+ return async function (this: AbstractGossipLog, signedMessage, isAppend: boolean) {
if (isSession(signedMessage)) {
return await handleSession(signedMessage)
} else if (isAction(signedMessage)) {
- return await handleAction(signedMessage, this)
+ return await handleAction(signedMessage, this, isAppend)
} else if (isSnapshot(signedMessage)) {
return await handleSnapshot(signedMessage, this)
+ } else if (isUpdates(signedMessage)) {
+ return await handleUpdates(signedMessage.message, isAppend)
} else {
throw new Error("invalid message payload type")
}
@@ -166,7 +201,11 @@ export abstract class AbstractRuntime {
await this.db.apply(effects)
}
- private async handleAction(signedMessage: SignedMessage, messageLog: AbstractGossipLog) {
+ private async handleAction(
+ signedMessage: SignedMessage,
+ messageLog: AbstractGossipLog,
+ isAppend: boolean,
+ ) {
const { id, signature, message } = signedMessage
const { did, name, context } = message.payload
@@ -249,6 +288,39 @@ export abstract class AbstractRuntime {
throw err
}
+ if (isAppend) {
+ const updates = []
+ for (const [model, modelCalls] of Object.entries(executionContext.yjsCalls)) {
+ for (const [key, calls] of Object.entries(modelCalls)) {
+ const update = await this.#documentStore.applyYjsCalls(model, key, calls)
+ updates.push(update)
+ }
+ }
+ if (updates.length > 0) {
+ this.additionalUpdates.set(id, [{ type: "updates", updates }])
+ } else {
+ this.additionalUpdates.set(id, [])
+ }
+ }
+
return result
}
+
+ public getYDoc(model: string, key: string) {
+ return this.#documentStore.getYDoc(model, key)
+ }
+
+ public getYDocId(model: string, key: string) {
+ return this.#documentStore.getId(model, key)
+ }
+
+ public async loadSavedDocuments() {
+ await this.#documentStore.loadSavedDocuments()
+ }
+
+ public async handleUpdates(message: Message, isAppend: boolean) {
+ if (!isAppend) {
+ return await this.#documentStore.consumeUpdatesMessage(message)
+ }
+ }
}
diff --git a/packages/core/src/runtime/ContractRuntime.ts b/packages/core/src/runtime/ContractRuntime.ts
index b64849418..4364388d7 100644
--- a/packages/core/src/runtime/ContractRuntime.ts
+++ b/packages/core/src/runtime/ContractRuntime.ts
@@ -8,6 +8,7 @@ import { assert, mapValues } from "@canvas-js/utils"
import { Contract } from "../types.js"
import { ExecutionContext } from "../ExecutionContext.js"
import { AbstractRuntime } from "./AbstractRuntime.js"
+import { DocumentStore } from "../DocumentStore.js"
export class ContractRuntime extends AbstractRuntime {
public static async init(
@@ -136,6 +137,28 @@ export class ContractRuntime extends AbstractRuntime {
const key = vm.context.getString(keyHandle)
this.context.deleteModelValue(model, key)
}),
+ ytext: vm.wrapObject({
+ insert: vm.context.newFunction("insert", (modelHandle, keyHandle, indexHandle, contentHandle) => {
+ const model = vm.context.getString(modelHandle)
+ const key = vm.context.getString(keyHandle)
+ const index = vm.context.getNumber(indexHandle)
+ const content = vm.context.getString(contentHandle)
+ this.context.pushYjsCall(model, key, { call: "insert", index, content })
+ }),
+ delete: vm.context.newFunction("delete", (modelHandle, keyHandle, indexHandle, lengthHandle) => {
+ const model = vm.context.getString(modelHandle)
+ const key = vm.context.getString(keyHandle)
+ const index = vm.context.getNumber(indexHandle)
+ const length = vm.context.getNumber(lengthHandle)
+ this.context.pushYjsCall(model, key, { call: "delete", index, length })
+ }),
+ applyDelta: vm.context.newFunction("applyDelta", (modelHandle, keyHandle, deltaHandle) => {
+ const model = vm.context.getString(modelHandle)
+ const key = vm.context.getString(keyHandle)
+ const delta = vm.context.getString(deltaHandle)
+ this.context.pushYjsCall(model, key, { call: "applyDelta", delta: JSON.parse(delta) })
+ }),
+ }),
})
.consume(vm.cache)
}
diff --git a/packages/core/src/runtime/FunctionRuntime.ts b/packages/core/src/runtime/FunctionRuntime.ts
index f2956207b..6d79cf176 100644
--- a/packages/core/src/runtime/FunctionRuntime.ts
+++ b/packages/core/src/runtime/FunctionRuntime.ts
@@ -174,6 +174,17 @@ export class FunctionRuntime extends AbstractRuntim
this.releaseLock()
}
},
+ ytext: {
+ insert: async (model: string, key: string, index: number, content: string) => {
+ this.context.pushYjsCall(model, key, { call: "insert", index, content })
+ },
+ delete: async (model: string, key: string, index: number, length: number) => {
+ this.context.pushYjsCall(model, key, { call: "delete", index, length })
+ },
+ applyDelta: async (model: string, key: string, delta: any) => {
+ this.context.pushYjsCall(model, key, { call: "applyDelta", delta: JSON.parse(delta) })
+ },
+ },
}
}
diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts
index e8605f5ba..fce4e89cd 100644
--- a/packages/core/src/schema.ts
+++ b/packages/core/src/schema.ts
@@ -1,7 +1,7 @@
import { fromDSL } from "@ipld/schema/from-dsl.js"
import { create } from "@ipld/schema/typed.js"
-import type { Action, MessageType, Session } from "@canvas-js/interfaces"
+import type { Action, MessageType, Session, Snapshot, Updates } from "@canvas-js/interfaces"
const schema = `
type ActionContext struct {
@@ -39,10 +39,21 @@ type Snapshot struct {
effects [SnapshotEffect]
}
+type Update struct {
+ model String
+ key String
+ diff Bytes
+}
+
+type Updates struct {
+ updates [Update]
+}
+
type Payload union {
| Action "action"
| Session "session"
| Snapshot "snapshot"
+ | Updates "updates"
} representation inline {
discriminantKey "type"
}
@@ -51,6 +62,11 @@ type Payload union {
const { toTyped } = create(fromDSL(schema), "Payload")
export function validatePayload(payload: unknown): payload is MessageType {
- const result = toTyped(payload) as { Action: Omit } | { Session: Omit } | undefined
+ const result = toTyped(payload) as
+ | { Action: Omit }
+ | { Session: Omit }
+ | { Snapshot: Omit }
+ | { Updates: Omit }
+ | undefined
return result !== undefined
}
diff --git a/packages/core/src/snapshot.ts b/packages/core/src/snapshot.ts
index 11388d8fb..790752170 100644
--- a/packages/core/src/snapshot.ts
+++ b/packages/core/src/snapshot.ts
@@ -1,6 +1,7 @@
import { sha256 } from "@noble/hashes/sha256"
import { bytesToHex } from "@noble/hashes/utils"
import * as cbor from "@ipld/dag-cbor"
+import * as Y from "yjs"
import { MIN_MESSAGE_ID } from "@canvas-js/gossiplog"
import { Snapshot, SnapshotEffect } from "@canvas-js/interfaces"
@@ -9,6 +10,7 @@ import type { PropertyType } from "@canvas-js/modeldb"
import { Canvas } from "./Canvas.js"
import { Contract } from "./types.js"
import { EffectRecord } from "./runtime/AbstractRuntime.js"
+import { DocumentUpdateRecord } from "./DocumentStore.js"
// typeguards
export const isIndexInit = (value: unknown): value is string[] => Array.isArray(value)
@@ -60,6 +62,25 @@ export async function createSnapshot>(app: Canvas): Prom
effectsMap.set(recordId, { key: effectKey, value, branch, clock })
}
}
+
+ // iterate over the document updates table
+ const documentsMap = new Map()
+ for await (const row of app.db.iterate("$document_updates")) {
+ const { primary, diff } = row
+
+ const doc = documentsMap.get(primary) || new Y.Doc()
+ Y.applyUpdate(doc, diff)
+ documentsMap.set(primary, doc)
+ }
+
+ modelData[`$document_updates`] = []
+ for (const [primary, doc] of documentsMap.entries()) {
+ const diff = Y.encodeStateAsUpdate(doc)
+ const [model, key, id_] = primary.split("/")
+ const row = { primary, model, key, id: 0, data: doc.getText().toDelta(), diff, isAppend: false }
+ modelData[`$document_updates`].push(cbor.encode(row))
+ }
+
const effects = Array.from(effectsMap.values()).map(({ key, value }: SnapshotEffect) => ({ key, value }))
return {
diff --git a/packages/core/src/targets/interface.ts b/packages/core/src/targets/interface.ts
index 3bd0b6101..88d189bf1 100644
--- a/packages/core/src/targets/interface.ts
+++ b/packages/core/src/targets/interface.ts
@@ -1,6 +1,6 @@
import type pg from "pg"
-import type { Action, MessageType, Session, Snapshot } from "@canvas-js/interfaces"
+import type { MessageType } from "@canvas-js/interfaces"
import type { AbstractGossipLog, GossipLogInit } from "@canvas-js/gossiplog"
import type { Canvas } from "@canvas-js/core"
import type { SqlStorage } from "@cloudflare/workers-types"
diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index 24d3b3acc..4fb57822f 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -17,14 +17,18 @@ export type ActionImplementation<
ModelsT extends ModelSchema = ModelSchema,
Args extends Array = any,
Result = any,
-> = (this: ActionContext>, db: ModelAPI>, ...args: Args) => Awaitable
+> = (
+ this: ActionContext>,
+ db: ModelAPI>,
+ ...args: Args
+) => Awaitable
export type Chainable> = Promise & {
link: (
model: T,
primaryKey: string,
through?: { through: string },
- ) => Promise,
+ ) => Promise
unlink: (
model: T,
primaryKey: string,
@@ -40,6 +44,16 @@ export type ModelAPI> = {
update: (model: T, value: Partial) => Chainable
merge: (model: T, value: Partial) => Chainable
delete: (model: T, key: string) => Promise
+ ytext: {
+ insert: (
+ model: T,
+ key: string,
+ index: number,
+ content: string,
+ ) => Promise
+ delete: (model: T, key: string, index: number, length: number) => Promise
+ applyDelta: (model: T, key: string, delta: string) => Promise
+ }
}
export type ActionContext> = {
diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts
index f1ead69d3..9c12a4ea9 100644
--- a/packages/core/src/utils.ts
+++ b/packages/core/src/utils.ts
@@ -4,7 +4,7 @@ import { blake3 } from "@noble/hashes/blake3"
import { utf8ToBytes } from "@noble/hashes/utils"
import { base64 } from "multiformats/bases/base64"
-import { Action, MessageType, Session, Snapshot } from "@canvas-js/interfaces"
+import { Action, MessageType, Session, Snapshot, Updates } from "@canvas-js/interfaces"
import { SignedMessage } from "@canvas-js/gossiplog"
import { PrimaryKeyValue } from "@canvas-js/modeldb"
@@ -17,6 +17,9 @@ export const isSession = (signedMessage: SignedMessage): signedMess
export const isSnapshot = (signedMessage: SignedMessage): signedMessage is SignedMessage =>
signedMessage.message.payload.type === "snapshot"
+export const isUpdates = (signedMessage: SignedMessage): signedMessage is SignedMessage =>
+ signedMessage.message.payload.type === "updates"
+
export const topicPattern = /^[a-zA-Z0-9.-]+$/
export function getErrorMessage(err: unknown): string {
diff --git a/packages/core/test/documentStore.test.ts b/packages/core/test/documentStore.test.ts
new file mode 100644
index 000000000..496ee4158
--- /dev/null
+++ b/packages/core/test/documentStore.test.ts
@@ -0,0 +1,29 @@
+import test from "ava"
+
+import { DocumentStore } from "@canvas-js/core"
+import { ModelDB } from "@canvas-js/modeldb-sqlite"
+
+test("save and load a document using the document store", async (t) => {
+ const db = await ModelDB.open(null, { models: DocumentStore.schema })
+ const ds = new DocumentStore()
+ ds.db = db
+
+ const delta = { ops: [{ insert: "hello world" }] }
+ await ds.applyYjsCalls("documents", "0", [{ call: "applyDelta", delta }])
+
+ const ds2 = new DocumentStore()
+ ds2.db = db
+ await ds2.loadSavedDocuments()
+ const doc = ds2.getYDoc("documents", "0")
+
+ t.deepEqual(doc.getText().toDelta(), delta.ops)
+})
+
+test("get and set id", async (t) => {
+ const ds = new DocumentStore()
+
+ t.is(ds.getId("documents", "0"), -1)
+ ds.setId("documents", "0", 42)
+ t.is(ds.getId("documents", "0"), 42)
+ t.is(ds.getNextId("documents", "0"), 43)
+})
diff --git a/packages/core/test/snapshot.test.ts b/packages/core/test/snapshot.test.ts
index 76149bc0d..3e1c459b3 100644
--- a/packages/core/test/snapshot.test.ts
+++ b/packages/core/test/snapshot.test.ts
@@ -11,6 +11,10 @@ test("snapshot persists data across apps", async (t) => {
id: "primary",
content: "string",
},
+ documents: {
+ id: "primary",
+ content: "yjs-doc",
+ },
},
actions: {
async createPost(db, { id, content }: { id: string; content: string }) {
@@ -19,6 +23,12 @@ test("snapshot persists data across apps", async (t) => {
async deletePost(db, { id }: { id: string }) {
await db.delete("posts", id)
},
+ async insertIntoDocument(db, key, index, text) {
+ await db.ytext.insert("documents", key, index, text)
+ },
+ async deleteFromDocument(db, key, index, length) {
+ await db.ytext.delete("documents", key, index, length)
+ },
},
},
}
@@ -35,9 +45,11 @@ test("snapshot persists data across apps", async (t) => {
await app.actions.createPost({ id: "d", content: "baz" })
await app.actions.deletePost({ id: "b" })
await app.actions.deletePost({ id: "d" })
+ await app.actions.insertIntoDocument("e", 0, "Hello, world")
+ await app.actions.deleteFromDocument("e", 5, 7)
const [clock, parents] = await app.messageLog.getClock()
- t.is(clock, 8) // one session, six actions
+ t.is(clock, 12) // one session, eight actions, two "updates" messages
t.is(parents.length, 1)
// snapshot and add some more actions
@@ -52,13 +64,17 @@ test("snapshot persists data across apps", async (t) => {
t.is(await app2.db.get("posts", "d"), null)
t.is(await app2.db.get("posts", "e"), null)
+ const doc1 = app2.getYDoc("documents", "e")
+ t.is(doc1.getText().toJSON(), "Hello")
+
await app2.actions.createPost({ id: "a", content: "1" })
await app2.actions.createPost({ id: "b", content: "2" })
await app2.actions.createPost({ id: "e", content: "3" })
await app2.actions.createPost({ id: "f", content: "4" })
+ await app2.actions.insertIntoDocument("e", 6, "?")
const [clock2, parents2] = await app2.messageLog.getClock()
- t.is(clock2, 7) // one snapshot, one session, four actions
+ t.is(clock2, 9) // one snapshot, one session, four actions
t.is(parents2.length, 1)
t.is((await app2.db.get("posts", "a"))?.content, "1")
@@ -67,6 +83,8 @@ test("snapshot persists data across apps", async (t) => {
t.is(await app2.db.get("posts", "d"), null)
t.is((await app2.db.get("posts", "e"))?.content, "3")
t.is((await app2.db.get("posts", "f"))?.content, "4")
+ const doc2 = app2.getYDoc("documents", "e")
+ t.is(doc2.getText().toJSON(), "Hello?")
// snapshot a second time
const snapshot2 = await app2.createSnapshot()
@@ -79,6 +97,8 @@ test("snapshot persists data across apps", async (t) => {
t.is((await app3.db.get("posts", "e"))?.content, "3")
t.is((await app3.db.get("posts", "f"))?.content, "4")
t.is(await app3.db.get("posts", "g"), null)
+ const doc3 = app3.getYDoc("documents", "e")
+ t.is(doc3.getText().toJSON(), "Hello?")
const [clock3] = await app3.messageLog.getClock()
t.is(clock3, 2) // one snapshot
diff --git a/packages/core/test/yjs.test.ts b/packages/core/test/yjs.test.ts
new file mode 100644
index 000000000..09b8a023b
--- /dev/null
+++ b/packages/core/test/yjs.test.ts
@@ -0,0 +1,74 @@
+import { SIWESigner } from "@canvas-js/chain-ethereum"
+import test, { ExecutionContext } from "ava"
+import { Canvas } from "@canvas-js/core"
+
+const contract = `
+export const models = {
+ articles: {
+ id: "primary",
+ content: "yjs-doc"
+ }
+};
+export const actions = {
+ async createNewArticle(db) {
+ const { id } = this
+ await db.ytext.insert("articles", id, 0, "")
+ },
+ async insertIntoDoc(db, key, index, text) {
+ await db.ytext.insert("articles", key, index, text)
+ },
+ async deleteFromDoc(db, key, index, length) {
+ await db.ytext.delete("articles", key, index, length)
+ }
+};
+`
+
+const init = async (t: ExecutionContext) => {
+ const signer = new SIWESigner()
+ const app = await Canvas.initialize({
+ contract,
+ topic: "com.example.app",
+ reset: true,
+ signers: [signer],
+ })
+
+ t.teardown(() => app.stop())
+ return { app, signer }
+}
+
+test("apply an action and read a record from the database", async (t) => {
+ const { app: app1 } = await init(t)
+
+ const { id } = await app1.actions.createNewArticle()
+
+ t.log(`applied action ${id}`)
+
+ await app1.actions.insertIntoDoc(id, 0, "Hello, world")
+ t.is(app1.getYDoc("articles", id).getText().toJSON(), "Hello, world")
+
+ // create another app
+ const { app: app2 } = await init(t)
+
+ // sync the apps
+ await app1.messageLog.serve((s) => app2.messageLog.sync(s))
+ t.is(app2.getYDoc("articles", id).getText().toJSON(), "Hello, world")
+
+ // insert ! into app1
+ await app1.actions.insertIntoDoc(id, 12, "!")
+ t.is(app1.getYDoc("articles", id).getText().toJSON(), "Hello, world!")
+
+ // insert ? into app2
+ await app2.actions.insertIntoDoc(id, 12, "?")
+ t.is(app2.getYDoc("articles", id).getText().toJSON(), "Hello, world?")
+
+ // sync app2 -> app1
+ await app2.messageLog.serve((s) => app1.messageLog.sync(s))
+ const app1MergedText = app1.getYDoc("articles", id).getText().toJSON()
+
+ // sync app1 -> app2
+ await app1.messageLog.serve((s) => app2.messageLog.sync(s))
+ const app2MergedText = app2.getYDoc("articles", id).getText().toJSON()
+
+ // both apps should now have converged
+ t.is(app1MergedText, app2MergedText)
+})
diff --git a/packages/gossiplog/src/AbstractGossipLog.ts b/packages/gossiplog/src/AbstractGossipLog.ts
index 48ae7d133..f6403d7ef 100644
--- a/packages/gossiplog/src/AbstractGossipLog.ts
+++ b/packages/gossiplog/src/AbstractGossipLog.ts
@@ -26,6 +26,7 @@ import { gossiplogTopicPattern } from "./utils.js"
export type GossipLogConsumer = (
this: AbstractGossipLog,
signedMessage: SignedMessage,
+ isAppend: boolean,
) => Awaitable
export interface GossipLogInit {
@@ -155,7 +156,8 @@ export abstract class AbstractGossipLog extends
const { signature, message, branch } = record
const signedMessage = this.encode(signature, message, { branch })
assert(signedMessage.id === id)
- await this.#apply.apply(this, [signedMessage])
+ // TODO: should isAppend be true or false?
+ await this.#apply.apply(this, [signedMessage, true])
}
})
}
@@ -290,7 +292,7 @@ export abstract class AbstractGossipLog extends
const signedMessage = this.encode(signature, message)
this.log("appending message %s at clock %d with parents %o", signedMessage.id, clock, parents)
- const applyResult = await this.apply(txn, signedMessage)
+ const applyResult = await this.apply(txn, signedMessage, true)
root = applyResult.root
heads = applyResult.heads
@@ -328,7 +330,7 @@ export abstract class AbstractGossipLog extends
return null
}
- return await this.apply(txn, signedMessage)
+ return await this.apply(txn, signedMessage, false)
})
if (result !== null) {
@@ -341,6 +343,7 @@ export abstract class AbstractGossipLog extends
private async apply(
txn: ReadWriteTransaction,
signedMessage: SignedMessage,
+ isAppend: boolean,
): Promise<{ root: Node; heads: string[]; result: Result }> {
const { id, signature, message, key, value } = signedMessage
this.log.trace("applying %s %O", id, message)
@@ -358,7 +361,7 @@ export abstract class AbstractGossipLog extends
const branch = await this.getBranch(id, parentMessageRecords)
signedMessage.branch = branch
- const result = await this.#apply.apply(this, [signedMessage])
+ const result = await this.#apply.apply(this, [signedMessage, isAppend])
const hash = toString(hashEntry(key, value), "hex")
diff --git a/packages/interfaces/src/MessageType.ts b/packages/interfaces/src/MessageType.ts
index 389aad8e3..3e47cf939 100644
--- a/packages/interfaces/src/MessageType.ts
+++ b/packages/interfaces/src/MessageType.ts
@@ -1,5 +1,6 @@
import { Action } from "./Action.js"
import { Session } from "./Session.js"
import { Snapshot } from "./Snapshot.js"
+import { Updates } from "./Updates.js"
-export type MessageType = Action | Session | Snapshot
+export type MessageType = Action | Session | Snapshot | Updates
diff --git a/packages/interfaces/src/SessionSigner.ts b/packages/interfaces/src/SessionSigner.ts
index 3b01566ed..af90b2661 100644
--- a/packages/interfaces/src/SessionSigner.ts
+++ b/packages/interfaces/src/SessionSigner.ts
@@ -1,7 +1,5 @@
import type { SignatureScheme, Signer } from "./Signer.js"
import type { Session } from "./Session.js"
-import type { Action } from "./Action.js"
-import type { Snapshot } from "./Snapshot.js"
import type { Awaitable } from "./Awaitable.js"
import { MessageType } from "./MessageType.js"
diff --git a/packages/interfaces/src/Updates.ts b/packages/interfaces/src/Updates.ts
new file mode 100644
index 000000000..c7b1ac963
--- /dev/null
+++ b/packages/interfaces/src/Updates.ts
@@ -0,0 +1,10 @@
+type Update = {
+ model: string
+ key: string
+ diff: Uint8Array
+}
+
+export type Updates = {
+ type: "updates"
+ updates: Update[]
+}
diff --git a/packages/interfaces/src/index.ts b/packages/interfaces/src/index.ts
index d389bbe20..eced3c72b 100644
--- a/packages/interfaces/src/index.ts
+++ b/packages/interfaces/src/index.ts
@@ -8,3 +8,4 @@ export * from "./Session.js"
export * from "./Snapshot.js"
export * from "./Awaitable.js"
export * from "./MessageType.js"
+export * from "./Updates.js"
diff --git a/packages/modeldb/src/types.ts b/packages/modeldb/src/types.ts
index 3a332e158..6cbe8ce25 100644
--- a/packages/modeldb/src/types.ts
+++ b/packages/modeldb/src/types.ts
@@ -8,6 +8,7 @@ export type NullablePrimitiveType = `${PrimitiveType}?`
export type ReferenceType = `@${string}`
export type NullableReferenceType = `@${string}?`
export type RelationType = `@${string}[]`
+export type YjsDocType = "yjs-doc"
export type PropertyType =
| PrimaryKeyType
@@ -16,6 +17,7 @@ export type PropertyType =
| ReferenceType
| NullableReferenceType
| RelationType
+ | YjsDocType
/** property name, or property names joined by slashes */
export type IndexInit = string
diff --git a/tsconfig.json b/tsconfig.json
index c8ebabe5b..7657e4021 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -44,6 +44,7 @@
{ "path": "./packages/utils/test" },
{ "path": "./examples/chat" },
{ "path": "./examples/chat-next" },
+ { "path": "./examples/document" },
{ "path": "./examples/encrypted-chat" },
{ "path": "./examples/snake" }
]