Files
lemonspace_app/lib/canvas-op-queue.ts
2026-04-01 10:37:20 +02:00

922 lines
25 KiB
TypeScript

import type { Id } from "@/convex/_generated/dataModel";
const DB_NAME = "lemonspace.canvas.sync";
const DB_VERSION = 1;
const STORE_NAME = "ops";
const FALLBACK_STORAGE_KEY = "lemonspace.canvas:sync-fallback:v1";
export const CANVAS_SYNC_RETENTION_MS = 24 * 60 * 60 * 1000;
export type CanvasSyncOpPayloadByType = {
createNode: {
canvasId: Id<"canvases">;
type: string;
positionX: number;
positionY: number;
width: number;
height: number;
data: unknown;
parentId?: Id<"nodes">;
zIndex?: number;
clientRequestId: string;
};
createNodeWithEdgeFromSource: {
canvasId: Id<"canvases">;
type: string;
positionX: number;
positionY: number;
width: number;
height: number;
data: unknown;
parentId?: Id<"nodes">;
zIndex?: number;
clientRequestId: string;
sourceNodeId: Id<"nodes">;
sourceHandle?: string;
targetHandle?: string;
};
createNodeWithEdgeToTarget: {
canvasId: Id<"canvases">;
type: string;
positionX: number;
positionY: number;
width: number;
height: number;
data: unknown;
parentId?: Id<"nodes">;
zIndex?: number;
clientRequestId: string;
targetNodeId: Id<"nodes">;
sourceHandle?: string;
targetHandle?: string;
};
createEdge: {
canvasId: Id<"canvases">;
sourceNodeId: Id<"nodes">;
targetNodeId: Id<"nodes">;
sourceHandle?: string;
targetHandle?: string;
clientRequestId: string;
};
removeEdge: {
edgeId: Id<"edges">;
};
batchRemoveNodes: {
nodeIds: Id<"nodes">[];
};
moveNode: { nodeId: Id<"nodes">; positionX: number; positionY: number };
resizeNode: { nodeId: Id<"nodes">; width: number; height: number };
updateData: { nodeId: Id<"nodes">; data: unknown };
};
export type CanvasSyncOpType = keyof CanvasSyncOpPayloadByType;
type CanvasSyncOpBase = {
id: string;
canvasId: string;
enqueuedAt: number;
attemptCount: number;
nextRetryAt: number;
expiresAt: number;
lastError?: string;
};
export type CanvasSyncOp = {
[TType in CanvasSyncOpType]: CanvasSyncOpBase & {
type: TType;
payload: CanvasSyncOpPayloadByType[TType];
};
}[CanvasSyncOpType];
type CanvasSyncOpFor<TType extends CanvasSyncOpType> = Extract<
CanvasSyncOp,
{ type: TType }
>;
type JsonRecord = Record<string, unknown>;
type EnqueueInput<TType extends CanvasSyncOpType> = {
id: string;
canvasId: string;
type: TType;
payload: CanvasSyncOpPayloadByType[TType];
now?: number;
};
let dbPromise: Promise<IDBDatabase | null> | null = null;
function isRecord(value: unknown): value is JsonRecord {
return typeof value === "object" && value !== null;
}
function getLocalStorage(): Storage | null {
if (typeof window === "undefined") return null;
try {
return window.localStorage;
} catch {
return null;
}
}
function safeParse(raw: string | null): unknown {
if (!raw) return null;
try {
return JSON.parse(raw);
} catch {
return null;
}
}
function readFallbackOps(): CanvasSyncOp[] {
const storage = getLocalStorage();
if (!storage) return [];
const parsed = safeParse(storage.getItem(FALLBACK_STORAGE_KEY));
if (!Array.isArray(parsed)) return [];
return parsed
.filter((entry): entry is JsonRecord => isRecord(entry))
.map(normalizeOp)
.filter((entry): entry is CanvasSyncOp => entry !== null);
}
function writeFallbackOps(ops: CanvasSyncOp[]): void {
const storage = getLocalStorage();
if (!storage) return;
try {
storage.setItem(FALLBACK_STORAGE_KEY, JSON.stringify(ops));
} catch {
// Ignore storage quota failures in fallback layer.
}
}
function openDb(): Promise<IDBDatabase | null> {
if (typeof window === "undefined" || typeof indexedDB === "undefined") {
return Promise.resolve(null);
}
if (dbPromise) return dbPromise;
dbPromise = new Promise((resolve) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
if (db.objectStoreNames.contains(STORE_NAME)) return;
const store = db.createObjectStore(STORE_NAME, { keyPath: "id" });
store.createIndex("by_canvas", "canvasId", { unique: false });
store.createIndex("by_nextRetryAt", "nextRetryAt", { unique: false });
};
request.onsuccess = () => {
const db = request.result;
db.onversionchange = () => {
db.close();
dbPromise = null;
};
resolve(db);
};
request.onerror = () => {
resolve(null);
};
});
return dbPromise;
}
function txDone(tx: IDBTransaction): Promise<void> {
return new Promise((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error ?? new Error("IndexedDB transaction failed"));
tx.onabort = () => reject(tx.error ?? new Error("IndexedDB transaction aborted"));
});
}
function reqToPromise<T>(req: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error ?? new Error("IndexedDB request failed"));
});
}
function getNodeIdFromOp(op: CanvasSyncOp): string {
const payload = op.payload as { nodeId?: string };
return typeof payload.nodeId === "string" ? payload.nodeId : "";
}
function normalizeOp(raw: unknown): CanvasSyncOp | null {
if (!isRecord(raw)) return null;
const id = raw.id;
const canvasId = raw.canvasId;
const type = raw.type;
const payload = raw.payload;
if (
typeof id !== "string" ||
!id ||
typeof canvasId !== "string" ||
!canvasId ||
type !== "createNode" &&
type !== "createNodeWithEdgeFromSource" &&
type !== "createNodeWithEdgeToTarget" &&
type !== "createEdge" &&
type !== "removeEdge" &&
type !== "batchRemoveNodes" &&
type !== "moveNode" &&
type !== "resizeNode" &&
type !== "updateData"
) {
return null;
}
const enqueuedAt = typeof raw.enqueuedAt === "number" ? raw.enqueuedAt : Date.now();
const attemptCount = typeof raw.attemptCount === "number" ? raw.attemptCount : 0;
const nextRetryAt =
typeof raw.nextRetryAt === "number" ? raw.nextRetryAt : enqueuedAt;
const expiresAt =
typeof raw.expiresAt === "number"
? raw.expiresAt
: enqueuedAt + CANVAS_SYNC_RETENTION_MS;
const lastError = typeof raw.lastError === "string" ? raw.lastError : undefined;
if (!isRecord(payload)) return null;
if (
type === "createNode" &&
typeof payload.canvasId === "string" &&
typeof payload.type === "string" &&
typeof payload.positionX === "number" &&
typeof payload.positionY === "number" &&
typeof payload.width === "number" &&
typeof payload.height === "number" &&
typeof payload.clientRequestId === "string"
) {
return {
id,
canvasId,
type,
payload: {
canvasId: payload.canvasId as Id<"canvases">,
type: payload.type,
positionX: payload.positionX,
positionY: payload.positionY,
width: payload.width,
height: payload.height,
data: payload.data,
parentId:
typeof payload.parentId === "string"
? (payload.parentId as Id<"nodes">)
: undefined,
zIndex: typeof payload.zIndex === "number" ? payload.zIndex : undefined,
clientRequestId: payload.clientRequestId,
},
enqueuedAt,
attemptCount,
nextRetryAt,
expiresAt,
lastError,
};
}
if (
type === "createNodeWithEdgeFromSource" &&
typeof payload.canvasId === "string" &&
typeof payload.type === "string" &&
typeof payload.positionX === "number" &&
typeof payload.positionY === "number" &&
typeof payload.width === "number" &&
typeof payload.height === "number" &&
typeof payload.clientRequestId === "string" &&
typeof payload.sourceNodeId === "string"
) {
return {
id,
canvasId,
type,
payload: {
canvasId: payload.canvasId as Id<"canvases">,
type: payload.type,
positionX: payload.positionX,
positionY: payload.positionY,
width: payload.width,
height: payload.height,
data: payload.data,
parentId:
typeof payload.parentId === "string"
? (payload.parentId as Id<"nodes">)
: undefined,
zIndex: typeof payload.zIndex === "number" ? payload.zIndex : undefined,
clientRequestId: payload.clientRequestId,
sourceNodeId: payload.sourceNodeId as Id<"nodes">,
sourceHandle:
typeof payload.sourceHandle === "string"
? payload.sourceHandle
: undefined,
targetHandle:
typeof payload.targetHandle === "string"
? payload.targetHandle
: undefined,
},
enqueuedAt,
attemptCount,
nextRetryAt,
expiresAt,
lastError,
};
}
if (
type === "createNodeWithEdgeToTarget" &&
typeof payload.canvasId === "string" &&
typeof payload.type === "string" &&
typeof payload.positionX === "number" &&
typeof payload.positionY === "number" &&
typeof payload.width === "number" &&
typeof payload.height === "number" &&
typeof payload.clientRequestId === "string" &&
typeof payload.targetNodeId === "string"
) {
return {
id,
canvasId,
type,
payload: {
canvasId: payload.canvasId as Id<"canvases">,
type: payload.type,
positionX: payload.positionX,
positionY: payload.positionY,
width: payload.width,
height: payload.height,
data: payload.data,
parentId:
typeof payload.parentId === "string"
? (payload.parentId as Id<"nodes">)
: undefined,
zIndex: typeof payload.zIndex === "number" ? payload.zIndex : undefined,
clientRequestId: payload.clientRequestId,
targetNodeId: payload.targetNodeId as Id<"nodes">,
sourceHandle:
typeof payload.sourceHandle === "string"
? payload.sourceHandle
: undefined,
targetHandle:
typeof payload.targetHandle === "string"
? payload.targetHandle
: undefined,
},
enqueuedAt,
attemptCount,
nextRetryAt,
expiresAt,
lastError,
};
}
if (
type === "createEdge" &&
typeof payload.canvasId === "string" &&
typeof payload.sourceNodeId === "string" &&
typeof payload.targetNodeId === "string" &&
typeof payload.clientRequestId === "string"
) {
return {
id,
canvasId,
type,
payload: {
canvasId: payload.canvasId as Id<"canvases">,
sourceNodeId: payload.sourceNodeId as Id<"nodes">,
targetNodeId: payload.targetNodeId as Id<"nodes">,
sourceHandle:
typeof payload.sourceHandle === "string"
? payload.sourceHandle
: undefined,
targetHandle:
typeof payload.targetHandle === "string"
? payload.targetHandle
: undefined,
clientRequestId: payload.clientRequestId,
},
enqueuedAt,
attemptCount,
nextRetryAt,
expiresAt,
lastError,
};
}
if (
type === "removeEdge" &&
typeof payload.edgeId === "string"
) {
return {
id,
canvasId,
type,
payload: {
edgeId: payload.edgeId as Id<"edges">,
},
enqueuedAt,
attemptCount,
nextRetryAt,
expiresAt,
lastError,
};
}
if (
type === "batchRemoveNodes" &&
Array.isArray(payload.nodeIds) &&
payload.nodeIds.every((entry) => typeof entry === "string")
) {
return {
id,
canvasId,
type,
payload: {
nodeIds: payload.nodeIds as Id<"nodes">[],
},
enqueuedAt,
attemptCount,
nextRetryAt,
expiresAt,
lastError,
};
}
if (
type === "moveNode" &&
typeof payload.nodeId === "string" &&
typeof payload.positionX === "number" &&
typeof payload.positionY === "number"
) {
return {
id,
canvasId,
type,
payload: {
nodeId: payload.nodeId as Id<"nodes">,
positionX: payload.positionX,
positionY: payload.positionY,
},
enqueuedAt,
attemptCount,
nextRetryAt,
expiresAt,
lastError,
};
}
if (
type === "resizeNode" &&
typeof payload.nodeId === "string" &&
typeof payload.width === "number" &&
typeof payload.height === "number"
) {
return {
id,
canvasId,
type,
payload: {
nodeId: payload.nodeId as Id<"nodes">,
width: payload.width,
height: payload.height,
},
enqueuedAt,
attemptCount,
nextRetryAt,
expiresAt,
lastError,
};
}
if (type === "updateData" && typeof payload.nodeId === "string") {
return {
id,
canvasId,
type,
payload: {
nodeId: payload.nodeId as Id<"nodes">,
data: payload.data,
},
enqueuedAt,
attemptCount,
nextRetryAt,
expiresAt,
lastError,
};
}
return null;
}
function sortByEnqueued(a: CanvasSyncOp, b: CanvasSyncOp): number {
if (a.enqueuedAt === b.enqueuedAt) return a.id.localeCompare(b.id);
return a.enqueuedAt - b.enqueuedAt;
}
function toStoredOp<TType extends CanvasSyncOpType>(
input: EnqueueInput<TType>,
): CanvasSyncOpFor<TType> {
const now = input.now ?? Date.now();
return {
id: input.id,
canvasId: input.canvasId,
type: input.type,
payload: input.payload,
enqueuedAt: now,
attemptCount: 0,
nextRetryAt: now,
expiresAt: now + CANVAS_SYNC_RETENTION_MS,
} as CanvasSyncOpFor<TType>;
}
function coalescingNodeId(
op: Pick<CanvasSyncOp, "type" | "payload">,
): string | null {
if (op.type !== "moveNode" && op.type !== "resizeNode" && op.type !== "updateData") {
return null;
}
const payload = op.payload as { nodeId?: string };
return typeof payload.nodeId === "string" && payload.nodeId.length > 0
? payload.nodeId
: null;
}
export async function listCanvasSyncOps(canvasId: string): Promise<CanvasSyncOp[]> {
const db = await openDb();
if (!db) {
return readFallbackOps()
.filter((entry) => entry.canvasId === canvasId)
.sort(sortByEnqueued);
}
const tx = db.transaction(STORE_NAME, "readonly");
const store = tx.objectStore(STORE_NAME);
const byCanvas = store.index("by_canvas");
const records = await reqToPromise(byCanvas.getAll(canvasId));
return (records as unknown[])
.map(normalizeOp)
.filter((entry): entry is CanvasSyncOp => entry !== null)
.sort(sortByEnqueued);
}
export async function countCanvasSyncOps(canvasId: string): Promise<number> {
const db = await openDb();
if (!db) {
return readFallbackOps().filter((entry) => entry.canvasId === canvasId).length;
}
const tx = db.transaction(STORE_NAME, "readonly");
const store = tx.objectStore(STORE_NAME);
const byCanvas = store.index("by_canvas");
const count = await reqToPromise(byCanvas.count(canvasId));
return count;
}
export async function enqueueCanvasSyncOp<TType extends CanvasSyncOpType>(
input: EnqueueInput<TType>,
): Promise<{ replacedIds: string[] }> {
const op = toStoredOp(input);
const existing = await listCanvasSyncOps(input.canvasId);
const nodeId = coalescingNodeId(op);
const replacedIds: string[] = [];
for (const candidate of existing) {
if (candidate.type !== op.type) continue;
if (nodeId === null) continue;
if (getNodeIdFromOp(candidate) !== nodeId) continue;
replacedIds.push(candidate.id);
}
const db = await openDb();
if (!db) {
const fallback = readFallbackOps().filter(
(entry) => !replacedIds.includes(entry.id),
);
fallback.push(op);
writeFallbackOps(fallback);
return { replacedIds };
}
const tx = db.transaction(STORE_NAME, "readwrite");
const store = tx.objectStore(STORE_NAME);
for (const id of replacedIds) {
store.delete(id);
}
store.put(op);
await txDone(tx);
return { replacedIds };
}
export async function ackCanvasSyncOp(opId: string): Promise<void> {
const db = await openDb();
if (!db) {
const fallback = readFallbackOps().filter((entry) => entry.id !== opId);
writeFallbackOps(fallback);
return;
}
const tx = db.transaction(STORE_NAME, "readwrite");
tx.objectStore(STORE_NAME).delete(opId);
await txDone(tx);
}
export async function markCanvasSyncOpFailed(
opId: string,
opts: { nextRetryAt: number; lastError?: string },
): Promise<void> {
const db = await openDb();
if (!db) {
const fallback = readFallbackOps().map((entry) => {
if (entry.id !== opId) return entry;
return {
...entry,
attemptCount: entry.attemptCount + 1,
nextRetryAt: opts.nextRetryAt,
lastError: opts.lastError,
};
});
writeFallbackOps(fallback);
return;
}
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readwrite");
const store = tx.objectStore(STORE_NAME);
const getReq = store.get(opId);
getReq.onerror = () => reject(getReq.error ?? new Error("IndexedDB get failed"));
getReq.onsuccess = () => {
const current = normalizeOp(getReq.result);
if (!current) return;
const next: CanvasSyncOp = {
...current,
attemptCount: current.attemptCount + 1,
nextRetryAt: opts.nextRetryAt,
lastError: opts.lastError,
};
store.put(next);
};
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error ?? new Error("IndexedDB transaction failed"));
tx.onabort = () => reject(tx.error ?? new Error("IndexedDB transaction aborted"));
});
}
export async function dropExpiredCanvasSyncOps(
canvasId: string,
now: number,
): Promise<string[]> {
const all = await listCanvasSyncOps(canvasId);
const expiredIds = all
.filter((entry) => entry.expiresAt <= now)
.map((entry) => entry.id);
if (expiredIds.length === 0) return [];
const db = await openDb();
if (!db) {
const fallback = readFallbackOps().filter((entry) => !expiredIds.includes(entry.id));
writeFallbackOps(fallback);
return expiredIds;
}
const tx = db.transaction(STORE_NAME, "readwrite");
const store = tx.objectStore(STORE_NAME);
for (const id of expiredIds) {
store.delete(id);
}
await txDone(tx);
return expiredIds;
}
function remapNodeIdInPayload(
op: CanvasSyncOp,
fromNodeId: string,
toNodeId: string,
): CanvasSyncOp {
if (op.type === "createNode" && op.payload.parentId === fromNodeId) {
return {
...op,
payload: { ...op.payload, parentId: toNodeId as Id<"nodes"> },
};
}
if (op.type === "createNodeWithEdgeFromSource") {
let changed = false;
const next = { ...op.payload };
if (next.parentId === fromNodeId) {
next.parentId = toNodeId as Id<"nodes">;
changed = true;
}
if (next.sourceNodeId === fromNodeId) {
next.sourceNodeId = toNodeId as Id<"nodes">;
changed = true;
}
if (changed) {
return { ...op, payload: next };
}
}
if (op.type === "createNodeWithEdgeToTarget") {
let changed = false;
const next = { ...op.payload };
if (next.parentId === fromNodeId) {
next.parentId = toNodeId as Id<"nodes">;
changed = true;
}
if (next.targetNodeId === fromNodeId) {
next.targetNodeId = toNodeId as Id<"nodes">;
changed = true;
}
if (changed) {
return { ...op, payload: next };
}
}
if (op.type === "moveNode" && op.payload.nodeId === fromNodeId) {
return {
...op,
payload: { ...op.payload, nodeId: toNodeId as Id<"nodes"> },
};
}
if (op.type === "resizeNode" && op.payload.nodeId === fromNodeId) {
return {
...op,
payload: { ...op.payload, nodeId: toNodeId as Id<"nodes"> },
};
}
if (op.type === "updateData" && op.payload.nodeId === fromNodeId) {
return {
...op,
payload: { ...op.payload, nodeId: toNodeId as Id<"nodes"> },
};
}
if (op.type === "createEdge") {
let changed = false;
const next = { ...op.payload };
if (next.sourceNodeId === fromNodeId) {
next.sourceNodeId = toNodeId as Id<"nodes">;
changed = true;
}
if (next.targetNodeId === fromNodeId) {
next.targetNodeId = toNodeId as Id<"nodes">;
changed = true;
}
if (changed) {
return { ...op, payload: next };
}
}
if (op.type === "batchRemoveNodes") {
if (!op.payload.nodeIds.includes(fromNodeId as Id<"nodes">)) {
return op;
}
return {
...op,
payload: {
...op.payload,
nodeIds: op.payload.nodeIds.map((nodeId) =>
nodeId === fromNodeId ? (toNodeId as Id<"nodes">) : nodeId,
),
},
};
}
return op;
}
export async function remapCanvasSyncNodeId(
canvasId: string,
fromNodeId: string,
toNodeId: string,
): Promise<number> {
const queue = await listCanvasSyncOps(canvasId);
let changed = 0;
const nextOps = queue.map((entry) => {
const next = remapNodeIdInPayload(entry, fromNodeId, toNodeId);
if (next !== entry) changed += 1;
return next;
});
if (changed === 0) return 0;
const db = await openDb();
if (!db) {
const fallback = readFallbackOps()
.filter((entry) => entry.canvasId !== canvasId)
.concat(nextOps);
writeFallbackOps(fallback);
return changed;
}
const tx = db.transaction(STORE_NAME, "readwrite");
const store = tx.objectStore(STORE_NAME);
for (const op of nextOps) {
store.put(op);
}
await txDone(tx);
return changed;
}
function opTouchesNodeId(op: CanvasSyncOp, nodeIdSet: ReadonlySet<string>): boolean {
if (op.type === "moveNode" || op.type === "resizeNode" || op.type === "updateData") {
return nodeIdSet.has(op.payload.nodeId);
}
if (op.type === "createEdge") {
return (
nodeIdSet.has(op.payload.sourceNodeId) || nodeIdSet.has(op.payload.targetNodeId)
);
}
if (op.type === "createNode") {
return op.payload.parentId !== undefined && nodeIdSet.has(op.payload.parentId);
}
if (op.type === "createNodeWithEdgeFromSource") {
return (
nodeIdSet.has(op.payload.sourceNodeId) ||
(op.payload.parentId !== undefined && nodeIdSet.has(op.payload.parentId))
);
}
if (op.type === "createNodeWithEdgeToTarget") {
return (
nodeIdSet.has(op.payload.targetNodeId) ||
(op.payload.parentId !== undefined && nodeIdSet.has(op.payload.parentId))
);
}
if (op.type === "batchRemoveNodes") {
return op.payload.nodeIds.some((nodeId) => nodeIdSet.has(nodeId));
}
return false;
}
function opHasClientRequestId(op: CanvasSyncOp, clientRequestIdSet: ReadonlySet<string>): boolean {
if (op.type === "createNode") {
return clientRequestIdSet.has(op.payload.clientRequestId);
}
if (op.type === "createNodeWithEdgeFromSource") {
return clientRequestIdSet.has(op.payload.clientRequestId);
}
if (op.type === "createNodeWithEdgeToTarget") {
return clientRequestIdSet.has(op.payload.clientRequestId);
}
if (op.type === "createEdge") {
return clientRequestIdSet.has(op.payload.clientRequestId);
}
return false;
}
function opTouchesEdgeId(op: CanvasSyncOp, edgeIdSet: ReadonlySet<string>): boolean {
if (op.type === "removeEdge") {
return edgeIdSet.has(op.payload.edgeId);
}
return false;
}
async function dropCanvasSyncOpsByPredicate(
canvasId: string,
predicate: (op: CanvasSyncOp) => boolean,
): Promise<string[]> {
const all = await listCanvasSyncOps(canvasId);
const idsToDrop = all.filter(predicate).map((entry) => entry.id);
if (idsToDrop.length === 0) return [];
const idSet = new Set(idsToDrop);
const db = await openDb();
if (!db) {
const fallback = readFallbackOps().filter((entry) => !idSet.has(entry.id));
writeFallbackOps(fallback);
return idsToDrop;
}
const tx = db.transaction(STORE_NAME, "readwrite");
const store = tx.objectStore(STORE_NAME);
for (const id of idsToDrop) {
store.delete(id);
}
await txDone(tx);
return idsToDrop;
}
export async function dropCanvasSyncOpsByNodeIds(
canvasId: string,
nodeIds: string[],
): Promise<string[]> {
if (nodeIds.length === 0) return [];
const nodeIdSet = new Set(nodeIds);
return await dropCanvasSyncOpsByPredicate(canvasId, (op) =>
opTouchesNodeId(op, nodeIdSet),
);
}
export async function dropCanvasSyncOpsByClientRequestIds(
canvasId: string,
clientRequestIds: string[],
): Promise<string[]> {
if (clientRequestIds.length === 0) return [];
const idSet = new Set(clientRequestIds);
return await dropCanvasSyncOpsByPredicate(canvasId, (op) =>
opHasClientRequestId(op, idSet),
);
}
export async function dropCanvasSyncOpsByEdgeIds(
canvasId: string,
edgeIds: string[],
): Promise<string[]> {
if (edgeIds.length === 0) return [];
const edgeIdSet = new Set(edgeIds);
return await dropCanvasSyncOpsByPredicate(canvasId, (op) =>
opTouchesEdgeId(op, edgeIdSet),
);
}