Files
gitmost/packages/mcp/test/mock/write-order.test.mjs
claude code agent 227 3b80285d57 fix(#260): open MCP collab docs by canonical UUID (slugId doc-name split)
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>
2026-06-30 10:04:49 +03:00

115 lines
3.8 KiB
JavaScript

// Mock-HTTP regression for the body-before-title write order (#159 finding #10,
// PR #185 review pt 3). `updatePage` / `updatePageJson` must write the page BODY
// (collab) BEFORE the title (REST POST /pages/update), so a failed body write
// never leaves a NEW title over the OLD body (split-brain). We point the client
// at a mock server that serves auth + collab-token but has NO WebSocket upgrade
// handler, so the collab body write fails fast; we then assert the title was
// never POSTed. With the pre-fix (title-first) order, /pages/update WOULD be hit
// before the body failed.
import { test, after } from "node:test";
import assert from "node:assert/strict";
import http from "node:http";
import { DocmostClient } from "../../build/client.js";
function readBody(req) {
return new Promise((resolve) => {
let raw = "";
req.on("data", (c) => (raw += c));
req.on("end", () => resolve(raw));
});
}
function startServer(handler) {
return new Promise((resolve) => {
const server = http.createServer(handler);
server.listen(0, "127.0.0.1", () => {
const { port } = server.address();
resolve({ server, baseURL: `http://127.0.0.1:${port}/api` });
});
});
}
function sendJson(res, status, obj, extraHeaders = {}) {
res.writeHead(status, {
"Content-Type": "application/json",
...extraHeaders,
});
res.end(JSON.stringify(obj));
}
const openServers = [];
async function spawn(handler) {
const { server, baseURL } = await startServer(handler);
openServers.push(server);
return { server, baseURL };
}
after(async () => {
await Promise.all(openServers.map((s) => new Promise((r) => s.close(r))));
});
// A mock server that authenticates and hands out a collab token, tracks whether
// the title endpoint was hit, but has NO WS upgrade handler -> collab fails fast.
function makeServer() {
const state = { titlePosted: false };
const handler = async (req, res) => {
await readBody(req);
if (req.url === "/api/auth/login") {
sendJson(
res,
200,
{ success: true },
{
"Set-Cookie": "authToken=t; Path=/; HttpOnly",
},
);
return;
}
if (req.url === "/api/auth/collab-token") {
sendJson(res, 200, { data: { token: "collab-jwt" } });
return;
}
if (req.url === "/api/pages/info") {
// Resolve the pageId -> canonical UUID (#260) so the test exercises the
// real body-write failure (no WS upgrade) rather than a resolve failure.
sendJson(res, 200, {
data: { id: "11111111-1111-4111-8111-111111111111", slugId: "page-1" },
});
return;
}
if (req.url === "/api/pages/update") {
state.titlePosted = true;
sendJson(res, 200, { data: {} });
return;
}
sendJson(res, 404, { message: "not found" });
};
return { state, handler };
}
test("updatePage does NOT POST the title when the body (collab) write fails (#159)", async () => {
const { state, handler } = makeServer();
const { baseURL } = await spawn(handler);
const client = new DocmostClient(baseURL, "u@e.com", "pw");
await assert.rejects(() =>
client.updatePage("page-1", "# Heading\n\nsome body", "New Title"),
);
assert.equal(
state.titlePosted,
false,
"title must NOT be posted when the body write failed (body-first order)",
);
});
test("updatePageJson does NOT POST the title when the body (collab) write fails (#159)", async () => {
const { state, handler } = makeServer();
const { baseURL } = await spawn(handler);
const client = new DocmostClient(baseURL, "u@e.com", "pw");
const doc = { type: "doc", content: [{ type: "paragraph" }] };
await assert.rejects(() => client.updatePageJson("page-1", doc, "New Title"));
assert.equal(
state.titlePosted,
false,
"title must NOT be posted when the body write failed (body-first order)",
);
});