UNPKG

@convex-dev/prosemirror-sync

Version:

Sync ProseMirror documents for Tiptap using this Convex component.

333 lines (320 loc) 9.47 kB
import { v } from "convex/values"; import { mutation, MutationCtx, query, QueryCtx } from "./_generated/server"; import { Doc } from "./_generated/dataModel"; import { vClientId } from "./schema"; import { api } from "./_generated/api"; const MAX_DELTA_FETCH = 1000; const MAX_SNAPSHOT_FETCH = 10; export const submitSnapshot = mutation({ args: { id: v.string(), version: v.number(), content: v.string(), pruneSnapshots: v.optional(v.boolean()), }, returns: v.null(), handler: async (ctx, args) => { const existing = await ctx.db .query("snapshots") .withIndex("id_version", (q) => q.eq("id", args.id).eq("version", args.version) ) .first(); if (existing) { if (existing.content === args.content) { return; } throw new Error( `Snapshot ${args.id} at version ${args.version} already exists ` + `with different content: ${existing.content} !== ${args.content}` ); } await ctx.db.insert("snapshots", { id: args.id, version: args.version, content: args.content, }); if (args.version > 1 && args.pruneSnapshots) { // Delete all older snapshots, except the original one. await deleteSnapshotsHelper(ctx, { id: args.id, afterVersion: 1, beforeVersion: args.version, }); } }, }); export const latestVersion = query({ args: { id: v.string() }, returns: v.union(v.null(), v.number()), handler: async (ctx, args) => { const latestDelta = await ctx.db .query("deltas") .withIndex("id_version", (q) => q.eq("id", args.id)) .order("desc") .first(); if (latestDelta) { return latestDelta.version; } const latestSnapshot = await ctx.db .query("snapshots") .withIndex("id_version", (q) => q.eq("id", args.id)) .order("desc") .first(); return latestSnapshot?.version ?? null; }, }); export const submitSteps = mutation({ args: { id: v.string(), version: v.number(), clientId: vClientId, steps: v.array(v.string()), }, returns: v.union( v.object({ status: v.literal("needs-rebase"), clientIds: v.array(vClientId), steps: v.array(v.string()), }), v.object({ status: v.literal("synced") }) ), handler: async (ctx, args) => { const changes = await ctx.db .query("deltas") .withIndex("id_version", (q) => q.eq("id", args.id).gt("version", args.version) ) .take(MAX_DELTA_FETCH); if (changes.length > 0) { const [steps, clientIds] = stepsAndClientIds(changes); return { status: "needs-rebase", clientIds, steps } as const; } await ctx.db.insert("deltas", { id: args.id, version: args.version + args.steps.length, clientId: args.clientId, steps: args.steps, }); return { status: "synced" } as const; }, }); function stepsAndClientIds(deltas: Doc<"deltas">[]) { const clientIds = []; const steps = []; for (const delta of deltas) { for (const step of delta.steps) { clientIds.push(delta.clientId); steps.push(step); } } return [steps, clientIds] as const; } export const getSnapshot = query({ args: { id: v.string(), version: v.optional(v.number()) }, returns: v.union( v.object({ content: v.null(), }), v.object({ content: v.string(), version: v.number(), }) ), handler: async (ctx, args) => { const snapshot = await ctx.db .query("snapshots") .withIndex("id_version", (q) => q.eq("id", args.id).lte("version", args.version ?? Infinity) ) .order("desc") .first(); if (!snapshot) { return { content: null, }; } return { content: snapshot.content, version: snapshot.version, }; }, }); async function fetchSteps( ctx: QueryCtx, id: string, afterVersion: number, targetVersion?: number ) { const deltas = await ctx.db .query("deltas") .withIndex("id_version", (q) => q .eq("id", id) .gt("version", afterVersion) .lte("version", targetVersion ?? Infinity) ) .take(MAX_DELTA_FETCH); if (deltas.length > 0) { const firstDelta = deltas[0]; if (firstDelta.version - firstDelta.steps.length > afterVersion) { throw new Error( `Missing steps ${afterVersion + 1}...${ firstDelta.version - firstDelta.steps.length }` ); } else if (firstDelta.version - firstDelta.steps.length < afterVersion) { firstDelta.steps = firstDelta.steps.slice( afterVersion - (firstDelta.version - firstDelta.steps.length) ); } } const [steps, clientIds] = stepsAndClientIds(deltas); if (deltas.length === MAX_DELTA_FETCH) { console.warn( `Max delta fetch reached: ${id} ${afterVersion}...${ targetVersion ?? "end" } stopped at ${deltas[deltas.length - 1].version}` ); return [steps, clientIds] as const; } const lastDelta = deltas[deltas.length - 1]; if (targetVersion && (!lastDelta || lastDelta.version < targetVersion)) { const nextDelta = await ctx.db .query("deltas") .withIndex("id_version", (q) => q.eq("id", id).gt("version", lastDelta.version) ) .first(); if (!nextDelta) { throw new Error( `Missing steps ${lastDelta ? lastDelta.version + 1 : afterVersion}...${targetVersion}` ); } for (let i = 0; i < targetVersion - lastDelta.version; i++) { steps.push(nextDelta.steps[i]); clientIds.push(nextDelta.clientId); } } if (targetVersion && steps.length !== targetVersion - afterVersion) { throw new Error( `Steps mismatch ${afterVersion}...${targetVersion}: ${steps.length}` ); } return [steps, clientIds] as const; } export const getSteps = query({ args: { id: v.string(), version: v.number() }, returns: v.object({ steps: v.array(v.string()), clientIds: v.array(vClientId), version: v.number(), }), handler: async (ctx, args) => { const [steps, clientIds] = await fetchSteps(ctx, args.id, args.version); return { steps, clientIds, version: args.version + steps.length }; }, }); /** * Delete snapshots in the given range, not including the bounds. * To clean up old snapshots, call this with the current version as the * beforeVersion and the first version (1) as the afterVersion. */ export const deleteSnapshots = mutation({ args: { id: v.string(), afterVersion: v.optional(v.number()), beforeVersion: v.optional(v.number()), }, returns: v.null(), handler: async (ctx, args) => { await deleteSnapshotsHelper(ctx, args); }, }); async function deleteSnapshotsHelper( ctx: MutationCtx, args: { id: string; afterVersion?: number; beforeVersion?: number } ) { const versions = await ctx.db .query("snapshots") .withIndex("id_version", (q) => { const eq = q.eq("id", args.id); const after = args.afterVersion !== undefined ? eq.gt("version", args.afterVersion) : eq; const before = args.beforeVersion !== undefined ? after.lt("version", args.beforeVersion) : after; return before; }) .take(MAX_SNAPSHOT_FETCH); await Promise.all(versions.map((doc) => ctx.db.delete(doc._id))); if (versions.length === MAX_SNAPSHOT_FETCH) { await ctx.scheduler.runAfter(0, api.lib.deleteSnapshots, { id: args.id, beforeVersion: args.beforeVersion, afterVersion: versions[versions.length - 1].version, }); } } /** * Delete steps before some timestamp. * To clean up old steps, call this with a date in the past for beforeTs. * By default it will ensure that all steps are older than the latest snapshot. */ export const deleteSteps = mutation({ args: { id: v.string(), afterVersion: v.optional(v.number()), beforeTs: v.number(), deleteNewerThanLatestSnapshot: v.optional(v.boolean()), }, returns: v.null(), handler: async (ctx, args) => { let beforeTs = args.beforeTs; if (!args.deleteNewerThanLatestSnapshot) { const latestSnapshot = await ctx.db .query("snapshots") .withIndex("id_version", (q) => q.eq("id", args.id)) .order("desc") .first(); if (latestSnapshot) { beforeTs = Math.min(beforeTs, latestSnapshot._creationTime); } } const deltas = ( await ctx.db .query("deltas") .withIndex("id_version", (q) => q.eq("id", args.id).gt("version", args.afterVersion ?? -Infinity) ) .take(MAX_DELTA_FETCH) ).filter((doc) => doc._creationTime < beforeTs); await Promise.all(deltas.map((doc) => ctx.db.delete(doc._id))); if (deltas.length === MAX_DELTA_FETCH) { await ctx.scheduler.runAfter(0, api.lib.deleteSteps, { id: args.id, beforeTs, // We already checked that the timestamp is before deleteNewerThanLatestSnapshot: true, }); } }, }); /** * Delete a document and all its snapshots & steps. */ export const deleteDocument = mutation({ args: { id: v.string() }, returns: v.null(), handler: async (ctx, args) => { await ctx.runMutation(api.lib.deleteSnapshots, { id: args.id }); await ctx.scheduler.runAfter(0, api.lib.deleteSteps, { id: args.id, beforeTs: Infinity, deleteNewerThanLatestSnapshot: true, }); }, });