[]previous and next page

Sync with Convex

Use your own Convex deployment as the realtime backend and the database for CanvasFlow documents, with your own access rules.

canvasflow/convex turns a Convex deployment into a CanvasFlow sync server: live edits, presence, persistence and offline catch-up, with your authorization deciding who can read and write each room. It doesn't depend on Convex itself: you pass in Convex's builders, so it works with whatever version you have.

Add the tables#

convex/schema.tsts
import { defineSchema, defineTable } from 'convex/server'
import { v } from 'convex/values'
import { canvasflowTables } from 'canvasflow/convex'

export default defineSchema({
  ...canvasflowTables({ defineTable, v }),
  // your own tables
})

This adds three tables: the change log, compacted snapshots, and presence.

Export the functions#

convex/canvasflow.tsts
import { v } from 'convex/values'
import { mutation, query } from './_generated/server'
import { canvasflowFunctions } from 'canvasflow/convex'

export const { load, pull, push, presence, setPresence, leave } = canvasflowFunctions({
  query,
  mutation,
  v,
  authorize: async (ctx, room, access) => {
    const identity = await ctx.auth.getUserIdentity()
    if (!identity) throw new Error('Sign in to continue')
    // look up the board for `room` and check that this person may `access` ('read' or 'write') it
  },
})

authorize runs for every read and write. Throw to deny.

Connect the client#

board.tsts
import { ConvexClient } from 'convex/browser'
import { CollabClient } from 'canvasflow/collab'
import { ConvexTransport } from 'canvasflow/convex'
import { api } from '../convex/_generated/api'

const convex = new ConvexClient(import.meta.env.VITE_CONVEX_URL)
const transport = new ConvexTransport(convex, api.canvasflow, boardId)
const client = new CollabClient(editor, transport, { room: boardId }).start()

With ConvexReactClient#

ConvexTransport needs query, mutation and onUpdate. The browser ConvexClient has all three. If your app uses ConvexReactClient, adapt it in a few lines:

convex-adapter.tsts
import type { ConvexReactClient } from 'convex/react'
import type { ConvexClientLike } from 'canvasflow/convex'

export function toCanvasFlowClient(client: ConvexReactClient): ConvexClientLike {
  return {
    query: (fn, args) => client.query(fn, args),
    mutation: (fn, args) => client.mutation(fn, args),
    onUpdate(fn, args, callback, onError) {
      const watch = client.watchQuery(fn, args)
      const read = () => {
        try {
          const value = watch.localQueryResult()
          if (value !== undefined) callback(value)
        } catch (error) {
          onError?.(error as Error)
        }
      }
      const unsubscribe = watch.onUpdate(read)
      read()
      return unsubscribe
    },
    connectionState: () => ({ isWebSocketConnected: client.connectionState().isWebSocketConnected }),
  }
}

Options#

Option Default What it does
authorize(ctx, room, access) allow all Your access rule. Throw to deny
compactEvery 400 Fold the log into a snapshot every N entries
presenceTtl 30000 Ignore presence older than this (ms)
maxPayload 900 KB Largest push accepted. Convex documents cap at 1 MB

How it stores a board#

Each edit is appended to an ordered log for its room. Every compactEvery entries, the log is merged into one snapshot, using the same CRDT the clients use, so loading a board reads one snapshot plus a short tail. Clients that were offline during a compaction notice and reload cleanly.

Images#

Keep images in Convex file storage with an asset store: upload through a mutation that returns an upload URL, and resolve stored ids to URLs. The document then holds references, not image data.