3b80285d57
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>
115 lines
3.8 KiB
JavaScript
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)",
|
|
);
|
|
});
|