Real root cause of the silent MCP edit loss: the web editor always opens the
collaboration document by the page UUID (`page.${page.id}`), but the MCP
opened it by the agent-supplied id — usually a slugId — so `page.${pageId}`
became `page.<slugId>`. For one DB page that is TWO independent Yjs documents;
both persist to the same `pages` row (findById/updatePage resolve id or
slugId), so the human tab's debounced store overwrites the agent edit
(last-store-wins) — gone after reload, never shown live. The slugId doc also
made the server's transclusion sync + embedding reindex throw Postgres 22P02.
Fix:
- MCP (primary): resolvePageId(pageId) returns the canonical UUID — a UUID
short-circuits with no network call, a slugId resolves once via getPageRaw
and is cached both ways. Every collab-write path (mutatePageContent /
updatePageContentRealtime / replacePageContent and the mutate/replace/
unlocked seams) now opens by the resolved UUID, so the MCP and the editor
share ONE Yjs doc. replaceImage's whole-operation page lock also keys on the
UUID so it serializes against the other (now-UUID-keyed) writes.
- Server (defense + kills the 22P02 noise): onStoreDocument passes the resolved
page.id — not the raw doc-name id — to syncTransclusion, the embedding queue,
the mention-notification job, addContributors, and the in-tx history read.
Content store and the empty-guard are untouched.
Tests: a new MCP test stands up a real Hocuspocus server and asserts a slugId
input opens `page.<uuid>` (never `page.<slugId>`), with UUID short-circuit and
single-resolve caching; the server spec asserts the side-effects receive the
UUID for a `page.<slugId>` doc. closes #260
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
106 lines
4.4 KiB
JavaScript
106 lines
4.4 KiB
JavaScript
// Wrapper tests for DocmostClient.insertFootnote (issue #228, review #11/#9):
|
|
// the page-locked write seam (mutatePage) is overridden so the wrapper's
|
|
// transform + response shaping can be exercised WITHOUT a live Hocuspocus collab
|
|
// socket. We assert the two guarantees that the pure insertInlineFootnote test
|
|
// can NOT prove on its own:
|
|
// - a missing anchor makes the transform throw "anchor text not found" and NO
|
|
// document is persisted (the no-partial-write guarantee), and
|
|
// - a success shapes footnoteId / reused / message / verify and writes a doc
|
|
// carrying the new reference + the derived single list.
|
|
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { DocmostClient } from "../../build/client.js";
|
|
|
|
const para = (...c) => ({ type: "paragraph", content: c });
|
|
const ref = (id) => ({ type: "footnoteReference", attrs: { id } });
|
|
const def = (id, text) => ({
|
|
type: "footnoteDefinition",
|
|
attrs: { id },
|
|
content: [{ type: "paragraph", content: [{ type: "text", text }] }],
|
|
});
|
|
const list = (...d) => ({ type: "footnotesList", content: d });
|
|
|
|
function findAll(node, type, acc = []) {
|
|
if (!node || typeof node !== "object") return acc;
|
|
if (node.type === type) acc.push(node);
|
|
if (Array.isArray(node.content)) for (const c of node.content) findAll(c, type, acc);
|
|
return acc;
|
|
}
|
|
|
|
// A DocmostClient whose auth + page-locked write are stubbed; `mutatePage`
|
|
// mirrors collaboration.mutatePageContent (run the transform against a clone of
|
|
// the live doc; if it throws, persist NOTHING and rethrow).
|
|
function makeClient(liveDoc) {
|
|
const calls = { writes: [] };
|
|
class TestClient extends DocmostClient {
|
|
async ensureAuthenticated() {}
|
|
async getCollabTokenWithReauth() {
|
|
return "collab-token";
|
|
}
|
|
// Identity resolution: this test isolates the footnote wrapper, so the
|
|
// slugId->uuid resolution (#260) is stubbed to a no-op and "p1" stays "p1".
|
|
async resolvePageId(pageId) {
|
|
return pageId;
|
|
}
|
|
async mutatePage(pageId, token, apiUrl, transform) {
|
|
calls.pageId = pageId;
|
|
calls.token = token;
|
|
const newDoc = transform(structuredClone(liveDoc));
|
|
calls.writes.push(newDoc);
|
|
return { doc: newDoc, verify: { ok: true, marker: "v" } };
|
|
}
|
|
}
|
|
const client = new TestClient("http://127.0.0.1:1/api", "e@x.com", "pw");
|
|
return { client, calls };
|
|
}
|
|
|
|
test("insertFootnote: anchor not found -> throws and persists nothing", async () => {
|
|
const { client, calls } = makeClient({
|
|
type: "doc",
|
|
content: [para({ type: "text", text: "nothing to anchor on" })],
|
|
});
|
|
await assert.rejects(
|
|
() => client.insertFootnote("p1", "ZZZ", "a note"),
|
|
/anchor text not found/i,
|
|
);
|
|
assert.equal(calls.writes.length, 0, "no document may be persisted on a missing anchor");
|
|
});
|
|
|
|
test("insertFootnote: success (new) writes a reference + derived list and shapes the response", async () => {
|
|
const { client, calls } = makeClient({
|
|
type: "doc",
|
|
content: [para({ type: "text", text: "The sky is blue today." })],
|
|
});
|
|
const res = await client.insertFootnote("p1", "blue", "Rayleigh scattering.");
|
|
assert.equal(res.success, true);
|
|
assert.equal(res.modified, true);
|
|
assert.equal(res.pageId, "p1");
|
|
assert.equal(res.reused, false);
|
|
assert.equal(typeof res.footnoteId, "string");
|
|
assert.ok(res.footnoteId.length > 0);
|
|
assert.equal(res.message, "Footnote inserted.");
|
|
assert.deepEqual(res.verify, { ok: true, marker: "v" });
|
|
assert.equal(calls.writes.length, 1, "exactly one write persisted");
|
|
assert.equal(findAll(calls.writes[0], "footnoteReference").length, 1);
|
|
assert.equal(findAll(calls.writes[0], "footnotesList").length, 1);
|
|
assert.equal(calls.pageId, "p1");
|
|
});
|
|
|
|
test("insertFootnote: success (reused) reuses the existing definition and reports it", async () => {
|
|
const liveDoc = {
|
|
type: "doc",
|
|
content: [
|
|
para({ type: "text", text: "Alpha and beta." }, ref("a")),
|
|
list(def("a", "shared note")),
|
|
],
|
|
};
|
|
const { client, calls } = makeClient(liveDoc);
|
|
const res = await client.insertFootnote("p1", "beta", "shared note");
|
|
assert.equal(res.reused, true);
|
|
assert.equal(res.footnoteId, "a");
|
|
assert.match(res.message, /reused an existing same-content definition/i);
|
|
// Still exactly one definition (the reused one), two references to it.
|
|
assert.equal(findAll(calls.writes[0], "footnoteDefinition").length, 1);
|
|
assert.equal(findAll(calls.writes[0], "footnoteReference").length, 2);
|
|
});
|