Compare commits

..

9 Commits

Author SHA1 Message Date
agent_coder 59db81b06b fix(comments): suggestion с вечным 409 (#496)
expectedText брался из debounced REST-снапшота (getAnchoredText по
pages/info), а метка ставилась в live-доке — при расхождении (док ушёл
вперёд за окно дебаунса) apply строго сравнивал текст под меткой с
устаревшим stored selection и давал 409 на каждый вызов.

MCP-клиент теперь в transform-фазе (та же версия live-дока, где ставится
метка) перечитывает фактическую подстроку под меткой и, если она
отличается от сохранённого selection, синкает её через новый эндпоинт
POST /comments/resync-suggestion-anchor. Best-effort: сбой синка не
откатывает уже заякоренный комментарий, а лишь выдаёт мягкое
предупреждение. Совпадающий снапшот не делает лишнего round-trip.

Сервер: resyncSuggestionAnchor правит только stored selection
незаселённой suggestion своего автора (guards: top-level, есть
suggestedText, не applied/resolved, отличается от suggestedText),
идемпотентно, без ws-бродкаста.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 18:09:10 +03:00
agent_coder 66386e39a2 fix(comments): orphan-anchor reconcile + docstring (#496)
deleteEphemeralSuggestion docstring обещал инвариант «метка снимается
FIRST and FATALLY» синхронно — после #399 это уже не так: fatal только
ENQUEUE снятия метки, сама операция идёт в воркере с ретраями. Docstring
переписан под фактическое поведение.

Reconcile: воркер COMMENT_MARK_UPDATE на resolve/unresolve, обнаружив что
строки комментария больше нет (hard-delete гонкой с ephemeral apply/
dismiss), теперь СНИМАЕТ осиротевшую метку вместо тихого return. Это
самозаживляет тихую дивергенцию и закрывает fire-and-forget resolve/
unresolve enqueue из resolveComment. Операция идемпотентна.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 17:56:30 +03:00
agent_coder 6bf670946e fix(comments): дедуп двойного WS-broadcast при apply треда с ответами (#496)
finalizeAppliedSuggestion на ветке «есть ответы» вызывал resolveComment
(бродкаст commentResolved с обогащённой строкой), а затем сам слал ещё и
commentUpdated — клиент получал два события на один apply. Теперь
commentUpdated шлётся только когда resolveComment НЕ вызывался (редкий
повторный вход по уже разрешённому треду), иначе один бродкаст.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 17:53:41 +03:00
agent_coder d96cb56914 fix(comments): apply не стирает форматирование (#496)
replaceYjsMarkedText вставлял замену только с меткой `{comment}`, молча
теряя bold/italic/code/link исходного run'а. Теперь захватываем полный
набор атрибутов доминирующего (самого длинного) сегмента заменяемого
диапазона и применяем его к вставке: однородное форматирование
сохраняется точно, для смешанного run'а берётся преобладающий стиль
вместо полной потери. Метка комментария при этом гарантированно
сохраняется (переутверждается явно).

Клиентское предупреждение в превью диффа не добавлялось: у клиента нет
марок из документа (только plain selection/suggestedText), а фикс на
сервере уже сохраняет форматирование, так что баннер был бы неточным.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 17:50:17 +03:00
agent_coder c1dee15258 feat(audit): DB-backed audit trail вместо Noop (#496)
Событие comment.suggestion_applied/dismissed раньше улетало в
NoopAuditService и молча терялось; при childless-ветке apply/dismiss
комментарий hard-delete'ится, поэтому восстановить, кто и что решил,
было невозможно.

- DatabaseAuditService пишет в уже существующую таблицу `audit`
  (миграция 20260228T223532); actor/workspace/ip берутся из CLS
  AuditContext, вне HTTP — из явного контекста (logWithContext).
  Аудит — побочная запись: сбой записи не ломает исходный запрос,
  EXCLUDED_AUDIT_EVENTS отбрасываются.
- Биндинг AUDIT_SERVICE переключён с Noop на DatabaseAuditService.
- payload apply/dismiss дополнен suggestedText/selection/commentAuthor/
  decidedBy — на childless-ветке это единственная уцелевшая запись.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 17:48:08 +03:00
vvzvlad fe5bd159c4 Merge pull request 'refactor(client): вставка markdown через канонический пакет + удаление md-слоя editor-ext (#347)' (#498) from refactor/347-client-md-paste into develop
Reviewed-on: #498
2026-07-11 04:33:37 +03:00
vvzvlad f12b685698 Merge pull request 'perf(mcp): content-addressed LRU-кэш конверсии getPage — доминирующая агентская нагрузка (#479)' (#480) from perf/479-getpage-cache into develop
Reviewed-on: #480
2026-07-11 04:32:50 +03:00
agent_coder 70a9e2a9cb fix(mcp): оживить счётчики кэша getPage + тест «хит пропускает конверсию» (#479, ревью)
Правки по ревью #480.

F1 (мёртвые метрики): счётчики mcp_getpage_cache_hits_total/_misses_total
эмитились через onMetricFn, но серверный синк mcp.service.ts диспатчил по имени
через if/else-if БЕЗ default и знал только 2 имени → мои дропались молча; в
metrics.registry их вообще не было. Починка по существующему 3-частному паттерну
(как collab_connect_timeouts_total): имена-константы в metrics.constants.ts; два
Counter'а + incGetPageCacheHit/Miss в metrics.registry.ts; два else-if в
mcp.service.ts, роутящие ровно эти имена (существующие 2 ветки не тронуты).
Проверено end-to-end: скрейп prom-реестра показывает hits=2/misses=1 после
прогона роутинга.

F2 (нет теста на пропуск конверсии): добавлен overridable seam
convertPageMarkdown в read.ts (идиома проекта для юнит-тестируемости ESM-импортов);
getPage miss-ветка идёт через него. Тест мокает convertPageMarkdown и ассертит
callCount===1 через MISS→HIT одной страницы (конверсия один раз на промахе, ноль
на хите). Мутационно доказан: пропатчил hit-ветку на повторную конверсию → тест
покраснел (callCount=2), откатил → зелёный.

mcp node --test 814/814 (+1); pmd+mcp tsc чисто; серверные metrics-файлы
компилируются изолированно, runtime-тест счётчиков зелёный.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 02:59:47 +03:00
agent_coder 3e945305c8 perf(mcp): content-addressed LRU-кэш конверсии getPage — снять доминирующую агентскую нагрузку (#479)
getPage — доминирующая операция агентского цикла (812 вызовов/2ч, p95 840мс):
полный обход ProseMirror-дерева convertProseMirrorToMarkdown на КАЖДЫЙ вызов,
кэша нет. При 812 read против 28 update большинство — повторная конверсия того
же неизменившегося контента в тот же Markdown на общем event loop. closes #479

- getpage-cache.ts: LRU-кэш (класс, не синглтон) результата конверсии. Ключ
  (canonical pageId UUID, updatedAt, optionsHash). updatedAt из ТОГО ЖЕ ответа
  /pages/info, что и content → инвалидация бесплатная и точная (страница
  изменилась → новый ключ). optionsHash — стабильная сериализация опций
  (dropResolvedCommentAnchors #328), getPage и export не коллизят. Границы: LRU
  по количеству (50) И по байтам (10МБ, Buffer.byteLength), вытеснение по любому;
  oversized-запись хранится, не заклинивает.
- Кэш — protected инстанс-поле в context.ts (один DocmostClient на сессию/
  идентичность) → изоляция как у клиента, межпользовательской утечки контента
  нет. Байт-идентичный вывод: кэшируется строка ДО подстановки {{SUBPAGES}},
  подстановка на живых subpages выполняется на hit и miss одинаково.
- Счётчики через существующий onMetricFn-синк: mcp_getpage_cache_hits_total /
  misses_total (honest hit-rate: miss на реальной конверсии, вкл. non-cacheable).
- Subpages НЕ параллелизуемы: listSidebarPages требует spaceId из ответа
  page-fetch (resolvePageId даёт UUID, но не spaceId) → последовательность
  сохранена (задокументировано); кэш — основной выигрыш.

Тесты: mcp node --test 813/813 (9 unit: hit/miss по updatedAt+options, вытеснение
по count И byte, recency, oversized, hash order-insensitive; 4 mock: MISS→HIT
байт-идентично + конверсия один раз, смена updatedAt→свежий MISS, slugId+UUID
одна запись, дифф-тест живой подстановки subpages на hit). tsc чисто.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 01:45:01 +03:00
25 changed files with 1676 additions and 40 deletions
+2 -2
View File
@@ -25,7 +25,7 @@ import { CacheModule } from '@nestjs/cache-manager';
import KeyvRedis from '@keyv/redis';
import { LoggerModule } from './common/logger/logger.module';
import { ClsModule } from 'nestjs-cls';
import { NoopAuditModule } from './integrations/audit/audit.module';
import { AuditModule } from './integrations/audit/audit.module';
import { ThrottleModule } from './integrations/throttle/throttle.module';
import { McpModule } from './integrations/mcp/mcp.module';
import { SandboxModule } from './integrations/sandbox/sandbox.module';
@@ -55,7 +55,7 @@ try {
middleware: { mount: true },
}),
LoggerModule,
NoopAuditModule,
AuditModule,
CoreModule,
DatabaseModule,
EnvironmentModule,
@@ -529,4 +529,79 @@ describe('replaceYjsMarkedText', () => {
expect(result).toEqual({ applied: false, currentText: 'abcdef' });
expect(text.toDelta()).toEqual(before);
});
// #496: apply must NOT silently strip the replaced run's inline formatting.
// Build a paragraph and format the marked range with extra marks, then assert
// the replacement carries them.
function buildFormatted(
runs: Array<{ text: string; attrs?: Record<string, any> }>,
): { fragment: Y.XmlFragment; text: Y.XmlText } {
const ydoc = new Y.Doc();
const fragment = ydoc.getXmlFragment('default');
const para = new Y.XmlElement('paragraph');
fragment.insert(0, [para]);
const text = new Y.XmlText();
para.insert(0, [text]);
text.insert(0, runs.map((r) => r.text).join(''));
let offset = 0;
for (const run of runs) {
if (run.attrs) text.format(offset, run.text.length, run.attrs);
offset += run.text.length;
}
return { fragment, text };
}
it('preserves the original run formatting (bold + link) on the replacement', () => {
const { fragment, text } = buildFormatted([
{ text: 'see ' },
{
text: 'old',
attrs: {
comment: { commentId: 'c1', resolved: false },
bold: true,
link: { href: 'https://x.test' },
},
},
{ text: ' end' },
]);
const result = replaceYjsMarkedText(fragment, 'c1', 'old', 'new');
expect(result).toEqual({ applied: true, currentText: 'new' });
// The comment anchor AND the bold/link marks survive the delete+insert.
expect(text.toDelta()).toEqual([
{ insert: 'see ' },
{
insert: 'new',
attributes: {
comment: { commentId: 'c1', resolved: false },
bold: true,
link: { href: 'https://x.test' },
},
},
{ insert: ' end' },
]);
});
it('mixed formatting under the mark: replacement takes the DOMINANT run marks', () => {
// Marked run = "bold" (bold, 4 chars) + "x" (plain, 1 char), same commentId.
// The dominant (longer) segment is bold, so the flat replacement is bold.
const { fragment, text } = buildFormatted([
{
text: 'bold',
attrs: { comment: { commentId: 'c1', resolved: false }, bold: true },
},
{ text: 'x', attrs: { comment: { commentId: 'c1', resolved: false } } },
]);
const result = replaceYjsMarkedText(fragment, 'c1', 'boldx', 'Z');
expect(result).toEqual({ applied: true, currentText: 'Z' });
expect(text.toDelta()).toEqual([
{
insert: 'Z',
attributes: { comment: { commentId: 'c1', resolved: false }, bold: true },
},
]);
});
});
+21 -5
View File
@@ -145,6 +145,10 @@ type MarkedSegment = {
length: number;
text: string;
markAttrs: Record<string, any>;
// The FULL attribute set of this delta run — the `comment` mark plus any
// inline formatting (bold/italic/code/link/…). Captured so apply can carry the
// original run's formatting onto the replacement instead of dropping it.
attributes: Record<string, any>;
};
/**
@@ -202,6 +206,7 @@ export function replaceYjsMarkedText(
length,
text: insert,
markAttrs: markAttr,
attributes,
});
}
offset += length;
@@ -251,15 +256,26 @@ export function replaceYjsMarkedText(
return { applied: false, currentText: joinedText };
}
// 3. All guards passed: delete the marked run and re-insert newText with the
// same comment attributes at the same offset. Atomic within the caller's
// transaction.
// 3. All guards passed: delete the marked run and re-insert newText at the
// same offset. Atomic within the caller's transaction.
const start = segments[0].offset;
const len = segments.reduce((sum, s) => sum + s.length, 0);
const markAttrs = segments[0].markAttrs;
// Carry the ORIGINAL run's formatting onto the replacement (#496): inserting
// with only the `comment` mark silently dropped bold/italic/code/link of the
// replaced text. Yjs applies one flat attribute set to the whole insert, so
// when the marked run mixes formatting we pick the DOMINANT segment (the one
// covering the most characters) and apply its attributes — a v1 that preserves
// the common single-format case exactly and, for a mixed run, keeps the
// prevailing style rather than losing all of it. The dominant segment already
// carries the `comment` mark (every collected segment does), so the anchor is
// preserved; we defensively re-assert it in case a future attribute shape
// omits it.
const dominant = segments.reduce((a, b) => (b.length > a.length ? b : a));
const insertAttrs = { ...dominant.attributes, comment: dominant.markAttrs };
node.delete(start, len);
node.insert(start, newText, { comment: markAttrs });
node.insert(start, newText, insertAttrs);
return { applied: true, currentText: newText };
}
@@ -16,6 +16,7 @@ import { UpdateCommentDto } from './dto/update-comment.dto';
import { ResolveCommentDto } from './dto/resolve-comment.dto';
import { ApplySuggestionDto } from './dto/apply-suggestion.dto';
import { DismissSuggestionDto } from './dto/dismiss-suggestion.dto';
import { ResyncSuggestionAnchorDto } from './dto/resync-suggestion-anchor.dto';
import { PageIdDto, CommentIdDto } from './dto/comments.input';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
@@ -235,6 +236,39 @@ export class CommentController {
return this.commentService.applySuggestion(comment, user, provenance);
}
@HttpCode(HttpStatus.OK)
@Post('resync-suggestion-anchor')
async resyncSuggestionAnchor(
@Body() dto: ResyncSuggestionAnchorDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const comment = await this.commentRepo.findById(dto.commentId, {
includeCreator: true,
includeResolvedBy: true,
});
if (!comment) {
throw new NotFoundException('Comment not found');
}
const page = await this.pageRepo.findById(comment.pageId);
if (!page || page.deletedAt) {
throw new NotFoundException('Page not found');
}
// Authorize BEFORE revealing structural detail (mirrors apply/dismiss).
// Re-anchoring does NOT change the page text — it only corrects the stored
// selection metadata — so the page-level gate is comment access. The service
// further restricts it to the suggestion's own author.
await this.pageAccessService.validateCanComment(page, user, workspace.id);
return this.commentService.resyncSuggestionAnchor(
comment,
dto.selection,
user,
);
}
@HttpCode(HttpStatus.OK)
@Post('dismiss-suggestion')
async dismissSuggestion(
@@ -146,11 +146,19 @@ describe('CommentService — applySuggestion', () => {
'page-1',
expect.objectContaining({ operation: 'commentDeleted', commentId: 'c-1' }),
);
// #496: hard-deleted row → the audit payload is the only surviving record.
expect(auditService.log).toHaveBeenCalledWith(
expect.objectContaining({
event: AuditEvent.COMMENT_SUGGESTION_APPLIED,
resourceType: AuditResource.COMMENT,
resourceId: 'c-1',
metadata: expect.objectContaining({
pageId: 'page-1',
suggestedText: 'new text',
selection: 'old text',
commentAuthor: 'user-1',
decidedBy: 'user-1',
}),
}),
);
expect(result.outcome).toBe('deleted');
@@ -189,17 +197,25 @@ describe('CommentService — applySuggestion', () => {
expect(resolvePatch.resolvedAt).toBeInstanceOf(Date);
expect(resolvePatch.resolvedById).toBe('user-1');
// NOT deleted; broadcast an update, not a deletion.
// NOT deleted.
expect(commentRepo.deleteComment).not.toHaveBeenCalled();
expect(collaborationGateway.handleYjsEvent).not.toHaveBeenCalledWith(
'deleteCommentMark',
expect.anything(),
expect.anything(),
);
// #496 dedup: resolveComment broadcasts `commentResolved` with the enriched
// row; finalize must NOT ALSO emit a redundant `commentUpdated`. So the
// thread receives exactly ONE resolve broadcast and no update broadcast.
expect(wsService.emitCommentEvent).toHaveBeenCalledWith(
'space-1',
'page-1',
expect.objectContaining({ operation: 'commentUpdated', comment: UPDATED }),
expect.objectContaining({ operation: 'commentResolved', comment: UPDATED }),
);
expect(wsService.emitCommentEvent).not.toHaveBeenCalledWith(
'space-1',
'page-1',
expect.objectContaining({ operation: 'commentUpdated' }),
);
expect(auditService.log).toHaveBeenCalledWith(
@@ -211,6 +227,36 @@ describe('CommentService — applySuggestion', () => {
expect(result.outcome).toBe('resolved');
});
it('re-entry: already applied+resolved WITH replies → emits commentUpdated (dedup does not over-suppress)', async () => {
// suggestionAppliedAt set → idempotent finalize; resolvedAt set → resolveComment
// is skipped, so there is NO commentResolved broadcast. The applied-stamp state
// must still reach clients via a single commentUpdated.
const { service, wsService } = makeService(
{ applied: false, currentText: 'new text' },
true,
);
await service.applySuggestion(
suggestionComment({
suggestionAppliedAt: new Date(),
resolvedAt: new Date(),
}),
user(),
);
expect(wsService.emitCommentEvent).toHaveBeenCalledWith(
'space-1',
'page-1',
expect.objectContaining({ operation: 'commentUpdated', comment: UPDATED }),
);
// Nothing resolved this time (already resolved) → no resolve broadcast.
expect(wsService.emitCommentEvent).not.toHaveBeenCalledWith(
'space-1',
'page-1',
expect.objectContaining({ operation: 'commentResolved' }),
);
});
// --- error / rejection branches -----------------------------------------
it('applied=false and currentText differs → ConflictException with currentText in payload', async () => {
@@ -107,11 +107,21 @@ describe('CommentService — dismissSuggestion', () => {
'page-1',
expect.objectContaining({ operation: 'commentDeleted', commentId: 'c-1' }),
);
// #496: the row is hard-deleted, so the audit payload must carry the
// decision's substance (what was suggested, the anchored text, who authored
// it, who decided) — it is the only surviving record.
expect(auditService.log).toHaveBeenCalledWith(
expect.objectContaining({
event: AuditEvent.COMMENT_SUGGESTION_DISMISSED,
resourceType: AuditResource.COMMENT,
resourceId: 'c-1',
metadata: expect.objectContaining({
pageId: 'page-1',
suggestedText: 'new text',
selection: 'old text',
commentAuthor: 'user-1',
decidedBy: 'user-1',
}),
}),
);
expect(result.outcome).toBe('deleted');
@@ -0,0 +1,126 @@
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { CommentService } from './comment.service';
/**
* Coverage for CommentService.resyncSuggestionAnchor (#496): re-anchoring a
* suggestion's stored selection (== apply-time expectedText) to the live-doc
* substring. The service is built directly with jest-mocked deps (the
* @InjectQueue tokens can't be resolved by Test.createTestingModule — see the
* sibling specs).
*/
describe('CommentService — resyncSuggestionAnchor', () => {
const UPDATED = { id: 'c-1', selection: 'new anchor', __updated: true } as any;
function makeService() {
const commentRepo: any = {
updateComment: jest.fn(async () => undefined),
findById: jest.fn(async () => UPDATED),
};
const service = new CommentService(
commentRepo,
{} as any,
{ emitCommentEvent: jest.fn() } as any,
{} as any,
{ add: jest.fn() } as any,
{ add: jest.fn() } as any,
{ log: jest.fn() } as any,
);
return { service, commentRepo };
}
const suggestion = (over?: Partial<any>): any => ({
id: 'c-1',
creatorId: 'user-1',
parentCommentId: null,
selection: 'old anchor',
suggestedText: 'new text',
suggestionAppliedAt: null,
resolvedAt: null,
...over,
});
const user = (over?: Partial<any>): any => ({ id: 'user-1', ...over });
it('persists the new selection and returns the enriched comment', async () => {
const { service, commentRepo } = makeService();
const out = await service.resyncSuggestionAnchor(
suggestion(),
'new anchor',
user(),
);
expect(commentRepo.updateComment).toHaveBeenCalledWith(
{ selection: 'new anchor' },
'c-1',
);
expect(out).toBe(UPDATED);
});
it('is idempotent: no write when the anchor already matches', async () => {
const { service, commentRepo } = makeService();
const out = await service.resyncSuggestionAnchor(
suggestion({ selection: 'same' }),
'same',
user(),
);
expect(commentRepo.updateComment).not.toHaveBeenCalled();
expect(out).toEqual(suggestion({ selection: 'same' }));
});
it('rejects a non-author (only the suggestion owner may re-anchor)', async () => {
const { service, commentRepo } = makeService();
await expect(
service.resyncSuggestionAnchor(suggestion(), 'new anchor', user({ id: 'other' })),
).rejects.toBeInstanceOf(ForbiddenException);
expect(commentRepo.updateComment).not.toHaveBeenCalled();
});
it('rejects a reply / a comment with no suggestion', async () => {
const { service } = makeService();
await expect(
service.resyncSuggestionAnchor(
suggestion({ parentCommentId: 'p-1' }),
'x',
user(),
),
).rejects.toBeInstanceOf(BadRequestException);
await expect(
service.resyncSuggestionAnchor(
suggestion({ suggestedText: null }),
'x',
user(),
),
).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects re-anchoring an already applied or resolved suggestion', async () => {
const { service } = makeService();
await expect(
service.resyncSuggestionAnchor(
suggestion({ suggestionAppliedAt: new Date() }),
'x',
user(),
),
).rejects.toBeInstanceOf(BadRequestException);
await expect(
service.resyncSuggestionAnchor(
suggestion({ resolvedAt: new Date() }),
'x',
user(),
),
).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects a no-op selection equal to the suggested text', async () => {
const { service } = makeService();
await expect(
service.resyncSuggestionAnchor(
suggestion({ suggestedText: 'new text' }),
'new text',
user(),
),
).rejects.toBeInstanceOf(BadRequestException);
});
});
+122 -17
View File
@@ -370,6 +370,76 @@ export class CommentService {
return updatedComment;
}
/**
* Re-sync a suggestion's stored `selection` (== apply-time expectedText) to the
* RAW substring the inline mark actually covers in the LIVE document (#496).
*
* The MCP client creates the comment from a DEBOUNCED REST snapshot, then
* anchors the mark in the live collab doc. When the two disagree (the doc moved
* on in the debounce window) the stored selection no longer equals the marked
* text, so EVERY apply 409s ("the commented text changed"). After anchoring the
* client re-reads the exact marked substring and calls this to store it, making
* apply's strict equality hold.
*
* Only meaningful for an un-settled top-level suggestion authored by the
* caller: applying/resolving freezes the anchor, and a reply-carrying thread is
* preserved rather than mutated. The new text must still differ from the
* suggestion (else "apply" would be a no-op), preserving create()'s invariant.
*/
async resyncSuggestionAnchor(
comment: Comment,
selection: string,
user: User,
): Promise<Comment> {
if (comment.creatorId !== user.id) {
throw new ForbiddenException(
'You can only re-anchor your own suggestion',
);
}
if (comment.parentCommentId) {
throw new BadRequestException(
'Only a top-level comment can carry a suggested edit',
);
}
if (!comment.suggestedText) {
throw new BadRequestException('This comment has no suggested edit');
}
// A settled suggestion's anchor is frozen: re-anchoring an applied/resolved
// thread is meaningless and could resurrect a stale expectedText.
if (comment.suggestionAppliedAt || comment.resolvedAt) {
throw new BadRequestException(
'Cannot re-anchor a suggestion that was already applied or resolved',
);
}
const trimmed = selection.trim();
if (trimmed.length === 0) {
throw new BadRequestException('The re-anchored selection cannot be empty');
}
// Same no-op guard as create(): the suggestion must differ from the text it
// replaces, or apply becomes indistinguishable from already-applied.
if (trimmed === comment.suggestedText.trim()) {
throw new BadRequestException(
'A suggested edit must differ from the selected text',
);
}
// Idempotent: nothing to persist when the anchor already matches.
if (comment.selection === selection) {
return comment;
}
await this.commentRepo.updateComment({ selection }, comment.id);
const updatedComment = await this.commentRepo.findById(comment.id, {
includeCreator: true,
includeResolvedBy: true,
});
// Re-anchoring only corrects stored metadata; it does not change the page
// text or the comment body, so no ws broadcast / notification is warranted.
return updatedComment;
}
/**
* Apply the suggested edit carried by a top-level inline comment: atomically
* replace the text under the comment mark in the collaborative document with
@@ -524,7 +594,7 @@ export class CommentService {
resourceType: AuditResource.COMMENT,
resourceId: comment.id,
spaceId: comment.spaceId,
metadata: { pageId: comment.pageId },
metadata: this.suggestionAuditMetadata(comment, user),
});
return { ...updatedComment, outcome: 'resolved' };
}
@@ -538,7 +608,7 @@ export class CommentService {
resourceType: AuditResource.COMMENT,
resourceId: comment.id,
spaceId: comment.spaceId,
metadata: { pageId: comment.pageId },
metadata: this.suggestionAuditMetadata(comment, user),
});
return settled;
}
@@ -577,8 +647,10 @@ export class CommentService {
// Auto-resolve the thread. resolveComment handles the resolve mark, its ws
// broadcast and the resolve notification. Stay defensive on re-entry.
let didResolveBroadcast = false;
if (!comment.resolvedAt) {
await this.resolveComment(comment, true, user, provenance);
didResolveBroadcast = true;
}
const updatedComment = await this.commentRepo.findById(comment.id, {
@@ -586,18 +658,27 @@ export class CommentService {
includeResolvedBy: true,
});
this.wsService.emitCommentEvent(comment.spaceId, comment.pageId, {
operation: 'commentUpdated',
pageId: comment.pageId,
comment: updatedComment,
});
// #496 dedup: resolveComment already broadcast `commentResolved` carrying
// the fully-enriched row (the applied stamps were persisted above, before
// that call, so its re-read reflects them). Emitting `commentUpdated` here
// too made the client receive TWO events for one apply. Broadcast the
// update ONLY when we did NOT resolve — i.e. the rare re-entry on an
// already-resolved thread, where the applied-stamp change still needs a
// broadcast and resolveComment did not run.
if (!didResolveBroadcast) {
this.wsService.emitCommentEvent(comment.spaceId, comment.pageId, {
operation: 'commentUpdated',
pageId: comment.pageId,
comment: updatedComment,
});
}
this.auditService.log({
event: AuditEvent.COMMENT_SUGGESTION_APPLIED,
resourceType: AuditResource.COMMENT,
resourceId: comment.id,
spaceId: comment.spaceId,
metadata: { pageId: comment.pageId },
metadata: this.suggestionAuditMetadata(comment, user),
});
return { ...updatedComment, outcome: 'resolved' };
@@ -616,7 +697,7 @@ export class CommentService {
resourceType: AuditResource.COMMENT,
resourceId: comment.id,
spaceId: comment.spaceId,
metadata: { pageId: comment.pageId },
metadata: this.suggestionAuditMetadata(comment, user),
});
return settled;
@@ -627,14 +708,17 @@ export class CommentService {
* inline `comment` anchor mark, then ATOMICALLY hard-delete the row only if it
* is still childless. Shared by the apply/dismiss no-replies branches (#329).
*
* ORDER MATTERS: the anchor mark is removed FIRST and FATALLY (mirrors
* applySuggestion, which mutates the doc before writing the DB). The row
* delete is irreversible, so if the mark removal fails — including the
* COLLAB_DISABLE_REDIS "no live instance" hard-error — we must NOT delete the
* row and report success, or the document is left with a permanent orphan
* anchor pointing at a comment that no longer exists (the exact data-integrity
* bug #329 targets). Let the exception propagate (→ 5xx); the operation is
* then repeatable with row + mark still consistent.
* ORDER MATTERS (updated #399 → #496): what runs FIRST and FATALLY here is the
* mark-removal ENQUEUE (a fast, durable Redis add), NOT the mark op itself.
* deleteCommentMark awaits only the enqueue, so a failed add throws BEFORE the
* irreversible row delete — the row + mark stay consistent and the operation is
* repeatable. The actual anchor strip then runs off the HTTP path in the worker
* (idempotent, 3 retries). Only an EXHAUSTED-retries job could leave the doc
* with an orphan anchor pointing at a hard-deleted comment (the data-integrity
* bug #329 targets); that residual divergence is now self-healed by the
* resolve/unresolve mark worker, which strips an orphan mark whenever its
* comment row is gone (#496), and it is meanwhile VISIBLE via BullMQ failed-job
* metrics rather than a silently-swallowed warn.
*
* RACE (#338 F4): the caller read `hasChildren` BEFORE the (slow) mark
* removal, so a reply can land in that window. `comments.parent_comment_id` is
@@ -732,6 +816,27 @@ export class CommentService {
return this.generalQueue.add(QueueJob.COMMENT_MARK_UPDATE, jobData);
}
/**
* Build the audit metadata for a suggestion apply/dismiss decision (#496).
* The subject comment is HARD-DELETED on the childless path, so the audit row
* is the only surviving record — capture the decision's substance (what was
* suggested, the anchored text it replaced, who authored it, who decided)
* before the row can vanish. `decidedBy` is the acting user; `commentAuthor`
* is the suggestion's creator.
*/
private suggestionAuditMetadata(
comment: Comment,
user: User,
): Record<string, any> {
return {
pageId: comment.pageId,
suggestedText: comment.suggestedText ?? null,
selection: comment.selection ?? null,
commentAuthor: comment.creatorId ?? null,
decidedBy: user.id,
};
}
private async queueCommentNotification(
content: any,
oldMentionIds: string[],
@@ -0,0 +1,20 @@
import { IsString, IsUUID, MaxLength, MinLength } from 'class-validator';
/**
* #496: after the MCP client anchors a suggestion in the LIVE collab doc, it
* re-reads the exact substring under the new mark and syncs it here as the
* comment's stored `selection` (== apply-time expectedText). Fixes the perpetual
* 409 where expectedText came from a debounced REST snapshot while the mark sat
* in the live doc.
*/
export class ResyncSuggestionAnchorDto {
@IsUUID()
commentId: string;
// The raw substring the mark now covers in the live document. Bounded like the
// create-time selection (2000) so a legitimate anchored span is never cut.
@IsString()
@MinLength(1)
@MaxLength(2000)
selection: string;
}
@@ -1,14 +1,19 @@
import { Global, Module } from '@nestjs/common';
import { AUDIT_SERVICE, NoopAuditService } from './audit.service';
import { AUDIT_SERVICE } from './audit.service';
import { DatabaseAuditService } from './database-audit.service';
// #496: bind the audit token to a real DB-backed trail (was NoopAuditService,
// which silently dropped every event). Kysely (@Global DatabaseModule) and
// ClsService (@Global ClsModule) are both globally available, so this module
// needs no extra imports.
@Global()
@Module({
providers: [
{
provide: AUDIT_SERVICE,
useClass: NoopAuditService,
useClass: DatabaseAuditService,
},
],
exports: [AUDIT_SERVICE],
})
export class NoopAuditModule {}
export class AuditModule {}
@@ -0,0 +1,171 @@
import { DatabaseAuditService } from './database-audit.service';
import { AUDIT_CONTEXT_KEY } from '../../common/middlewares/audit-context.middleware';
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
/**
* Observable-property coverage for the DB-backed audit trail (#496): every
* assertion pins what actually reaches the `audit` table (or that nothing does),
* driven through a chainable Kysely mock that captures the inserted rows.
*/
describe('DatabaseAuditService', () => {
function makeService(clsContext: any) {
const inserted: any[] = [];
const updated: any[] = [];
let failNextInsert = false;
const db: any = {
insertInto: jest.fn(() => ({
values: jest.fn((rows: any) => ({
execute: jest.fn(async () => {
if (failNextInsert) {
failNextInsert = false;
throw new Error('boom');
}
inserted.push(rows);
}),
})),
})),
updateTable: jest.fn(() => ({
set: jest.fn((patch: any) => ({
where: jest.fn(() => ({
execute: jest.fn(async () => {
updated.push(patch);
}),
})),
})),
})),
};
const store: Record<string, any> = { [AUDIT_CONTEXT_KEY]: clsContext };
const cls: any = {
get: jest.fn((key: string) => store[key]),
set: jest.fn((key: string, val: any) => {
store[key] = val;
}),
};
const service = new DatabaseAuditService(db, cls);
return {
service,
inserted,
updated,
cls,
store,
failInsert: () => {
failNextInsert = true;
},
};
}
const applyPayload = () => ({
event: AuditEvent.COMMENT_SUGGESTION_APPLIED,
resourceType: AuditResource.COMMENT,
resourceId: 'c-1',
spaceId: 'space-1',
metadata: { pageId: 'p-1', suggestedText: 'new', decidedBy: 'u-2' },
});
const ctx = (over?: any) => ({
workspaceId: 'ws-1',
actorId: 'u-2',
actorType: 'user',
ipAddress: '10.0.0.1',
userAgent: 'jest',
...over,
});
it('log() persists a row with the CLS context merged onto the payload', async () => {
const { service, inserted } = makeService(ctx());
service.log(applyPayload());
// log() is fire-and-forget; flush the microtask queue.
await Promise.resolve();
await Promise.resolve();
expect(inserted).toHaveLength(1);
expect(inserted[0]).toMatchObject({
workspaceId: 'ws-1',
actorId: 'u-2',
actorType: 'user',
event: AuditEvent.COMMENT_SUGGESTION_APPLIED,
resourceType: AuditResource.COMMENT,
resourceId: 'c-1',
spaceId: 'space-1',
ipAddress: '10.0.0.1',
metadata: { pageId: 'p-1', suggestedText: 'new', decidedBy: 'u-2' },
});
});
it('log() is a no-op when there is no workspace in scope', async () => {
const { service, inserted } = makeService(ctx({ workspaceId: null }));
service.log(applyPayload());
await Promise.resolve();
expect(inserted).toHaveLength(0);
});
it('log() drops EXCLUDED_AUDIT_EVENTS (e.g. comment.created)', async () => {
const { service, inserted } = makeService(ctx());
service.log({
event: AuditEvent.COMMENT_CREATED,
resourceType: AuditResource.COMMENT,
resourceId: 'c-1',
spaceId: 'space-1',
});
await Promise.resolve();
await Promise.resolve();
expect(inserted).toHaveLength(0);
});
it('a failed insert never throws to the caller (audit is a side-record)', async () => {
const { service, failInsert } = makeService(ctx());
failInsert();
// Must not reject / throw.
expect(() => service.log(applyPayload())).not.toThrow();
await Promise.resolve();
await Promise.resolve();
});
it('logWithContext() persists with an explicit (non-request) context', async () => {
const { service, inserted } = makeService(undefined);
service.logWithContext(applyPayload(), ctx({ actorType: 'system' }) as any);
await Promise.resolve();
await Promise.resolve();
expect(inserted).toHaveLength(1);
expect(inserted[0].actorType).toBe('system');
expect(inserted[0].workspaceId).toBe('ws-1');
});
it('logBatchWithContext() inserts only non-excluded events', async () => {
const { service, inserted } = makeService(undefined);
service.logBatchWithContext(
[
applyPayload(),
{
event: AuditEvent.COMMENT_CREATED,
resourceType: AuditResource.COMMENT,
resourceId: 'c-2',
},
],
ctx() as any,
);
await Promise.resolve();
await Promise.resolve();
// Batch is a single insert call carrying only the applied event.
expect(inserted).toHaveLength(1);
expect(inserted[0]).toHaveLength(1);
expect(inserted[0][0].event).toBe(AuditEvent.COMMENT_SUGGESTION_APPLIED);
});
it('setActorId / setActorType mutate the ambient CLS context', () => {
const { service, store } = makeService(ctx({ actorId: null }));
service.setActorId('u-9');
service.setActorType('api_key');
expect(store[AUDIT_CONTEXT_KEY].actorId).toBe('u-9');
expect(store[AUDIT_CONTEXT_KEY].actorType).toBe('api_key');
});
it('updateRetention() writes the workspace retention window', async () => {
const { service, updated } = makeService(ctx());
await service.updateRetention('ws-1', 30);
expect(updated).toEqual([{ auditRetentionDays: 30 }]);
});
});
@@ -0,0 +1,160 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { ClsService } from 'nestjs-cls';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import {
AuditContext,
AUDIT_CONTEXT_KEY,
} from '../../common/middlewares/audit-context.middleware';
import {
AuditLogPayload,
ActorType,
EXCLUDED_AUDIT_EVENTS,
} from '../../common/events/audit-events';
import { AuditLogContext, IAuditService } from './audit.service';
/**
* Minimal DB-backed audit trail (#496). Replaces NoopAuditService so that
* decision-bearing events — notably comment.suggestion_applied /
* comment.suggestion_dismissed, whose subject comment is HARD-DELETED on the
* childless path — leave a durable record of who decided what. Without this the
* events were emitted (comment.service / *.controller) but swallowed, so an
* applied/dismissed suggestion was unrecoverable once the row was gone.
*
* Rows land in the pre-existing `audit` table (migration 20260228T223532). The
* per-request actor/workspace/ip come from the CLS AuditContext populated by
* AuditContextMiddleware + AuditActorInterceptor; callers that run OUTSIDE a
* request (queue workers, imports) pass an explicit context via
* logWithContext / logBatchWithContext.
*
* Audit is a side-record: a write failure MUST NOT break the originating
* request, so every persistence path swallows its error with a warn. Events in
* EXCLUDED_AUDIT_EVENTS (high-volume/low-signal) are dropped.
*/
@Injectable()
export class DatabaseAuditService implements IAuditService {
private readonly logger = new Logger(DatabaseAuditService.name);
constructor(
@InjectKysely() private readonly db: KyselyDB,
private readonly cls: ClsService,
) {}
/**
* Persist a single event using the ambient request-scoped AuditContext. A
* no-op when there is no workspace in scope (the table's workspace_id is NOT
* NULL) or the event is excluded. Fire-and-forget: the returned promise is not
* awaited by hot callers, and its rejection is swallowed here.
*/
log(payload: AuditLogPayload): void {
const context = this.cls?.get<AuditContext>(AUDIT_CONTEXT_KEY);
if (!context?.workspaceId) {
// No workspace in scope — nothing we can attribute the row to. This is
// expected for events emitted outside an HTTP request; those callers must
// use logWithContext instead.
return;
}
void this.persist(payload, {
workspaceId: context.workspaceId,
actorId: context.actorId ?? undefined,
actorType: context.actorType,
ipAddress: context.ipAddress ?? undefined,
userAgent: context.userAgent ?? undefined,
});
}
/** Persist a single event with an explicit (non-request) context. */
logWithContext(payload: AuditLogPayload, context: AuditLogContext): void {
if (!context?.workspaceId) return;
void this.persist(payload, context);
}
/** Persist a batch of events sharing one explicit context (imports). */
logBatchWithContext(
payloads: AuditLogPayload[],
context: AuditLogContext,
): void {
if (!context?.workspaceId || payloads.length === 0) return;
const rows = payloads
.filter((p) => !EXCLUDED_AUDIT_EVENTS.has(p.event))
.map((p) => this.toRow(p, context));
if (rows.length === 0) return;
this.db
.insertInto('audit')
.values(rows)
.execute()
.catch((err: any) =>
this.logger.warn(`Failed to persist ${rows.length} audit events: ${err?.message}`),
);
}
/** Update the ambient request actor (e.g. after login resolves the user). */
setActorId(actorId: string): void {
const context = this.cls?.get<AuditContext>(AUDIT_CONTEXT_KEY);
if (context) {
context.actorId = actorId;
this.cls.set(AUDIT_CONTEXT_KEY, context);
}
}
/** Update the ambient request actor type (user | system | api_key). */
setActorType(actorType: ActorType): void {
const context = this.cls?.get<AuditContext>(AUDIT_CONTEXT_KEY);
if (context) {
context.actorType = actorType;
this.cls.set(AUDIT_CONTEXT_KEY, context);
}
}
/** Persist a workspace's audit-log retention window (days). */
async updateRetention(
workspaceId: string,
retentionDays: number,
): Promise<void> {
try {
await this.db
.updateTable('workspaces')
.set({ auditRetentionDays: retentionDays })
.where('id', '=', workspaceId)
.execute();
} catch (err: any) {
this.logger.warn(
`Failed to update audit retention for workspace ${workspaceId}: ${err?.message}`,
);
}
}
private async persist(
payload: AuditLogPayload,
context: AuditLogContext,
): Promise<void> {
if (EXCLUDED_AUDIT_EVENTS.has(payload.event)) return;
try {
await this.db
.insertInto('audit')
.values(this.toRow(payload, context))
.execute();
} catch (err: any) {
// Audit is a side-record; never let a failed write surface to the caller.
this.logger.warn(
`Failed to persist audit event ${payload.event}: ${err?.message}`,
);
}
}
private toRow(payload: AuditLogPayload, context: AuditLogContext) {
return {
workspaceId: context.workspaceId,
actorId: context.actorId ?? null,
actorType: context.actorType ?? 'user',
event: payload.event,
resourceType: payload.resourceType,
resourceId: payload.resourceId ?? null,
spaceId: payload.spaceId ?? null,
// jsonb columns: node-postgres serializes plain objects to JSON.
changes: payload.changes ? (payload.changes as any) : null,
metadata: payload.metadata ? (payload.metadata as any) : null,
ipAddress: context.ipAddress ?? null,
};
}
}
@@ -34,6 +34,8 @@ import {
isMetricsEnabled,
observeMcpTool,
incConnectTimeout,
incGetPageCacheHit,
incGetPageCacheMiss,
} from '../metrics/metrics.registry';
// Minimal shape of the embedded MCP HTTP handler exported by @docmost/mcp/http.
@@ -357,6 +359,10 @@ export class McpService implements OnModuleDestroy {
observeMcpTool(labels?.tool ?? 'other', value);
} else if (name === 'collab_connect_timeouts_total') {
incConnectTimeout();
} else if (name === 'mcp_getpage_cache_hits_total') {
incGetPageCacheHit();
} else if (name === 'mcp_getpage_cache_misses_total') {
incGetPageCacheMiss();
}
}
: undefined,
@@ -25,6 +25,15 @@ export const METRIC_COLLAB_CONNECT_TIMEOUTS_TOTAL =
export const METRIC_COLLAB_AUTH_DURATION = 'collab_auth_duration_seconds';
export const METRIC_MCP_TOOL_DURATION = 'mcp_tool_duration_seconds';
// #479 — getPage PM→Markdown conversion cache hit/miss counters. Emitted by the
// MCP package via its dependency-neutral onMetric sink and routed onto these two
// prom counters by the mcp.service onMetric callback; a >50% hit-rate is the
// success signal for the getPage perf work. Same "do not rename" contract.
export const METRIC_MCP_GETPAGE_CACHE_HITS_TOTAL =
'mcp_getpage_cache_hits_total';
export const METRIC_MCP_GETPAGE_CACHE_MISSES_TOTAL =
'mcp_getpage_cache_misses_total';
// Histogram buckets (seconds). Chosen to give useful p50/p95/p99 resolution
// for typical web/DB latencies without exploding series cardinality.
export const HTTP_BUCKETS = [
@@ -24,6 +24,8 @@ import {
METRIC_DB_QUERY_DURATION,
METRIC_HTTP_REQUEST_DURATION,
METRIC_MCP_TOOL_DURATION,
METRIC_MCP_GETPAGE_CACHE_HITS_TOTAL,
METRIC_MCP_GETPAGE_CACHE_MISSES_TOTAL,
sizeBucket,
} from './metrics.constants';
@@ -61,6 +63,9 @@ let connectTimeoutsCounter: Counter | null = null;
let collabConnectHist: Histogram | null = null;
let collabAuthHist: Histogram | null = null;
let mcpToolHist: Histogram<'tool'> | null = null;
// #479 — getPage conversion-cache hit/miss counters.
let getPageCacheHitsCounter: Counter | null = null;
let getPageCacheMissesCounter: Counter | null = null;
// #402 — read-on-scrape source for collab_docs_open. The gauge is NEVER
// inc/dec'd (that drifts under crashes/handoffs); instead its collect() callback
@@ -175,6 +180,18 @@ function init(): void {
buckets: MCP_TOOL_BUCKETS,
registers: [registry],
});
getPageCacheHitsCounter = new Counter({
name: METRIC_MCP_GETPAGE_CACHE_HITS_TOTAL,
help: 'Total getPage PM→Markdown conversions served from the cache (skipped)',
registers: [registry],
});
getPageCacheMissesCounter = new Counter({
name: METRIC_MCP_GETPAGE_CACHE_MISSES_TOTAL,
help: 'Total getPage PM→Markdown conversions computed (cache misses)',
registers: [registry],
});
}
// Runs once when this module is first imported. Safe to call again (idempotent).
@@ -247,6 +264,14 @@ export function observeCollabAuth(seconds: number): void {
collabAuthHist?.observe(seconds);
}
export function incGetPageCacheHit(): void {
getPageCacheHitsCounter?.inc();
}
export function incGetPageCacheMiss(): void {
getPageCacheMissesCounter?.inc();
}
export function observeMcpTool(tool: string, seconds: number): void {
// `tool` MUST be a bounded, registration-derived MCP tool name (the caller
// guarantees it comes from the registered-tool set) — never free-form input —
@@ -10,6 +10,8 @@ import {
incConnectTimeout,
incDocLoad,
incDocUnload,
incGetPageCacheHit,
incGetPageCacheMiss,
isMetricsEnabled,
observeCollabAuth,
observeCollabConnect,
@@ -197,6 +199,8 @@ describe('metrics helpers are safe no-ops when METRICS_PORT is unset', () => {
incDocLoad();
incDocUnload();
incConnectTimeout();
incGetPageCacheHit();
incGetPageCacheMiss();
// Registering a source must not create the gauge or invoke the fn.
registerDocsOpenSource(() => {
throw new Error('docsOpenSource must NOT be called when disabled');
@@ -139,13 +139,19 @@ describe('GeneralQueueProcessor — COMMENT_MARK_UPDATE (#399)', () => {
);
});
it('skips (no throw) when the comment row has vanished', async () => {
it('reconcile (#496): comment row vanished → strips the orphan anchor mark', async () => {
const { proc, collaborationGateway, commentRepo } = makeProc();
commentRepo.findById.mockResolvedValue(undefined);
await expect(
proc.process(job({ ...base, action: 'resolve', ts: 1000 })),
).resolves.toBeUndefined();
expect(collaborationGateway.handleYjsEvent).not.toHaveBeenCalled();
// A resolve/unresolve mark job whose comment row is gone leaves a silent
// orphan; the worker self-heals by stripping the anchor instead of returning.
expect(collaborationGateway.handleYjsEvent).toHaveBeenCalledWith(
'deleteCommentMark',
'page.page-1',
{ commentId: 'c-1', user: { id: 'user-1' } },
);
});
});
@@ -95,7 +95,8 @@ export class GeneralQueueProcessor
* #399: apply a comment's inline-mark mirror in the collab Y.Doc, off the HTTP
* critical path. Runs the SAME gateway path the synchronous comment.service
* code used (byte-identical mark op):
* - resolve / unresolve resolveCommentMark (flip the `resolved` attribute);
* - resolve / unresolve resolveCommentMark (flip the `resolved` attribute),
* OR strip an orphan anchor when the comment row has vanished (#496);
* - delete deleteCommentMark (strip the ephemeral-suggestion anchor #329).
* The op is idempotent, so a BullMQ retry is safe. Throwing propagates to
* WorkerHost the job is retried and, on exhaustion, surfaces in failed-job
@@ -133,7 +134,18 @@ export class GeneralQueueProcessor
// of this resolve), skip it rather than flip the mark to a stale state.
const comment = await this.commentRepo.findById(commentId);
if (!comment) {
// The comment vanished (e.g. hard-deleted) → nothing left to mirror.
// #496 reconcile: the comment row is GONE (e.g. an ephemeral apply/dismiss
// hard-deleted it while this resolve/unresolve mark job sat in the queue),
// but its inline anchor may still live in the doc — a silent orphan mark
// pointing at a comment that no longer exists. Self-heal by stripping it
// instead of just returning: this closes the divergence the fire-and-forget
// resolve/unresolve enqueue (comment.service resolveComment) could leave.
// Idempotent — deleteCommentMark on an already-absent mark is a no-op.
await this.getCollaborationGateway().handleYjsEvent(
'deleteCommentMark',
documentName,
{ commentId, user },
);
return;
}
const wantResolved = action === 'resolve';
+42
View File
@@ -450,6 +450,12 @@ export function CommentsMixin<TBase extends GConstructor<DocmostClientContext>>(
// can surface the closest-block / spans-multiple-blocks hint built from the
// LIVE document (the pre-check page is not in scope there).
let liveNotFoundError: Error | null = null;
// #496: the RAW substring the mark actually covers in the LIVE doc. The
// stored selection (payload.selection) came from a DEBOUNCED REST snapshot,
// which can differ from the live doc — and apply compares the marked live
// text to the stored selection strictly, so a stale snapshot 409s on EVERY
// apply. Captured here (same doc version the mark is set in) and synced below.
let liveAnchoredSelection: string | null = null;
try {
const collabToken = await this.getCollabTokenWithReauth();
// Open the collab doc by the canonical UUID, never the slugId (#260). The
@@ -489,6 +495,12 @@ export function CommentsMixin<TBase extends GConstructor<DocmostClientContext>>(
}
if (applyAnchorInDoc(doc, selection as string, newCommentId)) {
anchored = true;
// For a suggestion, re-read the exact substring now under the mark
// (the mark is an attribute, so it does not change the raw text) to
// sync as the stored expectedText after the mutation resolves.
if (hasSuggestion) {
liveAnchoredSelection = getAnchoredText(doc, selection as string);
}
return doc;
}
// Selection text not found in the LIVE document: abort the write. The
@@ -527,6 +539,36 @@ export function CommentsMixin<TBase extends GConstructor<DocmostClientContext>>(
);
}
// #496: sync the stored selection (== apply-time expectedText) to the RAW
// substring the mark actually covers in the LIVE doc when it diverged from
// the debounced REST snapshot we stored at create time. Without this, apply
// strictly compares the marked live text to a stale stored selection and
// 409s every time. Best-effort: the comment is already correctly anchored, so
// a resync failure must NOT roll it back — it only risks a later apply 409,
// which we surface as a soft warning.
if (
hasSuggestion &&
liveAnchoredSelection != null &&
liveAnchoredSelection !== payload.selection
) {
try {
await this.client.post("/comments/resync-suggestion-anchor", {
commentId: newCommentId,
selection: liveAnchoredSelection,
});
// Reflect the corrected anchor in the returned comment.
if (result.data) result.data.selection = liveAnchoredSelection;
} catch (e) {
if (process.env.DEBUG) {
console.error("Failed to resync suggestion anchor:", e);
}
result.warning =
"The suggestion was anchored, but its stored selection could not be " +
"synced to the live document; applying it may report a conflict if the " +
"text changed. Re-create the suggestion if Apply fails.";
}
}
// Soft warning (like editPageText): the selection only matched after
// stripping markdown, so the caller likely quoted a styled fragment.
if (anchorNormalized) {
+8
View File
@@ -23,6 +23,7 @@ import { acquireCollabSession } from "../lib/collab-session.js";
import { withPageLock, isUuid } from "../lib/page-lock.js";
import { getCollabToken, performLogin } from "../lib/auth-utils.js";
import { formatDocmostAxiosError } from "./errors.js";
import { GetPageConversionCache } from "./getpage-cache.js";
// A generic mixin base constructor (issue #450). Each domain mixin is a factory
// `<T extends GConstructor<DocmostClientContext>>(Base: T) => class extends Base`
@@ -159,6 +160,13 @@ export abstract class DocmostClientContext {
// bypassed on a forced refresh (the 401/403 reauth path). null = no token yet.
protected collabTokenCache: { token: string; mintedAt: number } | null = null;
// Content-addressed conversion cache for getPage (issue #479). Keyed on
// (canonical pageId, updatedAt, optionsHash) -> the converted Markdown, so a
// re-read of an UNCHANGED page skips the expensive convertProseMirrorToMarkdown
// tree walk. Per-instance (a DocmostClient is built per user / per chat), so a
// cached conversion can never leak across identities. See getpage-cache.ts.
protected getPageCache = new GetPageConversionCache();
// Two construction forms:
// - new DocmostClient(config) // discriminated union (current)
// - new DocmostClient(baseURL, email, password) // legacy positional creds
+147
View File
@@ -0,0 +1,147 @@
// Content-addressed LRU cache for the PM->Markdown conversion in getPage
// (issue #479). getPage is the dominant agent op (812 calls / 2h, p95 840ms);
// the bulk of its cost is convertProseMirrorToMarkdown — a full ProseMirror-tree
// walk over the page content (hundreds of KB of JSON on large pages) run on
// EVERY read. Since agents re-read far more than they write (812 reads vs 28
// writes in the sample), most conversions re-produce the SAME markdown from
// UNCHANGED content. This cache skips the recomputation on a hit.
//
// KEY = (pageId, updatedAt, optionsHash):
// - pageId: the page's CANONICAL UUID (resultData.id), not the agent-supplied
// slugId — so a slugId read and a UUID read of the same page share one entry.
// - updatedAt: comes from the SAME /pages/info response as `content`, so the
// two are mutually consistent; a changed page yields a new updatedAt -> a new
// key -> automatic, precise invalidation (no stale markdown is ever served).
// - optionsHash: a stable hash of the conversion options. getPage passes
// `{dropResolvedCommentAnchors:true}` while exportPageMarkdown passes the
// defaults (#328) — DIFFERENT output for the same content, so the options
// MUST be part of the key or a hit would serve the wrong variant.
//
// BOUNDS: evict the LEAST-recently-used entry when EITHER the entry count OR the
// total stored bytes would exceed its cap. Large pages are hundreds of KB, so a
// byte cap (not just a count cap) is what actually bounds memory. A Map iterates
// in insertion order, so the first key is the LRU entry; a hit re-inserts its key
// to move it to the most-recently-used end.
//
// This module is dependency-neutral (no axios/client/prom-client): a plain class
// the shared client context owns one instance of, so the cache persists across
// getPage calls on a single DocmostClient instance (built per user / per chat).
/** A stable, order-insensitive hash of the conversion options object. */
export function hashConvertOptions(options: unknown): string {
// JSON.stringify with SORTED keys makes the hash independent of key order, so
// {a:1,b:2} and {b:2,a:1} collapse to one entry. undefined/null options -> a
// fixed empty-object key, matching a caller that passes no options at all.
if (options === undefined || options === null) return "{}";
return stableStringify(options);
}
function stableStringify(value: any): string {
if (value === null || typeof value !== "object") return JSON.stringify(value);
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
const keys = Object.keys(value).sort();
return `{${keys
.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`)
.join(",")}}`;
}
interface CacheEntry {
markdown: string;
bytes: number;
}
export interface GetPageCacheOptions {
/** Max number of entries before LRU eviction. Default 50. */
maxEntries?: number;
/** Max total stored bytes before LRU eviction. Default 10 MB. */
maxBytes?: number;
}
export class GetPageConversionCache {
private readonly maxEntries: number;
private readonly maxBytes: number;
// Insertion-ordered: the FIRST key is the least-recently-used entry.
private readonly map = new Map<string, CacheEntry>();
private totalBytes = 0;
constructor(opts: GetPageCacheOptions = {}) {
// A non-positive/NaN cap is treated as "use the default", never as an
// unbounded (or always-empty) cache — a silently unbounded cache would leak
// memory, and an always-empty one would defeat the whole optimization.
this.maxEntries =
Number.isFinite(opts.maxEntries) && (opts.maxEntries as number) > 0
? Math.floor(opts.maxEntries as number)
: 50;
this.maxBytes =
Number.isFinite(opts.maxBytes) && (opts.maxBytes as number) > 0
? Math.floor(opts.maxBytes as number)
: 10 * 1024 * 1024;
}
/** Compose the content-addressed key from its three parts. */
static key(pageId: string, updatedAt: string, optionsHash: string): string {
// A space separates the parts so no combination of values can collide by
// concatenation: a canonical UUID and an ISO updatedAt never contain a
// space, so the boundaries between the three parts are unambiguous.
return `${pageId} ${updatedAt} ${optionsHash}`;
}
/**
* Return the cached markdown for `key`, or undefined on a miss. A hit moves
* the entry to the most-recently-used end (delete + re-set) so the LRU order
* reflects real access, not just insertion.
*/
get(key: string): string | undefined {
const entry = this.map.get(key);
if (entry === undefined) return undefined;
this.map.delete(key);
this.map.set(key, entry);
return entry.markdown;
}
/**
* Store `markdown` under `key`, then evict LRU entries until BOTH caps hold.
* Re-storing an existing key refreshes its value and recency (its old bytes
* are subtracted first, so totalBytes stays exact).
*/
set(key: string, markdown: string): void {
// Byte size of the stored string (UTF-8). A single entry larger than the
// whole byte cap is still stored (so getPage always gets a hit next time),
// then the eviction loop below simply cannot shrink below it — accepted:
// one oversized page is bounded by the page itself, not a cache leak.
const bytes = Buffer.byteLength(markdown, "utf8");
const existing = this.map.get(key);
if (existing !== undefined) {
this.totalBytes -= existing.bytes;
this.map.delete(key);
}
this.map.set(key, { markdown, bytes });
this.totalBytes += bytes;
this.evict();
}
/** Evict the LRU entry until both the count and byte caps are satisfied. */
private evict(): void {
while (
this.map.size > this.maxEntries ||
(this.totalBytes > this.maxBytes && this.map.size > 1)
) {
// The first key in insertion order is the least-recently-used.
const oldest = this.map.keys().next().value as string | undefined;
if (oldest === undefined) break;
const entry = this.map.get(oldest);
this.map.delete(oldest);
if (entry) this.totalBytes -= entry.bytes;
}
}
/** Current entry count (test/introspection). */
get size(): number {
return this.map.size;
}
/** Current total stored bytes (test/introspection). */
get bytes(): number {
return this.totalBytes;
}
}
+69 -7
View File
@@ -10,7 +10,14 @@ import {
filterComment,
filterSearchResult,
} from "../lib/filters.js";
import { convertProseMirrorToMarkdown } from "../lib/markdown-converter.js";
import {
convertProseMirrorToMarkdown,
type ConvertProseMirrorToMarkdownOptions,
} from "../lib/markdown-converter.js";
import {
GetPageConversionCache,
hashConvertOptions,
} from "./getpage-cache.js";
import {
collectInternalFileNodes,
normalizeFileUrl,
@@ -395,6 +402,19 @@ export function ReadMixin<TBase extends GConstructor<DocmostClientContext>>(Base
/** Raw page info including the ProseMirror JSON content and slugId. */
/**
* Overridable seam over convertProseMirrorToMarkdown (issue #479). Production
* just delegates; it exists as a method so a unit test can spy on it and
* assert the conversion is genuinely SKIPPED on a getPage cache HIT (the whole
* point of the cache) an ESM named import cannot be intercepted otherwise.
*/
protected convertPageMarkdown(
content: any,
options: ConvertProseMirrorToMarkdownOptions,
): string {
return convertProseMirrorToMarkdown(content, options);
}
async getPage(pageId: string) {
await this.ensureAuthenticated();
const resultData = await this.getPageRaw(pageId);
@@ -403,13 +423,55 @@ export function ReadMixin<TBase extends GConstructor<DocmostClientContext>>(Base
// discussions. Active anchors are kept. (The lossless exportPageMarkdown
// round-trip deliberately does NOT pass this flag — resolved anchors there
// must be preserved.)
let content = resultData.content
? convertProseMirrorToMarkdown(resultData.content, {
dropResolvedCommentAnchors: true,
})
: "";
//
// Content-addressed conversion cache (issue #479): the PM->Markdown walk is
// the dominant cost of this hot read op. Key on the page's canonical UUID +
// updatedAt (both from THIS /pages/info response, so mutually consistent) +
// a hash of the conversion options. A hit returns the cached markdown and
// skips the walk; a miss converts and stores. The cached value is the
// conversion output BEFORE the {{SUBPAGES}} substitution below, which uses
// live subpage data and stays outside the cache — so the final result is
// byte-identical to the uncached path.
const convertOptions = { dropResolvedCommentAnchors: true };
let content = "";
if (resultData.content) {
// Only cache when we have a stable identity+version for the key. Both come
// from the same response; if either is missing (unexpected server shape),
// fall back to converting uncached rather than keying on a partial tuple.
const cacheable =
typeof resultData.id === "string" &&
typeof resultData.updatedAt === "string";
const cacheKey = cacheable
? GetPageConversionCache.key(
resultData.id,
resultData.updatedAt,
hashConvertOptions(convertOptions),
)
: null;
// Always fetch subpages to provide context to the agent
const cached = cacheKey ? this.getPageCache.get(cacheKey) : undefined;
if (cached !== undefined) {
content = cached;
this.onMetricFn?.("mcp_getpage_cache_hits_total", 1);
} else {
// Goes through the convertPageMarkdown seam (not the raw import) so a
// test can assert the conversion is SKIPPED on a hit (issue #479 F2).
content = this.convertPageMarkdown(resultData.content, convertOptions);
if (cacheKey) this.getPageCache.set(cacheKey, content);
// A non-cacheable page (missing id/updatedAt) is still a genuine
// conversion, so it counts as a miss for an honest hit-rate.
this.onMetricFn?.("mcp_getpage_cache_misses_total", 1);
}
}
// Always fetch subpages to provide context to the agent.
//
// NOT parallelizable with the page fetch (issue #479 asked to check): the
// sidebar-pages endpoint REQUIRES spaceId in its POST body, and spaceId is
// only known FROM this page fetch's response (resolvePageId yields the UUID
// but never the spaceId). So `Promise.all([pageFetch, subpagesFetch])` would
// have to invent a spaceId it does not have — the two calls are inherently
// sequential. Correctness wins; the conversion cache above is the real speedup.
let subpages: any[] = [];
try {
// `pageId` may be a slugId, but the sidebar-pages endpoint requires the
@@ -553,6 +553,189 @@ test("suggestedText: the stored selection is the doc's RAW typographic substring
assert.equal(createPayload.suggestedText, "goodbye");
});
// -----------------------------------------------------------------------------
// 8b) #496: the DEBOUNCED REST snapshot (pages/info) DIFFERS from the LIVE collab
// doc (mutatePage) — the doc moved on in the debounce window. The stored
// selection is captured from the snapshot at create time, but the mark is set
// in the live doc, so apply would 409 forever. After anchoring, the client
// re-reads the RAW substring under the mark from the LIVE doc and POSTs it to
// /comments/resync-suggestion-anchor so the stored expectedText matches.
// -----------------------------------------------------------------------------
test("suggestion: re-syncs the stored selection to the LIVE doc substring when the REST snapshot lagged", async () => {
let createPayload = null;
let resyncPayload = null;
const { baseURL } = await spawn(async (req, res) => {
const raw = 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/pages/info") {
// DEBOUNCED snapshot: ASCII quotes (stale — the live doc has since been
// typographically corrected).
sendJson(res, 200, {
data: {
id: "33333333-3333-3333-3333-333333333333",
content: {
type: "doc",
content: [
{
type: "paragraph",
content: [{ type: "text", text: 'he said "hello" loudly' }],
},
],
},
},
});
return;
}
if (req.url === "/api/comments/create") {
createPayload = JSON.parse(raw);
sendJson(res, 200, {
data: {
id: "cmt-resync-1",
content: createPayload.content,
selection: createPayload.selection,
suggestedText: createPayload.suggestedText,
type: createPayload.type,
},
});
return;
}
if (req.url === "/api/comments/resync-suggestion-anchor") {
resyncPayload = JSON.parse(raw);
sendJson(res, 200, { data: { id: "cmt-resync-1" } });
return;
}
sendJson(res, 404, { message: "not found" });
});
class TestClient extends DocmostClient {
async getCollabTokenWithReauth() {
return "collab-token";
}
async resolvePageId() {
return "33333333-3333-3333-3333-333333333333";
}
async mutatePage(pageId, collabToken, apiUrl, transform) {
// LIVE doc: SMART quotes (what the mark is actually set over).
const doc = {
type: "doc",
content: [
{
type: "paragraph",
content: [{ type: "text", text: "he said “hello” loudly" }],
},
],
};
const out = transform(doc);
return { doc: out, verify: { ok: true } };
}
}
const client = new TestClient(baseURL, "user@example.com", "pw");
const result = await client.createComment(
"33333333-3333-3333-3333-333333333333",
"please change",
"inline",
'"hello"', // ASCII quotes
undefined,
"goodbye",
);
assert.equal(result.success, true);
assert.equal(result.anchored, true);
// Create stored the STALE snapshot substring (ASCII quotes).
assert.equal(createPayload.selection, '"hello"');
// …then the client re-synced to the LIVE marked substring (smart quotes).
assert.ok(resyncPayload, "/comments/resync-suggestion-anchor must be called");
assert.equal(resyncPayload.commentId, "cmt-resync-1");
assert.equal(resyncPayload.selection, "“hello”");
// The returned comment reflects the corrected anchor.
assert.equal(result.data.selection, "“hello”");
});
// -----------------------------------------------------------------------------
// 8c) #496: when the REST snapshot and the LIVE doc AGREE, no resync round-trip
// is made (the common case must stay a single write).
// -----------------------------------------------------------------------------
test("suggestion: no resync call when the snapshot already matches the live doc", async () => {
let resyncCalls = 0;
const { baseURL } = await spawn(async (req, res) => {
const raw = 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/pages/info") {
sendJson(res, 200, {
data: {
id: "44444444-4444-4444-4444-444444444444",
content: {
type: "doc",
content: [
{ type: "paragraph", content: [{ type: "text", text: "Hello brave world" }] },
],
},
},
});
return;
}
if (req.url === "/api/comments/create") {
const p = JSON.parse(raw);
sendJson(res, 200, {
data: { id: "cmt-nosync-1", content: p.content, selection: p.selection, suggestedText: p.suggestedText, type: p.type },
});
return;
}
if (req.url === "/api/comments/resync-suggestion-anchor") {
resyncCalls++;
sendJson(res, 200, { data: {} });
return;
}
sendJson(res, 404, { message: "not found" });
});
class TestClient extends DocmostClient {
async getCollabTokenWithReauth() {
return "collab-token";
}
async resolvePageId() {
return "44444444-4444-4444-4444-444444444444";
}
async mutatePage(pageId, collabToken, apiUrl, transform) {
const doc = {
type: "doc",
content: [
{ type: "paragraph", content: [{ type: "text", text: "Hello brave world" }] },
],
};
const out = transform(doc);
return { doc: out, verify: { ok: true } };
}
}
const client = new TestClient(baseURL, "user@example.com", "pw");
const result = await client.createComment(
"44444444-4444-4444-4444-444444444444",
"rename",
"inline",
"brave",
undefined,
"bold",
);
assert.equal(result.anchored, true);
assert.equal(resyncCalls, 0, "matching snapshot must NOT trigger a resync round-trip");
});
// -----------------------------------------------------------------------------
// 8) #408: a not-found selection error QUOTES the closest block text so the
// model can self-correct instead of blind-retrying.
@@ -0,0 +1,251 @@
// Mock-HTTP integration tests for the getPage conversion cache (issue #479).
// A local http.createServer stands in for Docmost (same harness style as
// get-page-context.test.mjs) so everything is deterministic and offline.
//
// Verifies end-to-end through the real client that:
// - the FIRST getPage of a page is a MISS (mcp_getpage_cache_misses_total)
// and converts the content (the server's convert-representative counter);
// - a SECOND getPage of the same (pageId, updatedAt) is a HIT
// (mcp_getpage_cache_hits_total) and returns BYTE-IDENTICAL output while
// skipping the conversion;
// - a changed updatedAt is a fresh key -> MISS again;
// - the returned shape still resolves page + subpages.
import { test, after, mock } 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", (chunk) => (raw += chunk));
req.on("end", () => resolve(raw));
});
}
function sendJson(res, status, obj, extraHeaders = {}) {
res.writeHead(status, { "Content-Type": "application/json", ...extraHeaders });
res.end(JSON.stringify(obj));
}
const openServers = [];
after(async () => {
await Promise.all(openServers.map((s) => new Promise((r) => s.close(r))));
});
const PAGE_UUID = "00000000-0000-4000-8000-000000000010";
const SPACE_UUID = "00000000-0000-4000-8000-0000000000aa";
const CHILD_UUID = "00000000-0000-4000-8000-0000000000bb";
// A small ProseMirror doc so the converter produces non-trivial markdown.
function makeDoc(text) {
return {
type: "doc",
content: [
{
type: "paragraph",
content: [{ type: "text", text }],
},
],
};
}
// state.info counts /pages/info hits; state.updatedAt / state.text drive the
// content+version returned; state.sidebar counts sidebar-pages hits;
// state.subpages (when set) drives the child list the sidebar endpoint returns,
// so a test can vary the live subpages across two reads of the same page.
function spawn(state) {
return new Promise((resolve) => {
const server = http.createServer(async (req, res) => {
await readBody(req);
if (req.url === "/api/auth/login") {
return sendJson(res, 200, { success: true }, {
"Set-Cookie": "authToken=t; Path=/; HttpOnly",
});
}
if (req.url === "/api/pages/info") {
state.info++;
return sendJson(res, 200, {
success: true,
data: {
id: PAGE_UUID,
slugId: "slug123456",
title: "Cached Page",
parentPageId: null,
spaceId: SPACE_UUID,
updatedAt: state.updatedAt,
content: makeDoc(state.text),
},
});
}
if (req.url === "/api/pages/sidebar-pages") {
state.sidebar++;
const items = state.subpages ?? [
{ id: CHILD_UUID, title: "Child", hasChildren: false },
];
return sendJson(res, 200, {
success: true,
data: {
items,
meta: { hasNextPage: false, nextCursor: null },
},
});
}
return sendJson(res, 404, { message: "not found" });
});
server.listen(0, "127.0.0.1", () => {
openServers.push(server);
resolve(`http://127.0.0.1:${server.address().port}/api`);
});
});
}
function makeClient(baseURL, metrics) {
return new DocmostClient({
apiUrl: baseURL,
getToken: async () => "access",
onMetric: (name, value) => {
metrics[name] = (metrics[name] ?? 0) + value;
},
});
}
test("first read MISS, second read HIT with byte-identical output; convert runs once", async () => {
const state = { info: 0, sidebar: 0, updatedAt: "2026-01-01T00:00:00Z", text: "Hello world" };
const baseURL = await spawn(state);
const metrics = {};
const client = makeClient(baseURL, metrics);
const first = await client.getPage(PAGE_UUID);
assert.equal(metrics["mcp_getpage_cache_misses_total"], 1, "first read is a miss");
assert.equal(metrics["mcp_getpage_cache_hits_total"] ?? 0, 0, "no hit yet");
const second = await client.getPage(PAGE_UUID);
assert.equal(metrics["mcp_getpage_cache_hits_total"], 1, "second read is a hit");
assert.equal(metrics["mcp_getpage_cache_misses_total"], 1, "still one miss");
// BYTE-IDENTICAL: the cache only skips recomputation, never changes output.
assert.deepEqual(second, first, "cached result is identical to the uncached one");
assert.equal(
JSON.stringify(second),
JSON.stringify(first),
"serialized output is byte-identical",
);
// The page fetch + subpages fetch still happen every call (only the CPU
// conversion is cached); both reads hit /pages/info and sidebar-pages.
assert.equal(state.info, 2, "both reads still fetch /pages/info");
assert.equal(state.sidebar, 2, "both reads still fetch subpages");
// Shape sanity: content present, subpages resolved.
assert.equal(typeof second.data.content, "string");
assert.ok(second.data.content.includes("Hello world"));
assert.deepEqual(second.data.subpages, [{ id: CHILD_UUID, title: "Child" }]);
});
test("a changed updatedAt is a fresh key -> MISS again, with the NEW content", async () => {
const state = { info: 0, sidebar: 0, updatedAt: "2026-01-01T00:00:00Z", text: "Version one" };
const baseURL = await spawn(state);
const metrics = {};
const client = makeClient(baseURL, metrics);
const a = await client.getPage(PAGE_UUID); // miss
const b = await client.getPage(PAGE_UUID); // hit
assert.equal(metrics["mcp_getpage_cache_misses_total"], 1);
assert.equal(metrics["mcp_getpage_cache_hits_total"], 1);
assert.ok(a.data.content.includes("Version one"));
// The page changes: new updatedAt AND new content.
state.updatedAt = "2026-02-02T00:00:00Z";
state.text = "Version two";
const c = await client.getPage(PAGE_UUID); // miss on the new key
assert.equal(metrics["mcp_getpage_cache_misses_total"], 2, "changed version -> miss");
assert.equal(metrics["mcp_getpage_cache_hits_total"], 1, "no stale hit");
assert.ok(c.data.content.includes("Version two"), "the NEW content is served");
assert.ok(!c.data.content.includes("Version one"), "no stale markdown");
const d = await client.getPage(PAGE_UUID); // hit on the new key
assert.equal(metrics["mcp_getpage_cache_hits_total"], 2, "the new snapshot caches too");
});
test("a slugId read and a UUID read of the same page share one cache entry", async () => {
// resolvePageId maps the slugId -> UUID via /pages/info; the cache keys on the
// canonical UUID (resultData.id), so both inputs land on the same entry.
const state = { info: 0, sidebar: 0, updatedAt: "2026-01-01T00:00:00Z", text: "Shared" };
const baseURL = await spawn(state);
const metrics = {};
const client = makeClient(baseURL, metrics);
await client.getPage(PAGE_UUID); // miss (keyed on UUID)
await client.getPage("slug123456"); // the server returns the same id -> HIT
assert.equal(metrics["mcp_getpage_cache_misses_total"], 1, "one conversion total");
assert.equal(metrics["mcp_getpage_cache_hits_total"], 1, "slugId read hits the UUID entry");
});
test("on a conversion HIT, the {{SUBPAGES}} block reflects the LIVE subpages, not the cached ones", async () => {
// The whole byte-identity guarantee: the cache stores the conversion output
// BEFORE the {{SUBPAGES}} substitution, so a re-read of an UNCHANGED page still
// splices the FRESH subpage list. The page body itself contains {{SUBPAGES}}
// (converts to a literal placeholder); getPage replaces it with the live list.
const CHILD_A = "00000000-0000-4000-8000-0000000000a1";
const CHILD_B = "00000000-0000-4000-8000-0000000000b2";
const state = {
info: 0,
sidebar: 0,
updatedAt: "2026-01-01T00:00:00Z", // FIXED across both reads -> conversion cache HIT
text: "Body before {{SUBPAGES}} body after",
subpages: [{ id: CHILD_A, title: "Alpha", hasChildren: false }],
};
const baseURL = await spawn(state);
const metrics = {};
const client = makeClient(baseURL, metrics);
// Read 1: MISS (converts). The substitution runs with list A.
const first = await client.getPage(PAGE_UUID);
assert.equal(metrics["mcp_getpage_cache_misses_total"], 1, "first read converts (miss)");
assert.ok(first.data.content.includes("[Alpha](page:" + CHILD_A + ")"), "list A spliced in");
assert.ok(!first.data.content.includes("{{SUBPAGES}}"), "placeholder consumed");
// The subpages change while the PAGE CONTENT/updatedAt do NOT: same conversion
// cache key -> a HIT that skips the CPU walk, but the live substitution must
// still run on the NEW list B.
state.subpages = [{ id: CHILD_B, title: "Beta", hasChildren: false }];
const second = await client.getPage(PAGE_UUID);
assert.equal(metrics["mcp_getpage_cache_hits_total"], 1, "second read is a conversion HIT");
assert.equal(metrics["mcp_getpage_cache_misses_total"], 1, "no second conversion");
// The cache did NOT freeze the subpages block: list B is present, list A gone.
assert.ok(second.data.content.includes("[Beta](page:" + CHILD_B + ")"), "live list B spliced in on a HIT");
assert.ok(!second.data.content.includes("Alpha"), "stale list A is NOT frozen into the output");
assert.deepEqual(second.data.subpages, [{ id: CHILD_B, title: "Beta" }], "subpages field reflects list B");
});
test("a cache HIT SKIPS the convertProseMirrorToMarkdown CPU walk (called once across MISS+HIT)", async () => {
// The single reason the cache exists: on a hit the expensive PM-tree walk must
// NOT run. The miss counter alone can't prove this — a broken hit branch that
// re-converted (same output, misses=1) would leave every other assert green.
// So spy directly on the conversion seam and assert the call COUNT.
const state = { info: 0, sidebar: 0, updatedAt: "2026-01-01T00:00:00Z", text: "Body text" };
const baseURL = await spawn(state);
const metrics = {};
const client = makeClient(baseURL, metrics);
// Spy on the seam that wraps convertProseMirrorToMarkdown; it still delegates,
// so output stays real and byte-identical — we only count invocations.
const spy = mock.method(client, "convertPageMarkdown");
await client.getPage(PAGE_UUID); // MISS -> converts once
assert.equal(spy.mock.callCount(), 1, "the miss converts exactly once");
await client.getPage(PAGE_UUID); // HIT -> must NOT convert again
assert.equal(
spy.mock.callCount(),
1,
"the hit skips the conversion: still exactly one call across MISS+HIT",
);
assert.equal(metrics["mcp_getpage_cache_hits_total"], 1, "and it was recorded as a hit");
spy.mock.restore();
});
@@ -0,0 +1,113 @@
// Unit tests for the getPage content-addressed conversion cache (issue #479).
// Exercises the GetPageConversionCache class in isolation: key composition,
// hit/miss, LRU recency on read, and eviction by BOTH the count cap and the
// byte cap. The getPage integration (counter emission, byte-identical output)
// is covered separately in test/mock/getpage-conversion-cache.test.mjs.
import { test } from "node:test";
import assert from "node:assert/strict";
import {
GetPageConversionCache,
hashConvertOptions,
} from "../../build/client/getpage-cache.js";
test("key: identical (pageId, updatedAt, optionsHash) collapse to one entry", () => {
const c = new GetPageConversionCache();
const k1 = GetPageConversionCache.key("uuid-1", "2026-01-01T00:00:00Z", "{}");
const k2 = GetPageConversionCache.key("uuid-1", "2026-01-01T00:00:00Z", "{}");
assert.equal(k1, k2, "same parts -> same key");
c.set(k1, "MD-A");
assert.equal(c.get(k2), "MD-A", "hit on the identical key");
assert.equal(c.size, 1);
});
test("miss after updatedAt changes (precise invalidation)", () => {
const c = new GetPageConversionCache();
const oldK = GetPageConversionCache.key("uuid-1", "v1", "{}");
const newK = GetPageConversionCache.key("uuid-1", "v2", "{}");
c.set(oldK, "OLD-MD");
assert.equal(c.get(newK), undefined, "a new updatedAt is a fresh key -> miss");
assert.equal(c.get(oldK), "OLD-MD", "the old snapshot is still addressable");
});
test("miss after options change (dropResolvedCommentAnchors true vs false)", () => {
const c = new GetPageConversionCache();
const hDrop = hashConvertOptions({ dropResolvedCommentAnchors: true });
const hKeep = hashConvertOptions({ dropResolvedCommentAnchors: false });
assert.notEqual(hDrop, hKeep, "different options hash to different keys");
const kDrop = GetPageConversionCache.key("uuid-1", "v1", hDrop);
const kKeep = GetPageConversionCache.key("uuid-1", "v1", hKeep);
c.set(kDrop, "AGENT-MD");
assert.equal(
c.get(kKeep),
undefined,
"the export variant must NOT be served the agent variant",
);
});
test("hashConvertOptions is order-insensitive and handles empty", () => {
assert.equal(
hashConvertOptions({ a: 1, b: 2 }),
hashConvertOptions({ b: 2, a: 1 }),
"key order does not change the hash",
);
assert.equal(hashConvertOptions(undefined), "{}");
assert.equal(hashConvertOptions(null), "{}");
assert.equal(hashConvertOptions({}), "{}");
});
test("LRU eviction by COUNT cap evicts the least-recently-used entry", () => {
const c = new GetPageConversionCache({ maxEntries: 2, maxBytes: 10 * 1024 * 1024 });
c.set("k1", "a");
c.set("k2", "b");
c.set("k3", "c"); // over the count cap -> evict k1 (oldest)
assert.equal(c.size, 2);
assert.equal(c.get("k1"), undefined, "k1 evicted");
assert.equal(c.get("k2"), "b");
assert.equal(c.get("k3"), "c");
});
test("a read refreshes recency so the OTHER entry is evicted next", () => {
const c = new GetPageConversionCache({ maxEntries: 2, maxBytes: 10 * 1024 * 1024 });
c.set("k1", "a");
c.set("k2", "b");
// Touch k1 so k2 becomes the least-recently-used.
assert.equal(c.get("k1"), "a");
c.set("k3", "c"); // evicts the LRU, which is now k2 (not k1)
assert.equal(c.get("k2"), undefined, "k2 was LRU and got evicted");
assert.equal(c.get("k1"), "a", "k1 survived because it was read");
assert.equal(c.get("k3"), "c");
});
test("LRU eviction by BYTE cap evicts oldest until under the cap", () => {
// Byte cap of 100; each value is 40 bytes -> at most 2 fit (80 < 100 < 120).
const big = "x".repeat(40);
const c = new GetPageConversionCache({ maxEntries: 50, maxBytes: 100 });
c.set("k1", big); // 40
c.set("k2", big); // 80
assert.equal(c.size, 2);
c.set("k3", big); // 120 > 100 -> evict k1 -> 80
assert.equal(c.size, 2, "byte cap forced an eviction despite the count cap");
assert.equal(c.get("k1"), undefined, "oldest evicted by bytes");
assert.equal(c.get("k2"), big);
assert.equal(c.get("k3"), big);
assert.ok(c.bytes <= 100, "total bytes stays within the cap");
});
test("re-setting an existing key updates value+recency and keeps bytes exact", () => {
const c = new GetPageConversionCache({ maxEntries: 5, maxBytes: 10 * 1024 * 1024 });
c.set("k1", "short");
const b1 = c.bytes;
assert.equal(b1, Buffer.byteLength("short", "utf8"));
c.set("k1", "a much longer value");
assert.equal(c.size, 1, "no duplicate entry");
assert.equal(c.bytes, Buffer.byteLength("a much longer value", "utf8"));
assert.equal(c.get("k1"), "a much longer value");
});
test("an oversized single entry is still stored (never a permanent miss)", () => {
const c = new GetPageConversionCache({ maxEntries: 5, maxBytes: 10 });
const big = "y".repeat(1000);
c.set("k1", big);
assert.equal(c.get("k1"), big, "the page bigger than the cap is still served");
assert.equal(c.size, 1);
});