@convex-dev/prosemirror-sync
Version:
Sync ProseMirror documents for Tiptap using this Convex component.
275 lines • 9.9 kB
JavaScript
import { v } from "convex/values";
import { mutation, query } from "./_generated/server";
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 };
}
await ctx.db.insert("deltas", {
id: args.id,
version: args.version + args.steps.length,
clientId: args.clientId,
steps: args.steps,
});
return { status: "synced" };
},
});
function stepsAndClientIds(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];
}
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, id, afterVersion, targetVersion) {
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];
}
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];
}
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, args) {
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,
});
},
});
//# sourceMappingURL=lib.js.map