Compare commits
8 Commits
feat/git-s
...
fix/260-co
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e04afee629 | ||
|
|
3b80285d57 | ||
| c4842367af | |||
|
|
96b9ec11d6 | ||
|
|
24b802baa3 | ||
|
|
f8d26420eb | ||
|
|
5c1187b864 | ||
|
|
14f83abe78 |
14
.github/workflows/develop.yml
vendored
14
.github/workflows/develop.yml
vendored
@@ -75,7 +75,9 @@ jobs:
|
||||
APP_URL: http://localhost:3000
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg18
|
||||
# via mirror.gcr.io (Docker Hub pull-through cache; avoids Hub anonymous
|
||||
# pull rate-limit that randomly fails on shared GitHub runner IPs).
|
||||
image: mirror.gcr.io/pgvector/pgvector:pg18
|
||||
env:
|
||||
POSTGRES_DB: docmost
|
||||
POSTGRES_USER: docmost
|
||||
@@ -88,7 +90,8 @@ jobs:
|
||||
--health-timeout 5s
|
||||
--health-retries 20
|
||||
redis:
|
||||
image: redis:7
|
||||
# via mirror.gcr.io (see postgres note above).
|
||||
image: mirror.gcr.io/library/redis:7
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: >-
|
||||
@@ -135,7 +138,9 @@ jobs:
|
||||
NODE_ENV: production
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg18
|
||||
# via mirror.gcr.io (Docker Hub pull-through cache; avoids Hub anonymous
|
||||
# pull rate-limit that randomly fails on shared GitHub runner IPs).
|
||||
image: mirror.gcr.io/pgvector/pgvector:pg18
|
||||
env:
|
||||
POSTGRES_DB: docmost
|
||||
POSTGRES_USER: docmost
|
||||
@@ -148,7 +153,8 @@ jobs:
|
||||
--health-timeout 5s
|
||||
--health-retries 20
|
||||
redis:
|
||||
image: redis:7
|
||||
# via mirror.gcr.io (see postgres note above).
|
||||
image: mirror.gcr.io/library/redis:7
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: >-
|
||||
|
||||
7
.github/workflows/test.yml
vendored
7
.github/workflows/test.yml
vendored
@@ -27,7 +27,9 @@ jobs:
|
||||
# TEST_*_URL overrides are needed.
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg18
|
||||
# via mirror.gcr.io (Docker Hub pull-through cache; avoids Hub anonymous
|
||||
# pull rate-limit that randomly fails on shared GitHub runner IPs).
|
||||
image: mirror.gcr.io/pgvector/pgvector:pg18
|
||||
env:
|
||||
POSTGRES_USER: docmost
|
||||
POSTGRES_PASSWORD: docmost_dev_pw
|
||||
@@ -40,7 +42,8 @@ jobs:
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis:7
|
||||
# via mirror.gcr.io (see postgres note above).
|
||||
image: mirror.gcr.io/library/redis:7
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: >-
|
||||
|
||||
@@ -514,6 +514,7 @@ knowledge layer, an embedded MCP server, and the Gitmost rebrand.
|
||||
- Build: drop the private EE submodule, retarget CI to GHCR, and update the
|
||||
Docker image to the GHCR registry.
|
||||
|
||||
[Unreleased]: https://github.com/vvzvlad/gitmost/compare/v0.93.0...HEAD
|
||||
[Unreleased]: https://github.com/vvzvlad/gitmost/compare/v0.94.0...HEAD
|
||||
[0.94.0]: https://github.com/vvzvlad/gitmost/compare/v0.93.0...v0.94.0
|
||||
[0.93.0]: https://github.com/vvzvlad/gitmost/compare/v0.91.0...v0.93.0
|
||||
[0.91.0]: https://github.com/vvzvlad/gitmost/compare/v0.90.1...v0.91.0
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
IconUnderline,
|
||||
IconMessage,
|
||||
IconEyeOff,
|
||||
IconClearFormatting,
|
||||
} from "@tabler/icons-react";
|
||||
import clsx from "clsx";
|
||||
import classes from "./bubble-menu.module.css";
|
||||
@@ -117,6 +118,14 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
command: () => props.editor.chain().focus().toggleSpoiler().run(),
|
||||
icon: IconEyeOff,
|
||||
},
|
||||
{
|
||||
name: "Clear formatting",
|
||||
// Action, not a toggle — never show an active/highlighted state.
|
||||
isActive: () => false,
|
||||
// Mirror the fixed-toolbar behavior: strip all inline marks from the selection.
|
||||
command: () => props.editor.chain().focus().unsetAllMarks().run(),
|
||||
icon: IconClearFormatting,
|
||||
},
|
||||
];
|
||||
|
||||
const commentItem: BubbleMenuItem = {
|
||||
|
||||
@@ -422,4 +422,51 @@ describe('PersistenceExtension.onStoreDocument — Approach-A boundary snapshot'
|
||||
expect(historyQueue.add).not.toHaveBeenCalled();
|
||||
expect(aiQueue.add).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// #260 — when the collab doc name carries a SLUGID (`page.<slugId>`) the
|
||||
// post-store side effects must use the resolved page.id (a UUID), NOT the
|
||||
// slugId. The transclusion sync + embedding reindex write uuid-typed columns,
|
||||
// so a slugId there threw Postgres 22P02; the contributors key must also match
|
||||
// the PAGE_HISTORY job, which is enqueued with page.id.
|
||||
it('uses the canonical page.id (not the slugId doc name) for post-store side effects (#260)', async () => {
|
||||
const SLUG = 'slug-1'; // persistedHumanPage.slugId; findById resolves it
|
||||
const document = ydocFor(doc('NEW AGENT CONTENT'));
|
||||
pageRepo.findById.mockResolvedValue(persistedHumanPage('NEW AGENT CONTENT'));
|
||||
pageHistoryRepo.findPageLastHistory.mockResolvedValue(null);
|
||||
|
||||
// A `page.<slugId>` document name (the bug's smoking gun), agent store over
|
||||
// a human page so the in-tx history-boundary read is also exercised.
|
||||
await ext.onStoreDocument({
|
||||
documentName: `page.${SLUG}`,
|
||||
document,
|
||||
context: { user: { id: USER_ID, name: 'Alice' }, actor: 'agent' },
|
||||
} as any);
|
||||
|
||||
// findById was queried with the slugId (it resolves either id or slugId).
|
||||
expect(pageRepo.findById).toHaveBeenCalledWith(SLUG, expect.anything());
|
||||
|
||||
// The in-tx history-boundary read uses the canonical UUID, never the slugId.
|
||||
expect(pageHistoryRepo.findPageLastHistory).toHaveBeenCalledWith(
|
||||
PAGE_ID,
|
||||
expect.anything(),
|
||||
);
|
||||
|
||||
// Transclusion sync (uuid-typed columns) must receive the UUID.
|
||||
expect(transclusionService.syncPageTransclusions.mock.calls[0][0]).toBe(
|
||||
PAGE_ID,
|
||||
);
|
||||
expect(transclusionService.syncPageReferences.mock.calls[0][0]).toBe(
|
||||
PAGE_ID,
|
||||
);
|
||||
expect(
|
||||
transclusionService.syncPageTemplateReferences.mock.calls[0][0],
|
||||
).toBe(PAGE_ID);
|
||||
|
||||
// Embedding reindex job keyed by the UUID (slugId there threw 22P02).
|
||||
expect(aiQueue.add).toHaveBeenCalledTimes(1);
|
||||
expect(aiQueue.add.mock.calls[0][1].pageIds).toEqual([PAGE_ID]);
|
||||
|
||||
// Contributors keyed by the UUID so they match the PAGE_HISTORY job (page.id).
|
||||
expect(collabHistory.addContributors.mock.calls[0][0]).toBe(PAGE_ID);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -329,8 +329,10 @@ export class PersistenceExtension implements Extension {
|
||||
lastUpdatedSource === 'agent' &&
|
||||
page.lastUpdatedSource !== 'agent'
|
||||
) {
|
||||
// pageHistory.pageId is uuid-typed; use page.id (never the doc-name
|
||||
// slugId) so a `page.<slugId>` doc cannot throw 22P02 here (#260).
|
||||
const lastHistory = await this.pageHistoryRepo.findPageLastHistory(
|
||||
pageId,
|
||||
page.id,
|
||||
{ includeContent: true, trx },
|
||||
);
|
||||
const humanBaselineMissing =
|
||||
@@ -398,11 +400,16 @@ export class PersistenceExtension implements Extension {
|
||||
}),
|
||||
);
|
||||
|
||||
await this.syncTransclusion(pageId, page.workspaceId, tiptapJson);
|
||||
// Use the canonical page UUID (page.id), not the doc-name id, which may be
|
||||
// a slugId for a `page.<slugId>` doc (#260). The transclusion/reference
|
||||
// syncs write uuid-typed columns, so a slugId here threw Postgres 22P02.
|
||||
await this.syncTransclusion(page.id, page.workspaceId, tiptapJson);
|
||||
}
|
||||
|
||||
if (page) {
|
||||
await this.collabHistory.addContributors(pageId, editingUserIds);
|
||||
// Key contributors by the page UUID so they MATCH the PAGE_HISTORY job,
|
||||
// which is enqueued with page.id and pops contributors by page.id (#260).
|
||||
await this.collabHistory.addContributors(page.id, editingUserIds);
|
||||
|
||||
const mentions = extractMentions(tiptapJson);
|
||||
|
||||
@@ -420,14 +427,17 @@ export class PersistenceExtension implements Extension {
|
||||
creatorId: m.creatorId,
|
||||
})),
|
||||
oldMentionedUserIds,
|
||||
pageId,
|
||||
// Canonical UUID, never the doc-name slugId (#260).
|
||||
pageId: page.id,
|
||||
spaceId: page.spaceId,
|
||||
workspaceId: page.workspaceId,
|
||||
} as IPageMentionNotificationJob);
|
||||
}
|
||||
|
||||
await this.aiQueue.add(QueueJob.PAGE_CONTENT_UPDATED, {
|
||||
pageIds: [pageId],
|
||||
// Canonical UUID: the embedding reindex resolves pages by uuid, so a
|
||||
// slugId here threw Postgres 22P02 invalid-uuid (#260).
|
||||
pageIds: [page.id],
|
||||
workspaceId: page.workspaceId,
|
||||
});
|
||||
|
||||
|
||||
@@ -12,14 +12,6 @@ function sanitizeMdLinkText(value: string): string {
|
||||
.replace(/[\r\n]+/g, ' ');
|
||||
}
|
||||
|
||||
// Escape a value placed inside a double-quoted HTML attribute (img src/alt/
|
||||
// data-caption in the raw-HTML image fallback). Only & and " are special in
|
||||
// that context; escaping them is idempotent because parse5/marked decode them
|
||||
// back on re-import.
|
||||
function escapeHtmlAttr(value: string): string {
|
||||
return value.replace(/&/g, '&').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// Tags turndown treats as void (self-closing). Footnote references render as an
|
||||
// empty <sup data-footnote-ref> whose meaning lives entirely in its data-id;
|
||||
// without marking it void, turndown's blank-node removal drops it before our
|
||||
|
||||
@@ -37,6 +37,15 @@ const MIME_TO_EXT = {
|
||||
"image/webp": ".webp",
|
||||
"image/svg+xml": ".svg",
|
||||
};
|
||||
// Canonical UUID shape (versions 1–8, matching the `uuid` package's `validate`
|
||||
// that the server's isValidUUID uses). page.repo.ts treats any non-UUID pageId
|
||||
// as a slugId, so the MCP detects a UUID locally and skips a /pages/info
|
||||
// round-trip in resolvePageId. A 10-char nanoid slugId never contains dashes,
|
||||
// so it can never be misread as a UUID here.
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
function isUuid(value) {
|
||||
return typeof value === "string" && UUID_RE.test(value);
|
||||
}
|
||||
export class DocmostClient {
|
||||
client;
|
||||
token = null;
|
||||
@@ -64,6 +73,11 @@ export class DocmostClient {
|
||||
// can all call login() at once. Memoizing a single promise collapses that
|
||||
// thundering herd into ONE /auth/login request that everyone awaits.
|
||||
loginPromise = null;
|
||||
// Canonical-UUID cache for resolvePageId: maps an agent-supplied slugId to the
|
||||
// page's canonical UUID, so repeated collab edits on the same page do not
|
||||
// re-fetch /pages/info. A UUID input short-circuits before this cache (see
|
||||
// resolvePageId), so only slugId->uuid entries are stored/read here.
|
||||
pageIdCache = new Map();
|
||||
constructor(configOrBaseURL, email, password) {
|
||||
// Normalize the legacy positional form into the object union.
|
||||
const config = typeof configOrBaseURL === "string"
|
||||
@@ -572,6 +586,35 @@ export class DocmostClient {
|
||||
const response = await this.client.post("/pages/info", { pageId });
|
||||
return response.data?.data ?? response.data;
|
||||
}
|
||||
/**
|
||||
* Resolve an agent-supplied pageId to the page's CANONICAL UUID (`page.id`),
|
||||
* so every collaboration document the MCP opens is named `page.<uuid>` — the
|
||||
* SAME name the web editor always uses (`page.${page.id}`).
|
||||
*
|
||||
* The agent commonly passes a 10-char public slugId (from URLs/listings) as
|
||||
* the pageId. The web editor opens the collab doc by UUID, but the MCP used to
|
||||
* pass that slugId straight into the collab doc name (`page.<slugId>`). For one
|
||||
* DB row that produced TWO independent Yjs documents whose debounced stores
|
||||
* clobbered each other — the agent's edit was silently lost (#260).
|
||||
*
|
||||
* A UUID input short-circuits with no network round-trip. A slugId is resolved
|
||||
* once via getPageRaw and cached (both slugId->uuid and uuid->uuid), so
|
||||
* repeated edits on the same page add no extra request.
|
||||
*/
|
||||
async resolvePageId(pageId) {
|
||||
if (isUuid(pageId))
|
||||
return pageId;
|
||||
const cached = this.pageIdCache.get(pageId);
|
||||
if (cached)
|
||||
return cached;
|
||||
const data = await this.getPageRaw(pageId);
|
||||
const uuid = data?.id;
|
||||
if (typeof uuid !== "string" || !uuid) {
|
||||
throw new Error(`Could not resolve a canonical page id for "${pageId}"`);
|
||||
}
|
||||
this.pageIdCache.set(pageId, uuid);
|
||||
return uuid;
|
||||
}
|
||||
async getPage(pageId) {
|
||||
await this.ensureAuthenticated();
|
||||
const resultData = await this.getPageRaw(pageId);
|
||||
@@ -863,10 +906,12 @@ export class DocmostClient {
|
||||
async tableInsertRow(pageId, tableRef, cells, index) {
|
||||
await this.ensureAuthenticated();
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260).
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
// Track insertion in an outer var, reset per-transform, so a collab retry
|
||||
// recomputes it cleanly (mirrors insertNode's pattern).
|
||||
let inserted = false;
|
||||
const mutation = await mutatePageContent(pageId, collabToken, this.apiUrl, (liveDoc) => {
|
||||
const mutation = await mutatePageContent(pageUuid, collabToken, this.apiUrl, (liveDoc) => {
|
||||
inserted = false;
|
||||
const { doc: nd, inserted: ins } = insertTableRow(liveDoc, tableRef, cells, index);
|
||||
inserted = ins;
|
||||
@@ -892,8 +937,10 @@ export class DocmostClient {
|
||||
async tableDeleteRow(pageId, tableRef, index) {
|
||||
await this.ensureAuthenticated();
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260).
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
let deleted = false;
|
||||
const mutation = await mutatePageContent(pageId, collabToken, this.apiUrl, (liveDoc) => {
|
||||
const mutation = await mutatePageContent(pageUuid, collabToken, this.apiUrl, (liveDoc) => {
|
||||
deleted = false;
|
||||
const { doc: nd, deleted: del } = deleteTableRow(liveDoc, tableRef, index);
|
||||
deleted = del;
|
||||
@@ -921,8 +968,10 @@ export class DocmostClient {
|
||||
async tableUpdateCell(pageId, tableRef, row, col, text) {
|
||||
await this.ensureAuthenticated();
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260).
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
let updated = false;
|
||||
const mutation = await mutatePageContent(pageId, collabToken, this.apiUrl, (liveDoc) => {
|
||||
const mutation = await mutatePageContent(pageUuid, collabToken, this.apiUrl, (liveDoc) => {
|
||||
updated = false;
|
||||
const { doc: nd, updated: upd } = updateTableCell(liveDoc, tableRef, row, col, text);
|
||||
updated = upd;
|
||||
@@ -1034,6 +1083,10 @@ export class DocmostClient {
|
||||
*/
|
||||
async updatePage(pageId, content, title) {
|
||||
await this.ensureAuthenticated();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260). The
|
||||
// REST /pages/update title write below keeps the agent-supplied id (the
|
||||
// server resolves a slugId there).
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
// Write the BODY first, then the title (#159 split-brain). If the collab
|
||||
// body write fails (e.g. a persist timeout), the title must be left
|
||||
// UNTOUCHED so the page never ends up with a new title over its old body.
|
||||
@@ -1043,7 +1096,7 @@ export class DocmostClient {
|
||||
let mutation;
|
||||
try {
|
||||
collabToken = await this.getCollabTokenWithReauth();
|
||||
mutation = await updatePageContentRealtime(pageId, content, collabToken, this.apiUrl);
|
||||
mutation = await updatePageContentRealtime(pageUuid, content, collabToken, this.apiUrl);
|
||||
}
|
||||
catch (error) {
|
||||
// Verbose diagnostics (incl. anything that could expose a token prefix)
|
||||
@@ -1259,7 +1312,9 @@ export class DocmostClient {
|
||||
// Write the BODY first, then the title (#159 split-brain): a failed body
|
||||
// write (e.g. persist timeout) must not leave a new title over the old body.
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
const mutation = await this.replacePage(pageId, doc, collabToken, this.apiUrl);
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260).
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
const mutation = await this.replacePage(pageUuid, doc, collabToken, this.apiUrl);
|
||||
// Body persisted successfully — now it is safe to set the title.
|
||||
if (title) {
|
||||
await this.client.post("/pages/update", { pageId, title });
|
||||
@@ -1294,8 +1349,10 @@ export class DocmostClient {
|
||||
throw new Error("insert_footnote: text is required");
|
||||
}
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260).
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
let result = null;
|
||||
const mutation = await this.mutatePage(pageId, collabToken, this.apiUrl, (liveDoc) => {
|
||||
const mutation = await this.mutatePage(pageUuid, collabToken, this.apiUrl, (liveDoc) => {
|
||||
const r = insertInlineFootnote(liveDoc, { anchorText, text });
|
||||
if (!r.inserted) {
|
||||
// Abort the page-locked write by throwing: mutatePageContent does not
|
||||
@@ -1383,7 +1440,9 @@ export class DocmostClient {
|
||||
// PAGE import: canonicalize footnotes (see markdownToProseMirrorCanonical).
|
||||
const doc = await markdownToProseMirrorCanonical(body);
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
const mutation = await replacePageContent(pageId, doc, collabToken, this.apiUrl);
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260).
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
const mutation = await replacePageContent(pageUuid, doc, collabToken, this.apiUrl);
|
||||
// Collect distinct comment ids that actually became comment marks in the doc.
|
||||
const collectCommentIds = (node, acc) => {
|
||||
if (!node || typeof node !== "object")
|
||||
@@ -1467,7 +1526,9 @@ export class DocmostClient {
|
||||
// to the target (parity with the other full-doc write paths).
|
||||
const canonical = canonicalizeFootnotes(content);
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
const mutation = await this.replacePage(targetPageId, canonical, collabToken, this.apiUrl);
|
||||
// Open the TARGET collab doc by its canonical UUID, never the slugId (#260).
|
||||
const targetUuid = await this.resolvePageId(targetPageId);
|
||||
const mutation = await this.replacePage(targetUuid, canonical, collabToken, this.apiUrl);
|
||||
return {
|
||||
success: true,
|
||||
sourcePageId,
|
||||
@@ -1483,6 +1544,8 @@ export class DocmostClient {
|
||||
async editPageText(pageId, edits) {
|
||||
await this.ensureAuthenticated();
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260).
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
// Apply the edits against the LIVE synced document, not the debounced REST
|
||||
// snapshot, so concurrent human edits/comments are preserved. applyTextEdits
|
||||
// records per-edit match problems in `failed` instead of throwing, and
|
||||
@@ -1495,7 +1558,7 @@ export class DocmostClient {
|
||||
// we must NOT write (no spurious history version) and must not claim a write
|
||||
// happened.
|
||||
let wrote = false;
|
||||
const mutation = await mutatePageContent(pageId, collabToken, this.apiUrl, (liveDoc) => {
|
||||
const mutation = await mutatePageContent(pageUuid, collabToken, this.apiUrl, (liveDoc) => {
|
||||
wrote = false;
|
||||
const r = applyTextEdits(liveDoc, edits);
|
||||
results = r.results;
|
||||
@@ -1580,10 +1643,12 @@ export class DocmostClient {
|
||||
target.attrs.id = nodeId;
|
||||
}
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260).
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
// Track the replacement count in an outer var, reset per-transform, so a
|
||||
// collab retry recomputes it cleanly (mirrors replaceImage's pattern).
|
||||
let replaced = 0;
|
||||
const mutation = await mutatePageContent(pageId, collabToken, this.apiUrl, (liveDoc) => {
|
||||
const mutation = await mutatePageContent(pageUuid, collabToken, this.apiUrl, (liveDoc) => {
|
||||
replaced = 0;
|
||||
const { doc: nd, replaced: r } = replaceNodeById(liveDoc, nodeId, target);
|
||||
replaced = r;
|
||||
@@ -1636,10 +1701,12 @@ export class DocmostClient {
|
||||
}
|
||||
}
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260).
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
// Track insertion in an outer var, reset per-transform, so a collab retry
|
||||
// recomputes it cleanly (mirrors replaceImage's pattern).
|
||||
let inserted = false;
|
||||
const mutation = await mutatePageContent(pageId, collabToken, this.apiUrl, (liveDoc) => {
|
||||
const mutation = await mutatePageContent(pageUuid, collabToken, this.apiUrl, (liveDoc) => {
|
||||
inserted = false;
|
||||
const { doc: nd, inserted: ins } = insertNodeRelative(liveDoc, node, opts);
|
||||
inserted = ins;
|
||||
@@ -1675,10 +1742,12 @@ export class DocmostClient {
|
||||
async deleteNode(pageId, nodeId) {
|
||||
await this.ensureAuthenticated();
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260).
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
// Track the deletion count in an outer var, reset per-transform, so a
|
||||
// collab retry recomputes it cleanly (mirrors replaceImage's pattern).
|
||||
let deleted = 0;
|
||||
const mutation = await mutatePageContent(pageId, collabToken, this.apiUrl, (liveDoc) => {
|
||||
const mutation = await mutatePageContent(pageUuid, collabToken, this.apiUrl, (liveDoc) => {
|
||||
deleted = 0;
|
||||
const { doc: nd, deleted: d } = deleteNodeById(liveDoc, nodeId);
|
||||
deleted = d;
|
||||
@@ -1921,7 +1990,10 @@ export class DocmostClient {
|
||||
let anchored = false;
|
||||
try {
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
const mutation = await mutatePageContent(pageId, collabToken, this.apiUrl, (liveDoc) => {
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260). The
|
||||
// /comments/create REST call above keeps the agent-supplied id.
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
const mutation = await mutatePageContent(pageUuid, collabToken, this.apiUrl, (liveDoc) => {
|
||||
const doc = liveDoc && liveDoc.type === "doc"
|
||||
? liveDoc
|
||||
: { type: "doc", content: [] };
|
||||
@@ -2324,6 +2396,9 @@ export class DocmostClient {
|
||||
if (opts.alt)
|
||||
node.attrs.alt = opts.alt;
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260). The
|
||||
// uploadImage /files/upload call above keeps the agent-supplied id.
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
// Recursively collect the plain text of a top-level block.
|
||||
const blockText = (n) => {
|
||||
let out = "";
|
||||
@@ -2337,7 +2412,7 @@ export class DocmostClient {
|
||||
// concurrent edits/comments/images are preserved and parallel insert_image
|
||||
// calls (serialized by the per-page lock) each see the previous insertion.
|
||||
let placement;
|
||||
const mutation = await mutatePageContent(pageId, collabToken, this.apiUrl, (liveDoc) => {
|
||||
const mutation = await mutatePageContent(pageUuid, collabToken, this.apiUrl, (liveDoc) => {
|
||||
const doc = liveDoc && liveDoc.type === "doc"
|
||||
? liveDoc
|
||||
: { type: "doc", content: [] };
|
||||
@@ -2424,6 +2499,13 @@ export class DocmostClient {
|
||||
*/
|
||||
async replaceImage(pageId, oldAttachmentId, url, opts = {}) {
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260). The
|
||||
// page lock must ALSO key on the UUID so this operation serializes against
|
||||
// other writes to the same page (mutatePageContent now locks by the resolved
|
||||
// UUID too); locking by the raw slugId here would desync the mutex key and
|
||||
// reopen the TOCTOU/orphan-attachment window the lock closes. uploadImage
|
||||
// keeps the agent-supplied id (it hits REST, not the collab doc).
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
// Hold ONE per-page lock for the WHOLE operation (scan -> upload -> write).
|
||||
// Previously the scan and the write were two separate mutatePageContent
|
||||
// calls, each acquiring + releasing the lock, with the upload happening in
|
||||
@@ -2435,7 +2517,7 @@ export class DocmostClient {
|
||||
// reentrant, so the self-locking mutatePageContent would deadlock here)
|
||||
// closes that TOCTOU window. uploadImage hits /files/upload over plain HTTP
|
||||
// and does not touch the page lock, so it is safe to call while held.
|
||||
return withPageLock(pageId, async () => {
|
||||
return withPageLock(pageUuid, async () => {
|
||||
// STEP 1: read-only live check. Scan the live document for any image node
|
||||
// matching oldAttachmentId BEFORE uploading anything, so a wrong/stale id
|
||||
// throws without ever creating an orphan attachment.
|
||||
@@ -2453,7 +2535,7 @@ export class DocmostClient {
|
||||
scan(node.content);
|
||||
}
|
||||
};
|
||||
await this.mutateLiveContentUnlocked(pageId, collabToken, (liveDoc) => {
|
||||
await this.mutateLiveContentUnlocked(pageUuid, collabToken, (liveDoc) => {
|
||||
matchFound = false; // reset per-transform (collab may retry the read).
|
||||
const doc = liveDoc && liveDoc.type === "doc"
|
||||
? liveDoc
|
||||
@@ -2501,7 +2583,7 @@ export class DocmostClient {
|
||||
walk(node.content);
|
||||
}
|
||||
};
|
||||
const mutation = await this.mutateLiveContentUnlocked(pageId, collabToken, (liveDoc) => {
|
||||
const mutation = await this.mutateLiveContentUnlocked(pageUuid, collabToken, (liveDoc) => {
|
||||
// Reset per-transform so collab retries recompute cleanly (no double-count).
|
||||
replaced = 0;
|
||||
const doc = liveDoc && liveDoc.type === "doc"
|
||||
@@ -2598,7 +2680,10 @@ export class DocmostClient {
|
||||
// JSON write path) before writing it back.
|
||||
this.validateDocUrls(version.content);
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
const mutation = await mutatePageContent(version.pageId, collabToken, this.apiUrl, () => version.content);
|
||||
// version.pageId is the page entity id (already a UUID); resolvePageId
|
||||
// short-circuits a UUID with no round-trip, so this is defensive only (#260).
|
||||
const pageUuid = await this.resolvePageId(version.pageId);
|
||||
const mutation = await mutatePageContent(pageUuid, collabToken, this.apiUrl, () => version.content);
|
||||
return {
|
||||
pageId: version.pageId,
|
||||
restoredFrom: historyId,
|
||||
@@ -2767,7 +2852,9 @@ export class DocmostClient {
|
||||
}
|
||||
// Apply atomically against the live doc.
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
const mutation = await mutatePageContent(pageId, collabToken, this.apiUrl, runTransform);
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260).
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
const mutation = await mutatePageContent(pageUuid, collabToken, this.apiUrl, runTransform);
|
||||
// Optionally delete consumed comments (best-effort; a delete failure must
|
||||
// not undo the successful write).
|
||||
const deletedComments = [];
|
||||
|
||||
@@ -133,6 +133,18 @@ export type DocmostMcpConfig = { apiUrl: string } & (
|
||||
};
|
||||
};
|
||||
|
||||
// Canonical UUID shape (versions 1–8, matching the `uuid` package's `validate`
|
||||
// that the server's isValidUUID uses). page.repo.ts treats any non-UUID pageId
|
||||
// as a slugId, so the MCP detects a UUID locally and skips a /pages/info
|
||||
// round-trip in resolvePageId. A 10-char nanoid slugId never contains dashes,
|
||||
// so it can never be misread as a UUID here.
|
||||
const UUID_RE =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
function isUuid(value: string): boolean {
|
||||
return typeof value === "string" && UUID_RE.test(value);
|
||||
}
|
||||
|
||||
export class DocmostClient {
|
||||
private client: AxiosInstance;
|
||||
private token: string | null = null;
|
||||
@@ -160,6 +172,11 @@ export class DocmostClient {
|
||||
// can all call login() at once. Memoizing a single promise collapses that
|
||||
// thundering herd into ONE /auth/login request that everyone awaits.
|
||||
private loginPromise: Promise<void> | null = null;
|
||||
// Canonical-UUID cache for resolvePageId: maps an agent-supplied slugId to the
|
||||
// page's canonical UUID, so repeated collab edits on the same page do not
|
||||
// re-fetch /pages/info. A UUID input short-circuits before this cache (see
|
||||
// resolvePageId), so only slugId->uuid entries are stored/read here.
|
||||
private pageIdCache = new Map<string, string>();
|
||||
|
||||
// Two construction forms:
|
||||
// - new DocmostClient(config) // discriminated union (current)
|
||||
@@ -751,6 +768,36 @@ export class DocmostClient {
|
||||
return response.data?.data ?? response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an agent-supplied pageId to the page's CANONICAL UUID (`page.id`),
|
||||
* so every collaboration document the MCP opens is named `page.<uuid>` — the
|
||||
* SAME name the web editor always uses (`page.${page.id}`).
|
||||
*
|
||||
* The agent commonly passes a 10-char public slugId (from URLs/listings) as
|
||||
* the pageId. The web editor opens the collab doc by UUID, but the MCP used to
|
||||
* pass that slugId straight into the collab doc name (`page.<slugId>`). For one
|
||||
* DB row that produced TWO independent Yjs documents whose debounced stores
|
||||
* clobbered each other — the agent's edit was silently lost (#260).
|
||||
*
|
||||
* A UUID input short-circuits with no network round-trip. A slugId is resolved
|
||||
* once via getPageRaw and cached (both slugId->uuid and uuid->uuid), so
|
||||
* repeated edits on the same page add no extra request.
|
||||
*/
|
||||
private async resolvePageId(pageId: string): Promise<string> {
|
||||
if (isUuid(pageId)) return pageId;
|
||||
const cached = this.pageIdCache.get(pageId);
|
||||
if (cached) return cached;
|
||||
const data = await this.getPageRaw(pageId);
|
||||
const uuid = data?.id;
|
||||
if (typeof uuid !== "string" || !uuid) {
|
||||
throw new Error(
|
||||
`Could not resolve a canonical page id for "${pageId}"`,
|
||||
);
|
||||
}
|
||||
this.pageIdCache.set(pageId, uuid);
|
||||
return uuid;
|
||||
}
|
||||
|
||||
async getPage(pageId: string) {
|
||||
await this.ensureAuthenticated();
|
||||
const resultData = await this.getPageRaw(pageId);
|
||||
@@ -1083,12 +1130,14 @@ export class DocmostClient {
|
||||
) {
|
||||
await this.ensureAuthenticated();
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260).
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
|
||||
// Track insertion in an outer var, reset per-transform, so a collab retry
|
||||
// recomputes it cleanly (mirrors insertNode's pattern).
|
||||
let inserted = false;
|
||||
const mutation = await mutatePageContent(
|
||||
pageId,
|
||||
pageUuid,
|
||||
collabToken,
|
||||
this.apiUrl,
|
||||
(liveDoc) => {
|
||||
@@ -1126,10 +1175,12 @@ export class DocmostClient {
|
||||
async tableDeleteRow(pageId: string, tableRef: string, index: number) {
|
||||
await this.ensureAuthenticated();
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260).
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
|
||||
let deleted = false;
|
||||
const mutation = await mutatePageContent(
|
||||
pageId,
|
||||
pageUuid,
|
||||
collabToken,
|
||||
this.apiUrl,
|
||||
(liveDoc) => {
|
||||
@@ -1174,10 +1225,12 @@ export class DocmostClient {
|
||||
) {
|
||||
await this.ensureAuthenticated();
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260).
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
|
||||
let updated = false;
|
||||
const mutation = await mutatePageContent(
|
||||
pageId,
|
||||
pageUuid,
|
||||
collabToken,
|
||||
this.apiUrl,
|
||||
(liveDoc) => {
|
||||
@@ -1313,6 +1366,10 @@ export class DocmostClient {
|
||||
*/
|
||||
async updatePage(pageId: string, content: string, title?: string) {
|
||||
await this.ensureAuthenticated();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260). The
|
||||
// REST /pages/update title write below keeps the agent-supplied id (the
|
||||
// server resolves a slugId there).
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
|
||||
// Write the BODY first, then the title (#159 split-brain). If the collab
|
||||
// body write fails (e.g. a persist timeout), the title must be left
|
||||
@@ -1324,7 +1381,7 @@ export class DocmostClient {
|
||||
try {
|
||||
collabToken = await this.getCollabTokenWithReauth();
|
||||
mutation = await updatePageContentRealtime(
|
||||
pageId,
|
||||
pageUuid,
|
||||
content,
|
||||
collabToken,
|
||||
this.apiUrl,
|
||||
@@ -1587,8 +1644,10 @@ export class DocmostClient {
|
||||
// Write the BODY first, then the title (#159 split-brain): a failed body
|
||||
// write (e.g. persist timeout) must not leave a new title over the old body.
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260).
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
const mutation = await this.replacePage(
|
||||
pageId,
|
||||
pageUuid,
|
||||
doc,
|
||||
collabToken,
|
||||
this.apiUrl,
|
||||
@@ -1630,9 +1689,11 @@ export class DocmostClient {
|
||||
throw new Error("insert_footnote: text is required");
|
||||
}
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260).
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
let result: { footnoteId: string; reused: boolean } | null = null;
|
||||
const mutation = await this.mutatePage(
|
||||
pageId,
|
||||
pageUuid,
|
||||
collabToken,
|
||||
this.apiUrl,
|
||||
(liveDoc: any) => {
|
||||
@@ -1740,8 +1801,10 @@ export class DocmostClient {
|
||||
// PAGE import: canonicalize footnotes (see markdownToProseMirrorCanonical).
|
||||
const doc = await markdownToProseMirrorCanonical(body);
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260).
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
const mutation = await replacePageContent(
|
||||
pageId,
|
||||
pageUuid,
|
||||
doc,
|
||||
collabToken,
|
||||
this.apiUrl,
|
||||
@@ -1840,8 +1903,10 @@ export class DocmostClient {
|
||||
const canonical = canonicalizeFootnotes(content);
|
||||
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the TARGET collab doc by its canonical UUID, never the slugId (#260).
|
||||
const targetUuid = await this.resolvePageId(targetPageId);
|
||||
const mutation = await this.replacePage(
|
||||
targetPageId,
|
||||
targetUuid,
|
||||
canonical,
|
||||
collabToken,
|
||||
this.apiUrl,
|
||||
@@ -1864,6 +1929,8 @@ export class DocmostClient {
|
||||
await this.ensureAuthenticated();
|
||||
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260).
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
|
||||
// Apply the edits against the LIVE synced document, not the debounced REST
|
||||
// snapshot, so concurrent human edits/comments are preserved. applyTextEdits
|
||||
@@ -1878,7 +1945,7 @@ export class DocmostClient {
|
||||
// happened.
|
||||
let wrote = false;
|
||||
const mutation = await mutatePageContent(
|
||||
pageId,
|
||||
pageUuid,
|
||||
collabToken,
|
||||
this.apiUrl,
|
||||
(liveDoc) => {
|
||||
@@ -1978,12 +2045,14 @@ export class DocmostClient {
|
||||
}
|
||||
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260).
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
|
||||
// Track the replacement count in an outer var, reset per-transform, so a
|
||||
// collab retry recomputes it cleanly (mirrors replaceImage's pattern).
|
||||
let replaced = 0;
|
||||
const mutation = await mutatePageContent(
|
||||
pageId,
|
||||
pageUuid,
|
||||
collabToken,
|
||||
this.apiUrl,
|
||||
(liveDoc) => {
|
||||
@@ -2066,12 +2135,14 @@ export class DocmostClient {
|
||||
}
|
||||
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260).
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
|
||||
// Track insertion in an outer var, reset per-transform, so a collab retry
|
||||
// recomputes it cleanly (mirrors replaceImage's pattern).
|
||||
let inserted = false;
|
||||
const mutation = await mutatePageContent(
|
||||
pageId,
|
||||
pageUuid,
|
||||
collabToken,
|
||||
this.apiUrl,
|
||||
(liveDoc) => {
|
||||
@@ -2120,12 +2191,14 @@ export class DocmostClient {
|
||||
await this.ensureAuthenticated();
|
||||
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260).
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
|
||||
// Track the deletion count in an outer var, reset per-transform, so a
|
||||
// collab retry recomputes it cleanly (mirrors replaceImage's pattern).
|
||||
let deleted = 0;
|
||||
const mutation = await mutatePageContent(
|
||||
pageId,
|
||||
pageUuid,
|
||||
collabToken,
|
||||
this.apiUrl,
|
||||
(liveDoc) => {
|
||||
@@ -2414,8 +2487,11 @@ export class DocmostClient {
|
||||
let anchored = false;
|
||||
try {
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260). The
|
||||
// /comments/create REST call above keeps the agent-supplied id.
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
const mutation = await mutatePageContent(
|
||||
pageId,
|
||||
pageUuid,
|
||||
collabToken,
|
||||
this.apiUrl,
|
||||
(liveDoc) => {
|
||||
@@ -2893,6 +2969,9 @@ export class DocmostClient {
|
||||
if (opts.alt) node.attrs.alt = opts.alt;
|
||||
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260). The
|
||||
// uploadImage /files/upload call above keeps the agent-supplied id.
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
|
||||
// Recursively collect the plain text of a top-level block.
|
||||
const blockText = (n: any): string => {
|
||||
@@ -2907,7 +2986,7 @@ export class DocmostClient {
|
||||
// calls (serialized by the per-page lock) each see the previous insertion.
|
||||
let placement: "replaced" | "after" | "appended" | undefined;
|
||||
const mutation = await mutatePageContent(
|
||||
pageId,
|
||||
pageUuid,
|
||||
collabToken,
|
||||
this.apiUrl,
|
||||
(liveDoc) => {
|
||||
@@ -3019,6 +3098,13 @@ export class DocmostClient {
|
||||
opts: { align?: "left" | "center" | "right"; alt?: string } = {},
|
||||
) {
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260). The
|
||||
// page lock must ALSO key on the UUID so this operation serializes against
|
||||
// other writes to the same page (mutatePageContent now locks by the resolved
|
||||
// UUID too); locking by the raw slugId here would desync the mutex key and
|
||||
// reopen the TOCTOU/orphan-attachment window the lock closes. uploadImage
|
||||
// keeps the agent-supplied id (it hits REST, not the collab doc).
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
|
||||
// Hold ONE per-page lock for the WHOLE operation (scan -> upload -> write).
|
||||
// Previously the scan and the write were two separate mutatePageContent
|
||||
@@ -3031,7 +3117,7 @@ export class DocmostClient {
|
||||
// reentrant, so the self-locking mutatePageContent would deadlock here)
|
||||
// closes that TOCTOU window. uploadImage hits /files/upload over plain HTTP
|
||||
// and does not touch the page lock, so it is safe to call while held.
|
||||
return withPageLock(pageId, async () => {
|
||||
return withPageLock(pageUuid, async () => {
|
||||
// STEP 1: read-only live check. Scan the live document for any image node
|
||||
// matching oldAttachmentId BEFORE uploading anything, so a wrong/stale id
|
||||
// throws without ever creating an orphan attachment.
|
||||
@@ -3050,7 +3136,7 @@ export class DocmostClient {
|
||||
}
|
||||
};
|
||||
|
||||
await this.mutateLiveContentUnlocked(pageId, collabToken, (liveDoc) => {
|
||||
await this.mutateLiveContentUnlocked(pageUuid, collabToken, (liveDoc) => {
|
||||
matchFound = false; // reset per-transform (collab may retry the read).
|
||||
const doc =
|
||||
liveDoc && liveDoc.type === "doc"
|
||||
@@ -3105,7 +3191,7 @@ export class DocmostClient {
|
||||
};
|
||||
|
||||
const mutation = await this.mutateLiveContentUnlocked(
|
||||
pageId,
|
||||
pageUuid,
|
||||
collabToken,
|
||||
(liveDoc) => {
|
||||
// Reset per-transform so collab retries recompute cleanly (no double-count).
|
||||
@@ -3214,8 +3300,11 @@ export class DocmostClient {
|
||||
// JSON write path) before writing it back.
|
||||
this.validateDocUrls(version.content);
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// version.pageId is the page entity id (already a UUID); resolvePageId
|
||||
// short-circuits a UUID with no round-trip, so this is defensive only (#260).
|
||||
const pageUuid = await this.resolvePageId(version.pageId);
|
||||
const mutation = await mutatePageContent(
|
||||
version.pageId,
|
||||
pageUuid,
|
||||
collabToken,
|
||||
this.apiUrl,
|
||||
() => version.content,
|
||||
@@ -3414,8 +3503,10 @@ export class DocmostClient {
|
||||
|
||||
// Apply atomically against the live doc.
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260).
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
const mutation = await mutatePageContent(
|
||||
pageId,
|
||||
pageUuid,
|
||||
collabToken,
|
||||
this.apiUrl,
|
||||
runTransform,
|
||||
|
||||
@@ -132,7 +132,7 @@ test("patch_node REFUSES an ambiguous (duplicate) id without writing to collab",
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
client.patchNode("page-1", DUP_ID, {
|
||||
client.patchNode("11111111-1111-4111-8111-111111111111", DUP_ID, {
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: "replacement" }],
|
||||
}),
|
||||
@@ -152,7 +152,7 @@ test("delete_node REFUSES an ambiguous (duplicate) id without writing to collab"
|
||||
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||
|
||||
await assert.rejects(
|
||||
() => client.deleteNode("page-2", DUP_ID),
|
||||
() => client.deleteNode("22222222-2222-4222-8222-222222222222", DUP_ID),
|
||||
/ambiguous/i,
|
||||
"delete_node must reject a duplicate-id target with an 'ambiguous' error",
|
||||
);
|
||||
|
||||
@@ -37,6 +37,11 @@ function makeClient(liveDoc) {
|
||||
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;
|
||||
|
||||
387
packages/mcp/test/mock/resolve-page-id-collab-doc-name.test.mjs
Normal file
387
packages/mcp/test/mock/resolve-page-id-collab-doc-name.test.mjs
Normal file
@@ -0,0 +1,387 @@
|
||||
// Mock collab regression for the #260 data-loss bug: the MCP must open every
|
||||
// collaboration document by the page's CANONICAL UUID (`page.<uuid>`) — the same
|
||||
// name the web editor uses — even when the agent supplies a public slugId.
|
||||
//
|
||||
// Root cause: the agent commonly passes a 10-char slugId (from URLs/listings) as
|
||||
// pageId. The web tab opens `page.<uuid>`, but the MCP used to pass the slugId
|
||||
// straight into the collab doc name (`page.<slugId>`), so one DB page ended up
|
||||
// with TWO independent Yjs documents whose debounced stores clobbered each other
|
||||
// — the agent's edit was silently lost on reload.
|
||||
//
|
||||
// We stand up a real Hocuspocus server (like ambiguous-node-id.test.mjs) and
|
||||
// capture the EXACT documentName each connection requests via onLoadDocument.
|
||||
// The /pages/info mock resolves the slugId -> uuid, and counts its own hits so we
|
||||
// can also prove the UUID short-circuit + cache (no redundant resolve round-trip).
|
||||
import { test, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import http from "node:http";
|
||||
import { WebSocketServer } from "ws";
|
||||
import { Hocuspocus } from "@hocuspocus/server";
|
||||
import { DocmostClient } from "../../build/client.js";
|
||||
import { buildYDoc } from "../../build/lib/collaboration.js";
|
||||
// Import the SAME page-lock module instance that build/client.js imports. ESM
|
||||
// caches modules by resolved URL, so this `withPageLock` shares the very
|
||||
// per-page mutex map (`chains`) the client uses — letting the replaceImage test
|
||||
// probe which key the operation actually locks on (see that test for details).
|
||||
import { withPageLock } from "../../build/lib/page-lock.js";
|
||||
|
||||
const SLUG = "dwzDdgPep2"; // 10-char nanoid public id (no dashes)
|
||||
const UUID = "11111111-1111-4111-8111-111111111111"; // canonical page.id
|
||||
|
||||
// A simple one-paragraph document; "hello world" gives editPageText a match and
|
||||
// insertFootnote an anchor. No table node, so tableInsertRow aborts with
|
||||
// "no table found" — but the collab doc was still OPENED by then, which is what
|
||||
// we assert (the doc NAME is fixed at connect time, before any transform runs).
|
||||
function seedDoc() {
|
||||
return {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
attrs: { id: "p1" },
|
||||
content: [{ type: "text", text: "hello world" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Same shape as seedDoc but with one image node carrying attachmentId "att-old"
|
||||
// (mirrors what client.addImage emits). replaceImage scans the live doc for this
|
||||
// node, so it must survive the Yjs round-trip with attachmentId intact.
|
||||
function seedDocWithImage() {
|
||||
return {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
attrs: { id: "p1" },
|
||||
content: [{ type: "text", text: "hello world" }],
|
||||
},
|
||||
{
|
||||
type: "image",
|
||||
attrs: {
|
||||
src: "/api/files/att-old/old.png",
|
||||
attachmentId: "att-old",
|
||||
size: 10,
|
||||
align: "center",
|
||||
width: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function readBody(req) {
|
||||
return new Promise((resolve) => {
|
||||
let raw = "";
|
||||
req.on("data", (c) => (raw += c));
|
||||
req.on("end", () => resolve(raw));
|
||||
});
|
||||
}
|
||||
|
||||
// Stand up an HTTP server that authenticates, hands out a collab token, serves
|
||||
// /pages/info (slugId -> uuid resolution), and upgrades /collab to a Hocuspocus
|
||||
// instance whose onLoadDocument records the requested documentName.
|
||||
// opts.seed: a function returning the ProseMirror doc the collab server loads
|
||||
// (defaults to seedDoc). opts.onUpload: an optional async hook invoked when
|
||||
// /files/upload is hit, letting a test GATE the upload (hold replaceImage inside
|
||||
// its page lock). Existing callers pass no opts and are unaffected.
|
||||
async function spawnCollabStack(opts = {}) {
|
||||
const seed = opts.seed ?? seedDoc;
|
||||
const state = { docNames: [], pagesInfoCalls: [] };
|
||||
|
||||
const hocuspocus = new Hocuspocus({
|
||||
quiet: true,
|
||||
async onLoadDocument({ documentName }) {
|
||||
state.docNames.push(documentName);
|
||||
return buildYDoc(seed());
|
||||
},
|
||||
});
|
||||
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const raw = await readBody(req);
|
||||
if (req.url === "/api/auth/login") {
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "application/json",
|
||||
"Set-Cookie": "authToken=t; Path=/; HttpOnly",
|
||||
});
|
||||
res.end(JSON.stringify({ success: true }));
|
||||
return;
|
||||
}
|
||||
if (req.url === "/api/auth/collab-token") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ data: { token: "collab-jwt" } }));
|
||||
return;
|
||||
}
|
||||
if (req.url === "/api/pages/info") {
|
||||
let pageId;
|
||||
try {
|
||||
pageId = JSON.parse(raw)?.pageId;
|
||||
} catch {
|
||||
pageId = undefined;
|
||||
}
|
||||
state.pagesInfoCalls.push(pageId);
|
||||
// Always resolve to the SAME canonical record, mirroring the server's
|
||||
// findById (which accepts either the uuid or the slugId).
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
id: UUID,
|
||||
slugId: SLUG,
|
||||
title: "Doc",
|
||||
spaceId: "space-1",
|
||||
content: seedDoc(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (req.url && req.url.endsWith(".png")) {
|
||||
// Serve image bytes for fetchRemoteImage (replaceImage downloads the new
|
||||
// image before uploading it). Any non-empty image/* body is enough;
|
||||
// fetchRemoteImage does not validate PNG magic bytes.
|
||||
res.writeHead(200, { "Content-Type": "image/png" });
|
||||
res.end(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
|
||||
return;
|
||||
}
|
||||
if (req.url === "/api/files/upload") {
|
||||
// Optional gate: a test can hold replaceImage parked here (inside its page
|
||||
// lock, after the scan) to probe the lock key. Default: respond at once.
|
||||
if (opts.onUpload) await opts.onUpload();
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
data: { id: "att-new", fileName: "replacement.png", fileSize: 8 },
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Title writes (/pages/update) and anything else: succeed quietly.
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ data: {} }));
|
||||
});
|
||||
|
||||
// buildCollabWsUrl maps http://host:port/api -> ws://host:port/collab.
|
||||
server.on("upgrade", (request, socket, head) => {
|
||||
if (!request.url || !request.url.startsWith("/collab")) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
wss.handleUpgrade(request, socket, head, (ws) => {
|
||||
hocuspocus.handleConnection(ws, request);
|
||||
});
|
||||
});
|
||||
|
||||
const baseURL = await new Promise((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const { port } = server.address();
|
||||
resolve(`http://127.0.0.1:${port}/api`);
|
||||
});
|
||||
});
|
||||
|
||||
openStacks.push({ server, hocuspocus });
|
||||
return { state, baseURL };
|
||||
}
|
||||
|
||||
const openStacks = [];
|
||||
after(async () => {
|
||||
await Promise.all(
|
||||
openStacks.map(
|
||||
({ server, hocuspocus }) =>
|
||||
new Promise((resolve) => {
|
||||
server.close(() => {
|
||||
Promise.resolve(hocuspocus.destroy?.()).finally(resolve);
|
||||
});
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("editPageText with a slugId opens the collab doc by the resolved UUID (#260)", async () => {
|
||||
const { state, baseURL } = await spawnCollabStack();
|
||||
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||
|
||||
const res = await client.editPageText(SLUG, [
|
||||
{ find: "hello", replace: "hi" },
|
||||
]);
|
||||
assert.equal(res.success, true);
|
||||
|
||||
assert.ok(
|
||||
state.docNames.includes(`page.${UUID}`),
|
||||
`collab doc must be opened as page.${UUID}, got ${JSON.stringify(state.docNames)}`,
|
||||
);
|
||||
assert.ok(
|
||||
!state.docNames.includes(`page.${SLUG}`),
|
||||
"collab doc must NEVER be opened by the slugId (that is the data-loss bug)",
|
||||
);
|
||||
// The slugId had to be resolved via /pages/info at least once.
|
||||
assert.ok(state.pagesInfoCalls.length >= 1);
|
||||
});
|
||||
|
||||
test("tableInsertRow with a slugId opens the collab doc by the resolved UUID (#260)", async () => {
|
||||
const { state, baseURL } = await spawnCollabStack();
|
||||
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||
|
||||
// No table in the seed doc, so this aborts with "no table found" — but the
|
||||
// collab doc has ALREADY been opened (by UUID) before the transform decides.
|
||||
await assert.rejects(
|
||||
() => client.tableInsertRow(SLUG, "#0", ["a", "b"]),
|
||||
/no table/i,
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
state.docNames,
|
||||
[`page.${UUID}`],
|
||||
"tableInsertRow must open the collab doc by the resolved UUID",
|
||||
);
|
||||
});
|
||||
|
||||
test("the generic mutate (insert_footnote) with a slugId opens by the resolved UUID (#260)", async () => {
|
||||
const { state, baseURL } = await spawnCollabStack();
|
||||
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||
|
||||
const res = await client.insertFootnote(SLUG, "world", "a note");
|
||||
assert.equal(res.success, true);
|
||||
|
||||
assert.deepEqual(
|
||||
state.docNames,
|
||||
[`page.${UUID}`],
|
||||
"insert_footnote (via the mutatePage seam) must open the collab doc by UUID",
|
||||
);
|
||||
});
|
||||
|
||||
test("a UUID input is passed through unchanged and triggers NO /pages/info fetch (short-circuit)", async () => {
|
||||
const { state, baseURL } = await spawnCollabStack();
|
||||
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||
|
||||
const res = await client.editPageText(UUID, [
|
||||
{ find: "hello", replace: "hi" },
|
||||
]);
|
||||
assert.equal(res.success, true);
|
||||
|
||||
assert.deepEqual(state.docNames, [`page.${UUID}`]);
|
||||
assert.equal(
|
||||
state.pagesInfoCalls.length,
|
||||
0,
|
||||
"a UUID input must short-circuit resolvePageId with no /pages/info round-trip",
|
||||
);
|
||||
});
|
||||
|
||||
test("a repeated slugId edit resolves the UUID only once (cache)", async () => {
|
||||
const { state, baseURL } = await spawnCollabStack();
|
||||
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||
|
||||
// Each mock connection re-seeds a fresh "hello world" doc (the mock does not
|
||||
// persist across connects), so both edits target "hello". The cache assertion
|
||||
// only concerns the slugId->uuid resolution, not the document content.
|
||||
await client.editPageText(SLUG, [{ find: "hello", replace: "hi" }]);
|
||||
await client.editPageText(SLUG, [{ find: "hello", replace: "hey" }]);
|
||||
|
||||
assert.deepEqual(state.docNames, [`page.${UUID}`, `page.${UUID}`]);
|
||||
assert.equal(
|
||||
state.pagesInfoCalls.length,
|
||||
1,
|
||||
"the slugId->uuid resolution must be cached across edits on the same page",
|
||||
);
|
||||
});
|
||||
|
||||
// PR#265 reviewer finding F1. replaceImage is the one path where the resolved
|
||||
// UUID gates BOTH (a) the collab-doc OPEN (mutateLiveContentUnlocked ->
|
||||
// page.<uuid>) AND (b) the per-page mutex key withPageLock(uuid). The lock
|
||||
// serializes the whole scan -> upload -> write against other writes to the same
|
||||
// page (which now also lock by the resolved UUID), closing a TOCTOU/orphan-
|
||||
// attachment window. A regression that re-keys this lock by the raw slugId would
|
||||
// desync it from mutatePageContent's UUID key and silently reopen that window.
|
||||
// This test pins both invariants and FAILS under either regression:
|
||||
// - open by slugId -> assertion (a) sees page.<slug> in docNames;
|
||||
// - lock by slugId -> assertion (b)'s UUID-keyed probe is no longer blocked.
|
||||
test("replaceImage opens by the resolved UUID AND keys its page lock by that UUID, not the slugId (#260 / PR#265 F1)", async () => {
|
||||
// A gate that holds the /files/upload response open, so replaceImage parks
|
||||
// INSIDE its page lock (after the read-only scan, mid-upload) until released.
|
||||
let releaseUpload;
|
||||
const uploadReleased = new Promise((r) => (releaseUpload = r));
|
||||
let uploadHit;
|
||||
const uploadStarted = new Promise((r) => (uploadHit = r));
|
||||
|
||||
const { state, baseURL } = await spawnCollabStack({
|
||||
seed: seedDocWithImage,
|
||||
onUpload: async () => {
|
||||
uploadHit(); // replaceImage is now holding its page lock...
|
||||
await uploadReleased; // ...and stays parked until the test releases it.
|
||||
},
|
||||
});
|
||||
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||
|
||||
// Kick off the replace but DO NOT await: it resolves SLUG->UUID, takes
|
||||
// withPageLock(UUID), scan-opens page.<UUID>, finds the seeded "att-old"
|
||||
// image, then blocks in uploadImage on our gate while still holding the lock.
|
||||
// The image URL is served as image/png by the mock (the ".png" route above).
|
||||
const imageUrl = `${baseURL}/x.png`;
|
||||
const replacePromise = client.replaceImage(SLUG, "att-old", imageUrl);
|
||||
|
||||
await uploadStarted; // deterministic: replaceImage now holds its page lock.
|
||||
|
||||
// (a) OPEN BY UUID: the only collab doc opened so far (the scan pass) used the
|
||||
// canonical UUID, never the slugId. (The write pass opens a second time after
|
||||
// we release the gate; asserted at the end.)
|
||||
assert.deepEqual(
|
||||
state.docNames,
|
||||
[`page.${UUID}`],
|
||||
"replaceImage must scan-open the collab doc by the resolved UUID, never the slugId",
|
||||
);
|
||||
|
||||
// (b) LOCK KEY == UUID (the distinct invariant). We share the SAME page-lock
|
||||
// module instance as build/client.js, so enqueuing on key=UUID contends on the
|
||||
// very chain replaceImage holds. Because replaceImage is deterministically
|
||||
// parked mid-upload (still holding the lock), a UUID-keyed probe MUST stay
|
||||
// queued; it cannot run until the lock frees. The contention here is pure
|
||||
// in-memory promise-chain microtask scheduling (no timers, no socket I/O), so
|
||||
// a single macrotask flush is a sufficient and deterministic observation.
|
||||
// If replaceImage were reverted to lock by the slugId, the UUID chain would be
|
||||
// free and this probe would run during the flush -> probeRan === true -> FAIL.
|
||||
let probeRan = false;
|
||||
const probeDone = withPageLock(UUID, async () => {
|
||||
probeRan = true;
|
||||
});
|
||||
// setImmediate runs after the microtask queue fully drains, so a probe on a
|
||||
// FREE chain would already have run by the time this resolves.
|
||||
await new Promise((r) => setImmediate(r));
|
||||
assert.equal(
|
||||
probeRan,
|
||||
false,
|
||||
"a probe on key=UUID must stay blocked while replaceImage holds the lock; " +
|
||||
"if it ran, replaceImage locked by a different key (e.g. the raw slugId)",
|
||||
);
|
||||
|
||||
// Non-vacuity guard: a probe on an UNRELATED key DOES run after the same
|
||||
// single flush. This proves the flush actually executes queued callbacks, so
|
||||
// probeRan === false above means "blocked", not "the flush never ran anyone".
|
||||
let freeRan = false;
|
||||
const freeDone = withPageLock(`page.free-${UUID}`, async () => {
|
||||
freeRan = true;
|
||||
});
|
||||
await new Promise((r) => setImmediate(r));
|
||||
assert.equal(
|
||||
freeRan,
|
||||
true,
|
||||
"sanity: a probe on a FREE key must run after one flush (the UUID probe was blocked by the held key, not by an inert flush)",
|
||||
);
|
||||
|
||||
// Release the gate; replaceImage finishes and the queued UUID probe can run.
|
||||
releaseUpload();
|
||||
const res = await replacePromise;
|
||||
await probeDone;
|
||||
await freeDone;
|
||||
|
||||
assert.equal(res.success, true);
|
||||
assert.equal(res.replaced, 1, "the one seeded image must be repointed");
|
||||
// Both opens (scan pass + write pass) used the UUID; the slugId never appears.
|
||||
assert.deepEqual(state.docNames, [`page.${UUID}`, `page.${UUID}`]);
|
||||
assert.ok(
|
||||
!state.docNames.includes(`page.${SLUG}`),
|
||||
"replaceImage must NEVER open the collab doc by the slugId (the #260 bug)",
|
||||
);
|
||||
});
|
||||
@@ -66,6 +66,14 @@ function makeServer() {
|
||||
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: {} });
|
||||
|
||||
@@ -74,6 +74,7 @@ const HOST_CONTRACT_METHODS = [
|
||||
"unsharePage",
|
||||
"restorePageVersion",
|
||||
"transformPage",
|
||||
"stashPage",
|
||||
// write (comment)
|
||||
"createComment",
|
||||
"resolveComment",
|
||||
|
||||
Reference in New Issue
Block a user