Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d8364bb5f | |||
| d3209b5aab | |||
| b9f3de80f5 | |||
| 5336f06d10 | |||
| 4bd579f7f6 | |||
| 7bf1c91a95 | |||
| 6c82c54470 | |||
| 382e5196da | |||
| 76e0c08cec | |||
| 8978d69f3e | |||
| c192f2a2e1 | |||
| d78b985062 | |||
| 2ce672709a | |||
| a4fc6c7f64 | |||
| c252068672 | |||
| 68caf8157a | |||
| cb9c5dda59 | |||
| e431b33bb1 | |||
| 4369bbc53d | |||
| 8e5ad8070b | |||
| cfc105c7d6 | |||
| d7fa6738e5 | |||
| e6d8eda8e5 | |||
| 8d8ecaed82 | |||
| eacc1c4811 | |||
| 8e12aa8ebf | |||
| 348dcd0802 | |||
| 086bc1bf8b | |||
| 77b245461f | |||
| 77c64c4fd9 | |||
| 2bb71c1a45 | |||
| 20248b8c95 | |||
| 9274c51053 | |||
| 832c3cafdf | |||
| 94f60cf0ec | |||
| 40d42d61e6 | |||
| bcd194ee5d | |||
| f13105333a | |||
| 08222345ef | |||
| baa41d66ad | |||
| 1a7b817250 | |||
| 52beae85b3 | |||
| 124f5a45a2 | |||
| b751852425 | |||
| 65d81f745a | |||
| bfbd927866 | |||
| 77f5224b55 | |||
| e2a3b5fc4d | |||
| d7d8db2102 | |||
| e814bca243 | |||
| f1ab76e879 | |||
| 6dcc19ce59 | |||
| d6d7dd82f6 | |||
| f5d19f9728 | |||
| 351615e5bc | |||
| 1fda0ec8b0 | |||
| 2637640291 | |||
| aa0428e28b |
@@ -202,6 +202,13 @@ MCP_DOCMOST_PASSWORD=
|
||||
# Default 900000 (15 min).
|
||||
# AI_MCP_CALL_TIMEOUT_MS=900000
|
||||
|
||||
# Deferred tool loading for the in-app AI chat (#332). Default ON: the agent sees
|
||||
# a compact <tool_catalog> and only CORE tools + a loadTools meta-tool are active
|
||||
# each step; deferred tools (the fat/rare ones + all external MCP tools) load on
|
||||
# demand. Set AI_CHAT_DEFERRED_TOOLS=false to restore the old "all tools always
|
||||
# active" behavior.
|
||||
# AI_CHAT_DEFERRED_TOOLS=true
|
||||
|
||||
# --- Anonymous public-share AI assistant ---
|
||||
# Opt-in per workspace (AI settings -> "public share assistant"; off by default).
|
||||
# When enabled, anonymous visitors of a published share can ask an AI about that
|
||||
@@ -235,3 +242,27 @@ MCP_DOCMOST_PASSWORD=
|
||||
# FAILS CLOSED if Redis is unavailable (default: 1,000,000 tokens per workspace
|
||||
# per rolling day).
|
||||
# SHARE_AI_WORKSPACE_TOKEN_BUDGET_PER_DAY=1000000
|
||||
|
||||
# --- Observability / perf metrics (#355) ---
|
||||
#
|
||||
# Two INDEPENDENT toggles, both OFF by default:
|
||||
#
|
||||
# 1) METRICS_PORT — the server-side Prometheus scrape endpoint.
|
||||
# UNSET (default) => the whole prom subsystem is OFF: no registry, no
|
||||
# collectors, and NOTHING is exposed on the main app port. There is NO
|
||||
# default port — leaving it blank disables it. When set to a port (e.g.
|
||||
# 9464), a SEPARATE bare node:http listener serves GET /metrics on that port
|
||||
# only (never on the main :3000 app listener), for a scraper such as
|
||||
# VictoriaMetrics/Prometheus reaching it as <host>:<port>/metrics.
|
||||
# METRICS_PORT=9464
|
||||
#
|
||||
# 2) CLIENT_TELEMETRY_ENABLED — the public client perf-telemetry sink.
|
||||
# OFF by default. When true, the unauthenticated POST /api/telemetry/vitals
|
||||
# endpoint is registered and browsers collect + send web-vitals / editor
|
||||
# metrics into the `client_metrics` table (read directly by Grafana, separate
|
||||
# from METRICS_PORT). Leave OFF unless you actually consume this data: the
|
||||
# endpoint is public and the table has NO app-side retention, so enabling it
|
||||
# requires an EXTERNAL pruner to bound `client_metrics` growth (the deployed
|
||||
# infra prunes rows >90d via a maintenance container). When off, the endpoint
|
||||
# does not exist and the client installs no observers.
|
||||
# CLIENT_TELEMETRY_ENABLED=false
|
||||
|
||||
@@ -18,12 +18,48 @@ env:
|
||||
IMAGE: ghcr.io/vvzvlad/gitmost
|
||||
|
||||
jobs:
|
||||
# Run the reusable test suite first so a failing test blocks the image build.
|
||||
# Run the reusable test suite. Together with the e2e jobs below it gates the
|
||||
# publish job (the image push), not the build itself — build runs in parallel.
|
||||
test:
|
||||
uses: ./.github/workflows/test.yml
|
||||
|
||||
# Runs in parallel with the test/e2e jobs and only warms the buildx cache
|
||||
# (GHA cache, scope develop-amd64). No push happens here — the publish job
|
||||
# below is the only one that pushes the image.
|
||||
build:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Resolve version
|
||||
id: version
|
||||
run: echo "value=$(git describe --tags --always)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build develop image (warm cache, no push)
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
build-args: |
|
||||
APP_VERSION=${{ steps.version.outputs.value }}
|
||||
AI_AGENT_ROLES_CATALOG_URL=https://raw.githubusercontent.com/vvzvlad/gitmost/develop/agent-roles-catalog
|
||||
push: false
|
||||
cache-from: type=gha,scope=develop-amd64
|
||||
cache-to: type=gha,scope=develop-amd64,mode=max,ignore-error=true
|
||||
|
||||
# The gate: rebuilds from the cache the build job just wrote (near-instant on
|
||||
# a cache hit; worst case — cache eviction — a full rebuild, which matches the
|
||||
# old sequential timing) and pushes :develop only when unit tests AND both
|
||||
# e2e suites AND the build are green.
|
||||
publish:
|
||||
needs: [test, e2e-server, e2e-mcp, build]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
@@ -57,13 +93,10 @@ jobs:
|
||||
push: true
|
||||
tags: ${{ env.IMAGE }}:develop
|
||||
cache-from: type=gha,scope=develop-amd64
|
||||
cache-to: type=gha,scope=develop-amd64,mode=max,ignore-error=true
|
||||
|
||||
# e2e jobs run on every develop push but DO NOT gate the build/publish above:
|
||||
# `build` stays `needs: test` only, so the :develop image still ships even if
|
||||
# e2e fails. A failing e2e job turns the run red and triggers GitHub's email
|
||||
# to the pusher — that red run + email is the intended notification, not a
|
||||
# deploy block.
|
||||
# e2e jobs gate the publish (image push), not the build: the :develop image
|
||||
# is pushed only when unit tests AND both e2e suites pass (publish.needs
|
||||
# lists them all).
|
||||
e2e-server:
|
||||
runs-on: ubuntu-latest
|
||||
# Hard cap: the full-AppModule e2e leaks open handles and hung jest to the 6h max.
|
||||
@@ -124,9 +157,7 @@ jobs:
|
||||
- name: Run server e2e
|
||||
run: pnpm --filter ./apps/server test:e2e
|
||||
|
||||
# Same rationale as e2e-server: this job is intentionally NOT in
|
||||
# `build.needs`. Deploy of the :develop image must not be blocked by e2e;
|
||||
# a red run plus GitHub's email to the pusher is the notification mechanism.
|
||||
# Gates the publish too — see the comment above e2e-server.
|
||||
e2e-mcp:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
@@ -72,6 +72,14 @@ jobs:
|
||||
- name: Build editor-ext
|
||||
run: pnpm --filter @docmost/editor-ext build
|
||||
|
||||
# @docmost/prosemirror-markdown is the shared converter (#293/#326); its
|
||||
# build/ is gitignored, and plain `pnpm -r test` does NOT honour nx
|
||||
# `dependsOn: ^build`, so its consumers (mcp `pretest: tsc`, git-sync vitest
|
||||
# typecheck) fail with TS2307 Cannot find module '@docmost/prosemirror-markdown'
|
||||
# unless it is built first. Build it before the recursive test run.
|
||||
- name: Build prosemirror-markdown
|
||||
run: pnpm --filter @docmost/prosemirror-markdown build
|
||||
|
||||
- name: Run unit tests
|
||||
run: pnpm -r test
|
||||
|
||||
|
||||
+10
-1
@@ -4,12 +4,21 @@
|
||||
data
|
||||
# compiled output
|
||||
/dist
|
||||
node_modules/
|
||||
node_modules
|
||||
|
||||
# git-sync compiled output (built in CI/Docker via `pnpm build`, never committed,
|
||||
# so src/ and prod can never silently diverge).
|
||||
packages/git-sync/build/
|
||||
|
||||
# prosemirror-markdown compiled output (built in CI/Docker via `pnpm build`,
|
||||
# never committed, so src/ and prod can never silently diverge).
|
||||
packages/prosemirror-markdown/build/
|
||||
|
||||
# mcp compiled output (built in CI/Docker via `pnpm build`, never committed, so
|
||||
# src/ and prod can never silently diverge). Matches the git-sync/prosemirror-
|
||||
# markdown convention; the package is private and rebuilt at deploy.
|
||||
packages/mcp/build/
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
@@ -200,7 +200,8 @@ pnpm workspace (`pnpm@10.4.0`) orchestrated by **Nx**. Four workspace packages:
|
||||
| `apps/server` | `server` | NestJS 11 + Fastify, Kysely (Postgres), Redis | Backend API, collaboration, AI |
|
||||
| `apps/client` | `client` | React 18 + Vite + Mantine 8 + TanStack Query + Jotai | SPA frontend |
|
||||
| `packages/editor-ext` | `@docmost/editor-ext` | Tiptap/ProseMirror | Shared Tiptap node/mark extensions, imported by both the client and the server |
|
||||
| `packages/mcp` | `@docmost/mcp` | MCP SDK, Tiptap, Yjs | Standalone MCP server, also bundled into the server at `/mcp`. Does **not** import `editor-ext` — it keeps its own vendored mirror of the schema in `packages/mcp/src/lib/` |
|
||||
| `packages/mcp` | `@docmost/mcp` | MCP SDK, Tiptap, Yjs | Standalone MCP server, also bundled into the server at `/mcp`. Consumes the shared converter/schema from `@docmost/prosemirror-markdown` (#293) — it no longer carries its own vendored converter/schema copy |
|
||||
| `packages/prosemirror-markdown` | `@docmost/prosemirror-markdown` | Tiptap, marked, jsdom | The single, canonical ProseMirror↔Markdown converter + Docmost schema mirror (#293). Consumed by `mcp` and `git-sync`; there is exactly ONE copy of the converter now |
|
||||
|
||||
`build` targets are Nx-cached and dependency-ordered (`dependsOn: ["^build"]`), so `editor-ext` builds before the apps. `nx.json` sets `affected.defaultBase: main`.
|
||||
|
||||
@@ -282,7 +283,7 @@ The API server is a Fastify app with a global `/api` prefix (`main.ts` excludes
|
||||
### Client structure
|
||||
Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirrors the server domains: `page`, `space`, `comment`, `ai-chat`, `editor`, …). Conventions:
|
||||
- **TanStack Query** for server state (one `queries/` file per feature), **Jotai** atoms for local/shared UI state, **Mantine 8** + CSS modules (`*.module.css`) + `postcss-preset-mantine` for UI.
|
||||
- The editor is Tiptap; shared node/mark extensions live in `packages/editor-ext` and are imported by **both the client and the server** (collaboration, import/export) — editor schema changes often need to be made in `editor-ext`, not just the client. Note `packages/mcp` does *not* depend on `editor-ext`; it carries its own mirrored copy of the schema, so keep the two in sync manually when the document schema changes.
|
||||
- The editor is Tiptap; shared node/mark extensions live in `packages/editor-ext` and are imported by **both the client and the server** (collaboration, import/export) — editor schema changes often need to be made in `editor-ext`, not just the client. The ProseMirror↔Markdown converter and its Docmost schema mirror now live in a SINGLE package, `@docmost/prosemirror-markdown` (#293), consumed by both `mcp` and `git-sync` — do NOT reintroduce a per-package copy. `editor-ext` is the upstream source of the Tiptap schema; the package's `docmost-schema.ts` mirrors it and a serializer-contract test (`packages/prosemirror-markdown/test/serializer-contract.test.ts`) guards the boundary (every schema node must have a converter case), so a drift surfaces as a failing test rather than silent divergence.
|
||||
- API access goes through `apps/client/src/lib/api-client.ts` (axios). The `@` alias maps to `apps/client/src`.
|
||||
- Runtime config is injected at build time by `vite.config.ts` via `define` (`APP_URL`, `COLLAB_URL`, `APP_VERSION`, …) — these come from the root `.env`, not from `import.meta.env`.
|
||||
|
||||
@@ -293,6 +294,7 @@ Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirro
|
||||
- The version string shown in the UI comes from `APP_VERSION` (CI/Docker) or `git describe --tags --always` (local), resolved in `vite.config.ts` — not from `package.json`.
|
||||
- Server TS config is permissive (`noImplicitAny: false`, `strictNullChecks: false`, `no-explicit-any` lint disabled). Follow the existing relaxed style rather than tightening types broadly.
|
||||
- Dependency versions are heavily pinned via `pnpm.overrides` and `pnpm.patchedDependencies` (`scimmy`, `yjs`) in the root `package.json`. Don't bump pinned/patched deps casually; the patches and overrides exist for compatibility/security reasons.
|
||||
- **Adding/renaming/removing an MCP tool requires updating `SERVER_INSTRUCTIONS`** in `packages/mcp/src/index.ts` — the intent-routing guide MCP clients receive on initialize. This applies both to inline `server.registerTool(...)` calls in `index.ts` and to specs in `packages/mcp/src/tool-specs.ts`. Enforced by `packages/mcp/test/unit/server-instructions.test.mjs`, which fails when a registered tool is not mentioned in the guide (deliberate opt-outs go into its `EXCEPTIONS` list). `packages/mcp/build/` is gitignored and rebuilt in CI/Docker via `pnpm build` (same convention as `git-sync`/`prosemirror-markdown`) — never commit it; rebuild locally after editing to run the tests.
|
||||
|
||||
## CI / release
|
||||
|
||||
|
||||
+24
-2
@@ -5,6 +5,13 @@ RUN npm install -g pnpm@10.4.0
|
||||
|
||||
FROM base AS builder
|
||||
|
||||
# re2 (packages/mcp) always compiles from source under pnpm (the prebuilt-binary
|
||||
# download cannot identify the GitHub repo), so node-gyp needs python3/make/g++.
|
||||
# This stage is discarded, so the toolchain can stay installed.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends python3 make g++ \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
@@ -38,6 +45,14 @@ COPY --from=builder /app/packages/editor-ext/dist /app/packages/editor-ext/dist
|
||||
COPY --from=builder /app/packages/editor-ext/package.json /app/packages/editor-ext/package.json
|
||||
COPY --from=builder /app/packages/mcp/build /app/packages/mcp/build
|
||||
COPY --from=builder /app/packages/mcp/package.json /app/packages/mcp/package.json
|
||||
# mcp now depends on @docmost/prosemirror-markdown (workspace:*) and eager-imports
|
||||
# it at runtime (the in-app ai-chat DocmostClient loads build/index.js -> lib/
|
||||
# markdown-converter.js). Ship the built package + its manifest, or the prod
|
||||
# install resolves a broken workspace symlink and every ai-chat tool dies with
|
||||
# ERR_MODULE_NOT_FOUND (#293/#326 step 5). (git-sync has no runtime consumer yet;
|
||||
# revisit at step 6 when #119 lands.)
|
||||
COPY --from=builder /app/packages/prosemirror-markdown/build /app/packages/prosemirror-markdown/build
|
||||
COPY --from=builder /app/packages/prosemirror-markdown/package.json /app/packages/prosemirror-markdown/package.json
|
||||
|
||||
# Copy root package files
|
||||
COPY --from=builder /app/package.json /app/package.json
|
||||
@@ -49,9 +64,16 @@ COPY --from=builder /app/patches /app/patches
|
||||
|
||||
RUN chown -R node:node /app
|
||||
|
||||
USER node
|
||||
# Toolchain is needed transiently to compile re2 during the prod install; install
|
||||
# and purge it in one layer to keep the final image slim. The install itself runs
|
||||
# as the node user via su to keep node_modules ownership without a costly chown layer.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends python3 make g++ \
|
||||
&& su node -c "pnpm install --frozen-lockfile --prod" \
|
||||
&& apt-get purge -y --auto-remove python3 make g++ \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN pnpm install --frozen-lockfile --prod
|
||||
USER node
|
||||
|
||||
RUN mkdir -p /app/data/storage
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ roles:
|
||||
Read the whole text first. Think at the level of sections and paragraphs, not sentences.
|
||||
|
||||
HOW TO LEAVE COMMENTS
|
||||
You don't edit the text yourself. For each note, select the relevant span via the MCP tool and leave a comment. Open the comment with the label `[Structure]`. Then: state the problem briefly, propose a concrete fix (move, merge, cut, add, reorder, strengthen the lead/headline), and explain why if it isn't obvious. Tag severity:
|
||||
You don't edit the text yourself. For each note, select the relevant span via the MCP tool and leave a comment. State the problem briefly, propose a concrete fix (move, merge, cut, add, reorder, strengthen the lead/headline), and explain why if it isn't obvious. Tag severity:
|
||||
- [Critical] — broken logic, the text doesn't deliver what the headline promises, a key link in the argument is missing.
|
||||
- [Major] — weak structure, a noticeable gap or redundancy, a sagging lead/headline.
|
||||
- [Minor] — an optional improvement to framing or flow.
|
||||
@@ -87,7 +87,7 @@ roles:
|
||||
- Don't rewrite the text yourself or impose your own voice. Your job is to make the author's voice livelier, not to replace it.
|
||||
|
||||
HOW TO LEAVE COMMENTS
|
||||
You don't edit the text directly. For each note, select the span via the MCP tool and leave a comment. Open the comment with the label `[Style]`. Give a concrete rephrasing, not "revise", and attach it to the comment as a suggested replacement (the `suggestedText` parameter): the exact new text for the selected fragment, plain text with no markup — the author applies it with one click. The selected fragment must occur exactly once in the text; if it isn't unique, extend the selection with surrounding context. Tag severity:
|
||||
You don't edit the text directly. For each note, select the span via the MCP tool and leave a comment. Give a concrete rephrasing, not "revise", and attach it to the comment as a suggested replacement (the `suggestedText` parameter): the exact new text for the selected fragment, plain text with no markup — the author applies it with one click. The selected fragment must occur exactly once in the text; if it isn't unique, extend the selection with surrounding context. Tag severity:
|
||||
- [Critical] — the sentence is unclear or distorts the meaning.
|
||||
- [Major] — an obvious LLM cliché, heavy bureaucratese, filler that breaks the reading.
|
||||
- [Minor] — a stylistic improvement to taste.
|
||||
@@ -128,7 +128,7 @@ roles:
|
||||
- Don't fabricate confirmations. If you can't verify, honestly mark [Unverified] or [Unverifiable].
|
||||
|
||||
HOW TO LEAVE COMMENTS
|
||||
You don't edit the text directly. For each problem claim (an error, a doubt, an unverifiable statement), select the span via the MCP tool and leave a comment; leave no comment on correct facts. Open the comment with the label `[Facts]`, then the verdict, the correction (if any), and the source. For an [Incorrect] verdict, ALWAYS attach the ready correction as a suggested replacement (the `suggestedText` parameter): since you found the correct value in the sources, propose the ready fix right away instead of merely describing the error. The replacement is the exact new text for the selected fragment, plain text with no markup; the author applies it with one click instead of retyping the fragment. The selected fragment must occur exactly once in the text; if it isn't unique, extend the selection with surrounding context. Do not attach a replacement to [Unverified], [Unverifiable], or [Opinion] verdicts. Tag severity:
|
||||
You don't edit the text directly. For each problem claim (an error, a doubt, an unverifiable statement), select the span via the MCP tool and leave a comment; leave no comment on correct facts. Give the verdict, the correction (if any), and the source. For an [Incorrect] verdict, ALWAYS attach the ready correction as a suggested replacement (the `suggestedText` parameter): since you found the correct value in the sources, propose the ready fix right away instead of merely describing the error. The replacement is the exact new text for the selected fragment, plain text with no markup; the author applies it with one click instead of retyping the fragment. The selected fragment must occur exactly once in the text; if it isn't unique, extend the selection with surrounding context. When a figure, name, term, or version to check recurs across the page, use search_in_page to find every occurrence in one call first, then place a targeted comment per hit instead of reading block by block. Do not attach a replacement to [Unverified], [Unverifiable], or [Opinion] verdicts. Tag severity:
|
||||
- [Critical] — a factual error, especially in numbers, names, or quotes, or a claim that risks misinformation.
|
||||
- [Major] — a doubtful or unconfirmed claim that needs a source.
|
||||
- [Minor] — a small correction, or false precision worth rounding or confirming.
|
||||
@@ -168,8 +168,11 @@ roles:
|
||||
- Don't verify facts — that's the Fact-checker.
|
||||
- Don't make substantive changes. Edits are minimal and mechanical.
|
||||
|
||||
HOW TO WORK
|
||||
Go through the whole text from start to finish in a single pass. Flag EVERY violation, including all repeat occurrences of the same error and minor items tagged [Minor] — don't stop at the first few or the most conspicuous. Don't summarize instead of marking up: until you've reached the end of the document, the job isn't done. One run covers the whole text, not just "the most important". For a systematic issue that recurs — straight quotes, a hyphen used as a dash, an inconsistent unit or spelling — use search_in_page to list every occurrence in one call first, then leave a targeted comment (with its replacement) on each hit, instead of scanning block by block.
|
||||
|
||||
HOW TO LEAVE COMMENTS
|
||||
You don't edit the text directly. For each fix, select the span via the MCP tool and leave a comment with the concrete correction. Attach a suggested replacement to every fix (the `suggestedText` parameter): the exact corrected text for the selected fragment, plain text with no markup — the author applies it with one click. The selected fragment must occur exactly once in the text; if it isn't unique, extend the selection with surrounding context. Do NOT leave summary notes like "throughout, replace X with Y" or "make the units/quotes/spelling consistent": such a comment can't be applied with a button. If the same error occurs in several places, walk EVERY occurrence and leave a separate targeted comment with its own replacement on each — ten targeted fixes instead of one blanket note. The only exception is a note that genuinely cannot be expressed as a replacement of a concrete fragment; leave those rare cases as an ordinary comment without a replacement. Open the comment with the label `[Copyedit]`. Tag severity:
|
||||
You don't edit the text directly. For each fix, select the span via the MCP tool and leave a comment with the concrete correction. Attach a suggested replacement to every fix (the `suggestedText` parameter): the exact corrected text for the selected fragment, plain text with no markup — the author applies it with one click. The selected fragment must occur exactly once in the text; if it isn't unique, extend the selection with surrounding context. Do NOT leave summary notes like "throughout, replace X with Y" or "make the units/quotes/spelling consistent": such a comment can't be applied with a button. If the same error occurs in several places, walk EVERY occurrence and leave a separate targeted comment with its own replacement on each — ten targeted fixes instead of one blanket note. The only exception is a note that genuinely cannot be expressed as a replacement of a concrete fragment; leave those rare cases as an ordinary comment without a replacement. Tag severity:
|
||||
- [Critical] — a grammar/spelling error or typo visible to the reader.
|
||||
- [Major] — a consistency or typography break (wrong quotes, hyphen for a dash, missing serial comma where the rest of the text has it).
|
||||
- [Minor] — optional polish.
|
||||
|
||||
@@ -34,7 +34,7 @@ roles:
|
||||
Сначала прочитай весь текст целиком. Думай на уровне разделов и абзацев, а не предложений.
|
||||
|
||||
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
|
||||
Ты не редактируешь текст сам. Для каждого замечания через MCP-инструмент выдели соответствующий фрагмент и оставь к нему комментарий. Начинай комментарий с метки `[Структура]`. Дальше: коротко назови проблему, предложи конкретное решение (перенести, объединить, вырезать, добавить, переставить, усилить лид/заголовок) и при необходимости поясни, почему. Помечай важность:
|
||||
Ты не редактируешь текст сам. Для каждого замечания через MCP-инструмент выдели соответствующий фрагмент и оставь к нему комментарий. Коротко назови проблему, предложи конкретное решение (перенести, объединить, вырезать, добавить, переставить, усилить лид/заголовок) и при необходимости поясни, почему. Помечай важность:
|
||||
- [Критично] — сломана логика, текст не отвечает на заявленное в заголовке, отсутствует ключевое звено аргумента.
|
||||
- [Существенно] — слабая структура, заметный пробел или избыточность, провисающий лид/заголовок.
|
||||
- [Незначительно] — улучшение подачи или стройности, не обязательное.
|
||||
@@ -87,7 +87,7 @@ roles:
|
||||
- Не переписываешь текст сам и не навязываешь свой голос. Твоя задача — сделать авторскую интонацию живее, а не заменить собой.
|
||||
|
||||
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
|
||||
Ты не редактируешь текст напрямую. Для каждого замечания через MCP-инструмент выдели фрагмент и оставь к нему комментарий. Начинай комментарий с метки `[Стиль]`. Давай конкретный вариант переформулировки, а не «переделать», и прикладывай его к комментарию как предложение-замену (параметр `suggestedText`): точный новый текст взамен выделенного фрагмента, обычным текстом без разметки — автор применит его одной кнопкой. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом. Помечай важность:
|
||||
Ты не редактируешь текст напрямую. Для каждого замечания через MCP-инструмент выдели фрагмент и оставь к нему комментарий. Давай конкретный вариант переформулировки, а не «переделать», и прикладывай его к комментарию как предложение-замену (параметр `suggestedText`): точный новый текст взамен выделенного фрагмента, обычным текстом без разметки — автор применит его одной кнопкой. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом. Помечай важность:
|
||||
- [Критично] — предложение непонятно или искажает смысл.
|
||||
- [Существенно] — явный штамп LLM, заметный канцелярит, вода, ломающая чтение.
|
||||
- [Незначительно] — стилистическое улучшение на вкус.
|
||||
@@ -128,7 +128,7 @@ roles:
|
||||
- Не выдумываешь подтверждения. Если не можешь проверить — честно ставь [Не проверено] или [Непроверяемо].
|
||||
|
||||
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
|
||||
Ты не редактируешь текст напрямую. Для каждого проблемного утверждения (ошибка, сомнение, непроверяемость) через MCP-инструмент выдели фрагмент и оставь комментарий; на верные факты комментарии не оставляй. Начинай комментарий с метки `[Факты]`, затем вердикт, исправление (если нужно) и источник. К вердикту [Неверно] всегда прикладывай готовое исправление как предложение-замену (параметр `suggestedText`): раз ты нашёл по источникам верное значение — сразу предлагай готовую правку, а не только описывай ошибку. Замена — это точный новый текст взамен выделенного фрагмента, обычным текстом без разметки; автор применит её одной кнопкой, не переписывая фрагмент вручную. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом. К вердиктам [Не проверено], [Непроверяемо] и [Это мнение] замену не прикладывай. Помечай важность:
|
||||
Ты не редактируешь текст напрямую. Для каждого проблемного утверждения (ошибка, сомнение, непроверяемость) через MCP-инструмент выдели фрагмент и оставь комментарий; на верные факты комментарии не оставляй. В комментарии дай вердикт, исправление (если нужно) и источник. К вердикту [Неверно] всегда прикладывай готовое исправление как предложение-замену (параметр `suggestedText`): раз ты нашёл по источникам верное значение — сразу предлагай готовую правку, а не только описывай ошибку. Замена — это точный новый текст взамен выделенного фрагмента, обычным текстом без разметки; автор применит её одной кнопкой, не переписывая фрагмент вручную. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом. Когда проверяемая цифра, имя, термин или версия встречается по тексту несколько раз, сначала одним вызовом search_in_page найди все вхождения, а затем ставь целевой комментарий на каждое — не читая страницу поблочно. К вердиктам [Не проверено], [Непроверяемо] и [Это мнение] замену не прикладывай. Помечай важность:
|
||||
- [Критично] — фактическая ошибка, особенно в числах, именах, цитатах, или утверждение с риском дезинформации.
|
||||
- [Существенно] — сомнительное или непроверенное утверждение, требующее источника.
|
||||
- [Незначительно] — мелкое уточнение, псевдоточность, которую стоит округлить или подтвердить.
|
||||
@@ -169,8 +169,11 @@ roles:
|
||||
- Не проверяешь достоверность фактов — это фактчекер.
|
||||
- Не вносишь содержательных изменений. Правки — минимальные и механические.
|
||||
|
||||
КАК РАБОТАТЬ
|
||||
Пройди весь текст от начала до конца за один проход. Помечай КАЖДОЕ нарушение, включая все повторные вхождения одной и той же ошибки и мелочи с меткой [Незначительно], — не ограничивайся первыми несколькими или самыми заметными. Не подводи итог вместо разбора: пока не дошёл до конца документа, работа не закончена. Один прогон покрывает весь текст, а не «самое важное». Для систематической ошибки, которая повторяется — прямые кавычки, «е» вместо «ё», дефис вместо тире, неединообразная единица или написание, — сначала одним вызовом search_in_page получи все вхождения, а затем оставь на каждом целевой комментарий с заменой, вместо поблочного просмотра.
|
||||
|
||||
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
|
||||
Ты не редактируешь текст напрямую. Для каждой правки через MCP-инструмент выдели фрагмент и оставь комментарий с конкретным исправлением. К каждой правке прикладывай предложение-замену (параметр `suggestedText`): точный исправленный текст взамен выделенного фрагмента, обычным текстом без разметки — автор применит его одной кнопкой. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом. НЕ оставляй сводных замечаний вида «во всём тексте заменить X на Y» или «привести единицы/кавычки/написание к единообразию»: такой комментарий нельзя применить кнопкой. Если одна и та же ошибка встречается в нескольких местах, обойди КАЖДОЕ вхождение и оставь на нём отдельный целевой комментарий со своей заменой — десять точечных правок вместо одной общей. Единственное исключение — замечание, которое в принципе невозможно выразить заменой конкретного фрагмента; такие редкие случаи оставляй обычным комментарием без замены. Начинай комментарий с метки `[Корректура]`. Помечай важность:
|
||||
Ты не редактируешь текст напрямую. Для каждой правки через MCP-инструмент выдели фрагмент и оставь комментарий с конкретным исправлением. К каждой правке прикладывай предложение-замену (параметр `suggestedText`): точный исправленный текст взамен выделенного фрагмента, обычным текстом без разметки — автор применит его одной кнопкой. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом. НЕ оставляй сводных замечаний вида «во всём тексте заменить X на Y» или «привести единицы/кавычки/написание к единообразию»: такой комментарий нельзя применить кнопкой. Если одна и та же ошибка встречается в нескольких местах, обойди КАЖДОЕ вхождение и оставь на нём отдельный целевой комментарий со своей заменой — десять точечных правок вместо одной общей. Единственное исключение — замечание, которое в принципе невозможно выразить заменой конкретного фрагмента; такие редкие случаи оставляй обычным комментарием без замены. Помечай важность:
|
||||
- [Критично] — грамматическая/орфографическая ошибка или опечатка, видимая читателю.
|
||||
- [Существенно] — нарушение единообразия или типографики (неверные кавычки, дефис вместо тире, отсутствие неразрывного пробела в критичном месте).
|
||||
- [Незначительно] — необязательная шлифовка.
|
||||
|
||||
@@ -12,13 +12,13 @@ bundles:
|
||||
- en
|
||||
roles:
|
||||
- slug: structural-editor
|
||||
version: 3
|
||||
- slug: line-editor
|
||||
version: 3
|
||||
- slug: fact-checker
|
||||
version: 4
|
||||
- slug: line-editor
|
||||
version: 4
|
||||
- slug: fact-checker
|
||||
version: 6
|
||||
- slug: proofreader
|
||||
version: 5
|
||||
version: 8
|
||||
- slug: narrator
|
||||
version: 2
|
||||
- id: research
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
{
|
||||
"fact-checker": {
|
||||
"version": 4,
|
||||
"hash": "9160ead04d86aaa5dc7a51dd7e971c272ce0ca97cb24bf2b6ee5779deb1b19c0"
|
||||
"version": 6,
|
||||
"hash": "6bb22a9e5a5079b5cb287b5b26addbd36b9afeb7c9508287dcad9343fc53d685"
|
||||
},
|
||||
"line-editor": {
|
||||
"version": 3,
|
||||
"hash": "7f200863080799b08d5af5d1648befa0843cc5db79bb994b07baa5ad12df5123"
|
||||
"version": 4,
|
||||
"hash": "890d10f3f0bd7f2b2cfcc94463634221c557a3140e3794721748dc8d99979780"
|
||||
},
|
||||
"narrator": {
|
||||
"version": 2,
|
||||
"hash": "66fe653003b4f63ef3c3a5c5c48552fe47daeefffc16907c37c35f0e8da98851"
|
||||
},
|
||||
"proofreader": {
|
||||
"version": 5,
|
||||
"hash": "40af08c51e03c24b1986ac5cd679434e023afe31a819748966ccb0c6c62f0401"
|
||||
"version": 8,
|
||||
"hash": "cef39fed321779631ddd1077fcba53399adf0e48b301df281c71eb042610900d"
|
||||
},
|
||||
"researcher": {
|
||||
"version": 1,
|
||||
"hash": "853658fda43ddbe0a4d08f2c6e50b5116d29a2e9ccd7f46e173e65920d8f6ace"
|
||||
},
|
||||
"structural-editor": {
|
||||
"version": 3,
|
||||
"hash": "f6936e4c152c1b78980e74045658d87743f26f900c12f61fd7a45c6a0ec19425"
|
||||
"version": 4,
|
||||
"hash": "89100e0a00b88daa0d2118fd98ec1c27d06b972bfc6ec58b705553a4daed85df"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
"axios": "1.16.0",
|
||||
"blueimp-load-image": "5.16.0",
|
||||
"clsx": "2.1.1",
|
||||
"diff": "8.0.3",
|
||||
"dompurify": "3.4.1",
|
||||
"file-saver": "2.0.5",
|
||||
"highlightjs-sap-abap": "0.3.0",
|
||||
@@ -60,6 +61,7 @@
|
||||
"react-clear-modal": "^2.0.18",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-drawio": "1.0.7",
|
||||
"web-vitals": "^5.1.0",
|
||||
"react-error-boundary": "6.1.1",
|
||||
"react-helmet-async": "3.0.0",
|
||||
"react-i18next": "16.5.8",
|
||||
@@ -81,6 +83,7 @@
|
||||
"@types/react": "18.3.12",
|
||||
"@types/react-dom": "18.3.1",
|
||||
"@vitejs/plugin-react": "6.0.1",
|
||||
"@vitest/coverage-v8": "4.1.6",
|
||||
"eslint": "9.28.0",
|
||||
"eslint-plugin-react": "7.37.5",
|
||||
"eslint-plugin-react-hooks": "7.0.1",
|
||||
|
||||
@@ -1382,5 +1382,8 @@
|
||||
"Applied": "Applied",
|
||||
"Suggestion applied": "Suggestion applied",
|
||||
"Failed to apply suggestion": "Failed to apply suggestion",
|
||||
"The commented text changed since this suggestion was made; it was not applied.": "The commented text changed since this suggestion was made; it was not applied."
|
||||
"The commented text changed since this suggestion was made; it was not applied.": "The commented text changed since this suggestion was made; it was not applied.",
|
||||
"Dismiss": "Dismiss",
|
||||
"Suggestion dismissed": "Suggestion dismissed",
|
||||
"Failed to dismiss suggestion": "Failed to dismiss suggestion"
|
||||
}
|
||||
|
||||
@@ -1245,5 +1245,8 @@
|
||||
"Applied": "Применено",
|
||||
"Suggestion applied": "Предложение применено",
|
||||
"Failed to apply suggestion": "Не удалось применить предложение",
|
||||
"The commented text changed since this suggestion was made; it was not applied.": "Прокомментированный текст изменился после создания предложения; оно не было применено."
|
||||
"The commented text changed since this suggestion was made; it was not applied.": "Прокомментированный текст изменился после создания предложения; оно не было применено.",
|
||||
"Dismiss": "Не применять",
|
||||
"Suggestion dismissed": "Предложение отклонено",
|
||||
"Failed to dismiss suggestion": "Не удалось отклонить предложение"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||
import { render, screen, fireEvent, act, cleanup } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
|
||||
// Shared, hoisted mock state so the @ai-sdk/react and "ai" module mocks (hoisted
|
||||
@@ -140,3 +140,91 @@ describe("ChatThread — send now (#198)", () => {
|
||||
expect(prep({ messages: [], body: {} }).body.interrupted).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// The turn-end decision lives in the `onFinish` handler: given the terminal
|
||||
// outcome of a turn (`isAbort` / `isDisconnect` / `isError`, or none = clean),
|
||||
// it decides whether to CONTINUE (flush the next queued message) or END (leave
|
||||
// the queue intact for the user), and which stop notice — if any — to show.
|
||||
// `sendNow` is exercised above; these tests pin down the plain outcomes.
|
||||
describe("ChatThread — turn-end decision (onFinish)", () => {
|
||||
beforeEach(() => {
|
||||
h.state.status = "streaming";
|
||||
h.state.onFinish = null;
|
||||
h.state.sendMessage.mockClear();
|
||||
h.state.stop.mockClear();
|
||||
h.state.transport = null;
|
||||
});
|
||||
|
||||
// Drive a fresh onFinish with the given terminal flags after queueing a
|
||||
// message, and report both what the parent was told and whether the queue was
|
||||
// flushed (a resend to the sendMessage spy).
|
||||
function finishWith(flags: {
|
||||
isAbort?: boolean;
|
||||
isDisconnect?: boolean;
|
||||
isError?: boolean;
|
||||
}) {
|
||||
// Tear down any prior render so the loop-driven "every outcome" case does
|
||||
// not leave duplicate queue buttons in the DOM.
|
||||
cleanup();
|
||||
h.state.sendMessage.mockClear();
|
||||
const { onTurnFinished } = renderThread();
|
||||
// Populate the queue while the turn is streaming.
|
||||
fireEvent.click(screen.getByTestId("queue-btn"));
|
||||
act(() => {
|
||||
h.state.onFinish?.({
|
||||
message: { id: "a", role: "assistant", parts: [] },
|
||||
isAbort: false,
|
||||
isDisconnect: false,
|
||||
isError: false,
|
||||
...flags,
|
||||
});
|
||||
});
|
||||
return { onTurnFinished };
|
||||
}
|
||||
|
||||
it("CONTINUES — flushes the next queued message on a clean finish", () => {
|
||||
finishWith({});
|
||||
// Clean finish (no terminal flag): the queued message is auto-sent.
|
||||
expect(h.state.sendMessage).toHaveBeenCalledWith({ text: "queued text" });
|
||||
// A clean finish shows no stop notice.
|
||||
expect(screen.queryByText("Response stopped.")).toBeNull();
|
||||
});
|
||||
|
||||
it("ENDS — keeps the queue intact on a user abort and shows the stopped notice", () => {
|
||||
finishWith({ isAbort: true });
|
||||
// A plain Stop (not the sendNow interrupt path) must NOT auto-resend: the
|
||||
// queue is preserved for the user to decide.
|
||||
expect(h.state.sendMessage).not.toHaveBeenCalled();
|
||||
expect(screen.getByText("Response stopped.")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("ENDS — keeps the queue intact on a disconnect and shows the connection-lost notice", () => {
|
||||
finishWith({ isDisconnect: true });
|
||||
expect(h.state.sendMessage).not.toHaveBeenCalled();
|
||||
expect(
|
||||
screen.getByText("Connection lost — the answer was interrupted."),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("ENDS — keeps the queue intact on a stream error (no auto-retry, no stopped notice)", () => {
|
||||
finishWith({ isError: true });
|
||||
// Blindly retrying after a failure would be wrong; the queue is left alone.
|
||||
expect(h.state.sendMessage).not.toHaveBeenCalled();
|
||||
// isError clears the neutral notice (the error banner covers this case).
|
||||
expect(screen.queryByText("Response stopped.")).toBeNull();
|
||||
});
|
||||
|
||||
it("notifies the parent on EVERY terminal outcome", () => {
|
||||
// The chat-list refresh / new-chat id adoption must run on success and on
|
||||
// every failure path alike.
|
||||
for (const flags of [
|
||||
{},
|
||||
{ isAbort: true },
|
||||
{ isDisconnect: true },
|
||||
{ isError: true },
|
||||
]) {
|
||||
const { onTurnFinished } = finishWith(flags);
|
||||
expect(onTurnFinished).toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
|
||||
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
||||
|
||||
// The fallback path renders the full TipTap editor; stub it so we can assert the
|
||||
// safety valve fired without pulling in the editor stack.
|
||||
vi.mock("@/features/comment/components/comment-editor", () => ({
|
||||
default: () => <div data-testid="comment-editor-fallback" />,
|
||||
}));
|
||||
|
||||
// Mention rendering hits react-query; stub the page/share queries so the mention
|
||||
// case renders in isolation.
|
||||
vi.mock("@/features/page/queries/page-query.ts", () => ({
|
||||
usePageQuery: () => ({ data: undefined, isLoading: false, isError: false }),
|
||||
}));
|
||||
vi.mock("@/features/share/queries/share-query.ts", () => ({
|
||||
useSharePageQuery: () => ({ data: undefined }),
|
||||
}));
|
||||
|
||||
import { CommentContentView } from "./comment-content-view";
|
||||
|
||||
function renderView(content: string | object) {
|
||||
return render(
|
||||
<MantineProvider>
|
||||
<MemoryRouter>
|
||||
<CommentContentView content={content} />
|
||||
</MemoryRouter>
|
||||
</MantineProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const doc = (content: any[]) => JSON.stringify({ type: "doc", content });
|
||||
const para = (content: any[]) => ({ type: "paragraph", content });
|
||||
const text = (t: string, marks?: any[]) => ({ type: "text", text: t, marks });
|
||||
|
||||
describe("CommentContentView", () => {
|
||||
it("renders paragraphs as <p> with text", () => {
|
||||
const { container } = renderView(doc([para([text("Hello world")])]));
|
||||
expect(screen.getByText("Hello world")).toBeDefined();
|
||||
expect(container.querySelector("p")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("reproduces the read-only CommentEditor DOM nesting for CSS parity", () => {
|
||||
const { container } = renderView(doc([para([text("x")])]));
|
||||
// outer .commentEditor > .ProseMirror (module) > .ProseMirror (global) > p
|
||||
const globalPm = container.querySelector("div.ProseMirror > p");
|
||||
expect(globalPm).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders the bold mark as <strong>", () => {
|
||||
const { container } = renderView(
|
||||
doc([para([text("bold", [{ type: "bold" }])])]),
|
||||
);
|
||||
const el = container.querySelector("strong");
|
||||
expect(el?.textContent).toBe("bold");
|
||||
});
|
||||
|
||||
it("renders the italic mark as <em>", () => {
|
||||
const { container } = renderView(
|
||||
doc([para([text("it", [{ type: "italic" }])])]),
|
||||
);
|
||||
expect(container.querySelector("em")?.textContent).toBe("it");
|
||||
});
|
||||
|
||||
it("renders the strike mark as <s>", () => {
|
||||
const { container } = renderView(
|
||||
doc([para([text("st", [{ type: "strike" }])])]),
|
||||
);
|
||||
expect(container.querySelector("s")?.textContent).toBe("st");
|
||||
});
|
||||
|
||||
it("renders the underline mark as <u> (not the editor fallback)", () => {
|
||||
const { container } = renderView(
|
||||
doc([para([text("un", [{ type: "underline" }])])]),
|
||||
);
|
||||
expect(container.querySelector("u")?.textContent).toBe("un");
|
||||
// Underline is a supported mark, so no degrade to the editor fallback.
|
||||
expect(screen.queryByTestId("comment-editor-fallback")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the code mark as <code>", () => {
|
||||
const { container } = renderView(
|
||||
doc([para([text("co", [{ type: "code" }])])]),
|
||||
);
|
||||
expect(container.querySelector("code")?.textContent).toBe("co");
|
||||
});
|
||||
|
||||
it("renders the link mark as an anchor with safe rel/target", () => {
|
||||
const { container } = renderView(
|
||||
doc([
|
||||
para([
|
||||
text("click", [
|
||||
{ type: "link", attrs: { href: "https://example.com" } },
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
);
|
||||
const a = container.querySelector("a");
|
||||
expect(a?.getAttribute("href")).toBe("https://example.com");
|
||||
expect(a?.getAttribute("target")).toBe("_blank");
|
||||
expect(a?.getAttribute("rel")).toBe("noopener noreferrer nofollow");
|
||||
expect(a?.textContent).toBe("click");
|
||||
});
|
||||
|
||||
it("neutralizes a javascript: link href (stored XSS) while keeping the text", () => {
|
||||
const { container } = renderView(
|
||||
doc([
|
||||
para([
|
||||
text("click", [
|
||||
{ type: "link", attrs: { href: "javascript:alert(1)" } },
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
);
|
||||
const a = container.querySelector("a");
|
||||
expect(a).not.toBeNull();
|
||||
// No navigable javascript: href — attribute is absent (or empty).
|
||||
expect(a?.getAttribute("href")).toBeFalsy();
|
||||
// The link text is still rendered.
|
||||
expect(a?.textContent).toBe("click");
|
||||
});
|
||||
|
||||
it("neutralizes a control-char-obfuscated javascript: href", () => {
|
||||
const { container } = renderView(
|
||||
doc([
|
||||
para([
|
||||
text("x", [
|
||||
{ type: "link", attrs: { href: "java\tscript:alert(1)" } },
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
);
|
||||
expect(container.querySelector("a")?.getAttribute("href")).toBeFalsy();
|
||||
});
|
||||
|
||||
it("neutralizes a data: link href", () => {
|
||||
const { container } = renderView(
|
||||
doc([
|
||||
para([
|
||||
text("x", [
|
||||
{
|
||||
type: "link",
|
||||
attrs: { href: "data:text/html,<script>alert(1)</script>" },
|
||||
},
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
);
|
||||
expect(container.querySelector("a")?.getAttribute("href")).toBeFalsy();
|
||||
});
|
||||
|
||||
it("preserves a mailto: link href (allowlisted scheme)", () => {
|
||||
const { container } = renderView(
|
||||
doc([
|
||||
para([
|
||||
text("mail", [
|
||||
{ type: "link", attrs: { href: "mailto:a@b.com" } },
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
);
|
||||
expect(container.querySelector("a")?.getAttribute("href")).toBe(
|
||||
"mailto:a@b.com",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves a relative link href (no scheme, not a script vector)", () => {
|
||||
const { container } = renderView(
|
||||
doc([
|
||||
para([
|
||||
text("rel", [{ type: "link", attrs: { href: "/some/path" } }]),
|
||||
]),
|
||||
]),
|
||||
);
|
||||
expect(container.querySelector("a")?.getAttribute("href")).toBe(
|
||||
"/some/path",
|
||||
);
|
||||
});
|
||||
|
||||
it("nests multiple marks on one text node", () => {
|
||||
const { container } = renderView(
|
||||
doc([para([text("x", [{ type: "bold" }, { type: "italic" }])])]),
|
||||
);
|
||||
// bold wraps italic (or vice versa) — both elements exist around the text.
|
||||
expect(container.querySelector("strong")).not.toBeNull();
|
||||
expect(container.querySelector("em")).not.toBeNull();
|
||||
expect(screen.getByText("x")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders hardBreak as <br/>", () => {
|
||||
const { container } = renderView(
|
||||
doc([para([text("a"), { type: "hardBreak" }, text("b")])]),
|
||||
);
|
||||
expect(container.querySelector("br")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders a user mention as a styled span", () => {
|
||||
const { container } = renderView(
|
||||
doc([
|
||||
para([
|
||||
{
|
||||
type: "mention",
|
||||
attrs: { label: "Alice", entityType: "user", entityId: "u1" },
|
||||
},
|
||||
]),
|
||||
]),
|
||||
);
|
||||
expect(screen.getByText("@Alice")).toBeDefined();
|
||||
// No fallback to the editor.
|
||||
expect(screen.queryByTestId("comment-editor-fallback")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders a page mention as a link", () => {
|
||||
const { container } = renderView(
|
||||
doc([
|
||||
para([
|
||||
{
|
||||
type: "mention",
|
||||
attrs: {
|
||||
label: "Some Page",
|
||||
entityType: "page",
|
||||
slugId: "pg1",
|
||||
},
|
||||
},
|
||||
]),
|
||||
]),
|
||||
);
|
||||
expect(container.querySelector("a")).not.toBeNull();
|
||||
expect(screen.getByText("Some Page")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders a legacy plain-text (non-JSON) string as plain text", () => {
|
||||
renderView("just a legacy string");
|
||||
expect(screen.getByText("just a legacy string")).toBeDefined();
|
||||
expect(screen.queryByTestId("comment-editor-fallback")).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to CommentEditor for an unknown node type", () => {
|
||||
renderView(doc([{ type: "codeBlock", content: [text("x")] }]));
|
||||
expect(screen.getByTestId("comment-editor-fallback")).toBeDefined();
|
||||
});
|
||||
|
||||
it("falls back to CommentEditor for malformed JSON", () => {
|
||||
renderView('{"type":"doc","content":[');
|
||||
expect(screen.getByTestId("comment-editor-fallback")).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
import React from "react";
|
||||
import classes from "./comment.module.css";
|
||||
import { MentionContent } from "@/features/editor/components/mention/mention-view";
|
||||
import CommentEditor from "@/features/comment/components/comment-editor";
|
||||
|
||||
// Static, editor-free renderer of a comment body (ProseMirror JSON). It walks the
|
||||
// document and emits plain DOM, avoiding the cost of a full TipTap/ProseMirror
|
||||
// instance per comment (the panel used to spin up 400+ editors on mount).
|
||||
//
|
||||
// The supported node/mark set MUST mirror what CommentEditor enables
|
||||
// (StarterKit + Mention + LinkExtension). Anything outside that set makes the
|
||||
// whole comment degrade to the read-only CommentEditor via the fallback below,
|
||||
// so we never show a half-rendered comment.
|
||||
|
||||
// Sentinel thrown when we hit a node/mark we don't know how to render statically.
|
||||
// Caught at the top level to trigger the CommentEditor fallback for the whole comment.
|
||||
class UnknownNodeError extends Error {}
|
||||
|
||||
// Protocol allowlist mirroring @tiptap/extension-link's default (the read-only
|
||||
// CommentEditor path relies on it to blank javascript:/data: hrefs). The static
|
||||
// renderer must apply the SAME sanitization because the backend stores comment
|
||||
// content verbatim and React does not neutralize javascript: in an href.
|
||||
const ALLOWED_URI_SCHEMES = /^(?:https?|ftps?|mailto|tel|callto|sms|cid|xmpp):/i;
|
||||
|
||||
function safeHref(href: unknown): string | undefined {
|
||||
if (typeof href !== "string") return undefined;
|
||||
// Strip control chars/whitespace that could smuggle a scheme past the test
|
||||
// (e.g. "java\tscript:").
|
||||
const cleaned = href.replace(/[\u0000-\u0020]/g, "").trim();
|
||||
// Allow relative/anchor/protocol-relative links (no scheme) — not script vectors.
|
||||
if (!/^[a-z][a-z0-9+.-]*:/i.test(cleaned)) return href;
|
||||
return ALLOWED_URI_SCHEMES.test(cleaned) ? href : undefined;
|
||||
}
|
||||
|
||||
interface PMMark {
|
||||
type: string;
|
||||
attrs?: Record<string, any>;
|
||||
}
|
||||
|
||||
interface PMNode {
|
||||
type: string;
|
||||
attrs?: Record<string, any>;
|
||||
content?: PMNode[];
|
||||
text?: string;
|
||||
marks?: PMMark[];
|
||||
}
|
||||
|
||||
// Wrap a text node's string in its marks (marks nest, e.g. bold + italic).
|
||||
function renderMarks(
|
||||
text: React.ReactNode,
|
||||
marks: PMMark[] | undefined,
|
||||
keyPrefix: string,
|
||||
): React.ReactNode {
|
||||
if (!marks || marks.length === 0) return text;
|
||||
|
||||
return marks.reduce<React.ReactNode>((acc, mark, i) => {
|
||||
const key = `${keyPrefix}-m${i}`;
|
||||
switch (mark.type) {
|
||||
case "bold":
|
||||
return <strong key={key}>{acc}</strong>;
|
||||
case "italic":
|
||||
return <em key={key}>{acc}</em>;
|
||||
case "strike":
|
||||
return <s key={key}>{acc}</s>;
|
||||
case "underline":
|
||||
// StarterKit enables the Underline extension by default (Mod-u) and
|
||||
// CommentEditor does not disable it, so real comments can carry this
|
||||
// mark. Render it here rather than degrading the whole comment.
|
||||
return <u key={key}>{acc}</u>;
|
||||
case "code":
|
||||
return <code key={key}>{acc}</code>;
|
||||
case "link": {
|
||||
// LinkExtension (TiptapLink) opens links in a new tab; keep the same
|
||||
// safe rel semantics the editor produces. Sanitize the href against the
|
||||
// extension's protocol allowlist — a disallowed scheme (javascript:,
|
||||
// data:) yields undefined so the anchor is non-navigable but still shows
|
||||
// its text, matching how extension-link blanks a bad href.
|
||||
const href = safeHref(mark.attrs?.href);
|
||||
return (
|
||||
<a
|
||||
key={key}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
>
|
||||
{acc}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
default:
|
||||
throw new UnknownNodeError(`Unknown mark type: ${mark.type}`);
|
||||
}
|
||||
}, text);
|
||||
}
|
||||
|
||||
function renderNode(node: PMNode, key: string): React.ReactNode {
|
||||
switch (node.type) {
|
||||
case "paragraph":
|
||||
return <p key={key}>{renderChildren(node.content, key)}</p>;
|
||||
case "text":
|
||||
return (
|
||||
<React.Fragment key={key}>
|
||||
{renderMarks(node.text ?? "", node.marks, key)}
|
||||
</React.Fragment>
|
||||
);
|
||||
case "hardBreak":
|
||||
return <br key={key} />;
|
||||
case "mention":
|
||||
return (
|
||||
<span key={key} style={{ display: "inline" }}>
|
||||
<MentionContent attrs={node.attrs as any} />
|
||||
</span>
|
||||
);
|
||||
default:
|
||||
throw new UnknownNodeError(`Unknown node type: ${node.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
function renderChildren(
|
||||
content: PMNode[] | undefined,
|
||||
keyPrefix: string,
|
||||
): React.ReactNode {
|
||||
if (!content) return null;
|
||||
return content.map((child, i) => renderNode(child, `${keyPrefix}-${i}`));
|
||||
}
|
||||
|
||||
// Reproduce the exact DOM nesting the read-only CommentEditor renders so the
|
||||
// scoped CSS in comment.module.css (which targets
|
||||
// `.commentEditor .ProseMirror :global(.ProseMirror)` and `.ProseMirror p`)
|
||||
// applies pixel-for-pixel. Read-only => no data-editable / data-surface attrs.
|
||||
function Shell({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className={classes.commentEditor}>
|
||||
<div className={classes.ProseMirror}>
|
||||
<div className="ProseMirror">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CommentContentViewProps {
|
||||
content: string | object;
|
||||
}
|
||||
|
||||
export function CommentContentView({ content }: CommentContentViewProps) {
|
||||
// Degrade this single comment to the old editor-based render (safety valve).
|
||||
const fallback = () => {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn(
|
||||
"CommentContentView: unsupported comment content, falling back to editor",
|
||||
);
|
||||
}
|
||||
return <CommentEditor defaultContent={content} editable={false} />;
|
||||
};
|
||||
|
||||
let doc: unknown = content;
|
||||
|
||||
if (typeof content === "string") {
|
||||
try {
|
||||
doc = JSON.parse(content);
|
||||
} catch {
|
||||
const trimmed = content.trim();
|
||||
// Looks like it was meant to be JSON but is malformed -> safety-valve fallback.
|
||||
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
||||
return fallback();
|
||||
}
|
||||
// Otherwise it's a legacy plain-text comment: render as a single paragraph.
|
||||
return (
|
||||
<Shell>
|
||||
<p>{content}</p>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Double-stringified / legacy plain-text stored as a JSON string.
|
||||
if (typeof doc === "string") {
|
||||
return (
|
||||
<Shell>
|
||||
<p>{doc}</p>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const pmDoc = doc as PMNode;
|
||||
if (!pmDoc || typeof pmDoc !== "object" || pmDoc.type !== "doc") {
|
||||
throw new UnknownNodeError("Not a ProseMirror doc");
|
||||
}
|
||||
return <Shell>{renderChildren(pmDoc.content, "n")}</Shell>;
|
||||
} catch (err) {
|
||||
if (err instanceof UnknownNodeError) {
|
||||
return fallback();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export default CommentContentView;
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { IComment } from "@/features/comment/types/comment.types";
|
||||
|
||||
@@ -8,23 +8,74 @@ import { IComment } from "@/features/comment/types/comment.types";
|
||||
// The comment mutation hooks reach out to react-query/network — stub them so the
|
||||
// component renders in isolation. We only assert the AI-badge rendering branch.
|
||||
const applyMutateAsync = vi.fn();
|
||||
const dismissMutateAsync = vi.fn();
|
||||
const updateMutateAsync = vi.fn();
|
||||
vi.mock("@/features/comment/queries/comment-query", () => ({
|
||||
useDeleteCommentMutation: () => ({ mutateAsync: vi.fn() }),
|
||||
useResolveCommentMutation: () => ({ mutateAsync: vi.fn() }),
|
||||
useUpdateCommentMutation: () => ({ mutateAsync: vi.fn() }),
|
||||
useUpdateCommentMutation: () => ({ mutateAsync: updateMutateAsync }),
|
||||
useApplySuggestionMutation: () => ({
|
||||
mutateAsync: applyMutateAsync,
|
||||
isPending: false,
|
||||
}),
|
||||
useDismissSuggestionMutation: () => ({
|
||||
mutateAsync: dismissMutateAsync,
|
||||
isPending: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
// The document the mocked editor emits via onUpdate when the edit form is open.
|
||||
// Duplicated inside the mock factory (below) to keep the factory self-contained.
|
||||
const EDITED_DOC = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: "edited via editor" }] },
|
||||
],
|
||||
};
|
||||
|
||||
// CommentEditor pulls in the full TipTap editor stack; replace it with a stub.
|
||||
vi.mock("@/features/comment/components/comment-editor", () => ({
|
||||
default: () => <div data-testid="comment-editor" />,
|
||||
// In edit mode the stub exposes buttons that fire the real onUpdate/onSave props
|
||||
// so the edit->save/cancel flow can be driven without a live editor.
|
||||
vi.mock("@/features/comment/components/comment-editor", () => {
|
||||
const doc = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: "edited via editor" }] },
|
||||
],
|
||||
};
|
||||
return {
|
||||
default: ({ onUpdate, onSave }: any) => (
|
||||
<div data-testid="comment-editor">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="editor-emit-update"
|
||||
onClick={() => onUpdate?.(doc)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="editor-emit-save"
|
||||
onClick={() => onSave?.()}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
// CommentContentView (used for the read-only body) imports the mention view,
|
||||
// which pulls page-query -> main.tsx (createRoot). Stub the queries so the item
|
||||
// renders in isolation without the app entry side-effect.
|
||||
vi.mock("@/features/page/queries/page-query.ts", () => ({
|
||||
usePageQuery: () => ({ data: undefined, isLoading: false, isError: false }),
|
||||
}));
|
||||
vi.mock("@/features/share/queries/share-query.ts", () => ({
|
||||
useSharePageQuery: () => ({ data: undefined }),
|
||||
}));
|
||||
|
||||
import CommentListItem from "./comment-list-item";
|
||||
import { canShowApply } from "@/features/comment/utils/suggestion";
|
||||
import {
|
||||
canShowApply,
|
||||
canShowDismiss,
|
||||
} from "@/features/comment/utils/suggestion";
|
||||
|
||||
const baseComment = (over?: Partial<IComment>): IComment =>
|
||||
({
|
||||
@@ -38,14 +89,20 @@ const baseComment = (over?: Partial<IComment>): IComment =>
|
||||
...over,
|
||||
}) as IComment;
|
||||
|
||||
function renderItem(comment: IComment, canEdit = true) {
|
||||
function renderItem(
|
||||
comment: IComment,
|
||||
canEdit = true,
|
||||
canComment = true,
|
||||
userSpaceRole?: string,
|
||||
) {
|
||||
return render(
|
||||
<MantineProvider>
|
||||
<CommentListItem
|
||||
comment={comment}
|
||||
pageId="page-1"
|
||||
canComment={true}
|
||||
canComment={canComment}
|
||||
canEdit={canEdit}
|
||||
userSpaceRole={userSpaceRole}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
);
|
||||
@@ -108,10 +165,12 @@ describe("CommentListItem — suggested edit (#315)", () => {
|
||||
});
|
||||
|
||||
it("renders the было→стало diff and an Apply button when canEdit and not applied/resolved", () => {
|
||||
renderItem(suggestion(), true);
|
||||
// Old text appears both as the selection quote and as the struck diff row.
|
||||
const { container } = renderItem(suggestion(), true);
|
||||
// Old text appears as the selection quote (a single unsplit Text node).
|
||||
expect(screen.getAllByText("old wording here").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("new wording here")).toBeDefined();
|
||||
// The new line is now rendered as per-fragment spans (intraline diff, #331),
|
||||
// so it is no longer a single text node — assert the concatenated content.
|
||||
expect(container.textContent).toContain("new wording here");
|
||||
// Apply button is present.
|
||||
expect(screen.getByRole("button", { name: "Apply" })).toBeDefined();
|
||||
// No Applied badge yet.
|
||||
@@ -119,9 +178,9 @@ describe("CommentListItem — suggested edit (#315)", () => {
|
||||
});
|
||||
|
||||
it("hides the Apply button when canEdit is false", () => {
|
||||
renderItem(suggestion(), false);
|
||||
// Diff still renders...
|
||||
expect(screen.getByText("new wording here")).toBeDefined();
|
||||
const { container } = renderItem(suggestion(), false);
|
||||
// Diff still renders (as per-fragment spans, #331)...
|
||||
expect(container.textContent).toContain("new wording here");
|
||||
// ...but no Apply button.
|
||||
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
|
||||
});
|
||||
@@ -157,6 +216,65 @@ describe("CommentListItem — suggested edit (#315)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("CommentListItem — dismiss suggestion (#329)", () => {
|
||||
const suggestion = (over?: Partial<IComment>): IComment =>
|
||||
baseComment({
|
||||
selection: "old wording here",
|
||||
suggestedText: "new wording here",
|
||||
...over,
|
||||
});
|
||||
|
||||
// A space admin (userSpaceRole="admin") satisfies the owner-or-admin gate
|
||||
// regardless of who authored the comment; the tests below use it as the lever
|
||||
// since the currentUser atom is unseeded (null) in this harness.
|
||||
it("renders a Dismiss button alongside Apply when canEdit and canComment (owner/admin)", () => {
|
||||
renderItem(suggestion(), true, true, "admin");
|
||||
expect(screen.getByRole("button", { name: "Apply" })).toBeDefined();
|
||||
expect(screen.getByRole("button", { name: "Dismiss" })).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows Dismiss but NOT Apply for an admin commenter who cannot edit", () => {
|
||||
renderItem(suggestion(), false, true, "admin");
|
||||
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "Dismiss" })).toBeDefined();
|
||||
});
|
||||
|
||||
it("hides Dismiss when the viewer cannot comment", () => {
|
||||
renderItem(suggestion(), false, false, "admin");
|
||||
expect(screen.queryByRole("button", { name: "Dismiss" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
|
||||
});
|
||||
|
||||
it("hides Dismiss for a non-owner non-admin even with canComment (#338 F5: mirrors server 403)", () => {
|
||||
// canComment=true but NOT a space admin and NOT the comment owner (the
|
||||
// currentUser atom is null while the comment is authored by user-1), so the
|
||||
// server would 403 a dismiss — the button must not be shown at all.
|
||||
renderItem(suggestion(), false, true, "member");
|
||||
expect(screen.queryByRole("button", { name: "Dismiss" })).toBeNull();
|
||||
});
|
||||
|
||||
it("hides Dismiss once the thread is resolved", () => {
|
||||
renderItem(suggestion({ resolvedAt: new Date() }), true, true, "admin");
|
||||
expect(screen.queryByRole("button", { name: "Dismiss" })).toBeNull();
|
||||
});
|
||||
|
||||
it("hides Dismiss (shows the Applied badge) once applied", () => {
|
||||
renderItem(suggestion({ suggestionAppliedAt: new Date() }), true, true, "admin");
|
||||
expect(screen.queryByRole("button", { name: "Dismiss" })).toBeNull();
|
||||
expect(screen.getByText("Applied")).toBeDefined();
|
||||
});
|
||||
|
||||
it("calls the dismiss mutation when the Dismiss button is clicked", () => {
|
||||
dismissMutateAsync.mockClear();
|
||||
renderItem(suggestion(), true, true, "admin");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
|
||||
expect(dismissMutateAsync).toHaveBeenCalledWith({
|
||||
commentId: "c-1",
|
||||
pageId: "page-1",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("canShowApply predicate", () => {
|
||||
const c = (over?: Partial<IComment>): IComment =>
|
||||
({ suggestedText: "x", ...over }) as IComment;
|
||||
@@ -182,3 +300,161 @@ describe("canShowApply predicate", () => {
|
||||
expect(canShowApply(c({ parentCommentId: "p" }), true)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canShowDismiss predicate", () => {
|
||||
const c = (over?: Partial<IComment>): IComment =>
|
||||
({ suggestedText: "x", ...over }) as IComment;
|
||||
|
||||
it("true when suggestion present, can comment, owner/admin, not applied/resolved, top-level", () => {
|
||||
expect(canShowDismiss(c(), true, true)).toBe(true);
|
||||
});
|
||||
it("false without comment permission", () => {
|
||||
expect(canShowDismiss(c(), false, true)).toBe(false);
|
||||
});
|
||||
it("false when not owner and not admin (#338 F5)", () => {
|
||||
expect(canShowDismiss(c(), true, false)).toBe(false);
|
||||
});
|
||||
it("false when no suggestion", () => {
|
||||
expect(canShowDismiss(c({ suggestedText: null }), true, true)).toBe(false);
|
||||
});
|
||||
it("false when already applied", () => {
|
||||
expect(canShowDismiss(c({ suggestionAppliedAt: new Date() }), true, true)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
it("false when resolved", () => {
|
||||
expect(canShowDismiss(c({ resolvedAt: new Date() }), true, true)).toBe(false);
|
||||
});
|
||||
it("false for a reply comment", () => {
|
||||
expect(canShowDismiss(c({ parentCommentId: "p" }), true, true)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CommentListItem — edit -> save/cancel flow (#340 F3)", () => {
|
||||
const body = (t: string) =>
|
||||
JSON.stringify({
|
||||
type: "doc",
|
||||
content: [{ type: "paragraph", content: [{ type: "text", text: t }] }],
|
||||
});
|
||||
|
||||
// The edit menu item is gated on the viewer owning the comment
|
||||
// (currentUser.id === creatorId). currentUserAtom is atomWithStorage-backed,
|
||||
// so seed localStorage to make the viewer the owner (creatorId "user-1").
|
||||
beforeEach(() => {
|
||||
updateMutateAsync.mockClear();
|
||||
localStorage.setItem(
|
||||
"currentUser",
|
||||
JSON.stringify({ user: { id: "user-1", name: "Owner" } }),
|
||||
);
|
||||
});
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
async function openEditor() {
|
||||
// Open the comment menu, then click "Edit comment" to toggle into edit mode.
|
||||
fireEvent.click(screen.getByLabelText("Comment menu"));
|
||||
fireEvent.click(await screen.findByText("Edit comment"));
|
||||
// Edit form (mocked editor + actions) is now mounted.
|
||||
await screen.findByTestId("comment-editor");
|
||||
}
|
||||
|
||||
it("saves the edited content and, on cache update, shows the new body", async () => {
|
||||
const { rerender } = renderItem(
|
||||
baseComment({ content: body("original body") }),
|
||||
);
|
||||
// Static body first.
|
||||
expect(screen.getByText("original body")).toBeDefined();
|
||||
|
||||
await openEditor();
|
||||
|
||||
// Editor emits an update (populates editContentRef), then Save is clicked.
|
||||
fireEvent.click(screen.getByTestId("editor-emit-update"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
// mutateAsync is called with the stringified edited doc.
|
||||
expect(updateMutateAsync).toHaveBeenCalledWith({
|
||||
commentId: "c-1",
|
||||
content: JSON.stringify(EDITED_DOC),
|
||||
});
|
||||
|
||||
// On success the form closes (isEditing -> false); the static body renders
|
||||
// from the comment.content prop again.
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId("comment-editor")).toBeNull(),
|
||||
);
|
||||
|
||||
// Simulate the cache invalidation swapping in a new comment object with the
|
||||
// updated content — the static body reflects it.
|
||||
rerender(
|
||||
<MantineProvider>
|
||||
<CommentListItem
|
||||
comment={baseComment({ content: body("updated body after save") })}
|
||||
pageId="page-1"
|
||||
canComment={true}
|
||||
canEdit={true}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
);
|
||||
expect(screen.getByText("updated body after save")).toBeDefined();
|
||||
expect(screen.queryByText("original body")).toBeNull();
|
||||
});
|
||||
|
||||
it("cancel restores the static body and does not call the update mutation", async () => {
|
||||
renderItem(baseComment({ content: body("original body") }));
|
||||
await openEditor();
|
||||
|
||||
// Type something (editContentRef set), then cancel.
|
||||
fireEvent.click(screen.getByTestId("editor-emit-update"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
|
||||
// Editor unmounts, static body restored, no save happened.
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId("comment-editor")).toBeNull(),
|
||||
);
|
||||
expect(screen.getByText("original body")).toBeDefined();
|
||||
expect(updateMutateAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("saving without editing sends the existing content (editContentRef cleared after cancel)", async () => {
|
||||
renderItem(baseComment({ content: body("original body") }));
|
||||
|
||||
// Cancel path clears editContentRef...
|
||||
await openEditor();
|
||||
fireEvent.click(screen.getByTestId("editor-emit-update"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId("comment-editor")).toBeNull(),
|
||||
);
|
||||
|
||||
// ...so re-opening and saving WITHOUT an update falls back to comment.content.
|
||||
await openEditor();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
expect(updateMutateAsync).toHaveBeenCalledWith({
|
||||
commentId: "c-1",
|
||||
content: JSON.stringify(body("original body")),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("CommentListItem — read-only body renders statically", () => {
|
||||
it("renders the comment body as static text without a TipTap editor", () => {
|
||||
renderItem(
|
||||
baseComment({
|
||||
content: JSON.stringify({
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: "Hello static world" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
// Body text is present...
|
||||
expect(screen.getByText("Hello static world")).toBeDefined();
|
||||
// ...and it did NOT go through the (mocked) CommentEditor instance.
|
||||
expect(screen.queryByTestId("comment-editor")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Group, Text, Box, Badge, Button } from "@mantine/core";
|
||||
import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import React, { useMemo, useRef, useState } from "react";
|
||||
import classes from "./comment.module.css";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago";
|
||||
import CommentEditor from "@/features/comment/components/comment-editor";
|
||||
import CommentContentView from "@/features/comment/components/comment-content-view";
|
||||
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
|
||||
import CommentActions from "@/features/comment/components/comment-actions";
|
||||
import CommentMenu from "@/features/comment/components/comment-menu";
|
||||
@@ -13,11 +14,16 @@ import { useHover } from "@mantine/hooks";
|
||||
import {
|
||||
useApplySuggestionMutation,
|
||||
useDeleteCommentMutation,
|
||||
useDismissSuggestionMutation,
|
||||
useResolveCommentMutation,
|
||||
useUpdateCommentMutation,
|
||||
} from "@/features/comment/queries/comment-query";
|
||||
import { IComment } from "@/features/comment/types/comment.types";
|
||||
import { canShowApply } from "@/features/comment/utils/suggestion";
|
||||
import {
|
||||
canShowApply,
|
||||
canShowDismiss,
|
||||
computeSuggestionDiff,
|
||||
} from "@/features/comment/utils/suggestion";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -45,31 +51,43 @@ function CommentListItem({
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const editor = useAtomValue(pageEditorAtom);
|
||||
const [content, setContent] = useState<string>(comment.content);
|
||||
const editContentRef = useRef<any>(null);
|
||||
const updateCommentMutation = useUpdateCommentMutation();
|
||||
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
|
||||
const resolveCommentMutation = useResolveCommentMutation();
|
||||
const applySuggestionMutation = useApplySuggestionMutation();
|
||||
const dismissSuggestionMutation = useDismissSuggestionMutation();
|
||||
const [currentUser] = useAtom(currentUserAtom);
|
||||
const createdAtAgo = useTimeAgo(comment.createdAt);
|
||||
|
||||
useEffect(() => {
|
||||
setContent(comment.content);
|
||||
}, [comment]);
|
||||
// Intraline "before -> after" diff (#331) for a suggested edit: only the
|
||||
// fragments that actually changed get emphasised inside the red/green block,
|
||||
// instead of striking through / greening the whole line. Memoised on the
|
||||
// (selection, suggestedText) pair so it recomputes only when they change.
|
||||
const suggestionDiff = useMemo(
|
||||
() =>
|
||||
comment.suggestedText != null
|
||||
? computeSuggestionDiff(comment.selection ?? "", comment.suggestedText)
|
||||
: null,
|
||||
[comment.selection, comment.suggestedText],
|
||||
);
|
||||
|
||||
// Owner-or-space-admin gate (#338): mirrors the server authz for both the
|
||||
// comment menu (edit/delete) and the suggestion Dismiss button, so we never
|
||||
// render an action the server will 403.
|
||||
const isOwnerOrAdmin =
|
||||
currentUser?.user?.id === comment.creatorId || userSpaceRole === "admin";
|
||||
|
||||
|
||||
async function handleUpdateComment() {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const commentToUpdate = {
|
||||
commentId: comment.id,
|
||||
content: JSON.stringify(editContentRef.current ?? content),
|
||||
content: JSON.stringify(editContentRef.current ?? comment.content),
|
||||
};
|
||||
await updateCommentMutation.mutateAsync(commentToUpdate);
|
||||
if (editContentRef.current) {
|
||||
setContent(editContentRef.current);
|
||||
editContentRef.current = null;
|
||||
}
|
||||
editContentRef.current = null;
|
||||
setIsEditing(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to update comment:", error);
|
||||
@@ -115,6 +133,19 @@ function CommentListItem({
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDismissSuggestion() {
|
||||
try {
|
||||
await dismissSuggestionMutation.mutateAsync({
|
||||
commentId: comment.id,
|
||||
pageId: comment.pageId,
|
||||
});
|
||||
} catch (error) {
|
||||
// Idempotent races are reconciled to success in the mutation's onError;
|
||||
// anything else surfaces there as a notification.
|
||||
console.error("Failed to dismiss suggestion:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCommentClick(comment: IComment) {
|
||||
const el = document.querySelector(
|
||||
`.comment-mark[data-comment-id="${comment.id}"]`,
|
||||
@@ -190,7 +221,7 @@ function CommentListItem({
|
||||
/>
|
||||
)}
|
||||
|
||||
{(currentUser?.user?.id === comment.creatorId || userSpaceRole === 'admin') && (
|
||||
{isOwnerOrAdmin && (
|
||||
<CommentMenu
|
||||
onEditComment={handleEditToggle}
|
||||
onDeleteComment={handleDeleteComment}
|
||||
@@ -236,12 +267,28 @@ function CommentListItem({
|
||||
{!comment.parentCommentId && comment.suggestedText && (
|
||||
<Box className={classes.suggestionBlock}>
|
||||
{comment.selection && (
|
||||
// Old line: read as removed as a whole (line-through/red); only the
|
||||
// changed fragments carry the extra intraline emphasis.
|
||||
<Text size="xs" className={classes.suggestionOld}>
|
||||
{comment.selection}
|
||||
{suggestionDiff?.old.map((segment, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className={segment.changed ? classes.suggestionChanged : undefined}
|
||||
>
|
||||
{segment.text}
|
||||
</span>
|
||||
))}
|
||||
</Text>
|
||||
)}
|
||||
<Text size="xs" className={classes.suggestionNew}>
|
||||
{comment.suggestedText}
|
||||
{suggestionDiff?.new.map((segment, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className={segment.changed ? classes.suggestionChanged : undefined}
|
||||
>
|
||||
{segment.text}
|
||||
</span>
|
||||
))}
|
||||
</Text>
|
||||
|
||||
{comment.suggestionAppliedAt ? (
|
||||
@@ -255,29 +302,53 @@ function CommentListItem({
|
||||
{t("Applied")}
|
||||
</Badge>
|
||||
) : (
|
||||
canShowApply(comment, canEdit) && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="green"
|
||||
mt={6}
|
||||
onClick={handleApplySuggestion}
|
||||
loading={applySuggestionMutation.isPending}
|
||||
disabled={applySuggestionMutation.isPending}
|
||||
>
|
||||
{t("Apply")}
|
||||
</Button>
|
||||
(canShowApply(comment, canEdit) ||
|
||||
canShowDismiss(comment, canComment, isOwnerOrAdmin)) && (
|
||||
<Group gap="xs" mt={6}>
|
||||
{canShowApply(comment, canEdit) && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="green"
|
||||
onClick={handleApplySuggestion}
|
||||
loading={applySuggestionMutation.isPending}
|
||||
disabled={
|
||||
applySuggestionMutation.isPending ||
|
||||
dismissSuggestionMutation.isPending
|
||||
}
|
||||
>
|
||||
{t("Apply")}
|
||||
</Button>
|
||||
)}
|
||||
{/* Dismiss ("Не применять", #329): removes the suggestion
|
||||
without changing the page text. Gated on canComment. */}
|
||||
{canShowDismiss(comment, canComment, isOwnerOrAdmin) && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={handleDismissSuggestion}
|
||||
loading={dismissSuggestionMutation.isPending}
|
||||
disabled={
|
||||
applySuggestionMutation.isPending ||
|
||||
dismissSuggestionMutation.isPending
|
||||
}
|
||||
>
|
||||
{t("Dismiss")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
)
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!isEditing ? (
|
||||
<CommentEditor defaultContent={content} editable={false} />
|
||||
<CommentContentView content={comment.content} />
|
||||
) : (
|
||||
<>
|
||||
<CommentEditor
|
||||
defaultContent={content}
|
||||
defaultContent={comment.content}
|
||||
editable={true}
|
||||
onUpdate={(newContent: any) => { editContentRef.current = newContent; }}
|
||||
onSave={handleUpdateComment}
|
||||
@@ -297,4 +368,6 @@ function CommentListItem({
|
||||
);
|
||||
}
|
||||
|
||||
export default CommentListItem;
|
||||
// Memoized so a resolve/apply/reply cache update (which only replaces the touched
|
||||
// comment's object identity) re-renders that one thread, not all ~356 items.
|
||||
export default React.memo(CommentListItem);
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { IComment } from "@/features/comment/types/comment.types";
|
||||
|
||||
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
||||
|
||||
// CommentEditor pulls in the full TipTap editor stack; replace it with a stub so
|
||||
// the lazy reply editor's mount transition can be observed without the editor.
|
||||
vi.mock("@/features/comment/components/comment-editor", () => ({
|
||||
default: () => <div data-testid="comment-editor" />,
|
||||
}));
|
||||
|
||||
// page-query -> main.tsx (createRoot) is a module side effect; stub the queries
|
||||
// pulled in transitively so importing the module is side-effect free.
|
||||
vi.mock("@/features/page/queries/page-query.ts", () => ({
|
||||
usePageQuery: () => ({ data: undefined, isLoading: false, isError: false }),
|
||||
}));
|
||||
vi.mock("@/features/share/queries/share-query.ts", () => ({
|
||||
useSharePageQuery: () => ({ data: undefined }),
|
||||
}));
|
||||
// space-query -> main.tsx (createRoot) is another module side effect; stub it.
|
||||
vi.mock("@/features/space/queries/space-query.ts", () => ({
|
||||
useGetSpaceBySlugQuery: () => ({ data: undefined }),
|
||||
}));
|
||||
|
||||
import {
|
||||
buildChildrenByParent,
|
||||
CommentEditorWithActions,
|
||||
} from "./comment-list-with-tabs";
|
||||
|
||||
const c = (id: string, parentCommentId: string | null = null): IComment =>
|
||||
({ id, parentCommentId }) as IComment;
|
||||
|
||||
describe("buildChildrenByParent (childrenByParent grouping)", () => {
|
||||
it("returns an empty map for undefined or empty input", () => {
|
||||
expect(buildChildrenByParent(undefined).size).toBe(0);
|
||||
expect(buildChildrenByParent([]).size).toBe(0);
|
||||
});
|
||||
|
||||
it("does not index a top-level comment (parentCommentId null)", () => {
|
||||
const map = buildChildrenByParent([c("p1", null)]);
|
||||
expect(map.size).toBe(0);
|
||||
expect(map.has("p1")).toBe(false);
|
||||
});
|
||||
|
||||
it("groups replies under the correct parent, including reply-to-reply nesting", () => {
|
||||
const p1 = c("p1", null);
|
||||
const r1 = c("r1", "p1");
|
||||
const r2 = c("r2", "r1"); // a reply to a reply
|
||||
const map = buildChildrenByParent([p1, r1, r2]);
|
||||
expect(map.get("p1")).toEqual([r1]);
|
||||
expect(map.get("r1")).toEqual([r2]);
|
||||
// The top-level comment itself is never a key.
|
||||
expect(map.has("p1") && map.get("p1")?.length).toBe(1);
|
||||
});
|
||||
|
||||
it("still groups a reply whose parent is not present in items", () => {
|
||||
const orphan = c("o1", "missing-parent");
|
||||
const map = buildChildrenByParent([orphan]);
|
||||
expect(map.get("missing-parent")).toEqual([orphan]);
|
||||
});
|
||||
|
||||
it("preserves insertion order among sibling replies", () => {
|
||||
const map = buildChildrenByParent([
|
||||
c("a", "p1"),
|
||||
c("b", "p1"),
|
||||
c("d", "p1"),
|
||||
]);
|
||||
expect(map.get("p1")?.map((x) => x.id)).toEqual(["a", "b", "d"]);
|
||||
});
|
||||
});
|
||||
|
||||
function renderReplyEditor() {
|
||||
return render(
|
||||
<MantineProvider>
|
||||
<CommentEditorWithActions commentId="c-1" onSave={vi.fn()} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("CommentEditorWithActions — lazy reply editor activation", () => {
|
||||
it("shows only the stub initially (no editor instance mounted)", () => {
|
||||
renderReplyEditor();
|
||||
expect(screen.getByRole("button")).toBeDefined();
|
||||
expect(screen.queryByTestId("comment-editor")).toBeNull();
|
||||
});
|
||||
|
||||
it("mounts the real editor when the stub is clicked and keeps it mounted", () => {
|
||||
renderReplyEditor();
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
expect(screen.getByTestId("comment-editor")).toBeDefined();
|
||||
// The stub button is replaced by the editor subtree.
|
||||
expect(screen.queryByRole("button")).toBeNull();
|
||||
});
|
||||
|
||||
it("mounts the editor when the stub receives focus", () => {
|
||||
renderReplyEditor();
|
||||
fireEvent.focus(screen.getByRole("button"));
|
||||
expect(screen.getByTestId("comment-editor")).toBeDefined();
|
||||
});
|
||||
|
||||
it("mounts the editor on Enter keydown of the stub", () => {
|
||||
renderReplyEditor();
|
||||
fireEvent.keyDown(screen.getByRole("button"), { key: "Enter" });
|
||||
expect(screen.getByTestId("comment-editor")).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -23,7 +23,6 @@ import CommentActions from "@/features/comment/components/comment-actions";
|
||||
import { useFocusWithin } from "@mantine/hooks";
|
||||
import { IComment } from "@/features/comment/types/comment.types.ts";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
||||
@@ -36,6 +35,24 @@ interface CommentListWithTabsProps {
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
// Index replies by their parent id once (O(n)), instead of an O(n^2) filter per
|
||||
// thread. Replies whose parent is not in `items` are still grouped under their
|
||||
// parentCommentId (they simply won't be reached by the top-level walk).
|
||||
// Exported for unit testing.
|
||||
export function buildChildrenByParent(
|
||||
items: IComment[] | undefined,
|
||||
): Map<string, IComment[]> {
|
||||
const m = new Map<string, IComment[]>();
|
||||
for (const c of items ?? []) {
|
||||
if (c.parentCommentId) {
|
||||
const arr = m.get(c.parentCommentId);
|
||||
if (arr) arr.push(c);
|
||||
else m.set(c.parentCommentId, [c]);
|
||||
}
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
||||
const { t } = useTranslation();
|
||||
const { pageSlug } = useParams();
|
||||
@@ -46,7 +63,9 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
||||
isError,
|
||||
} = useCommentsQuery({ pageId: page?.id });
|
||||
const createCommentMutation = useCreateCommentMutation();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
// mutateAsync is a stable reference across renders; depend on it (not the
|
||||
// mutation object) so the reply/comment callbacks stay stable.
|
||||
const createCommentAsync = createCommentMutation.mutateAsync;
|
||||
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
|
||||
|
||||
const canEdit = page?.permissions?.canEdit ?? false;
|
||||
@@ -75,13 +94,21 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
||||
return { activeComments: active, resolvedComments: resolved };
|
||||
}, [comments]);
|
||||
|
||||
// Index replies by their parent once, instead of an O(n^2) filter per thread.
|
||||
// The map ref changes on any comments update, so MemoizedChildComments re-runs
|
||||
// (cheap) and re-looks-up, while memoized CommentListItems skip unchanged items.
|
||||
const childrenByParent = useMemo(
|
||||
() => buildChildrenByParent(comments?.items),
|
||||
[comments?.items],
|
||||
);
|
||||
|
||||
const [isPageCommentLoading, setIsPageCommentLoading] = useState(false);
|
||||
|
||||
const handleAddPageComment = useCallback(
|
||||
async (_commentId: string, content: string) => {
|
||||
try {
|
||||
setIsPageCommentLoading(true);
|
||||
const createdComment = await createCommentMutation.mutateAsync({
|
||||
const createdComment = await createCommentAsync({
|
||||
pageId: page?.id,
|
||||
content: JSON.stringify(content),
|
||||
});
|
||||
@@ -100,27 +127,26 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
||||
setIsPageCommentLoading(false);
|
||||
}
|
||||
},
|
||||
[createCommentMutation, page?.id],
|
||||
[createCommentAsync, page?.id],
|
||||
);
|
||||
|
||||
const handleAddReply = useCallback(
|
||||
async (commentId: string, content: string) => {
|
||||
// Pending state lives inside CommentEditorWithActions so sending a reply
|
||||
// does not churn renderComments and re-render the whole list.
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const commentData = {
|
||||
pageId: page?.id,
|
||||
parentCommentId: commentId,
|
||||
content: JSON.stringify(content),
|
||||
};
|
||||
|
||||
await createCommentMutation.mutateAsync(commentData);
|
||||
await createCommentAsync(commentData);
|
||||
} catch (error) {
|
||||
console.error("Failed to post comment:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[createCommentMutation, page?.id],
|
||||
[createCommentAsync, page?.id],
|
||||
);
|
||||
|
||||
const renderComments = useCallback(
|
||||
@@ -143,7 +169,7 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
||||
userSpaceRole={space?.membership?.role}
|
||||
/>
|
||||
<MemoizedChildComments
|
||||
comments={comments}
|
||||
childrenByParent={childrenByParent}
|
||||
parentId={comment.id}
|
||||
pageId={page?.id}
|
||||
canComment={canComment}
|
||||
@@ -158,16 +184,15 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
||||
<CommentEditorWithActions
|
||||
commentId={comment.id}
|
||||
onSave={handleAddReply}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
),
|
||||
[
|
||||
comments,
|
||||
childrenByParent,
|
||||
handleAddReply,
|
||||
isLoading,
|
||||
page?.id,
|
||||
space?.membership?.role,
|
||||
canComment,
|
||||
canEdit,
|
||||
@@ -203,6 +228,11 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
||||
<Tabs
|
||||
defaultValue="open"
|
||||
variant="default"
|
||||
// Default to not mounting an inactive tab (the heavy Resolved list stays
|
||||
// unmounted while Open is shown). The Open panel overrides this with its
|
||||
// own keepMounted (below) so an in-progress reply/edit draft survives an
|
||||
// Open -> Resolved -> Open switch.
|
||||
keepMounted={false}
|
||||
style={{
|
||||
flex: "1 1 auto",
|
||||
display: "flex",
|
||||
@@ -261,7 +291,10 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
||||
type="scroll"
|
||||
>
|
||||
<div style={{ paddingBottom: "8px" }}>
|
||||
<Tabs.Panel value="open" pt="xs">
|
||||
{/* keepMounted keeps the Open panel alive even while Resolved is
|
||||
active, so a lazily-mounted reply editor's draft (and an
|
||||
in-progress edit) is not discarded on tab switch. */}
|
||||
<Tabs.Panel value="open" pt="xs" keepMounted>
|
||||
{activeComments.length === 0 ? (
|
||||
<Center py="xl">
|
||||
<Stack align="center" gap="xs">
|
||||
@@ -307,7 +340,7 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
||||
}
|
||||
|
||||
interface ChildCommentsProps {
|
||||
comments: IPagination<IComment>;
|
||||
childrenByParent: Map<string, IComment[]>;
|
||||
parentId: string;
|
||||
pageId: string;
|
||||
canComment: boolean;
|
||||
@@ -315,24 +348,18 @@ interface ChildCommentsProps {
|
||||
userSpaceRole?: string;
|
||||
}
|
||||
const ChildComments = ({
|
||||
comments,
|
||||
childrenByParent,
|
||||
parentId,
|
||||
pageId,
|
||||
canComment,
|
||||
canEdit,
|
||||
userSpaceRole,
|
||||
}: ChildCommentsProps) => {
|
||||
const getChildComments = useCallback(
|
||||
(parentId: string) =>
|
||||
comments.items.filter(
|
||||
(comment: IComment) => comment.parentCommentId === parentId,
|
||||
),
|
||||
[comments.items],
|
||||
);
|
||||
const children = childrenByParent.get(parentId) ?? [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{getChildComments(parentId).map((childComment) => (
|
||||
{children.map((childComment) => (
|
||||
<div key={childComment.id}>
|
||||
<CommentListItem
|
||||
comment={childComment}
|
||||
@@ -342,7 +369,7 @@ const ChildComments = ({
|
||||
userSpaceRole={userSpaceRole}
|
||||
/>
|
||||
<MemoizedChildComments
|
||||
comments={comments}
|
||||
childrenByParent={childrenByParent}
|
||||
parentId={childComment.id}
|
||||
pageId={pageId}
|
||||
canComment={canComment}
|
||||
@@ -357,22 +384,61 @@ const ChildComments = ({
|
||||
|
||||
const MemoizedChildComments = memo(ChildComments);
|
||||
|
||||
const CommentEditorWithActions = ({
|
||||
export const CommentEditorWithActions = ({
|
||||
commentId,
|
||||
onSave,
|
||||
isLoading,
|
||||
placeholder = undefined,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
// Lazily mount the TipTap reply editor: until the user interacts with the
|
||||
// stub, no editor instance is created for this thread. Once mounted it stays
|
||||
// mounted so the draft is preserved.
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [content, setContent] = useState("");
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const { ref, focused } = useFocusWithin();
|
||||
const commentEditorRef = useRef(null);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
onSave(commentId, content);
|
||||
setContent("");
|
||||
commentEditorRef.current?.clearContent();
|
||||
const activate = useCallback(() => setMounted(true), []);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
try {
|
||||
setIsSending(true);
|
||||
await onSave(commentId, content);
|
||||
setContent("");
|
||||
commentEditorRef.current?.clearContent();
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
}
|
||||
}, [commentId, content, onSave]);
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={activate}
|
||||
onFocus={activate}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
activate();
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
padding: "6px",
|
||||
fontSize: "var(--mantine-font-size-sm)",
|
||||
lineHeight: 1.4,
|
||||
color: "var(--mantine-color-placeholder)",
|
||||
cursor: "text",
|
||||
borderRadius: "var(--mantine-radius-sm)",
|
||||
}}
|
||||
>
|
||||
{placeholder || t("Reply...")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<CommentEditor
|
||||
@@ -381,8 +447,9 @@ const CommentEditorWithActions = ({
|
||||
onSave={handleSave}
|
||||
editable={true}
|
||||
placeholder={placeholder}
|
||||
autofocus={true}
|
||||
/>
|
||||
{focused && <CommentActions onSave={handleSave} isLoading={isLoading} />}
|
||||
{focused && <CommentActions onSave={handleSave} isLoading={isSending} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -53,6 +53,21 @@
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Intraline diff (#331): the fragment that actually changed within the
|
||||
red "before" / green "after" block. It inherits the surrounding red/green
|
||||
framing and adds a stronger tint plus bold weight so the eye lands on the
|
||||
changed letters/words (git/GitHub-style) rather than the whole line. The
|
||||
container's line-through (old) / green (new) still marks the full line. */
|
||||
.suggestionChanged {
|
||||
/* Stronger tint of the surrounding red/green so the changed fragment pops
|
||||
within the block. `currentColor` follows the parent's red (old) or green
|
||||
(new) text colour. No `text-decoration` here on purpose: the old block's
|
||||
inherited line-through must survive on the changed letters too. */
|
||||
background: color-mix(in srgb, currentColor 22%, transparent);
|
||||
border-radius: 2px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.commentEditor {
|
||||
|
||||
&[data-editable][data-surface="muted"] .ProseMirror:not(.focused) {
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import React from "react";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import {
|
||||
QueryClient,
|
||||
QueryClientProvider,
|
||||
InfiniteData,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
/**
|
||||
* Coverage for the ephemeral-suggestion (#329) cache reconciliation in
|
||||
* useApplySuggestionMutation / useDismissSuggestionMutation: the mutations act on
|
||||
* the server `outcome` — 'deleted' drops the comment from the local list,
|
||||
* 'resolved' relocates it (by stamping resolvedAt, which the tabs split on).
|
||||
*/
|
||||
|
||||
vi.mock("@mantine/notifications", () => ({
|
||||
notifications: { show: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("@/features/comment/services/comment-service", () => ({
|
||||
applySuggestion: vi.fn(),
|
||||
dismissSuggestion: vi.fn(),
|
||||
createComment: vi.fn(),
|
||||
updateComment: vi.fn(),
|
||||
deleteComment: vi.fn(),
|
||||
resolveComment: vi.fn(),
|
||||
getPageComments: vi.fn(),
|
||||
}));
|
||||
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import {
|
||||
applySuggestion,
|
||||
dismissSuggestion,
|
||||
} from "@/features/comment/services/comment-service";
|
||||
import {
|
||||
useApplySuggestionMutation,
|
||||
useDismissSuggestionMutation,
|
||||
RQ_KEY,
|
||||
} from "@/features/comment/queries/comment-query";
|
||||
import { IComment } from "@/features/comment/types/comment.types";
|
||||
|
||||
const PAGE_ID = "page-1";
|
||||
|
||||
function seededClient(comment: IComment) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { mutations: { retry: false } },
|
||||
});
|
||||
const seed: InfiniteData<any> = {
|
||||
pageParams: [undefined],
|
||||
pages: [{ items: [comment], meta: { hasNextPage: false, nextCursor: null } }],
|
||||
};
|
||||
queryClient.setQueryData(RQ_KEY(PAGE_ID), seed);
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
return { queryClient, wrapper };
|
||||
}
|
||||
|
||||
function items(queryClient: QueryClient): IComment[] {
|
||||
const cache = queryClient.getQueryData(RQ_KEY(PAGE_ID)) as
|
||||
| InfiniteData<any>
|
||||
| undefined;
|
||||
return cache?.pages.flatMap((p) => p.items) ?? [];
|
||||
}
|
||||
|
||||
const comment = (over?: Partial<IComment>): IComment =>
|
||||
({
|
||||
id: "c-1",
|
||||
pageId: PAGE_ID,
|
||||
content: "{}",
|
||||
creatorId: "u-1",
|
||||
workspaceId: "ws-1",
|
||||
createdAt: new Date(),
|
||||
suggestedText: "new",
|
||||
...over,
|
||||
}) as IComment;
|
||||
|
||||
describe("useApplySuggestionMutation — outcome handling (#329)", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("outcome=deleted → removes the comment from the list", async () => {
|
||||
vi.mocked(applySuggestion).mockResolvedValue({
|
||||
id: "c-1",
|
||||
pageId: PAGE_ID,
|
||||
outcome: "deleted",
|
||||
} as any);
|
||||
const { queryClient, wrapper } = seededClient(comment());
|
||||
|
||||
const { result } = renderHook(() => useApplySuggestionMutation(), {
|
||||
wrapper,
|
||||
});
|
||||
await result.current.mutateAsync({ commentId: "c-1", pageId: PAGE_ID });
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
expect(items(queryClient)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("outcome=resolved → keeps the comment and stamps resolvedAt/applied fields", async () => {
|
||||
const resolvedAt = new Date();
|
||||
vi.mocked(applySuggestion).mockResolvedValue({
|
||||
id: "c-1",
|
||||
pageId: PAGE_ID,
|
||||
outcome: "resolved",
|
||||
resolvedAt,
|
||||
resolvedById: "u-1",
|
||||
resolvedBy: { id: "u-1", name: "A" },
|
||||
suggestionAppliedAt: resolvedAt,
|
||||
suggestionAppliedById: "u-1",
|
||||
} as any);
|
||||
const { queryClient, wrapper } = seededClient(comment());
|
||||
|
||||
const { result } = renderHook(() => useApplySuggestionMutation(), {
|
||||
wrapper,
|
||||
});
|
||||
await result.current.mutateAsync({ commentId: "c-1", pageId: PAGE_ID });
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
const list = items(queryClient);
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list[0].resolvedAt).toBe(resolvedAt);
|
||||
expect(list[0].suggestionAppliedAt).toBe(resolvedAt);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useDismissSuggestionMutation — outcome handling (#329)", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("outcome=deleted → removes the comment from the list", async () => {
|
||||
vi.mocked(dismissSuggestion).mockResolvedValue({
|
||||
id: "c-1",
|
||||
pageId: PAGE_ID,
|
||||
outcome: "deleted",
|
||||
} as any);
|
||||
const { queryClient, wrapper } = seededClient(comment());
|
||||
|
||||
const { result } = renderHook(() => useDismissSuggestionMutation(), {
|
||||
wrapper,
|
||||
});
|
||||
await result.current.mutateAsync({ commentId: "c-1", pageId: PAGE_ID });
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
expect(items(queryClient)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("outcome=resolved → keeps the comment and stamps resolvedAt", async () => {
|
||||
const resolvedAt = new Date();
|
||||
vi.mocked(dismissSuggestion).mockResolvedValue({
|
||||
id: "c-1",
|
||||
pageId: PAGE_ID,
|
||||
outcome: "resolved",
|
||||
resolvedAt,
|
||||
resolvedById: "u-1",
|
||||
resolvedBy: { id: "u-1", name: "A" },
|
||||
} as any);
|
||||
const { queryClient, wrapper } = seededClient(comment());
|
||||
|
||||
const { result } = renderHook(() => useDismissSuggestionMutation(), {
|
||||
wrapper,
|
||||
});
|
||||
await result.current.mutateAsync({ commentId: "c-1", pageId: PAGE_ID });
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
const list = items(queryClient);
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list[0].resolvedAt).toBe(resolvedAt);
|
||||
});
|
||||
|
||||
it("idempotent race (404) → treated as success, comment removed from the list", async () => {
|
||||
vi.mocked(dismissSuggestion).mockRejectedValue({
|
||||
response: { status: 404 },
|
||||
});
|
||||
const { queryClient, wrapper } = seededClient(comment());
|
||||
|
||||
const { result } = renderHook(() => useDismissSuggestionMutation(), {
|
||||
wrapper,
|
||||
});
|
||||
// mutateAsync rejects even though onError reconciles the cache; swallow it.
|
||||
await result.current
|
||||
.mutateAsync({ commentId: "c-1", pageId: PAGE_ID })
|
||||
.catch(() => undefined);
|
||||
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||
|
||||
expect(items(queryClient)).toHaveLength(0);
|
||||
// #338 F3: the idempotent race must still fire the SUCCESS toast, not just
|
||||
// silently drop the comment.
|
||||
expect(notifications.show).toHaveBeenCalledWith({
|
||||
message: "Suggestion dismissed",
|
||||
});
|
||||
});
|
||||
|
||||
it("dismiss 400 (thread still alive) → NOT a success, comment kept, no green toast (#338 F2)", async () => {
|
||||
// 400 means the thread is alive (already resolved / a reply raced in).
|
||||
// Narrowed onError: only 404 is a success-noop; 400 must surface a real error
|
||||
// and keep the comment in the cache.
|
||||
vi.mocked(dismissSuggestion).mockRejectedValue({
|
||||
response: { status: 400 },
|
||||
});
|
||||
const { queryClient, wrapper } = seededClient(comment());
|
||||
|
||||
const { result } = renderHook(() => useDismissSuggestionMutation(), {
|
||||
wrapper,
|
||||
});
|
||||
await result.current
|
||||
.mutateAsync({ commentId: "c-1", pageId: PAGE_ID })
|
||||
.catch(() => undefined);
|
||||
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||
|
||||
// Comment NOT dropped from the cache.
|
||||
expect(items(queryClient)).toHaveLength(1);
|
||||
// A real (red) error, never the success message.
|
||||
expect(notifications.show).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ color: "red" }),
|
||||
);
|
||||
expect(notifications.show).not.toHaveBeenCalledWith({
|
||||
message: "Suggestion dismissed",
|
||||
});
|
||||
});
|
||||
|
||||
it("APPLY idempotent race (404) → treated as success, comment removed from the list", async () => {
|
||||
// After #329 an applied reply-less suggestion is hard-deleted, so a racing
|
||||
// second apply hits 404 — must reconcile to success like dismiss, not a red
|
||||
// error (restores the #315 apply idempotency).
|
||||
vi.mocked(applySuggestion).mockRejectedValue({
|
||||
response: { status: 404 },
|
||||
});
|
||||
const { queryClient, wrapper } = seededClient(comment());
|
||||
|
||||
const { result } = renderHook(() => useApplySuggestionMutation(), {
|
||||
wrapper,
|
||||
});
|
||||
await result.current
|
||||
.mutateAsync({ commentId: "c-1", pageId: PAGE_ID })
|
||||
.catch(() => undefined);
|
||||
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||
|
||||
expect(items(queryClient)).toHaveLength(0);
|
||||
// #338 F3: the idempotent race must still fire the SUCCESS toast.
|
||||
expect(notifications.show).toHaveBeenCalledWith({
|
||||
message: "Suggestion applied",
|
||||
});
|
||||
});
|
||||
|
||||
it("APPLY 400 (thread resolved, not applied) → NOT a success, comment kept, red error (#338 F2)", async () => {
|
||||
// apply's only 400 is "Cannot apply … on a resolved comment thread" — the
|
||||
// thread was resolved (often with discussion) but NOT applied. It must be a
|
||||
// real error surfacing the server message, and must NOT drop the live thread.
|
||||
vi.mocked(applySuggestion).mockRejectedValue({
|
||||
response: {
|
||||
status: 400,
|
||||
data: {
|
||||
message: "Cannot apply a suggested edit on a resolved comment thread",
|
||||
},
|
||||
},
|
||||
});
|
||||
const { queryClient, wrapper } = seededClient(comment());
|
||||
|
||||
const { result } = renderHook(() => useApplySuggestionMutation(), {
|
||||
wrapper,
|
||||
});
|
||||
await result.current
|
||||
.mutateAsync({ commentId: "c-1", pageId: PAGE_ID })
|
||||
.catch(() => undefined);
|
||||
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||
|
||||
// The live thread is NOT dropped from the cache.
|
||||
expect(items(queryClient)).toHaveLength(1);
|
||||
// Surfaces the server's specific message as a red error, never a success.
|
||||
expect(notifications.show).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "Cannot apply a suggested edit on a resolved comment thread",
|
||||
color: "red",
|
||||
}),
|
||||
);
|
||||
expect(notifications.show).not.toHaveBeenCalledWith({
|
||||
message: "Suggestion applied",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
applySuggestion,
|
||||
createComment,
|
||||
deleteComment,
|
||||
dismissSuggestion,
|
||||
getPageComments,
|
||||
resolveComment,
|
||||
updateComment,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
ICommentParams,
|
||||
IComment,
|
||||
IResolveComment,
|
||||
ISuggestionOutcome,
|
||||
} from "@/features/comment/types/comment.types";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
@@ -51,7 +53,10 @@ export function useCommentsQuery(params: ICommentParams) {
|
||||
|
||||
return {
|
||||
data,
|
||||
isLoading: query.isLoading || query.hasNextPage,
|
||||
// Paint the first page as soon as it arrives instead of blocking until every
|
||||
// page has loaded; the background effect above keeps streaming the rest
|
||||
// (tab counts grow as pages arrive).
|
||||
isLoading: query.isLoading,
|
||||
isError: query.isError,
|
||||
};
|
||||
}
|
||||
@@ -177,40 +182,121 @@ function updateCommentInCache(
|
||||
};
|
||||
}
|
||||
|
||||
function removeCommentFromCache(
|
||||
cache: InfiniteData<IPagination<IComment>>,
|
||||
commentId: string,
|
||||
): InfiniteData<IPagination<IComment>> {
|
||||
return {
|
||||
...cache,
|
||||
pages: cache.pages.map((page) => ({
|
||||
...page,
|
||||
items: page.items.filter((comment) => comment.id !== commentId),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// Reconcile the local comment cache with an ephemeral-suggestion outcome (#329)
|
||||
// returned by apply/dismiss: 'deleted' → drop the comment (it disappeared);
|
||||
// 'resolved' → the thread had replies and was resolved, so carry the resolved
|
||||
// state through (which relocates it to the resolved tab).
|
||||
function applySuggestionOutcomeToCache(
|
||||
queryClient: ReturnType<typeof useQueryClient>,
|
||||
pageId: string,
|
||||
commentId: string,
|
||||
data: ISuggestionOutcome,
|
||||
) {
|
||||
const cache = queryClient.getQueryData(RQ_KEY(pageId)) as
|
||||
| InfiniteData<IPagination<IComment>>
|
||||
| undefined;
|
||||
if (!cache) return;
|
||||
|
||||
if (data.outcome === "deleted") {
|
||||
queryClient.setQueryData(RQ_KEY(pageId), removeCommentFromCache(cache, commentId));
|
||||
return;
|
||||
}
|
||||
|
||||
// 'resolved' (or an older server that omits outcome): reflect the resolved
|
||||
// state and the applied stamps (apply sets them; dismiss leaves them null).
|
||||
queryClient.setQueryData(
|
||||
RQ_KEY(pageId),
|
||||
updateCommentInCache(cache, commentId, (comment) => ({
|
||||
...comment,
|
||||
suggestionAppliedAt: data.suggestionAppliedAt,
|
||||
suggestionAppliedById: data.suggestionAppliedById,
|
||||
resolvedAt: data.resolvedAt,
|
||||
resolvedById: data.resolvedById,
|
||||
resolvedBy: data.resolvedBy,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
export function useApplySuggestionMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<IComment, any, { commentId: string; pageId: string }>({
|
||||
return useMutation<
|
||||
ISuggestionOutcome,
|
||||
any,
|
||||
{ commentId: string; pageId: string }
|
||||
>({
|
||||
// No optimistic update: apply can fail with 409 (the commented text drifted),
|
||||
// so we only mutate the cache once the server confirms.
|
||||
mutationFn: ({ commentId }) => applySuggestion(commentId),
|
||||
onSuccess: (data, variables) => {
|
||||
const cache = queryClient.getQueryData(
|
||||
RQ_KEY(variables.pageId),
|
||||
) as InfiniteData<IPagination<IComment>> | undefined;
|
||||
|
||||
if (cache) {
|
||||
queryClient.setQueryData(
|
||||
RQ_KEY(variables.pageId),
|
||||
updateCommentInCache(cache, variables.commentId, (comment) => ({
|
||||
...comment,
|
||||
suggestionAppliedAt: data.suggestionAppliedAt,
|
||||
suggestionAppliedById: data.suggestionAppliedById,
|
||||
// The server auto-resolves the thread on apply — carry that through.
|
||||
resolvedAt: data.resolvedAt,
|
||||
resolvedById: data.resolvedById,
|
||||
resolvedBy: data.resolvedBy,
|
||||
})),
|
||||
);
|
||||
}
|
||||
// Ephemeral (#329): the server hard-deletes the applied suggestion when the
|
||||
// thread has no replies ('deleted') or resolves it when it does ('resolved').
|
||||
applySuggestionOutcomeToCache(
|
||||
queryClient,
|
||||
variables.pageId,
|
||||
variables.commentId,
|
||||
data,
|
||||
);
|
||||
|
||||
notifications.show({ message: t("Suggestion applied") });
|
||||
},
|
||||
onError: (err: any) => {
|
||||
onError: (err: any, variables) => {
|
||||
const status = err?.response?.status;
|
||||
// Idempotent race (double-click, or apply↔dismiss): after #329 an applied
|
||||
// reply-less suggestion is hard-deleted, so a second/racing apply hits 404
|
||||
// (already gone). ONLY 404 is a real success-noop — drop it from the cache
|
||||
// and report success, the user's intent is already satisfied (restores the
|
||||
// #315 apply idempotency the ephemeral delete would otherwise break).
|
||||
//
|
||||
// 400 is NOT success (#338 F2): apply's only 400 is "Cannot apply … on a
|
||||
// resolved comment thread" — the thread was resolved (often WITH a live
|
||||
// discussion) but the edit was NOT applied. Treating it as "Suggestion
|
||||
// applied" is a false success that also drops a live thread from the cache.
|
||||
// The #315 idempotent repeat does NOT produce 400 (childless → 404;
|
||||
// with-replies → 200), so we never lose idempotency by excluding it here.
|
||||
if (status === 404) {
|
||||
const cache = queryClient.getQueryData(RQ_KEY(variables.pageId)) as
|
||||
| InfiniteData<IPagination<IComment>>
|
||||
| undefined;
|
||||
if (cache) {
|
||||
queryClient.setQueryData(
|
||||
RQ_KEY(variables.pageId),
|
||||
removeCommentFromCache(cache, variables.commentId),
|
||||
);
|
||||
}
|
||||
notifications.show({ message: t("Suggestion applied") });
|
||||
return;
|
||||
}
|
||||
// 400 => the thread was resolved and the edit could not be applied. Show a
|
||||
// real error and KEEP the comment in the cache (it is still alive). Prefer
|
||||
// the server's specific message when it carries one.
|
||||
if (status === 400) {
|
||||
const serverMsg = err?.response?.data?.message;
|
||||
notifications.show({
|
||||
message:
|
||||
typeof serverMsg === "string" && serverMsg.length > 0
|
||||
? serverMsg
|
||||
: t("Failed to apply suggestion"),
|
||||
color: "red",
|
||||
});
|
||||
return;
|
||||
}
|
||||
// 409 => the commented text changed since the suggestion was made. Surface
|
||||
// a specific message (with the current text) rather than a generic error.
|
||||
const status = err?.response?.status;
|
||||
const currentText = err?.response?.data?.currentText;
|
||||
if (status === 409 && typeof currentText === "string") {
|
||||
const shortText =
|
||||
@@ -234,6 +320,58 @@ export function useApplySuggestionMutation() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useDismissSuggestionMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<
|
||||
ISuggestionOutcome,
|
||||
any,
|
||||
{ commentId: string; pageId: string }
|
||||
>({
|
||||
mutationFn: ({ commentId }) => dismissSuggestion(commentId),
|
||||
onSuccess: (data, variables) => {
|
||||
// Ephemeral (#329): dismiss hard-deletes the suggestion when the thread has
|
||||
// no replies ('deleted') or resolves it when it does ('resolved').
|
||||
applySuggestionOutcomeToCache(
|
||||
queryClient,
|
||||
variables.pageId,
|
||||
variables.commentId,
|
||||
data,
|
||||
);
|
||||
|
||||
notifications.show({ message: t("Suggestion dismissed") });
|
||||
},
|
||||
onError: (err: any, variables) => {
|
||||
// Idempotent race (double-click, or apply↔dismiss): the comment is already
|
||||
// gone (404). ONLY 404 is a real success-noop — drop it from the cache and
|
||||
// report success, the user's intent (make it disappear) is satisfied.
|
||||
//
|
||||
// 400 is NOT success (#338 F2): it means the thread is still ALIVE (already
|
||||
// resolved, or a reply raced in), so treating it as "dismissed" would drop
|
||||
// a live thread from the cache. Show a real error and keep the comment.
|
||||
const status = err?.response?.status;
|
||||
if (status === 404) {
|
||||
const cache = queryClient.getQueryData(RQ_KEY(variables.pageId)) as
|
||||
| InfiniteData<IPagination<IComment>>
|
||||
| undefined;
|
||||
if (cache) {
|
||||
queryClient.setQueryData(
|
||||
RQ_KEY(variables.pageId),
|
||||
removeCommentFromCache(cache, variables.commentId),
|
||||
);
|
||||
}
|
||||
notifications.show({ message: t("Suggestion dismissed") });
|
||||
return;
|
||||
}
|
||||
notifications.show({
|
||||
message: t("Failed to dismiss suggestion"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useResolveCommentMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ICommentParams,
|
||||
IComment,
|
||||
IResolveComment,
|
||||
ISuggestionOutcome,
|
||||
} from "@/features/comment/types/comment.types";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
|
||||
@@ -18,13 +19,24 @@ export async function resolveComment(data: IResolveComment): Promise<IComment> {
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function applySuggestion(commentId: string): Promise<IComment> {
|
||||
export async function applySuggestion(
|
||||
commentId: string,
|
||||
): Promise<ISuggestionOutcome> {
|
||||
// Mirrors resolveComment: let axios reject on non-2xx so the mutation can read
|
||||
// the 409 body (`{ message, currentText }`) off err.response.data.
|
||||
const req = await api.post("/comments/apply-suggestion", { commentId });
|
||||
return req.data.data ?? req.data;
|
||||
}
|
||||
|
||||
export async function dismissSuggestion(
|
||||
commentId: string,
|
||||
): Promise<ISuggestionOutcome> {
|
||||
// Dismiss ("Не применять") a suggested edit (#329): the server hard-deletes
|
||||
// the comment (or resolves it when it has replies) and returns the outcome.
|
||||
const req = await api.post("/comments/dismiss-suggestion", { commentId });
|
||||
return req.data.data ?? req.data;
|
||||
}
|
||||
|
||||
export async function updateComment(
|
||||
data: Partial<IComment>,
|
||||
): Promise<IComment> {
|
||||
|
||||
@@ -60,6 +60,15 @@ export interface IResolveComment {
|
||||
resolved: boolean;
|
||||
}
|
||||
|
||||
// Result of applying or dismissing an ephemeral suggested edit (#329). The
|
||||
// server hard-deletes the comment (`deleted`) unless the thread has replies, in
|
||||
// which case it is resolved (`resolved`). The returned comment fields carry the
|
||||
// resolved-branch state; `outcome` tells the client which optimistic action to
|
||||
// take (drop the comment vs. move it to the resolved tab).
|
||||
export type ISuggestionOutcome = IComment & {
|
||||
outcome?: "deleted" | "resolved";
|
||||
};
|
||||
|
||||
export interface ICommentParams extends QueryParams {
|
||||
pageId: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { computeSuggestionDiff, Segment } from "@/features/comment/utils/suggestion";
|
||||
|
||||
// Reconstruct the plain string from a segment stream — the diff must be
|
||||
// lossless (concatenating every fragment yields the original input).
|
||||
const join = (segments: Segment[]): string =>
|
||||
segments.map((s) => s.text).join("");
|
||||
|
||||
// The subset of segments (in order) that the UI would emphasise.
|
||||
const changed = (segments: Segment[]): string[] =>
|
||||
segments.filter((s) => s.changed).map((s) => s.text);
|
||||
|
||||
// Find the segment that contains a substring, to assert its `changed` flag.
|
||||
const segmentWith = (segments: Segment[], needle: string): Segment | undefined =>
|
||||
segments.find((s) => s.text.includes(needle));
|
||||
|
||||
describe("computeSuggestionDiff", () => {
|
||||
it("highlights only the single changed letter in a one-letter edit", () => {
|
||||
const { old, new: neu } = computeSuggestionDiff("заведем", "заведём");
|
||||
|
||||
// Lossless.
|
||||
expect(join(old)).toBe("заведем");
|
||||
expect(join(neu)).toBe("заведём");
|
||||
|
||||
// Old side: exactly the `е` is changed, the rest is common.
|
||||
expect(changed(old)).toEqual(["е"]);
|
||||
expect(old).toEqual([
|
||||
{ text: "завед", changed: false },
|
||||
{ text: "е", changed: true },
|
||||
{ text: "м", changed: false },
|
||||
]);
|
||||
|
||||
// New side: exactly the `ё` is changed.
|
||||
expect(changed(neu)).toEqual(["ё"]);
|
||||
expect(neu).toEqual([
|
||||
{ text: "завед", changed: false },
|
||||
{ text: "ё", changed: true },
|
||||
{ text: "м", changed: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("marks the differing words changed but keeps the shared word common", () => {
|
||||
const { old, new: neu } = computeSuggestionDiff(
|
||||
"привет мир",
|
||||
"здравствуй мир",
|
||||
);
|
||||
|
||||
// Lossless.
|
||||
expect(join(old)).toBe("привет мир");
|
||||
expect(join(neu)).toBe("здравствуй мир");
|
||||
|
||||
// The shared trailing word stays common on both sides (no per-letter noise
|
||||
// leaking across the differing words into `мир`).
|
||||
expect(segmentWith(old, "мир")?.changed).toBe(false);
|
||||
expect(segmentWith(neu, "мир")?.changed).toBe(false);
|
||||
|
||||
// The differing words are emphasised somewhere on each side.
|
||||
expect(changed(old).length).toBeGreaterThan(0);
|
||||
expect(changed(neu).length).toBeGreaterThan(0);
|
||||
expect(changed(old).join("")).toContain("п"); // from `привет`
|
||||
expect(changed(neu).join("")).toContain("зд"); // from `здравствуй`
|
||||
|
||||
// No changed fragment on either side touches the word `мир`.
|
||||
expect(changed(old).some((t) => t.includes("мир"))).toBe(false);
|
||||
expect(changed(neu).some((t) => t.includes("мир"))).toBe(false);
|
||||
});
|
||||
|
||||
it("marks a whole inserted word changed and leaves the old line common", () => {
|
||||
const { old, new: neu } = computeSuggestionDiff("a c", "a b c");
|
||||
|
||||
expect(join(old)).toBe("a c");
|
||||
expect(join(neu)).toBe("a b c");
|
||||
|
||||
// Old line has no changed fragment (nothing was removed).
|
||||
expect(changed(old)).toEqual([]);
|
||||
// The inserted word is the only changed fragment on the new side.
|
||||
expect(neu).toContainEqual({ text: "b ", changed: true });
|
||||
expect(changed(neu)).toEqual(["b "]);
|
||||
});
|
||||
|
||||
it("marks a whole deleted word changed and leaves the new line common", () => {
|
||||
const { old, new: neu } = computeSuggestionDiff("a b c", "a c");
|
||||
|
||||
expect(join(old)).toBe("a b c");
|
||||
expect(join(neu)).toBe("a c");
|
||||
|
||||
// The deleted word is the only changed fragment on the old side.
|
||||
expect(old).toContainEqual({ text: "b ", changed: true });
|
||||
expect(changed(old)).toEqual(["b "]);
|
||||
// New line has no changed fragment (nothing was added).
|
||||
expect(changed(neu)).toEqual([]);
|
||||
});
|
||||
|
||||
it("marks everything common for identical strings", () => {
|
||||
const { old, new: neu } = computeSuggestionDiff("hello", "hello");
|
||||
|
||||
expect(old).toEqual([{ text: "hello", changed: false }]);
|
||||
expect(neu).toEqual([{ text: "hello", changed: false }]);
|
||||
expect(changed(old)).toEqual([]);
|
||||
expect(changed(neu)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { diffWordsWithSpace, diffChars } from "diff";
|
||||
import { IComment } from "@/features/comment/types/comment.types";
|
||||
|
||||
// Whether the suggested-edit (#315) "Apply" button should be shown for a
|
||||
@@ -12,3 +13,127 @@ export function canShowApply(comment: IComment, canEdit?: boolean): boolean {
|
||||
!comment.parentCommentId,
|
||||
);
|
||||
}
|
||||
|
||||
// One contiguous run of text within a suggestion's "before" or "after" line.
|
||||
// `changed` marks the fragment that actually differs from the other side, so
|
||||
// the UI can emphasise only the intraline delta (git/GitHub-style) instead of
|
||||
// the whole line.
|
||||
export interface Segment {
|
||||
text: string;
|
||||
changed: boolean;
|
||||
}
|
||||
|
||||
// A pure "before -> after" intraline diff (#331): the old line split into
|
||||
// common vs. removed-and-changed fragments, and the new line split into common
|
||||
// vs. added-and-changed fragments. Concatenating each side's `text` reproduces
|
||||
// the original strings.
|
||||
export interface SuggestionDiff {
|
||||
old: Segment[];
|
||||
new: Segment[];
|
||||
}
|
||||
|
||||
// Push a segment, coalescing runs of the same `changed` flag on the same side
|
||||
// so the render emits as few spans as possible and tests stay predictable.
|
||||
function pushSegment(segments: Segment[], text: string, changed: boolean): void {
|
||||
if (text === "") return;
|
||||
const last = segments[segments.length - 1];
|
||||
if (last && last.changed === changed) {
|
||||
last.text += text;
|
||||
} else {
|
||||
segments.push({ text, changed });
|
||||
}
|
||||
}
|
||||
|
||||
// Compute an intraline diff between the old `selection` and the new
|
||||
// `suggestedText` of a suggestion. PURE — no React, no DOM, no I/O.
|
||||
//
|
||||
// Hybrid word + char algorithm (per #331):
|
||||
// 1. `diffWordsWithSpace` yields word-granular parts [{value, added, removed}].
|
||||
// 2. An ADJACENT removed+added pair (a word replacement) is refined with
|
||||
// `diffChars`: shared characters stay common, differing characters are
|
||||
// marked `changed` on their respective side. This is what keeps a
|
||||
// one-letter edit (заведем -> заведём) from highlighting the whole word.
|
||||
// 3. A lone `added` (insertion) or lone `removed` (deletion) marks the whole
|
||||
// fragment `changed`.
|
||||
// 4. An unchanged part is `common` on both sides.
|
||||
//
|
||||
// Rejected alternatives: pure `diffChars` is noisy on word swaps; pure
|
||||
// `diffWordsWithSpace` highlights the whole word rather than the changed letter.
|
||||
export function computeSuggestionDiff(
|
||||
oldStr: string,
|
||||
newStr: string,
|
||||
): SuggestionDiff {
|
||||
const oldSegments: Segment[] = [];
|
||||
const newSegments: Segment[] = [];
|
||||
|
||||
const parts = diffWordsWithSpace(oldStr, newStr);
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i];
|
||||
const next = parts[i + 1];
|
||||
|
||||
// A word replacement: a removed part immediately followed by an added part
|
||||
// (or the reverse). Refine it character-by-character so only the differing
|
||||
// letters are highlighted while shared letters stay common.
|
||||
const isReplacementPair =
|
||||
next &&
|
||||
((part.removed && next.added) || (part.added && next.removed));
|
||||
|
||||
if (isReplacementPair) {
|
||||
const removedPart = part.removed ? part : next;
|
||||
const addedPart = part.added ? part : next;
|
||||
|
||||
const charParts = diffChars(removedPart.value, addedPart.value);
|
||||
for (const cp of charParts) {
|
||||
if (cp.added) {
|
||||
pushSegment(newSegments, cp.value, true);
|
||||
} else if (cp.removed) {
|
||||
pushSegment(oldSegments, cp.value, true);
|
||||
} else {
|
||||
// Shared character: common on both sides.
|
||||
pushSegment(oldSegments, cp.value, false);
|
||||
pushSegment(newSegments, cp.value, false);
|
||||
}
|
||||
}
|
||||
|
||||
i++; // consume the paired part as well
|
||||
continue;
|
||||
}
|
||||
|
||||
if (part.added) {
|
||||
// Lone insertion: only present in the new line, wholly changed.
|
||||
pushSegment(newSegments, part.value, true);
|
||||
} else if (part.removed) {
|
||||
// Lone deletion: only present in the old line, wholly changed.
|
||||
pushSegment(oldSegments, part.value, true);
|
||||
} else {
|
||||
// Unchanged: common on both sides.
|
||||
pushSegment(oldSegments, part.value, false);
|
||||
pushSegment(newSegments, part.value, false);
|
||||
}
|
||||
}
|
||||
|
||||
return { old: oldSegments, new: newSegments };
|
||||
}
|
||||
|
||||
// Whether the suggested-edit (#329) "Не применять" (Dismiss) button should be
|
||||
// shown. Dismiss does NOT change the page text (so it needs only canComment, not
|
||||
// canEdit), BUT a childless dismiss IRREVERSIBLY hard-deletes the comment, so the
|
||||
// server gates it on comment-owner-OR-space-admin (#338 F5). The button must
|
||||
// mirror that authz or a non-owner non-admin sees a live Dismiss that always
|
||||
// 403s → red error. Hence isOwnerOrAdmin is required IN ADDITION to canComment.
|
||||
// Same not-applied/not-resolved/top-level conditions as Apply.
|
||||
export function canShowDismiss(
|
||||
comment: IComment,
|
||||
canComment?: boolean,
|
||||
isOwnerOrAdmin?: boolean,
|
||||
): boolean {
|
||||
return Boolean(
|
||||
canComment &&
|
||||
isOwnerOrAdmin &&
|
||||
comment.suggestedText &&
|
||||
!comment.suggestionAppliedAt &&
|
||||
!comment.resolvedAt &&
|
||||
!comment.parentCommentId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,9 +11,19 @@ import {
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
import classes from "./mention.module.css";
|
||||
|
||||
export default function MentionView(props: NodeViewProps) {
|
||||
const { node } = props;
|
||||
const { label, entityType, entityId, slugId, anchorId } = node.attrs;
|
||||
interface MentionAttrs {
|
||||
label?: string;
|
||||
entityType?: string;
|
||||
entityId?: string;
|
||||
slugId?: string;
|
||||
anchorId?: string;
|
||||
}
|
||||
|
||||
// Presentational mention renderer (no NodeViewWrapper). Shared by the editor
|
||||
// NodeView (MentionView) and the static comment renderer (CommentContentView)
|
||||
// so mention click/nav/icon behavior stays identical outside of an editor.
|
||||
export function MentionContent({ attrs }: { attrs: MentionAttrs }) {
|
||||
const { label, entityType, slugId, anchorId } = attrs;
|
||||
const isPageMention = entityType === "page";
|
||||
const { spaceSlug, pageSlug } = useParams();
|
||||
const { shareId } = useParams();
|
||||
@@ -56,7 +66,7 @@ export default function MentionView(props: NodeViewProps) {
|
||||
});
|
||||
|
||||
return (
|
||||
<NodeViewWrapper style={{ display: "inline" }} data-drag-handle>
|
||||
<>
|
||||
{entityType === "user" && (
|
||||
<Text className={classes.userMention} component="span">
|
||||
@{label}
|
||||
@@ -139,6 +149,14 @@ export default function MentionView(props: NodeViewProps) {
|
||||
</span>
|
||||
</Anchor>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MentionView(props: NodeViewProps) {
|
||||
return (
|
||||
<NodeViewWrapper style={{ display: "inline" }} data-drag-handle>
|
||||
<MentionContent attrs={props.node.attrs} />
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -93,6 +93,11 @@ import {
|
||||
isBodyEditable,
|
||||
isCollabSynced,
|
||||
} from "@/features/editor/editor-sync-state";
|
||||
import {
|
||||
isVitalsActive,
|
||||
measurePageOpen,
|
||||
reportEditorTx,
|
||||
} from "@/lib/telemetry/vitals";
|
||||
|
||||
interface PageEditorProps {
|
||||
pageId: string;
|
||||
@@ -351,6 +356,40 @@ export default function PageEditor({
|
||||
editor.storage.pageId = pageId;
|
||||
handleScrollTo(editor);
|
||||
editorRef.current = editor;
|
||||
|
||||
// #355 — perf instrumentation. Skip ALL of it when telemetry is
|
||||
// disabled (F1 flag off) or this session isn't sampled: no page-open
|
||||
// measure, and crucially NO dispatch wrapping, so a non-collecting
|
||||
// session pays zero per-transaction cost.
|
||||
if (isVitalsActive()) {
|
||||
// page_open_ms: this is the first editor-content render, so measure
|
||||
// against any page-open mark set on the tree-row/link click.
|
||||
measurePageOpen();
|
||||
|
||||
// editor_tx_ms: time the SYNCHRONOUS part of applying each
|
||||
// transaction (state.apply + updateState) by wrapping the view's
|
||||
// dispatch. Only slow syncs (>8ms) are reported (see reportEditorTx),
|
||||
// so the common path adds just one performance.now() pair. Passive:
|
||||
// the original dispatch still runs unchanged.
|
||||
try {
|
||||
const view = editor.view as unknown as {
|
||||
dispatch: (tr: unknown) => void;
|
||||
};
|
||||
const originalDispatch = view.dispatch.bind(view);
|
||||
view.dispatch = (tr: unknown) => {
|
||||
const started = performance.now();
|
||||
originalDispatch(tr);
|
||||
const elapsed = performance.now() - started;
|
||||
try {
|
||||
reportEditorTx(elapsed, editor.state.doc.content.size);
|
||||
} catch {
|
||||
// never let telemetry break editing
|
||||
}
|
||||
};
|
||||
} catch {
|
||||
// if the view shape changes, skip editor_tx instrumentation
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onUpdate({ editor }) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback } from "react";
|
||||
import { useAtom, useStore } from "jotai";
|
||||
import { useAtom, useSetAtom, useStore } from "jotai";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from "@/features/page/queries/page-query.ts";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
import { getSpaceUrl } from "@/lib/config.ts";
|
||||
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||
|
||||
export type UseTreeMutation = {
|
||||
handleMove: (sourceId: string, op: DropOp) => Promise<void>;
|
||||
@@ -43,6 +44,7 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
|
||||
const removePageMutation = useRemovePageMutation();
|
||||
const movePageMutation = useMovePageMutation();
|
||||
const navigate = useNavigate();
|
||||
const setMobileSidebar = useSetAtom(mobileSidebarAtom);
|
||||
const { spaceSlug, pageSlug } = useParams();
|
||||
|
||||
const handleMove = useCallback(
|
||||
@@ -201,8 +203,23 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
|
||||
createdPage.title,
|
||||
);
|
||||
navigate(pageUrl);
|
||||
// On mobile the create action is triggered from inside the off-canvas
|
||||
// sidebar drawer (space sidebar "+", tree-row "add subpage"). Navigating
|
||||
// alone leaves that drawer open on top of the freshly created page, so the
|
||||
// editor stays hidden behind the tree. Close it here so the new page opens
|
||||
// in the editor — mirrors the row-click drawer-close in space-tree-row.
|
||||
// No-op on desktop, where the mobile drawer atom is already false.
|
||||
setMobileSidebar(false);
|
||||
},
|
||||
[spaceId, createPageMutation, setData, store, navigate, spaceSlug],
|
||||
[
|
||||
spaceId,
|
||||
createPageMutation,
|
||||
setData,
|
||||
store,
|
||||
navigate,
|
||||
spaceSlug,
|
||||
setMobileSidebar,
|
||||
],
|
||||
);
|
||||
|
||||
const handleRename = useCallback(
|
||||
|
||||
@@ -47,6 +47,13 @@ export function isCompactPageTreeEnabled(): boolean {
|
||||
return castToBoolean(getConfigValue("COMPACT_PAGE_TREE", "true"));
|
||||
}
|
||||
|
||||
// #355 — operator toggle for client perf-telemetry. DEFAULT OFF: the server
|
||||
// mirrors CLIENT_TELEMETRY_ENABLED into window.CONFIG; when off the client
|
||||
// installs no observers and sends nothing (the sink endpoint doesn't exist).
|
||||
export function isClientTelemetryEnabled(): boolean {
|
||||
return castToBoolean(getConfigValue("CLIENT_TELEMETRY_ENABLED", "false"));
|
||||
}
|
||||
|
||||
export function getAvatarUrl(
|
||||
avatarUrl: string,
|
||||
type: AvatarIconType = AvatarIconType.AVATAR,
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { templateRoute } from "./route-template";
|
||||
|
||||
describe("templateRoute", () => {
|
||||
it("templates a space page path (never leaks slugs)", () => {
|
||||
const t = templateRoute("/s/engineering/p/design-doc-abc123");
|
||||
expect(t).toBe("/s/:space/p/:slug");
|
||||
expect(t).not.toContain("engineering");
|
||||
expect(t).not.toContain("design-doc");
|
||||
});
|
||||
|
||||
it("templates share, redirect and space paths", () => {
|
||||
expect(templateRoute("/share/abc/p/xyz")).toBe("/share/:shareId/p/:slug");
|
||||
expect(templateRoute("/share/p/xyz")).toBe("/share/p/:slug");
|
||||
expect(templateRoute("/p/some-slug")).toBe("/p/:slug");
|
||||
expect(templateRoute("/s/team")).toBe("/s/:space");
|
||||
expect(templateRoute("/s/team/trash")).toBe("/s/:space/trash");
|
||||
expect(templateRoute("/labels/urgent")).toBe("/labels/:label");
|
||||
});
|
||||
|
||||
it("keeps known static routes verbatim", () => {
|
||||
expect(templateRoute("/home")).toBe("/home");
|
||||
expect(templateRoute("/settings/members")).toBe("/settings/members");
|
||||
expect(templateRoute("/")).toBe("/");
|
||||
});
|
||||
|
||||
it("normalises a trailing slash", () => {
|
||||
expect(templateRoute("/s/team/p/slug/")).toBe("/s/:space/p/:slug");
|
||||
});
|
||||
|
||||
it("collapses unknown paths to 'other' (bounded cardinality)", () => {
|
||||
expect(templateRoute("/weird/unknown/thing")).toBe("other");
|
||||
expect(templateRoute("/s/team/p/slug/extra/segments")).toBe("other");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Map a raw pathname to a BOUNDED route TEMPLATE (#355).
|
||||
*
|
||||
* Perf metrics must be labelled by route template only — never a raw path with
|
||||
* slugs/ids — so the server-side `route` column and any downstream aggregation
|
||||
* stay low-cardinality and carry NO page slugs/titles (privacy). Anything that
|
||||
* does not match a known pattern collapses to `other`.
|
||||
*
|
||||
* The template vocabulary mirrors the issue's example (`/s/:space/p/:slug`).
|
||||
*/
|
||||
const ROUTE_PATTERNS: { re: RegExp; template: string }[] = [
|
||||
// Share pages (public).
|
||||
{ re: /^\/share\/[^/]+\/p\/[^/]+$/, template: '/share/:shareId/p/:slug' },
|
||||
{ re: /^\/share\/p\/[^/]+$/, template: '/share/p/:slug' },
|
||||
{ re: /^\/share\/[^/]+$/, template: '/share/:shareId' },
|
||||
// Page redirect.
|
||||
{ re: /^\/p\/[^/]+$/, template: '/p/:slug' },
|
||||
// Space + page.
|
||||
{ re: /^\/s\/[^/]+\/p\/[^/]+$/, template: '/s/:space/p/:slug' },
|
||||
{ re: /^\/s\/[^/]+\/trash$/, template: '/s/:space/trash' },
|
||||
{ re: /^\/s\/[^/]+$/, template: '/s/:space' },
|
||||
// Misc dynamic.
|
||||
{ re: /^\/labels\/[^/]+$/, template: '/labels/:label' },
|
||||
{ re: /^\/invites\/[^/]+$/, template: '/invites/:invitationId' },
|
||||
{ re: /^\/settings\/groups\/[^/]+$/, template: '/settings/groups/:groupId' },
|
||||
];
|
||||
|
||||
// Static routes we accept verbatim (finite set).
|
||||
const STATIC_ROUTES = new Set<string>([
|
||||
'/home',
|
||||
'/spaces',
|
||||
'/favorites',
|
||||
'/login',
|
||||
'/forgot-password',
|
||||
'/password-reset',
|
||||
'/setup/register',
|
||||
'/settings/account/profile',
|
||||
'/settings/account/preferences',
|
||||
'/settings/workspace',
|
||||
'/settings/ai',
|
||||
'/settings/members',
|
||||
'/settings/groups',
|
||||
'/settings/spaces',
|
||||
'/settings/sharing',
|
||||
]);
|
||||
|
||||
export function templateRoute(pathname: string): string {
|
||||
// Normalise a trailing slash (except root).
|
||||
const path =
|
||||
pathname.length > 1 && pathname.endsWith('/')
|
||||
? pathname.slice(0, -1)
|
||||
: pathname;
|
||||
|
||||
if (path === '' || path === '/') return '/';
|
||||
if (STATIC_ROUTES.has(path)) return path;
|
||||
|
||||
for (const { re, template } of ROUTE_PATTERNS) {
|
||||
if (re.test(path)) return template;
|
||||
}
|
||||
return 'other';
|
||||
}
|
||||
|
||||
/** Template for the current window location. */
|
||||
export function currentRouteTemplate(): string {
|
||||
try {
|
||||
return templateRoute(window.location.pathname);
|
||||
} catch {
|
||||
return 'other';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import {
|
||||
onCLS,
|
||||
onINP,
|
||||
onLCP,
|
||||
onTTFB,
|
||||
type CLSMetricWithAttribution,
|
||||
type INPMetricWithAttribution,
|
||||
type LCPMetricWithAttribution,
|
||||
type TTFBMetricWithAttribution,
|
||||
} from "web-vitals/attribution";
|
||||
import { isClientTelemetryEnabled } from "@/lib/config";
|
||||
import { currentRouteTemplate } from "./route-template";
|
||||
|
||||
/**
|
||||
* Client perf-telemetry (#355): web-vitals + custom metrics buffered and posted
|
||||
* to POST /api/telemetry/vitals via sendBeacon.
|
||||
*
|
||||
* Design constraints from the issue:
|
||||
* - Sampling is decided ONCE per session (25%), cached in sessionStorage,
|
||||
* BEFORE any observer is subscribed. Non-sampled sessions send nothing.
|
||||
* - Route labels are TEMPLATES only; attr is truncated to 120 chars; no page
|
||||
* titles/slugs/text ever leave the browser.
|
||||
* - Observers are passive and reporting is best-effort — telemetry must not
|
||||
* degrade the perf it measures.
|
||||
*/
|
||||
|
||||
const ENDPOINT = "/api/telemetry/vitals";
|
||||
const SAMPLE_RATE = 0.25;
|
||||
const SAMPLE_KEY = "gm_vitals_sampled";
|
||||
const FLUSH_INTERVAL_MS = 15_000;
|
||||
const MAX_BUFFER = 40; // flush early if the buffer fills between timers
|
||||
const MAX_ATTR_LENGTH = 120;
|
||||
const EDITOR_TX_MIN_MS = 8; // only report editor transactions slower than this
|
||||
|
||||
const ALLOWED_NAMES = new Set([
|
||||
"INP",
|
||||
"LCP",
|
||||
"CLS",
|
||||
"TTFB",
|
||||
"editor_tx_ms",
|
||||
"page_open_ms",
|
||||
"longtask_ms",
|
||||
]);
|
||||
|
||||
interface VitalEvent {
|
||||
name: string;
|
||||
value: number;
|
||||
rating?: string;
|
||||
route?: string;
|
||||
attr?: string;
|
||||
docSize?: number;
|
||||
}
|
||||
|
||||
let sampledCache: boolean | null = null;
|
||||
let initialised = false;
|
||||
let buffer: VitalEvent[] = [];
|
||||
let longtaskSum = 0; // accumulated longtask duration (ms) for the current window
|
||||
|
||||
/**
|
||||
* Decide once per session whether this session is sampled. Cached in
|
||||
* sessionStorage so the choice is stable across reloads within the session and
|
||||
* identical for every observer/custom-metric caller.
|
||||
*/
|
||||
export function isVitalsSampled(): boolean {
|
||||
if (sampledCache !== null) return sampledCache;
|
||||
try {
|
||||
const stored = sessionStorage.getItem(SAMPLE_KEY);
|
||||
if (stored === "1") return (sampledCache = true);
|
||||
if (stored === "0") return (sampledCache = false);
|
||||
const sampled = Math.random() < SAMPLE_RATE;
|
||||
sessionStorage.setItem(SAMPLE_KEY, sampled ? "1" : "0");
|
||||
return (sampledCache = sampled);
|
||||
} catch {
|
||||
// sessionStorage unavailable (private mode / SSR): default to not sampled.
|
||||
return (sampledCache = false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True only when telemetry is BOTH enabled by the operator (F1 flag) AND this
|
||||
* session is sampled. Callers outside initVitals (e.g. the editor dispatch
|
||||
* wrapper) use this to skip ALL instrumentation cost on disabled/non-sampled
|
||||
* sessions — no observers, no per-transaction timing.
|
||||
*/
|
||||
export function isVitalsActive(): boolean {
|
||||
return isClientTelemetryEnabled() && isVitalsSampled();
|
||||
}
|
||||
|
||||
function truncateAttr(value: unknown): string | undefined {
|
||||
if (typeof value !== "string" || value.length === 0) return undefined;
|
||||
return value.slice(0, MAX_ATTR_LENGTH);
|
||||
}
|
||||
|
||||
function enqueue(event: VitalEvent): void {
|
||||
if (!ALLOWED_NAMES.has(event.name)) return;
|
||||
if (!Number.isFinite(event.value)) return;
|
||||
buffer.push(event);
|
||||
if (buffer.length >= MAX_BUFFER) flush();
|
||||
}
|
||||
|
||||
function flush(): void {
|
||||
// Fold any pending longtask total into the batch first.
|
||||
if (longtaskSum > 0) {
|
||||
buffer.push({
|
||||
name: "longtask_ms",
|
||||
value: Math.round(longtaskSum),
|
||||
route: currentRouteTemplate(),
|
||||
});
|
||||
longtaskSum = 0;
|
||||
}
|
||||
if (buffer.length === 0) return;
|
||||
|
||||
const payload = JSON.stringify({ events: buffer });
|
||||
buffer = [];
|
||||
|
||||
try {
|
||||
const blob = new Blob([payload], { type: "application/json" });
|
||||
if (navigator.sendBeacon && navigator.sendBeacon(ENDPOINT, blob)) return;
|
||||
// Fallback for browsers without sendBeacon: keepalive fetch.
|
||||
void fetch(ENDPOINT, {
|
||||
method: "POST",
|
||||
body: payload,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
keepalive: true,
|
||||
}).catch(() => undefined);
|
||||
} catch {
|
||||
// Best-effort: never throw out of telemetry.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Report a custom client metric (editor_tx_ms, page_open_ms). No-op unless the
|
||||
* session is sampled. Route is always the current TEMPLATE.
|
||||
*/
|
||||
export function reportClientMetric(
|
||||
name: "editor_tx_ms" | "page_open_ms",
|
||||
value: number,
|
||||
extra?: { docSize?: number },
|
||||
): void {
|
||||
if (!isVitalsActive()) return;
|
||||
if (!Number.isFinite(value)) return;
|
||||
enqueue({
|
||||
name,
|
||||
value,
|
||||
route: currentRouteTemplate(),
|
||||
docSize: extra?.docSize,
|
||||
});
|
||||
}
|
||||
|
||||
/** Threshold-gated editor transaction reporter (only reports slow syncs). */
|
||||
export function reportEditorTx(ms: number, docSize: number): void {
|
||||
if (ms <= EDITOR_TX_MIN_MS) return;
|
||||
reportClientMetric("editor_tx_ms", ms, { docSize });
|
||||
}
|
||||
|
||||
const PAGE_OPEN_MARK = "gm_page_open_start";
|
||||
|
||||
/** Mark the start of a page-open interaction (tree-row / link click). */
|
||||
export function markPageOpenStart(): void {
|
||||
try {
|
||||
performance.clearMarks(PAGE_OPEN_MARK);
|
||||
performance.mark(PAGE_OPEN_MARK);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure page_open_ms at first editor-content render, if a start mark exists.
|
||||
* Consumes the mark so a later render doesn't double-count.
|
||||
*/
|
||||
export function measurePageOpen(): void {
|
||||
try {
|
||||
const marks = performance.getEntriesByName(PAGE_OPEN_MARK, "mark");
|
||||
if (marks.length === 0) return;
|
||||
const started = marks[0].startTime;
|
||||
const elapsed = performance.now() - started;
|
||||
performance.clearMarks(PAGE_OPEN_MARK);
|
||||
if (elapsed > 0 && Number.isFinite(elapsed)) {
|
||||
reportClientMetric("page_open_ms", elapsed);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function attrTarget(
|
||||
metric:
|
||||
| INPMetricWithAttribution
|
||||
| LCPMetricWithAttribution
|
||||
| CLSMetricWithAttribution,
|
||||
): string | undefined {
|
||||
const a = metric.attribution as Record<string, unknown> | undefined;
|
||||
if (!a) return undefined;
|
||||
// Different vitals expose their culprit element under different keys; only a
|
||||
// CSS-selector-ish target string is taken (no text content / titles).
|
||||
return (
|
||||
truncateAttr(a.interactionTarget) ??
|
||||
truncateAttr(a.element) ??
|
||||
truncateAttr(a.largestShiftTarget) ??
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialise client telemetry. Safe to call multiple times (idempotent). Returns
|
||||
* immediately without subscribing when the session is not sampled — so a
|
||||
* non-sampled session subscribes to NO observers and sends nothing.
|
||||
*/
|
||||
export function initVitals(): void {
|
||||
if (initialised) return;
|
||||
initialised = true;
|
||||
|
||||
// Operator flag gate (F1, default OFF): when telemetry is disabled the sink
|
||||
// endpoint does not even exist server-side, so install ZERO observers.
|
||||
if (!isClientTelemetryEnabled()) return;
|
||||
|
||||
// Sampling gate is evaluated BEFORE any observer subscription.
|
||||
if (!isVitalsSampled()) return;
|
||||
|
||||
const report = (
|
||||
metric:
|
||||
| INPMetricWithAttribution
|
||||
| LCPMetricWithAttribution
|
||||
| CLSMetricWithAttribution
|
||||
| TTFBMetricWithAttribution,
|
||||
) => {
|
||||
enqueue({
|
||||
name: metric.name,
|
||||
value: metric.value,
|
||||
rating: metric.rating,
|
||||
route: currentRouteTemplate(),
|
||||
attr:
|
||||
metric.name === "TTFB"
|
||||
? undefined
|
||||
: attrTarget(
|
||||
metric as
|
||||
| INPMetricWithAttribution
|
||||
| LCPMetricWithAttribution
|
||||
| CLSMetricWithAttribution,
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
onINP(report);
|
||||
onLCP(report);
|
||||
onCLS(report);
|
||||
onTTFB(report);
|
||||
|
||||
// Long tasks: aggregate the total blocking time per flush window (a passive
|
||||
// observer; individual entries are summed, never stored/sent individually).
|
||||
try {
|
||||
if (typeof PerformanceObserver !== "undefined") {
|
||||
const observer = new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
longtaskSum += entry.duration;
|
||||
}
|
||||
});
|
||||
observer.observe({ type: "longtask", buffered: true });
|
||||
}
|
||||
} catch {
|
||||
// longtask entry type unsupported: skip silently.
|
||||
}
|
||||
|
||||
// page_open_ms start: mark when the user clicks a page link/tree-row (any
|
||||
// anchor navigating to a page URL). Passive capture listener; the matching
|
||||
// measure fires at first editor-content render (measurePageOpen). No page
|
||||
// titles/slugs are read — only the click timing is marked.
|
||||
document.addEventListener(
|
||||
"click",
|
||||
(event) => {
|
||||
const target = event.target as Element | null;
|
||||
const anchor = target?.closest?.("a[href]") as HTMLAnchorElement | null;
|
||||
if (!anchor) return;
|
||||
const href = anchor.getAttribute("href") ?? "";
|
||||
// A page link is `/s/:space/p/:slug`, `/p/:slug` or a share page path.
|
||||
if (/\/p\//.test(href)) markPageOpenStart();
|
||||
},
|
||||
{ capture: true, passive: true },
|
||||
);
|
||||
|
||||
// Flush on tab hide (most reliable delivery point) and periodically.
|
||||
const onHidden = () => {
|
||||
if (document.visibilityState === "hidden") flush();
|
||||
};
|
||||
document.addEventListener("visibilitychange", onHidden);
|
||||
window.addEventListener("pagehide", flush);
|
||||
|
||||
setInterval(flush, FLUSH_INTERVAL_MS);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
isPostHogEnabled,
|
||||
} from "@/lib/config.ts";
|
||||
import posthog from "posthog-js";
|
||||
import { initVitals } from "@/lib/telemetry/vitals";
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -43,6 +44,10 @@ if (isCloud() && isPostHogEnabled) {
|
||||
});
|
||||
}
|
||||
|
||||
// #355 — client perf-telemetry. Decides sampling ONCE (25%/session) before
|
||||
// subscribing to any observer; non-sampled sessions send nothing.
|
||||
initVitals();
|
||||
|
||||
const container = document.getElementById("root") as HTMLElement;
|
||||
const root = (container as any).__reactRoot ??= ReactDOM.createRoot(container);
|
||||
|
||||
|
||||
@@ -13,5 +13,22 @@ export default defineConfig({
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: ['./vitest.setup.ts'],
|
||||
// Coverage gate (issue #324). v8 provider (not istanbul) so ESM barrels
|
||||
// like `@docmost/editor-ext` are not re-parsed/instrumented. Thresholds are
|
||||
// set a few points below the level measured on develop, scoped to the files
|
||||
// the suite exercises (`all: false`) rather than the whole app, so the gate
|
||||
// passes today but fails on a genuine coverage regression.
|
||||
coverage: {
|
||||
enabled: true,
|
||||
provider: 'v8',
|
||||
reporter: ['text-summary', 'text'],
|
||||
all: false,
|
||||
thresholds: {
|
||||
statements: 55,
|
||||
branches: 53,
|
||||
functions: 44,
|
||||
lines: 55,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -111,6 +111,7 @@
|
||||
"pino-pretty": "^13.1.3",
|
||||
"postgres": "^3.4.8",
|
||||
"postmark": "^4.0.7",
|
||||
"prom-client": "^15.1.3",
|
||||
"react": "^18.3.1",
|
||||
"react-email": "6.0.8",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
|
||||
@@ -31,6 +31,8 @@ import { McpModule } from './integrations/mcp/mcp.module';
|
||||
import { SandboxModule } from './integrations/sandbox/sandbox.module';
|
||||
import { AiModule } from './integrations/ai/ai.module';
|
||||
import { AiChatModule } from './core/ai-chat/ai-chat.module';
|
||||
import { MetricsModule } from './integrations/metrics/metrics.module';
|
||||
import { ClientTelemetryModule } from './core/telemetry/client-telemetry.module';
|
||||
|
||||
const enterpriseModules = [];
|
||||
try {
|
||||
@@ -93,6 +95,10 @@ try {
|
||||
SandboxModule,
|
||||
AiModule,
|
||||
AiChatModule,
|
||||
MetricsModule,
|
||||
// Gated OFF by default: only registers the public vitals sink controller
|
||||
// when CLIENT_TELEMETRY_ENABLED=true (maintainer decision E1=B).
|
||||
ClientTelemetryModule.register(),
|
||||
...enterpriseModules,
|
||||
],
|
||||
controllers: [AppController],
|
||||
|
||||
@@ -130,3 +130,59 @@ describe('CollaborationHandler.applyCommentSuggestion', () => {
|
||||
expect(value).toBe(42);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CollaborationHandler.deleteCommentMark', () => {
|
||||
it('strips the comment mark for the given commentId (ephemeral suggestion #329)', async () => {
|
||||
const doc = buildDocWithComment('Hello world', 'c1');
|
||||
const { hocuspocus, connection } = fakeHocuspocus(doc);
|
||||
const handler = new CollaborationHandler();
|
||||
const handlers = handler.getHandlers(hocuspocus);
|
||||
|
||||
await handlers.deleteCommentMark('doc-1', { commentId: 'c1', user });
|
||||
|
||||
// The mark is gone; the text itself stays (deleting the anchor, not the run).
|
||||
const xmlText = (
|
||||
doc.getXmlFragment('default').get(0) as Y.XmlElement
|
||||
).get(0) as Y.XmlText;
|
||||
expect(xmlText.toDelta()).toEqual([{ insert: 'Hello world' }]);
|
||||
expect(connection.transact).toHaveBeenCalledTimes(1);
|
||||
expect(connection.disconnect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('routes the removal through removeYjsMarkByAttribute with the right args', async () => {
|
||||
const doc = buildDocWithComment('abc', 'c9');
|
||||
const { hocuspocus } = fakeHocuspocus(doc);
|
||||
const spy = jest.spyOn(yjsUtil, 'removeYjsMarkByAttribute');
|
||||
const handler = new CollaborationHandler();
|
||||
const handlers = handler.getHandlers(hocuspocus);
|
||||
|
||||
await handlers.deleteCommentMark('doc-1', { commentId: 'c9', user });
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
doc.getXmlFragment('default'),
|
||||
'comment',
|
||||
'commentId',
|
||||
'c9',
|
||||
);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('leaves a different comment\'s mark intact', async () => {
|
||||
const doc = buildDocWithComment('keep me', 'other');
|
||||
const { hocuspocus } = fakeHocuspocus(doc);
|
||||
const handler = new CollaborationHandler();
|
||||
const handlers = handler.getHandlers(hocuspocus);
|
||||
|
||||
await handlers.deleteCommentMark('doc-1', { commentId: 'c1', user });
|
||||
|
||||
const xmlText = (
|
||||
doc.getXmlFragment('default').get(0) as Y.XmlElement
|
||||
).get(0) as Y.XmlText;
|
||||
expect(xmlText.toDelta()).toEqual([
|
||||
{
|
||||
insert: 'keep me',
|
||||
attributes: { comment: { commentId: 'other', resolved: false } },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
tiptapExtensions,
|
||||
} from './collaboration.util';
|
||||
import {
|
||||
removeYjsMarkByAttribute,
|
||||
replaceYjsMarkedText,
|
||||
setYjsMark,
|
||||
updateYjsMarkAttribute,
|
||||
@@ -78,6 +79,40 @@ export class CollaborationHandler {
|
||||
},
|
||||
);
|
||||
},
|
||||
deleteCommentMark: async (
|
||||
documentName: string,
|
||||
payload: {
|
||||
commentId: string;
|
||||
user: User;
|
||||
},
|
||||
) => {
|
||||
const { commentId, user } = payload;
|
||||
// Ephemeral suggestions (#329): when a suggestion-edit is dismissed or an
|
||||
// applied one has no replies, the comment is hard-deleted and its inline
|
||||
// anchor must vanish too. Mirror resolveCommentMark exactly, but instead
|
||||
// of flipping the mark's `resolved` attribute we STRIP the `comment` mark
|
||||
// entirely via removeYjsMarkByAttribute so no orphan highlight remains in
|
||||
// the collaborative document.
|
||||
//
|
||||
// Routing this through collaboration.gateway's handleYjsEvent means the
|
||||
// COLLAB_DISABLE_REDIS path invokes this handler directly (never a silent
|
||||
// no-op) and a missing live instance is a hard error — the same guarantee
|
||||
// applyCommentSuggestion/resolveCommentMark rely on.
|
||||
await this.withYdocConnection(
|
||||
hocuspocus,
|
||||
documentName,
|
||||
{ user },
|
||||
(doc) => {
|
||||
const fragment = doc.getXmlFragment('default');
|
||||
removeYjsMarkByAttribute(
|
||||
fragment,
|
||||
'comment',
|
||||
'commentId',
|
||||
commentId,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
applyCommentSuggestion: async (
|
||||
documentName: string,
|
||||
payload: {
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
HISTORY_INTERVAL,
|
||||
} from '../constants';
|
||||
import { TransclusionService } from '../../core/page/transclusion/transclusion.service';
|
||||
import { observeCollabStore } from '../../integrations/metrics/metrics.registry';
|
||||
|
||||
/**
|
||||
* #251 — wire format of the client→server stateless message that signals a
|
||||
@@ -192,6 +193,17 @@ export class PersistenceExtension implements Extension {
|
||||
}
|
||||
|
||||
async onStoreDocument(data: onStoreDocumentPayload) {
|
||||
// #355 — time the full store (persist + post-store side effects) into
|
||||
// collab_store_duration_seconds. No-op when METRICS_PORT is unset.
|
||||
const startedAt = performance.now();
|
||||
try {
|
||||
await this.storeDocument(data);
|
||||
} finally {
|
||||
observeCollabStore((performance.now() - startedAt) / 1000);
|
||||
}
|
||||
}
|
||||
|
||||
private async storeDocument(data: onStoreDocumentPayload) {
|
||||
const { documentName, document, context } = data;
|
||||
|
||||
const pageId = getPageId(documentName);
|
||||
|
||||
@@ -52,6 +52,7 @@ export const AuditEvent = {
|
||||
COMMENT_RESOLVED: 'comment.resolved',
|
||||
COMMENT_REOPENED: 'comment.reopened',
|
||||
COMMENT_SUGGESTION_APPLIED: 'comment.suggestion_applied',
|
||||
COMMENT_SUGGESTION_DISMISSED: 'comment.suggestion_dismissed',
|
||||
|
||||
// Page
|
||||
PAGE_CREATED: 'page.created',
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { buildSystemPrompt, buildMcpToolingBlock } from './ai-chat.prompt';
|
||||
import {
|
||||
buildSystemPrompt,
|
||||
buildMcpToolingBlock,
|
||||
buildToolCatalogBlock,
|
||||
} from './ai-chat.prompt';
|
||||
import { Workspace } from '@docmost/db/types/entity.types';
|
||||
|
||||
/**
|
||||
@@ -396,3 +400,62 @@ describe('buildSystemPrompt page-changed note (#274)', () => {
|
||||
expect(opens).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* #332 deferred tool loading — the <tool_catalog> block builder and its
|
||||
* gating inside buildSystemPrompt.
|
||||
*/
|
||||
describe('buildToolCatalogBlock (#332)', () => {
|
||||
const catalog = [
|
||||
{ name: 'createPage', catalogLine: 'createPage — create a new page.' },
|
||||
{ name: 'transformPage', catalogLine: 'transformPage — run a JS transform.' },
|
||||
];
|
||||
|
||||
it('renders nothing when the feature is disabled', () => {
|
||||
expect(buildToolCatalogBlock(catalog, false)).toBe('');
|
||||
});
|
||||
|
||||
it('renders nothing when the catalog is empty', () => {
|
||||
expect(buildToolCatalogBlock([], true)).toBe('');
|
||||
expect(buildToolCatalogBlock(undefined, true)).toBe('');
|
||||
});
|
||||
|
||||
it('renders the verbatim header + each deferred catalogLine when enabled', () => {
|
||||
const block = buildToolCatalogBlock(catalog, true);
|
||||
expect(block).toContain('<tool_catalog note="deferred tools;');
|
||||
expect(block).toContain('NEVER tell the user you lack a capability');
|
||||
expect(block).toContain('Deferred tools (name — purpose):');
|
||||
expect(block).toContain('- createPage — create a new page.');
|
||||
expect(block).toContain('- transformPage — run a JS transform.');
|
||||
expect(block).toContain('</tool_catalog>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSystemPrompt <tool_catalog> gating (#332)', () => {
|
||||
const workspace = { name: 'Acme' } as unknown as Workspace;
|
||||
const catalog = [
|
||||
{ name: 'createPage', catalogLine: 'createPage — create a new page.' },
|
||||
];
|
||||
|
||||
it('omits the catalog when the toggle is off (unchanged behavior)', () => {
|
||||
const prompt = buildSystemPrompt({
|
||||
workspace,
|
||||
deferredToolsEnabled: false,
|
||||
toolCatalog: catalog,
|
||||
});
|
||||
expect(prompt).not.toContain('<tool_catalog');
|
||||
expect(prompt).not.toContain('createPage — create a new page.');
|
||||
});
|
||||
|
||||
it('includes the catalog (deferred lines only) when enabled', () => {
|
||||
const prompt = buildSystemPrompt({
|
||||
workspace,
|
||||
deferredToolsEnabled: true,
|
||||
toolCatalog: catalog,
|
||||
});
|
||||
expect(prompt).toContain('<tool_catalog');
|
||||
expect(prompt).toContain('createPage — create a new page.');
|
||||
// A core tool line is never in the catalog (the caller passes deferred only).
|
||||
expect(prompt).not.toContain('searchPages —');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Workspace } from '@docmost/db/types/entity.types';
|
||||
import type { McpServerInstruction } from './external-mcp/mcp-clients.service';
|
||||
import type { ToolCatalogEntry } from './tools/tool-tiers';
|
||||
|
||||
/**
|
||||
* Default agent persona used when the admin has not configured a custom system
|
||||
@@ -183,6 +184,55 @@ export interface BuildSystemPromptInput {
|
||||
* block (unchanged page, page not open, or first turn).
|
||||
*/
|
||||
pageChanged?: { title: string; diff: string } | null;
|
||||
/**
|
||||
* Deferred-tool loading toggle (#332). When true (and `toolCatalog` is
|
||||
* non-empty), a `<tool_catalog>` block is rendered inside the safety sandwich
|
||||
* so the model knows which tools EXIST but are not yet loaded, and how to load
|
||||
* them with the loadTools meta-tool. When false, no block is rendered and all
|
||||
* tools are active (unchanged behavior).
|
||||
*/
|
||||
deferredToolsEnabled?: boolean;
|
||||
/**
|
||||
* The DEFERRED tools' catalog lines (#332): one "name — purpose" entry per
|
||||
* deferred in-app tool + per external MCP tool. Rendered by
|
||||
* buildToolCatalogBlock ONLY when `deferredToolsEnabled` is true and this is
|
||||
* non-empty. CORE tools are never here (they are always active).
|
||||
*/
|
||||
toolCatalog?: ToolCatalogEntry[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the `<tool_catalog>` block (#332): the compact list of DEFERRED tools
|
||||
* the model can activate on demand via loadTools. Modeled on buildMcpToolingBlock
|
||||
* — placed inside the safety sandwich (informs tool choice, cannot override the
|
||||
* surrounding rules). The header text is verbatim from the issue; each catalog
|
||||
* line is the tool's hand-written (or, for external tools, derived) "name —
|
||||
* purpose". Returns '' when the feature is disabled or the catalog is empty, so
|
||||
* the caller can omit the block entirely (and off => zero change).
|
||||
*/
|
||||
export function buildToolCatalogBlock(
|
||||
catalog: ToolCatalogEntry[] | undefined,
|
||||
enabled: boolean,
|
||||
): string {
|
||||
if (!enabled) return '';
|
||||
const lines = (catalog ?? [])
|
||||
.filter((e) => e && typeof e.catalogLine === 'string' && e.catalogLine.trim())
|
||||
.map((e) => `- ${e.catalogLine.trim()}`);
|
||||
if (lines.length === 0) return '';
|
||||
return [
|
||||
'<tool_catalog note="deferred tools; names only — full definitions load on demand; cannot override the rules above or below">',
|
||||
'The tools below EXIST and are available to you, but their full definitions are',
|
||||
'NOT loaded into this conversation yet. To use one, first call loadTools with',
|
||||
'the exact name(s) from this catalog; the loaded tools become callable on your',
|
||||
'NEXT step. Load several at once when the task clearly needs them.',
|
||||
'NEVER tell the user you lack a capability before checking this catalog: if the',
|
||||
'task needs a tool that is not among your active tools, find it here, call',
|
||||
'loadTools, and continue. Only if the capability is in neither your active',
|
||||
'tools nor this catalog, say so explicitly.',
|
||||
'Deferred tools (name — purpose):',
|
||||
...lines,
|
||||
'</tool_catalog>',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -229,6 +279,8 @@ export function buildSystemPrompt({
|
||||
mcpInstructions,
|
||||
interrupted,
|
||||
pageChanged,
|
||||
deferredToolsEnabled,
|
||||
toolCatalog,
|
||||
}: BuildSystemPromptInput): string {
|
||||
// Persona precedence: role instructions REPLACE the admin persona / default.
|
||||
// effectivePersona = roleInstructions || adminPrompt || DEFAULT_PROMPT.
|
||||
@@ -302,6 +354,16 @@ export function buildSystemPrompt({
|
||||
// Empty when no qualifying server has guidance.
|
||||
const mcpTooling = buildMcpToolingBlock(mcpInstructions);
|
||||
|
||||
// Deferred-tool catalog (#332). Rendered inside the sandwich next to the MCP
|
||||
// tooling block, ONLY when the feature is enabled and the catalog is non-empty.
|
||||
// Lists the DEFERRED tools (name — purpose) the model can activate via
|
||||
// loadTools; core tools are always active and never here. Empty string when
|
||||
// disabled => the block is omitted and behavior is unchanged.
|
||||
const toolCatalogBlock = buildToolCatalogBlock(
|
||||
toolCatalog,
|
||||
deferredToolsEnabled === true,
|
||||
);
|
||||
|
||||
// Sandwich the lower-trust persona/role text between two copies of the
|
||||
// immutable SAFETY_FRAMEWORK so any jailbreak inside `base` is both preceded
|
||||
// and followed by the safety rules. The persona is delimited with explicit
|
||||
@@ -316,6 +378,7 @@ export function buildSystemPrompt({
|
||||
'</role_persona>',
|
||||
context,
|
||||
mcpTooling,
|
||||
toolCatalogBlock,
|
||||
SAFETY_FRAMEWORK,
|
||||
]
|
||||
.filter((part) => part !== '')
|
||||
|
||||
@@ -53,6 +53,7 @@ describe('AiChatService.resolveRoleForRequest', () => {
|
||||
aiAgentRoleRepo as never,
|
||||
{} as never, // pageRepo
|
||||
{} as never, // pageAccess
|
||||
{} as never, // environment
|
||||
);
|
||||
return { service, aiChatRepo, aiAgentRoleRepo };
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ describe('AiChatService.onModuleInit (startup sweep)', () => {
|
||||
{} as never, // aiAgentRoleRepo
|
||||
{} as never, // pageRepo
|
||||
{} as never, // pageAccess
|
||||
{} as never, // environment
|
||||
);
|
||||
return { service, aiChatMessageRepo };
|
||||
}
|
||||
|
||||
@@ -217,23 +217,78 @@ describe('rowToUiMessage', () => {
|
||||
* a text-only synthesis answer (toolChoice 'none') with the FINAL_STEP_INSTRUCTION
|
||||
* appended onto — not replacing — the original system prompt.
|
||||
*/
|
||||
// Narrowing helpers for the prepareAgentStep union return type.
|
||||
const asLockdown = (r: ReturnType<typeof prepareAgentStep>) =>
|
||||
r as { toolChoice: 'none'; system: string };
|
||||
const asActive = (r: ReturnType<typeof prepareAgentStep>) =>
|
||||
r as { activeTools: string[] };
|
||||
|
||||
describe('prepareAgentStep', () => {
|
||||
it('returns undefined for the first step', () => {
|
||||
// --- toggle OFF (default): unchanged behavior ---
|
||||
it('returns undefined for the first step (toggle off)', () => {
|
||||
expect(prepareAgentStep(0, 'SYS')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for a non-final step (just before the last)', () => {
|
||||
it('returns undefined for a non-final step (toggle off)', () => {
|
||||
expect(prepareAgentStep(MAX_AGENT_STEPS - 2, 'SYS')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('forces a text-only synthesis on the final allowed step', () => {
|
||||
const result = prepareAgentStep(MAX_AGENT_STEPS - 1, 'SYS');
|
||||
it('forces a text-only synthesis on the final allowed step (toggle off)', () => {
|
||||
const result = asLockdown(prepareAgentStep(MAX_AGENT_STEPS - 1, 'SYS'));
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.toolChoice).toBe('none');
|
||||
expect(result.toolChoice).toBe('none');
|
||||
// The original persona is preserved (prefix), not replaced.
|
||||
expect(result?.system.startsWith('SYS')).toBe(true);
|
||||
expect(result.system.startsWith('SYS')).toBe(true);
|
||||
// The synthesis instruction is appended.
|
||||
expect(result?.system).toContain(FINAL_STEP_INSTRUCTION);
|
||||
expect(result.system).toContain(FINAL_STEP_INSTRUCTION);
|
||||
});
|
||||
|
||||
it('does NOT narrow activeTools when the toggle is off', () => {
|
||||
const result = prepareAgentStep(0, 'SYS', new Set(['createPage']), false);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
// --- toggle ON (#332): deferred tool visibility ---
|
||||
it('a non-final step exposes CORE + loadTools + activatedTools', () => {
|
||||
const activated = new Set<string>();
|
||||
const result = asActive(prepareAgentStep(0, 'SYS', activated, true));
|
||||
expect(result.activeTools).toContain('searchPages'); // core
|
||||
expect(result.activeTools).toContain('searchInPage'); // #330, core
|
||||
expect(result.activeTools).toContain('editPageText'); // core
|
||||
expect(result.activeTools).toContain('loadTools'); // meta-tool
|
||||
// No deferred tool is active before it is loaded.
|
||||
expect(result.activeTools).not.toContain('createPage');
|
||||
expect(result.activeTools).not.toContain('transformPage');
|
||||
});
|
||||
|
||||
it('adding a name to activatedTools makes it appear on the next step', () => {
|
||||
const activated = new Set<string>();
|
||||
// Before loading: createPage is not active.
|
||||
expect(
|
||||
asActive(prepareAgentStep(1, 'SYS', activated, true)).activeTools,
|
||||
).not.toContain('createPage');
|
||||
// loadTools grows the SAME set…
|
||||
activated.add('createPage');
|
||||
// …so the next step sees it.
|
||||
const next = asActive(prepareAgentStep(2, 'SYS', activated, true));
|
||||
expect(next.activeTools).toContain('createPage');
|
||||
expect(next.activeTools).toContain('loadTools');
|
||||
});
|
||||
|
||||
it('accepts an array for activatedTools too', () => {
|
||||
const result = asActive(prepareAgentStep(0, 'SYS', ['transformPage'], true));
|
||||
expect(result.activeTools).toContain('transformPage');
|
||||
expect(result.activeTools).toContain('loadTools');
|
||||
});
|
||||
|
||||
it('final-step lockdown WINS even when the toggle is on', () => {
|
||||
const result = asLockdown(
|
||||
prepareAgentStep(MAX_AGENT_STEPS - 1, 'SYS', new Set(['createPage']), true),
|
||||
);
|
||||
// The lockdown shape (toolChoice none + synthesis) — not the activeTools shape.
|
||||
expect(result.toolChoice).toBe('none');
|
||||
expect(result.system).toContain(FINAL_STEP_INSTRUCTION);
|
||||
expect((result as unknown as { activeTools?: string[] }).activeTools).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -30,7 +30,15 @@ import {
|
||||
} from '@docmost/db/types/entity.types';
|
||||
import { AiChatToolsService } from './tools/ai-chat-tools.service';
|
||||
import { McpClientsService } from './external-mcp/mcp-clients.service';
|
||||
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
||||
import { buildSystemPrompt } from './ai-chat.prompt';
|
||||
import {
|
||||
CORE_TOOL_KEYS,
|
||||
CORE_TOOL_SET,
|
||||
LOAD_TOOLS_NAME,
|
||||
makeLoadToolsTool,
|
||||
buildExternalToolCatalog,
|
||||
} from './tools/tool-tiers';
|
||||
import { computePageChange } from './page-change/page-change.util';
|
||||
import { roleModelOverride } from './roles/role-model-config';
|
||||
import {
|
||||
@@ -54,24 +62,52 @@ const FINAL_STEP_INSTRUCTION =
|
||||
'language. If the information is incomplete, say so explicitly: summarize ' +
|
||||
'what you found, what is still missing, and give your best partial conclusion.';
|
||||
|
||||
// Pure, unit-testable: decide per-step overrides. Returns undefined for normal
|
||||
// steps; on the final allowed step forces a text-only synthesis answer.
|
||||
// Pure, unit-testable: decide per-step overrides. Two responsibilities:
|
||||
// 1. Final-step lockdown (always): on the final allowed step force a text-only
|
||||
// synthesis answer (toolChoice 'none' + FINAL_STEP_INSTRUCTION). This WINS —
|
||||
// it takes precedence over the deferred-tool narrowing below.
|
||||
// 2. Deferred tool visibility (#332): when `deferredEnabled` and NOT the final
|
||||
// step, expose only the CORE tools + loadTools + whatever loadTools has
|
||||
// activated so far this turn (`activatedTools`), via `activeTools`. Deferred
|
||||
// tools stay in the <tool_catalog> until the model loads them.
|
||||
// When `deferredEnabled` is false the behavior is unchanged: undefined on normal
|
||||
// steps (all tools active), lockdown on the final step.
|
||||
//
|
||||
// `system` is the in-scope system prompt; we CONCATENATE so the original
|
||||
// persona/context is preserved — a bare `system` override would REPLACE the
|
||||
// whole system prompt for the step.
|
||||
// whole system prompt for the step. `activatedTools` is PER-TURN mutable state
|
||||
// owned by the streaming loop (a closure Set grown by loadTools); it is passed
|
||||
// in (not module-global, not persisted) so this stays a pure function of its
|
||||
// arguments.
|
||||
//
|
||||
// NOTE: at AI SDK v7 the per-step `system` field is renamed to `instructions`.
|
||||
// On v6 (`^6.0.134`) `system` is the correct field — adjust when bumping.
|
||||
export function prepareAgentStep(
|
||||
stepNumber: number,
|
||||
system: string,
|
||||
): { toolChoice: 'none'; system: string } | undefined {
|
||||
activatedTools: ReadonlySet<string> | readonly string[] = [],
|
||||
deferredEnabled = false,
|
||||
):
|
||||
| { toolChoice: 'none'; system: string }
|
||||
| { activeTools: string[] }
|
||||
| undefined {
|
||||
// Final-step lockdown WINS (applies regardless of the deferred toggle).
|
||||
if (stepNumber >= MAX_AGENT_STEPS - 1) {
|
||||
return {
|
||||
toolChoice: 'none',
|
||||
system: `${system}\n\n${FINAL_STEP_INSTRUCTION}`,
|
||||
};
|
||||
}
|
||||
// Deferred tool loading: narrow this step's visible tools to CORE + loadTools
|
||||
// + the tools already activated this turn.
|
||||
if (deferredEnabled) {
|
||||
const activated = Array.isArray(activatedTools)
|
||||
? activatedTools
|
||||
: [...activatedTools];
|
||||
return {
|
||||
activeTools: [...CORE_TOOL_KEYS, LOAD_TOOLS_NAME, ...activated],
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -206,6 +242,9 @@ export class AiChatService implements OnModuleInit {
|
||||
private readonly aiAgentRoleRepo: AiAgentRoleRepo,
|
||||
private readonly pageRepo: PageRepo,
|
||||
private readonly pageAccess: PageAccessService,
|
||||
// Reads the AI_CHAT_DEFERRED_TOOLS toggle (#332). Injected last so existing
|
||||
// positional constructor callers (tests) only append one stub.
|
||||
private readonly environment: EnvironmentService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -625,9 +664,25 @@ export class AiChatService implements OnModuleInit {
|
||||
// Build the system prompt + Docmost toolset. If either throws after the
|
||||
// external MCP lease was taken above, release the lease before rethrowing so
|
||||
// the leased transports are not leaked (#185 review).
|
||||
// Deferred tool loading toggle (#332). When ON, the model sees a compact
|
||||
// <tool_catalog> and only CORE tools + loadTools are active each step; other
|
||||
// tools (fat/rare in-app tools + ALL external MCP tools) load on demand. When
|
||||
// OFF, every tool is active and nothing below changes.
|
||||
const deferredEnabled = this.environment.isAiChatDeferredToolsEnabled();
|
||||
|
||||
let system: string;
|
||||
let docmostTools: Awaited<ReturnType<AiChatToolsService['forUser']>>;
|
||||
try {
|
||||
// Assemble the deferred catalog for the system prompt: hand-written lines
|
||||
// for the in-app deferred tools + a derived line for each external MCP tool
|
||||
// (also deferred by default). Only built when the feature is enabled.
|
||||
const toolCatalog = deferredEnabled
|
||||
? [
|
||||
...(await this.tools.getInAppDeferredCatalog()),
|
||||
...buildExternalToolCatalog(external.tools),
|
||||
]
|
||||
: [];
|
||||
|
||||
system = buildSystemPrompt({
|
||||
workspace,
|
||||
adminPrompt: resolved?.systemPrompt,
|
||||
@@ -644,6 +699,10 @@ export class AiChatService implements OnModuleInit {
|
||||
// Detected between-turns human edit to the open page (#274): adds the
|
||||
// page_changed note + unified diff so the agent doesn't overwrite it.
|
||||
pageChanged,
|
||||
// Deferred tool loading (#332): renders the <tool_catalog> block (only
|
||||
// when enabled + non-empty) so the model can activate deferred tools.
|
||||
deferredToolsEnabled: deferredEnabled,
|
||||
toolCatalog,
|
||||
});
|
||||
|
||||
// Pass the resolved chatId so the write tools can mint provenance tokens
|
||||
@@ -664,7 +723,31 @@ export class AiChatService implements OnModuleInit {
|
||||
throw err;
|
||||
}
|
||||
|
||||
const tools = { ...external.tools, ...docmostTools };
|
||||
// Base toolset: external MCP tools + Docmost in-app tools (Docmost wins on a
|
||||
// name clash — external are namespaced, so no clash is expected).
|
||||
const baseTools = { ...external.tools, ...docmostTools };
|
||||
|
||||
// Deferred tool loading state (#332), scoped to THIS streaming loop:
|
||||
// - `activatedTools` is per-TURN mutable state — a fresh closure Set created
|
||||
// per streamText call, NOT module-global and NOT persisted, so a new turn
|
||||
// starts cold. loadTools.execute adds to it; prepareAgentStep reads it to
|
||||
// widen `activeTools` on the NEXT step.
|
||||
// - `validDeferredNames` = every tool that is NOT core (the in-app deferred
|
||||
// tools + ALL external MCP tools), computed from the ACTUAL toolset so an
|
||||
// external tool is loadable by its namespaced name. loadTools rejects any
|
||||
// name outside this set.
|
||||
const activatedTools = new Set<string>();
|
||||
const validDeferredNames = new Set<string>(
|
||||
Object.keys(baseTools).filter((k) => !CORE_TOOL_SET.has(k)),
|
||||
);
|
||||
// Add the loadTools meta-tool ONLY when the feature is enabled; when off the
|
||||
// toolset and behavior are exactly as before.
|
||||
const tools = deferredEnabled
|
||||
? {
|
||||
...baseTools,
|
||||
[LOAD_TOOLS_NAME]: makeLoadToolsTool(activatedTools, validDeferredNames),
|
||||
}
|
||||
: baseTools;
|
||||
|
||||
// Accumulate the turn's streamed output so a provider error / disconnect can
|
||||
// persist the PARTIAL answer the user already saw — the SDK's onError/onAbort
|
||||
@@ -799,7 +882,8 @@ export class AiChatService implements OnModuleInit {
|
||||
// ends with no assistant text (an empty turn). prepareAgentStep forbids
|
||||
// further tool calls and appends a synthesis instruction on that step,
|
||||
// concatenated onto the original `system` so the persona is preserved.
|
||||
prepareStep: ({ stepNumber }) => prepareAgentStep(stepNumber, system),
|
||||
prepareStep: ({ stepNumber }) =>
|
||||
prepareAgentStep(stepNumber, system, activatedTools, deferredEnabled),
|
||||
abortSignal: signal,
|
||||
onChunk: ({ chunk }) => {
|
||||
// DIAGNOSTIC (Safari stream-drop investigation) — temporary. Any model
|
||||
|
||||
@@ -17,6 +17,10 @@ import { resolveCurrentPageResult } from './current-page.util';
|
||||
import { parseNodeArg } from './parse-node-arg';
|
||||
import { modelFriendlyInput } from './model-friendly-input';
|
||||
import { SandboxStore } from '../../../integrations/sandbox/sandbox.store';
|
||||
import {
|
||||
buildInAppDeferredCatalog,
|
||||
type ToolCatalogEntry,
|
||||
} from './tool-tiers';
|
||||
|
||||
/**
|
||||
* Per-user, per-request adapter that exposes Docmost READ operations to the
|
||||
@@ -123,6 +127,18 @@ export class AiChatToolsService {
|
||||
return client.exportPageMarkdown(pageId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the IN-APP deferred <tool_catalog> entries (#332): one "name — purpose"
|
||||
* line per DEFERRED tool, merging the per-layer INLINE_TOOL_TIERS with the
|
||||
* shared registry's own catalogLine. Loads @docmost/mcp for the shared specs
|
||||
* (memoized). Core tools are always active and are NOT listed here. External
|
||||
* MCP tools are catalogued separately by the caller (they are runtime-scoped).
|
||||
*/
|
||||
async getInAppDeferredCatalog(): Promise<ToolCatalogEntry[]> {
|
||||
const { sharedToolSpecs } = await loadDocmostMcp();
|
||||
return buildInAppDeferredCatalog(sharedToolSpecs);
|
||||
}
|
||||
|
||||
async forUser(
|
||||
user: User,
|
||||
sessionId: string,
|
||||
@@ -303,7 +319,9 @@ export class AiChatToolsService {
|
||||
getPage: tool({
|
||||
description:
|
||||
'Fetch a single page as Markdown by its page id. Returns the page ' +
|
||||
'title and its Markdown content.',
|
||||
'title and its Markdown content. Inline <span data-comment-id> tags ' +
|
||||
'in the markdown are comment highlight anchors (also present for ' +
|
||||
'RESOLVED threads) — treat them as markup, not page text.',
|
||||
inputSchema: modelFriendlyInput({
|
||||
pageId: z.string().describe('The id (or slugId) of the page.'),
|
||||
}),
|
||||
@@ -628,6 +646,16 @@ export class AiChatToolsService {
|
||||
async ({ pageId, nodeId }) => await client.getNode(pageId, nodeId),
|
||||
),
|
||||
|
||||
searchInPage: sharedTool(
|
||||
sharedToolSpecs.searchInPage,
|
||||
async ({ pageId, query, regex, caseSensitive, limit }) =>
|
||||
await client.searchInPage(pageId, query, {
|
||||
regex,
|
||||
caseSensitive,
|
||||
limit,
|
||||
}),
|
||||
),
|
||||
|
||||
getTable: tool({
|
||||
description:
|
||||
'Read a table as a matrix of cell texts (plus a parallel cellIds ' +
|
||||
@@ -647,11 +675,21 @@ export class AiChatToolsService {
|
||||
|
||||
listComments: tool({
|
||||
description:
|
||||
'List all comments on a page (content as Markdown).',
|
||||
'List comments on a page in one call. By DEFAULT only ACTIVE ' +
|
||||
'threads are returned; resolved threads (a resolved top-level ' +
|
||||
'comment and all its replies) are hidden and their count reported ' +
|
||||
'as `resolvedThreadsHidden` so you can re-query with ' +
|
||||
'`includeResolved: true` to see everything. Returns ' +
|
||||
'`{ items, resolvedThreadsHidden }`. Content is returned as Markdown.',
|
||||
inputSchema: modelFriendlyInput({
|
||||
pageId: z.string().describe('The id of the page.'),
|
||||
includeResolved: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('default only active threads; true — include resolved'),
|
||||
}),
|
||||
execute: async ({ pageId }) => await client.listComments(pageId),
|
||||
execute: async ({ pageId, includeResolved }) =>
|
||||
await client.listComments(pageId, includeResolved),
|
||||
}),
|
||||
|
||||
getComment: tool({
|
||||
|
||||
@@ -55,8 +55,18 @@ export interface DocmostClientLike {
|
||||
getOutline(pageId: string): Promise<Record<string, unknown>>;
|
||||
getPageJson(pageId: string): Promise<Record<string, unknown>>;
|
||||
getNode(pageId: string, nodeId: string): Promise<Record<string, unknown>>;
|
||||
searchInPage(
|
||||
pageId: string,
|
||||
query: string,
|
||||
opts?: { regex?: boolean; caseSensitive?: boolean; limit?: number },
|
||||
): Promise<Record<string, unknown>>;
|
||||
getTable(pageId: string, tableRef: string): Promise<Record<string, unknown>>;
|
||||
listComments(pageId: string): Promise<unknown[]>;
|
||||
// Returns `{ items, resolvedThreadsHidden }`. DEFAULT (includeResolved unset/
|
||||
// false) hides resolved threads wholesale; pass true for the full feed.
|
||||
listComments(
|
||||
pageId: string,
|
||||
includeResolved?: boolean,
|
||||
): Promise<{ items: unknown[]; resolvedThreadsHidden: number }>;
|
||||
getComment(
|
||||
commentId: string,
|
||||
): Promise<{ data: Record<string, unknown>; success: boolean }>;
|
||||
@@ -231,6 +241,11 @@ export interface SharedToolSpec {
|
||||
mcpName: string;
|
||||
inAppKey: string;
|
||||
description: string;
|
||||
// Deferred-tool metadata (#332). Optional in this mirror so an older/stale
|
||||
// @docmost/mcp build (pre-#332) still type-checks; the in-app catalog builder
|
||||
// reads them defensively. The external /mcp server ignores both fields.
|
||||
tier?: 'core' | 'deferred';
|
||||
catalogLine?: string;
|
||||
// Loose `z` on purpose: the registry is zod-agnostic so the server can pass
|
||||
// its own zod (v4) and the MCP package its own (v3) into the same builder.
|
||||
buildShape?: (z: any) => Record<string, unknown>;
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
import {
|
||||
CORE_TOOL_KEYS,
|
||||
CORE_TOOL_SET,
|
||||
LOAD_TOOLS_NAME,
|
||||
LOAD_TOOLS_DESCRIPTION,
|
||||
INLINE_TOOL_TIERS,
|
||||
buildInAppDeferredCatalog,
|
||||
buildExternalToolCatalog,
|
||||
shortenForCatalog,
|
||||
applyLoadTools,
|
||||
} from './tool-tiers';
|
||||
// The real shared registry, imported from source (same approach as the
|
||||
// SHARED_TOOL_SPECS contract spec) so the tier metadata is checked against
|
||||
// exactly what @docmost/mcp ships.
|
||||
import { SHARED_TOOL_SPECS } from '../../../../../../packages/mcp/src/tool-specs';
|
||||
// For the live-toolset partition test (F3): the REAL adapter, so the catalog is
|
||||
// checked against the tools AiChatToolsService.forUser() actually builds — not a
|
||||
// static list that could drift from it.
|
||||
import { AiChatToolsService } from './ai-chat-tools.service';
|
||||
import * as loader from './docmost-client.loader';
|
||||
import type { DocmostClientLike } from './docmost-client.loader';
|
||||
|
||||
/**
|
||||
* #332 deferred tool loading — tier metadata, catalog assembly, and the
|
||||
* loadTools meta-tool. Pure units; no Nest graph, no @docmost/mcp build (the
|
||||
* registry is imported from TS source).
|
||||
*/
|
||||
|
||||
describe('tool tier metadata (#332)', () => {
|
||||
it('core set is the documented 13 + searchInPage (14)', () => {
|
||||
expect(CORE_TOOL_KEYS).toHaveLength(14);
|
||||
expect(CORE_TOOL_SET.has('searchInPage')).toBe(true); // #330, promoted to core
|
||||
// loadTools is a meta-tool, not a normal core key.
|
||||
expect(CORE_TOOL_SET.has(LOAD_TOOLS_NAME)).toBe(false);
|
||||
});
|
||||
|
||||
it('SHARED_TOOL_SPECS tier agrees with CORE_TOOL_SET for every shared tool', () => {
|
||||
for (const [key, spec] of Object.entries(SHARED_TOOL_SPECS)) {
|
||||
const isCoreByTier = spec.tier === 'core';
|
||||
const isCoreByList = CORE_TOOL_SET.has(key);
|
||||
expect(isCoreByTier).toBe(isCoreByList);
|
||||
// Every spec carries a non-empty catalogLine (core tools too).
|
||||
expect(typeof spec.catalogLine).toBe('string');
|
||||
expect(spec.catalogLine.trim().length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('every INLINE tool tier agrees with CORE_TOOL_SET and has a catalogLine', () => {
|
||||
for (const [key, meta] of Object.entries(INLINE_TOOL_TIERS)) {
|
||||
expect(meta.tier === 'core').toBe(CORE_TOOL_SET.has(key));
|
||||
expect(meta.catalogLine.trim().length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildInAppDeferredCatalog (#332)', () => {
|
||||
const catalog = buildInAppDeferredCatalog(SHARED_TOOL_SPECS as never);
|
||||
const names = catalog.map((e) => e.name);
|
||||
|
||||
it('includes deferred tools from BOTH the inline map and the shared registry', () => {
|
||||
expect(names).toContain('transformPage'); // inline deferred
|
||||
expect(names).toContain('getPageJson'); // shared deferred
|
||||
expect(names).toContain('patchNode'); // shared deferred
|
||||
expect(names).toContain('createPage'); // inline deferred
|
||||
});
|
||||
|
||||
it('NEVER lists a core tool', () => {
|
||||
for (const core of CORE_TOOL_KEYS) {
|
||||
expect(names).not.toContain(core);
|
||||
}
|
||||
// spot-check a couple that are core in each source.
|
||||
expect(names).not.toContain('searchInPage'); // shared core
|
||||
expect(names).not.toContain('searchPages'); // inline core
|
||||
expect(names).not.toContain('editPageText'); // shared core
|
||||
});
|
||||
|
||||
it('renders every entry as a "name — purpose" line', () => {
|
||||
// Non-empty catalog (the length is pinned structurally by the live-toolset
|
||||
// partition test below, not by a magic constant that rots on every new tool).
|
||||
expect(catalog.length).toBeGreaterThan(0);
|
||||
for (const entry of catalog) {
|
||||
expect(entry.catalogLine).toMatch(/ — /);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* F3 — the deferred <tool_catalog> is built from STATIC metadata (INLINE_TOOL_TIERS
|
||||
* + SHARED_TOOL_SPECS), but the loadable-by-name set is derived at RUNTIME from the
|
||||
* actual toolset (`Object.keys(baseTools)` in ai-chat.service.ts). Those two must
|
||||
* agree or a tool becomes loadable-but-invisible (agent thinks it doesn't exist) or
|
||||
* catalogued-but-phantom. INLINE_TOOL_TIERS is a plain hand-maintained Record with
|
||||
* no compile-time link to the tools AiChatToolsService.forUser() builds, so nothing
|
||||
* else catches that drift. This test uses forUser()'s LIVE keys as the source of
|
||||
* truth (mirroring ai-chat-tools.service.spec.ts's loader mock) and asserts a
|
||||
* two-way partition against buildInAppDeferredCatalog — replacing the old magic
|
||||
* toHaveLength(28), so a tool added to forUser() without a catalog line (or a
|
||||
* catalog line without a real tool) fails the suite instead of silently vanishing.
|
||||
*/
|
||||
describe('deferred catalog ↔ live forUser() toolset partition (#332, F3)', () => {
|
||||
let toolKeys: string[];
|
||||
const catalogNames = buildInAppDeferredCatalog(SHARED_TOOL_SPECS as never).map(
|
||||
(e) => e.name,
|
||||
);
|
||||
|
||||
beforeAll(async () => {
|
||||
// Intercept the ESM loader so forUser() builds against the TS-source shared
|
||||
// specs (no @docmost/mcp build) and never touches the network.
|
||||
jest.spyOn(loader, 'loadDocmostMcp').mockResolvedValue({
|
||||
DocmostClient: function () {
|
||||
return {} as DocmostClientLike;
|
||||
} as unknown as loader.DocmostClientCtor,
|
||||
sharedToolSpecs: SHARED_TOOL_SPECS as Record<string, loader.SharedToolSpec>,
|
||||
});
|
||||
const service = new AiChatToolsService(
|
||||
{
|
||||
generateAccessToken: jest.fn().mockResolvedValue('access-token'),
|
||||
generateCollabToken: jest.fn().mockResolvedValue('collab-token'),
|
||||
} as never,
|
||||
{} as never, // aiService — not exercised while merely BUILDING the tools
|
||||
{} as never, // pageEmbeddingRepo
|
||||
{} as never, // spaceMemberRepo
|
||||
{} as never, // pagePermissionRepo
|
||||
// sandboxStore: forUser() eagerly calls asSink() to wire the stash tool.
|
||||
{
|
||||
asSink: () => ({ put: jest.fn(), has: jest.fn(), evict: jest.fn() }),
|
||||
} as never,
|
||||
);
|
||||
const tools = await service.forUser(
|
||||
{ id: 'user-1', email: 'u@example.com', workspaceId: 'ws-1' } as never,
|
||||
'session-1',
|
||||
'ws-1',
|
||||
'chat-1',
|
||||
);
|
||||
toolKeys = Object.keys(tools);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('exposes a non-trivial toolset (sanity: the mock actually built tools)', () => {
|
||||
expect(toolKeys.length).toBeGreaterThan(20);
|
||||
});
|
||||
|
||||
it('every non-core live tool is present in the catalog (no capability silently hidden)', () => {
|
||||
// forUser() does not itself add loadTools (ai-chat.service does), but guard
|
||||
// anyway. Every remaining non-core key MUST have a catalog line.
|
||||
const catalogSet = new Set(catalogNames);
|
||||
const missing = toolKeys.filter(
|
||||
(k) => !CORE_TOOL_SET.has(k) && k !== LOAD_TOOLS_NAME && !catalogSet.has(k),
|
||||
);
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it('every catalog entry corresponds to a real, non-core live tool (no phantom)', () => {
|
||||
const liveSet = new Set(toolKeys);
|
||||
const phantom = catalogNames.filter(
|
||||
(n) => !liveSet.has(n) || CORE_TOOL_SET.has(n),
|
||||
);
|
||||
expect(phantom).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildExternalToolCatalog + shortenForCatalog (#332)', () => {
|
||||
it('derives a short "name — purpose" line from each external tool description', () => {
|
||||
const catalog = buildExternalToolCatalog({
|
||||
tavily_search: { description: 'Search the web for fresh results. More detail here.' },
|
||||
tavily_extract: { description: '' },
|
||||
});
|
||||
expect(catalog).toEqual([
|
||||
{ name: 'tavily_search', catalogLine: 'tavily_search — Search the web for fresh results.' },
|
||||
{ name: 'tavily_extract', catalogLine: 'tavily_extract — external tool' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('caps a very long description', () => {
|
||||
const long = 'x'.repeat(500);
|
||||
expect(shortenForCatalog(long).length).toBeLessThanOrEqual(140);
|
||||
expect(shortenForCatalog(long).endsWith('…')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyLoadTools (#332)', () => {
|
||||
const valid = new Set(['createPage', 'transformPage', 'tavily_search']);
|
||||
|
||||
it('adds valid names to the activated set and returns { loaded }', () => {
|
||||
const activated = new Set<string>();
|
||||
const result = applyLoadTools(['createPage', 'tavily_search'], activated, valid);
|
||||
expect(result).toEqual({ loaded: ['createPage', 'tavily_search'] });
|
||||
expect(activated.has('createPage')).toBe(true);
|
||||
expect(activated.has('tavily_search')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects an unknown name with an error listing the valid deferred names', () => {
|
||||
const activated = new Set<string>();
|
||||
expect(() => applyLoadTools(['nope'], activated, valid)).toThrow(/unknown tool name/i);
|
||||
try {
|
||||
applyLoadTools(['nope'], activated, valid);
|
||||
} catch (e) {
|
||||
const msg = (e as Error).message;
|
||||
// Lists every valid name (sorted).
|
||||
expect(msg).toContain('createPage');
|
||||
expect(msg).toContain('transformPage');
|
||||
expect(msg).toContain('tavily_search');
|
||||
}
|
||||
// Nothing is activated on a rejected call.
|
||||
expect(activated.size).toBe(0);
|
||||
});
|
||||
|
||||
it('tolerates a non-array / empty input (loads nothing)', () => {
|
||||
const activated = new Set<string>();
|
||||
expect(applyLoadTools(undefined, activated, valid)).toEqual({ loaded: [] });
|
||||
expect(applyLoadTools([], activated, valid)).toEqual({ loaded: [] });
|
||||
expect(activated.size).toBe(0);
|
||||
});
|
||||
|
||||
it('loadTools description is the verbatim issue text', () => {
|
||||
expect(LOAD_TOOLS_DESCRIPTION).toContain('only ACTIVATES them');
|
||||
expect(LOAD_TOOLS_DESCRIPTION).toContain('callable on your NEXT step');
|
||||
});
|
||||
});
|
||||
|
||||
describe('editorial "Corrector" scenario is fully served by CORE (#332)', () => {
|
||||
it('read + comment + edit + search need no loadTools', () => {
|
||||
// A Corrector role reads a page, searches within it, edits text, and leaves
|
||||
// inline comments — every tool it needs is core, so it never has to load a
|
||||
// deferred tool.
|
||||
const needed = [
|
||||
'getCurrentPage',
|
||||
'getPage',
|
||||
'searchPages',
|
||||
'searchInPage',
|
||||
'editPageText',
|
||||
'createComment',
|
||||
'listComments',
|
||||
'getComment',
|
||||
'resolveComment',
|
||||
];
|
||||
for (const t of needed) {
|
||||
expect(CORE_TOOL_SET.has(t)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,309 @@
|
||||
import { tool, type Tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import type { SharedToolSpec } from './docmost-client.loader';
|
||||
|
||||
/**
|
||||
* Deferred tool loading for the in-app AI chat (#332).
|
||||
*
|
||||
* The agent otherwise sends ALL ~41 tool definitions on EVERY model call every
|
||||
* step, bloating context. Instead we split the in-app tools into two tiers:
|
||||
*
|
||||
* - CORE (hot, always active): frequent OR tiny tools whose full schema is
|
||||
* always visible, plus the `loadTools` meta-tool. Deferring a one-line tool is
|
||||
* pure loss, so tiny tools stay core even if rare.
|
||||
* - DEFERRED (loaded on demand): the fat/rare tools + ALL external MCP tools by
|
||||
* default. The model sees only a compact <tool_catalog> (name — purpose) and
|
||||
* calls `loadTools(names)` to ACTIVATE a tool's full schema for the NEXT step
|
||||
* (one extra round-trip on first use).
|
||||
*
|
||||
* This module is the single source of truth for the IN-APP tiering:
|
||||
* - CORE_TOOL_KEYS / CORE_TOOL_SET — the authoritative core list (used by
|
||||
* prepareAgentStep to build per-step `activeTools`).
|
||||
* - INLINE_TOOL_TIERS — tier + catalogLine for the per-layer INLINE tools (the
|
||||
* ones NOT in @docmost/mcp's SHARED_TOOL_SPECS, which carry their own).
|
||||
* - buildInAppDeferredCatalog / buildExternalToolCatalog — assemble the
|
||||
* <tool_catalog> deferred lines.
|
||||
* - applyLoadTools / makeLoadToolsTool — the loadTools meta-tool.
|
||||
*
|
||||
* The tier/catalogLine fields on SHARED_TOOL_SPECS are IN-APP metadata only; the
|
||||
* external /mcp server ignores them and exposes every tool normally.
|
||||
*/
|
||||
|
||||
/** A single rendered <tool_catalog> line: the tool name + its "name — purpose". */
|
||||
export interface ToolCatalogEntry {
|
||||
/** Exact tool name the model must pass to loadTools. */
|
||||
name: string;
|
||||
/** Hand-written (in-app) or derived (external) "name — purpose" line. */
|
||||
catalogLine: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* CORE (always-active) in-app tool keys — 13 frequent/tiny tools. `searchInPage`
|
||||
* (#330) is added to core on top of the issue's original tier list: it is
|
||||
* frequent for the editorial roles this feature targets. `loadTools` is active
|
||||
* too but is not a normal tool key (it is added to activeTools separately).
|
||||
*/
|
||||
export const CORE_TOOL_KEYS = [
|
||||
'searchPages',
|
||||
'listPages',
|
||||
'listSpaces',
|
||||
'getWorkspace',
|
||||
'getCurrentPage',
|
||||
'getPage',
|
||||
'getOutline',
|
||||
'getNode',
|
||||
'createComment',
|
||||
'getComment',
|
||||
'listComments',
|
||||
'resolveComment',
|
||||
'editPageText',
|
||||
// #330 search_in_page — frequent for editorial sweeps; core despite predating
|
||||
// the issue's tier list.
|
||||
'searchInPage',
|
||||
] as const;
|
||||
|
||||
/** O(1) membership test for the core tier. */
|
||||
export const CORE_TOOL_SET: ReadonlySet<string> = new Set(CORE_TOOL_KEYS);
|
||||
|
||||
/** The meta-tool name (always active alongside the core tools when enabled). */
|
||||
export const LOAD_TOOLS_NAME = 'loadTools';
|
||||
|
||||
/**
|
||||
* loadTools description — VERBATIM from issue #332. Tells the model that the
|
||||
* catalog names EXIST, that loadTools only ACTIVATES them (callable next step),
|
||||
* and to load several at once.
|
||||
*/
|
||||
export const LOAD_TOOLS_DESCRIPTION =
|
||||
'loadTools — Load the full definitions of deferred tools from the <tool_catalog>\n' +
|
||||
'block in your instructions. Pass the EXACT tool names from the catalog; this\n' +
|
||||
'call only ACTIVATES them and returns { loaded: [...] } — the tools become\n' +
|
||||
'callable on your NEXT step. Load several names in one call when the task clearly\n' +
|
||||
'needs them. Unknown names are rejected with the list of valid ones.';
|
||||
|
||||
/**
|
||||
* Tier + catalogLine for the INLINE ai-chat tools — those defined per-layer in
|
||||
* ai-chat-tools.service.ts and NOT present in @docmost/mcp's SHARED_TOOL_SPECS
|
||||
* (which carries its own tier/catalogLine). Together with the shared registry
|
||||
* this describes every in-app tool. catalogLine is present for core tools too
|
||||
* (uniformity), but only DEFERRED tools are rendered into the catalog.
|
||||
*/
|
||||
export const INLINE_TOOL_TIERS: Record<
|
||||
string,
|
||||
{ tier: 'core' | 'deferred'; catalogLine: string }
|
||||
> = {
|
||||
// --- core inline ---
|
||||
searchPages: {
|
||||
tier: 'core',
|
||||
catalogLine: 'searchPages — hybrid semantic + keyword search across the wiki.',
|
||||
},
|
||||
getCurrentPage: {
|
||||
tier: 'core',
|
||||
catalogLine: 'getCurrentPage — the page the user is currently viewing.',
|
||||
},
|
||||
getPage: {
|
||||
tier: 'core',
|
||||
catalogLine: 'getPage — fetch a page as Markdown by its id.',
|
||||
},
|
||||
listPages: {
|
||||
tier: 'core',
|
||||
catalogLine: "listPages — list recent pages, or a space's full page tree.",
|
||||
},
|
||||
listComments: {
|
||||
tier: 'core',
|
||||
catalogLine: 'listComments — list all comments on a page (including resolved).',
|
||||
},
|
||||
getComment: {
|
||||
tier: 'core',
|
||||
catalogLine: 'getComment — fetch a single comment by id.',
|
||||
},
|
||||
createComment: {
|
||||
tier: 'core',
|
||||
catalogLine:
|
||||
'createComment — add an inline comment (optionally with a suggested edit).',
|
||||
},
|
||||
resolveComment: {
|
||||
tier: 'core',
|
||||
catalogLine: 'resolveComment — resolve or reopen a comment thread.',
|
||||
},
|
||||
|
||||
// --- deferred inline ---
|
||||
createPage: {
|
||||
tier: 'deferred',
|
||||
catalogLine: 'createPage — create a new page with a Markdown body in a space.',
|
||||
},
|
||||
updatePageContent: {
|
||||
tier: 'deferred',
|
||||
catalogLine:
|
||||
"updatePageContent — replace a page's body (and optionally title) with new Markdown.",
|
||||
},
|
||||
renamePage: {
|
||||
tier: 'deferred',
|
||||
catalogLine: "renamePage — change a page's title only (body untouched).",
|
||||
},
|
||||
movePage: {
|
||||
tier: 'deferred',
|
||||
catalogLine: 'movePage — move a page under a new parent or to the space root.',
|
||||
},
|
||||
deletePage: {
|
||||
tier: 'deferred',
|
||||
catalogLine: 'deletePage — move a page to trash (soft delete, reversible).',
|
||||
},
|
||||
listSidebarPages: {
|
||||
tier: 'deferred',
|
||||
catalogLine:
|
||||
"listSidebarPages — list a space's root pages or a page's direct children.",
|
||||
},
|
||||
getTable: {
|
||||
tier: 'deferred',
|
||||
catalogLine: 'getTable — read a table as a matrix of cell texts and cell ids.',
|
||||
},
|
||||
checkNewComments: {
|
||||
tier: 'deferred',
|
||||
catalogLine:
|
||||
'checkNewComments — find comments in a space created after a timestamp.',
|
||||
},
|
||||
getPageHistory: {
|
||||
tier: 'deferred',
|
||||
catalogLine:
|
||||
'getPageHistory — fetch one page-history version with its ProseMirror content.',
|
||||
},
|
||||
exportPageMarkdown: {
|
||||
tier: 'deferred',
|
||||
catalogLine:
|
||||
'exportPageMarkdown — export a page to self-contained Markdown (body + comments).',
|
||||
},
|
||||
updatePageJson: {
|
||||
tier: 'deferred',
|
||||
catalogLine:
|
||||
"updatePageJson — overwrite a page's body with a full ProseMirror document.",
|
||||
},
|
||||
tableInsertRow: {
|
||||
tier: 'deferred',
|
||||
catalogLine: 'tableInsertRow — insert a row of plain-text cells into a table.',
|
||||
},
|
||||
tableDeleteRow: {
|
||||
tier: 'deferred',
|
||||
catalogLine: 'tableDeleteRow — delete a table row at a 0-based index.',
|
||||
},
|
||||
tableUpdateCell: {
|
||||
tier: 'deferred',
|
||||
catalogLine: 'tableUpdateCell — set the text of a table cell at [row, col].',
|
||||
},
|
||||
sharePage: {
|
||||
tier: 'deferred',
|
||||
catalogLine: 'sharePage — make a page publicly accessible and return its URL.',
|
||||
},
|
||||
transformPage: {
|
||||
tier: 'deferred',
|
||||
catalogLine: "transformPage — run a sandboxed JS transform over a page's document.",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the <tool_catalog> deferred lines for the IN-APP tools by merging the
|
||||
* two metadata sources: the per-layer INLINE_TOOL_TIERS and the shared registry
|
||||
* (SHARED_TOOL_SPECS, loaded at runtime). Only DEFERRED tools are included; core
|
||||
* tools are always active and never appear in the catalog. Pure — the caller
|
||||
* passes the loaded specs so this stays unit-testable.
|
||||
*/
|
||||
export function buildInAppDeferredCatalog(
|
||||
sharedToolSpecs: Record<string, SharedToolSpec>,
|
||||
): ToolCatalogEntry[] {
|
||||
const entries: ToolCatalogEntry[] = [];
|
||||
// Inline deferred tools (hand-written lines).
|
||||
for (const [name, meta] of Object.entries(INLINE_TOOL_TIERS)) {
|
||||
if (meta.tier === 'deferred') {
|
||||
entries.push({ name, catalogLine: meta.catalogLine });
|
||||
}
|
||||
}
|
||||
// Shared deferred tools (line comes from the registry's own catalogLine).
|
||||
for (const [name, spec] of Object.entries(sharedToolSpecs)) {
|
||||
if (spec.tier === 'deferred' && spec.catalogLine) {
|
||||
entries.push({ name, catalogLine: spec.catalogLine });
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cap an external tool's (untrusted) description into a short catalog purpose.
|
||||
* External MCP tools have no hand-written catalogLine, so we derive one from the
|
||||
* first sentence of the description, hard-capped. Whitespace is collapsed.
|
||||
*/
|
||||
export function shortenForCatalog(description: string, max = 140): string {
|
||||
const flat = description.replace(/\s+/g, ' ').trim();
|
||||
if (!flat) return 'external tool';
|
||||
// Prefer the first sentence if it is reasonably short.
|
||||
const firstSentence = flat.split(/(?<=[.!?])\s/)[0];
|
||||
const base =
|
||||
firstSentence.length > 0 && firstSentence.length <= max
|
||||
? firstSentence
|
||||
: flat;
|
||||
return base.length > max ? `${base.slice(0, max - 1).trimEnd()}…` : base;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build catalog lines for the EXTERNAL MCP tools (all deferred by default,
|
||||
* #332). Their names are the namespaced tool keys; the purpose is derived from
|
||||
* each tool's own description (no hand-written line exists). Pure.
|
||||
*/
|
||||
export function buildExternalToolCatalog(
|
||||
externalTools: Record<string, { description?: string } | undefined>,
|
||||
): ToolCatalogEntry[] {
|
||||
return Object.entries(externalTools).map(([name, t]) => ({
|
||||
name,
|
||||
catalogLine: `${name} — ${shortenForCatalog(t?.description ?? '')}`,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure core of the loadTools meta-tool. Validates the requested names against
|
||||
* the per-turn set of valid deferred names, ADDS the valid ones to the caller's
|
||||
* mutable `activatedTools` set (so they become callable next step), and returns
|
||||
* `{ loaded }`. An unknown name throws a clear error listing the valid deferred
|
||||
* names — surfaced to the model as a tool error so it can retry.
|
||||
*/
|
||||
export function applyLoadTools(
|
||||
names: unknown,
|
||||
activatedTools: Set<string>,
|
||||
validDeferredNames: ReadonlySet<string>,
|
||||
): { loaded: string[] } {
|
||||
const requested = Array.isArray(names)
|
||||
? names.filter((n): n is string => typeof n === 'string')
|
||||
: [];
|
||||
const unknown = requested.filter((n) => !validDeferredNames.has(n));
|
||||
if (unknown.length > 0) {
|
||||
const valid = [...validDeferredNames].sort().join(', ');
|
||||
throw new Error(
|
||||
`loadTools: unknown tool name(s): ${unknown.join(', ')}. ` +
|
||||
`Valid deferred tools are: ${valid || '(none)'}.`,
|
||||
);
|
||||
}
|
||||
for (const n of requested) activatedTools.add(n);
|
||||
return { loaded: requested };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the loadTools AI-SDK tool bound to THIS turn's mutable state: the
|
||||
* `activatedTools` set (grown by execute, read by prepareAgentStep next step)
|
||||
* and the `validDeferredNames` set (every non-core tool in this turn's toolset,
|
||||
* incl. external MCP). Created per streamText call — never module-global.
|
||||
*/
|
||||
export function makeLoadToolsTool(
|
||||
activatedTools: Set<string>,
|
||||
validDeferredNames: ReadonlySet<string>,
|
||||
): Tool {
|
||||
return tool({
|
||||
description: LOAD_TOOLS_DESCRIPTION,
|
||||
inputSchema: z.object({
|
||||
names: z
|
||||
.array(z.string())
|
||||
.describe(
|
||||
'EXACT deferred tool names from the <tool_catalog> to activate for ' +
|
||||
'your next step.',
|
||||
),
|
||||
}),
|
||||
execute: async ({ names }) =>
|
||||
applyLoadTools(names, activatedTools, validDeferredNames),
|
||||
});
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
AUTH_THROTTLER,
|
||||
PAGE_TEMPLATE_THROTTLER,
|
||||
PUBLIC_SHARE_AI_THROTTLER,
|
||||
VITALS_THROTTLER,
|
||||
} from '../../integrations/throttle/throttler-names';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { AuthService } from './services/auth.service';
|
||||
@@ -184,16 +185,21 @@ export class AuthController {
|
||||
}
|
||||
|
||||
// The global ThrottlerGuard applies ALL named throttlers to every route by
|
||||
// default, so each non-AUTH bucket (AI chat, page template, public-share AI)
|
||||
// is explicitly skipped here. collab-token is auth-guarded (JwtAuthGuard),
|
||||
// per-user and client-cached, so those feature buckets are irrelevant to it;
|
||||
// skipping them avoids spurious 429s when a user opens many pages in a short
|
||||
// window. The AUTH bucket is skipped too for the same per-user, cached reason.
|
||||
// default, so each non-AUTH bucket (AI chat, page template, public-share AI,
|
||||
// client vitals) is explicitly skipped here. collab-token is auth-guarded
|
||||
// (JwtAuthGuard), per-user and client-cached, so those feature buckets are
|
||||
// irrelevant to it; skipping them avoids spurious 429s when a user opens many
|
||||
// pages in a short window. The VITALS bucket must be skipped too: it is a
|
||||
// process-wide named throttler, so without this skip its per-IP limit would
|
||||
// silently cap collab-token (the one route that opts out of every other
|
||||
// bucket) and break editing behind shared/NAT IPs. The AUTH bucket is skipped
|
||||
// for the same per-user, cached reason.
|
||||
@SkipThrottle({
|
||||
[AUTH_THROTTLER]: true,
|
||||
[AI_CHAT_THROTTLER]: true,
|
||||
[PAGE_TEMPLATE_THROTTLER]: true,
|
||||
[PUBLIC_SHARE_AI_THROTTLER]: true,
|
||||
[VITALS_THROTTLER]: true,
|
||||
})
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
@@ -117,3 +118,207 @@ describe('CommentController apply-suggestion authz', () => {
|
||||
expect(commentService.applySuggestion).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Authz-gate tests for the dismiss-suggestion route (#329). Dismissing a
|
||||
* suggestion does NOT change the page text, so it authorizes with
|
||||
* validateCanComment (NOT validateCanEdit) — a viewer allowed to comment but not
|
||||
* edit can still dismiss. The gate MUST run BEFORE the service (which performs
|
||||
* the delete/resolve + mark removal). These tests pin that boundary.
|
||||
*/
|
||||
describe('CommentController dismiss-suggestion authz', () => {
|
||||
// isAdmin=false → ability.cannot(Manage, Settings) returns true (i.e. the user
|
||||
// is NOT a space admin). Flip to true to model a space admin.
|
||||
function makeController(isAdmin = false) {
|
||||
const commentService = {
|
||||
dismissSuggestion: jest.fn(async () => ({
|
||||
id: 'c-1',
|
||||
outcome: 'deleted',
|
||||
})),
|
||||
};
|
||||
const commentRepo = { findById: jest.fn() };
|
||||
const pageRepo = { findById: jest.fn() };
|
||||
const spaceAbility = {
|
||||
createForUser: jest.fn(async () => ({
|
||||
cannot: jest.fn(() => !isAdmin),
|
||||
})),
|
||||
} as any;
|
||||
const pageAccessService = {
|
||||
validateCanComment: jest.fn(async () => undefined),
|
||||
validateCanEdit: jest.fn(async () => undefined),
|
||||
};
|
||||
const wsService = {} as any;
|
||||
const auditService = { log: jest.fn() };
|
||||
|
||||
const controller = new CommentController(
|
||||
commentService as any,
|
||||
commentRepo as any,
|
||||
pageRepo as any,
|
||||
spaceAbility,
|
||||
pageAccessService as any,
|
||||
wsService,
|
||||
auditService as any,
|
||||
);
|
||||
return {
|
||||
controller,
|
||||
commentService,
|
||||
commentRepo,
|
||||
pageRepo,
|
||||
pageAccessService,
|
||||
spaceAbility,
|
||||
};
|
||||
}
|
||||
|
||||
const user: any = { id: 'u-1' };
|
||||
const workspace: any = { id: 'ws-1' };
|
||||
const provenance: any = undefined;
|
||||
const dto: any = { commentId: 'c-1' };
|
||||
// Owned by the acting user (u-1) unless a test overrides creatorId.
|
||||
const comment = {
|
||||
id: 'c-1',
|
||||
pageId: 'p-1',
|
||||
spaceId: 'sp-1',
|
||||
creatorId: 'u-1',
|
||||
suggestedText: 'new text',
|
||||
selection: 'old text',
|
||||
};
|
||||
const page = { id: 'p-1', spaceId: 'sp-1', deletedAt: null };
|
||||
|
||||
it('authorizes with validateCanComment (NOT validateCanEdit) then calls the service', async () => {
|
||||
const {
|
||||
controller,
|
||||
commentRepo,
|
||||
pageRepo,
|
||||
pageAccessService,
|
||||
commentService,
|
||||
} = makeController();
|
||||
commentRepo.findById.mockResolvedValue(comment);
|
||||
pageRepo.findById.mockResolvedValue(page);
|
||||
const dismissed = { id: 'c-1', outcome: 'deleted' };
|
||||
commentService.dismissSuggestion.mockResolvedValue(dismissed);
|
||||
|
||||
const result = await controller.dismissSuggestion(
|
||||
dto,
|
||||
user,
|
||||
workspace,
|
||||
provenance,
|
||||
);
|
||||
|
||||
expect(pageAccessService.validateCanComment).toHaveBeenCalledWith(
|
||||
page,
|
||||
user,
|
||||
workspace.id,
|
||||
);
|
||||
// Dismiss must NOT require edit access.
|
||||
expect(pageAccessService.validateCanEdit).not.toHaveBeenCalled();
|
||||
expect(commentService.dismissSuggestion).toHaveBeenCalledWith(
|
||||
comment,
|
||||
user,
|
||||
provenance,
|
||||
);
|
||||
expect(result).toBe(dismissed);
|
||||
});
|
||||
|
||||
it('validateCanComment throwing Forbidden rejects AND dismissSuggestion is never called', async () => {
|
||||
const {
|
||||
controller,
|
||||
commentRepo,
|
||||
pageRepo,
|
||||
pageAccessService,
|
||||
commentService,
|
||||
} = makeController();
|
||||
commentRepo.findById.mockResolvedValue(comment);
|
||||
pageRepo.findById.mockResolvedValue(page);
|
||||
pageAccessService.validateCanComment.mockRejectedValue(
|
||||
new ForbiddenException('no comment access'),
|
||||
);
|
||||
|
||||
await expect(
|
||||
controller.dismissSuggestion(dto, user, workspace, provenance),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
|
||||
expect(commentService.dismissSuggestion).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('missing comment: NotFound without authorizing or dismissing', async () => {
|
||||
const { controller, commentRepo, pageRepo, pageAccessService, commentService } =
|
||||
makeController();
|
||||
commentRepo.findById.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
controller.dismissSuggestion(dto, user, workspace, provenance),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
|
||||
expect(pageRepo.findById).not.toHaveBeenCalled();
|
||||
expect(pageAccessService.validateCanComment).not.toHaveBeenCalled();
|
||||
expect(commentService.dismissSuggestion).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('propagates a service BadRequest (e.g. already applied/resolved) unchanged', async () => {
|
||||
const { controller, commentRepo, pageRepo, commentService } =
|
||||
makeController();
|
||||
commentRepo.findById.mockResolvedValue(comment);
|
||||
pageRepo.findById.mockResolvedValue(page);
|
||||
commentService.dismissSuggestion.mockRejectedValue(
|
||||
new BadRequestException('already applied'),
|
||||
);
|
||||
|
||||
await expect(
|
||||
controller.dismissSuggestion(dto, user, workspace, provenance),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
// --- #338 owner-or-space-admin gate (mirrors POST /comments/delete) --------
|
||||
// A childless dismiss irreversibly hard-deletes the comment, so canComment is
|
||||
// not enough: only the comment owner or a space admin may dismiss.
|
||||
|
||||
it('owner dismisses their own suggestion → allowed, no admin check needed', async () => {
|
||||
const { controller, commentRepo, pageRepo, commentService, spaceAbility } =
|
||||
makeController(false);
|
||||
// comment.creatorId === user.id (owner).
|
||||
commentRepo.findById.mockResolvedValue(comment);
|
||||
pageRepo.findById.mockResolvedValue(page);
|
||||
|
||||
await controller.dismissSuggestion(dto, user, workspace, provenance);
|
||||
|
||||
// Owner short-circuits the admin lookup.
|
||||
expect(spaceAbility.createForUser).not.toHaveBeenCalled();
|
||||
expect(commentService.dismissSuggestion).toHaveBeenCalledWith(
|
||||
comment,
|
||||
user,
|
||||
provenance,
|
||||
);
|
||||
});
|
||||
|
||||
it('non-owner non-admin → Forbidden AND the service is never called', async () => {
|
||||
const { controller, commentRepo, pageRepo, commentService, spaceAbility } =
|
||||
makeController(false); // NOT a space admin
|
||||
commentRepo.findById.mockResolvedValue({
|
||||
...comment,
|
||||
creatorId: 'someone-else',
|
||||
});
|
||||
pageRepo.findById.mockResolvedValue(page);
|
||||
|
||||
await expect(
|
||||
controller.dismissSuggestion(dto, user, workspace, provenance),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
|
||||
expect(spaceAbility.createForUser).toHaveBeenCalledWith(user, comment.spaceId);
|
||||
expect(commentService.dismissSuggestion).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('non-owner space admin → allowed to dismiss another user’s suggestion', async () => {
|
||||
const { controller, commentRepo, pageRepo, commentService, spaceAbility } =
|
||||
makeController(true); // space admin
|
||||
commentRepo.findById.mockResolvedValue({
|
||||
...comment,
|
||||
creatorId: 'someone-else',
|
||||
});
|
||||
pageRepo.findById.mockResolvedValue(page);
|
||||
|
||||
await controller.dismissSuggestion(dto, user, workspace, provenance);
|
||||
|
||||
expect(spaceAbility.createForUser).toHaveBeenCalledWith(user, comment.spaceId);
|
||||
expect(commentService.dismissSuggestion).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import { CreateCommentDto } from './dto/create-comment.dto';
|
||||
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 { PageIdDto, CommentIdDto } from './dto/comments.input';
|
||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
||||
@@ -234,6 +235,59 @@ export class CommentController {
|
||||
return this.commentService.applySuggestion(comment, user, provenance);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('dismiss-suggestion')
|
||||
async dismissSuggestion(
|
||||
@Body() dto: DismissSuggestionDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
@AuthProvenance() provenance: AuthProvenanceData,
|
||||
) {
|
||||
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 any structural detail (metadata-disclosure
|
||||
// hygiene, mirroring apply-suggestion). Dismissing a suggestion does NOT
|
||||
// change the page text — it only removes/resolves the comment — so the
|
||||
// page-level gate is comment access (canComment), NOT edit access. A viewer
|
||||
// allowed to comment but not edit can still dismiss their own suggestion.
|
||||
// The structural 400s (top-level / has-a-suggested-edit / not applied /
|
||||
// not resolved) are re-checked by the service below.
|
||||
await this.pageAccessService.validateCanComment(page, user, workspace.id);
|
||||
|
||||
// AUTHZ (#338): a childless dismiss IRREVERSIBLY hard-deletes the comment,
|
||||
// so — beyond canComment — restrict it to the comment owner OR a space
|
||||
// admin, exactly like POST /comments/delete. canComment alone is not enough:
|
||||
// it would let any bystander commenter erase another user's suggestion for
|
||||
// good. (apply-suggestion deliberately stays on canEdit: accepting an edit
|
||||
// is the editor's semantics, not the suggestion author's.)
|
||||
const isOwner = comment.creatorId === user.id;
|
||||
if (!isOwner) {
|
||||
const ability = await this.spaceAbility.createForUser(
|
||||
user,
|
||||
comment.spaceId,
|
||||
);
|
||||
// Space admin can dismiss any suggestion.
|
||||
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)) {
|
||||
throw new ForbiddenException(
|
||||
'You can only dismiss your own suggestions',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return this.commentService.dismissSuggestion(comment, user, provenance);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('delete')
|
||||
async delete(@Body() input: CommentIdDto, @AuthUser() user: User, @AuthWorkspace() workspace: Workspace) {
|
||||
|
||||
@@ -13,17 +13,27 @@ import { AuditEvent, AuditResource } from '../../common/events/audit-events';
|
||||
*
|
||||
* The collaboration gateway verdict is the pivot of the whole flow, so each test
|
||||
* pins a specific { applied, currentText } and asserts the DB persistence,
|
||||
* auto-resolve, audit, ws broadcast, and error mapping that follow from it.
|
||||
* settle (ephemeral delete vs. resolve), audit, ws broadcast, and error mapping
|
||||
* that follow from it.
|
||||
*
|
||||
* Ephemeral rule (#329): once applied a suggestion DISAPPEARS (hard-delete +
|
||||
* strip the inline anchor mark) UNLESS the thread has replies, in which case it
|
||||
* is resolved to preserve the discussion. `hasChildren` selects the branch.
|
||||
*/
|
||||
describe('CommentService — applySuggestion', () => {
|
||||
const UPDATED = { id: 'c-1', __updated: true } as any;
|
||||
|
||||
function makeService(verdict: unknown) {
|
||||
function makeService(verdict: unknown, hasChildren = false, deletedRows = 1) {
|
||||
const commentRepo: any = {
|
||||
// Both the applied-stamp re-read and resolveComment's re-read go through
|
||||
// findById; return a recognizable enriched row.
|
||||
findById: jest.fn(async () => UPDATED),
|
||||
updateComment: jest.fn(async () => undefined),
|
||||
hasChildren: jest.fn(async () => hasChildren),
|
||||
deleteComment: jest.fn(async () => undefined),
|
||||
// #338 F1: the childless ephemeral delete is atomic-conditional and
|
||||
// returns the number of rows removed (1 = deleted, 0 = a reply raced in).
|
||||
deleteCommentIfChildless: jest.fn(async () => deletedRows),
|
||||
};
|
||||
const pageRepo: any = {};
|
||||
const wsService: any = { emitCommentEvent: jest.fn() };
|
||||
@@ -74,7 +84,9 @@ describe('CommentService — applySuggestion', () => {
|
||||
.map((c: any[]) => c[0])
|
||||
.find((patch: any) => 'suggestionAppliedAt' in patch);
|
||||
|
||||
it('applied=true → replaces text, persists applied stamps, auto-resolves, audits, returns updated', async () => {
|
||||
// --- no replies → ephemeral delete branch -------------------------------
|
||||
|
||||
it('applied=true, no replies → replaces text, hard-deletes, strips the anchor mark, audits APPLIED, outcome=deleted', async () => {
|
||||
const { service, commentRepo, wsService, collaborationGateway, auditService } =
|
||||
makeService({ applied: true, currentText: 'new text' });
|
||||
|
||||
@@ -92,37 +104,34 @@ describe('CommentService — applySuggestion', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
// Applied stamps persisted.
|
||||
const patch = appliedPatch(commentRepo);
|
||||
expect(patch.suggestionAppliedAt).toBeInstanceOf(Date);
|
||||
expect(patch.suggestionAppliedById).toBe('user-1');
|
||||
// Ephemeral: the redundant comment is hard-deleted (atomic-conditional) and
|
||||
// its inline anchor mark removed via the deleteCommentMark collab event.
|
||||
expect(commentRepo.deleteCommentIfChildless).toHaveBeenCalledWith('c-1');
|
||||
expect(collaborationGateway.handleYjsEvent).toHaveBeenCalledWith(
|
||||
'deleteCommentMark',
|
||||
'page.page-1',
|
||||
expect.objectContaining({ commentId: 'c-1', user: expect.any(Object) }),
|
||||
);
|
||||
// No applied stamps are written for a row about to be deleted.
|
||||
expect(appliedPatch(commentRepo)).toBeUndefined();
|
||||
|
||||
// Auto-resolved: resolveComment writes a resolvedAt/resolvedById patch too.
|
||||
const resolvePatch = commentRepo.updateComment.mock.calls
|
||||
.map((c: any[]) => c[0])
|
||||
.find((p: any) => 'resolvedAt' in p);
|
||||
expect(resolvePatch.resolvedAt).toBeInstanceOf(Date);
|
||||
expect(resolvePatch.resolvedById).toBe('user-1');
|
||||
|
||||
// Audit + broadcast + return.
|
||||
// Broadcast a deletion, audit the (still-applied) suggestion, report outcome.
|
||||
expect(wsService.emitCommentEvent).toHaveBeenCalledWith(
|
||||
'space-1',
|
||||
'page-1',
|
||||
expect.objectContaining({ operation: 'commentDeleted', commentId: 'c-1' }),
|
||||
);
|
||||
expect(auditService.log).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
event: AuditEvent.COMMENT_SUGGESTION_APPLIED,
|
||||
resourceType: AuditResource.COMMENT,
|
||||
resourceId: 'c-1',
|
||||
spaceId: 'space-1',
|
||||
metadata: { pageId: 'page-1' },
|
||||
}),
|
||||
);
|
||||
expect(wsService.emitCommentEvent).toHaveBeenCalledWith(
|
||||
'space-1',
|
||||
'page-1',
|
||||
expect.objectContaining({ operation: 'commentUpdated', comment: UPDATED }),
|
||||
);
|
||||
expect(result).toBe(UPDATED);
|
||||
expect(result.outcome).toBe('deleted');
|
||||
});
|
||||
|
||||
it('applied=false but currentText === suggestedText → idempotent success (no 409)', async () => {
|
||||
it('applied=false but currentText === suggestedText, no replies → idempotent delete (no 409)', async () => {
|
||||
const { service, commentRepo, auditService } = makeService({
|
||||
applied: false,
|
||||
currentText: 'new text',
|
||||
@@ -130,15 +139,55 @@ describe('CommentService — applySuggestion', () => {
|
||||
|
||||
const result = await service.applySuggestion(suggestionComment(), user());
|
||||
|
||||
// The stamps are still persisted (reconciling a crash between the doc
|
||||
// mutation and the DB write) and the call succeeds.
|
||||
expect(commentRepo.deleteCommentIfChildless).toHaveBeenCalledWith('c-1');
|
||||
expect(auditService.log).toHaveBeenCalledTimes(1);
|
||||
expect(result.outcome).toBe('deleted');
|
||||
});
|
||||
|
||||
// --- has replies → resolve branch (discussion preserved) ----------------
|
||||
|
||||
it('applied=true, WITH replies → resolves (not delete), persists applied stamps, audits, outcome=resolved', async () => {
|
||||
const { service, commentRepo, wsService, collaborationGateway, auditService } =
|
||||
makeService({ applied: true, currentText: 'new text' }, true);
|
||||
|
||||
const result = await service.applySuggestion(suggestionComment(), user());
|
||||
|
||||
// Applied stamps persisted.
|
||||
const patch = appliedPatch(commentRepo);
|
||||
expect(patch.suggestionAppliedAt).toBeInstanceOf(Date);
|
||||
expect(patch.suggestionAppliedById).toBe('user-1');
|
||||
expect(auditService.log).toHaveBeenCalledTimes(1);
|
||||
expect(result).toBe(UPDATED);
|
||||
|
||||
// Auto-resolved (resolveComment writes the resolve patch + resolve mark).
|
||||
const resolvePatch = commentRepo.updateComment.mock.calls
|
||||
.map((c: any[]) => c[0])
|
||||
.find((p: any) => 'resolvedAt' in p);
|
||||
expect(resolvePatch.resolvedAt).toBeInstanceOf(Date);
|
||||
expect(resolvePatch.resolvedById).toBe('user-1');
|
||||
|
||||
// NOT deleted; broadcast an update, not a deletion.
|
||||
expect(commentRepo.deleteComment).not.toHaveBeenCalled();
|
||||
expect(collaborationGateway.handleYjsEvent).not.toHaveBeenCalledWith(
|
||||
'deleteCommentMark',
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(wsService.emitCommentEvent).toHaveBeenCalledWith(
|
||||
'space-1',
|
||||
'page-1',
|
||||
expect.objectContaining({ operation: 'commentUpdated', comment: UPDATED }),
|
||||
);
|
||||
|
||||
expect(auditService.log).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
event: AuditEvent.COMMENT_SUGGESTION_APPLIED,
|
||||
}),
|
||||
);
|
||||
expect(result.id).toBe('c-1');
|
||||
expect(result.outcome).toBe('resolved');
|
||||
});
|
||||
|
||||
// --- error / rejection branches -----------------------------------------
|
||||
|
||||
it('applied=false and currentText differs → ConflictException with currentText in payload', async () => {
|
||||
const { service, commentRepo, auditService } = makeService({
|
||||
applied: false,
|
||||
@@ -153,14 +202,14 @@ describe('CommentService — applySuggestion', () => {
|
||||
expect(err.getResponse()).toMatchObject({
|
||||
currentText: 'someone else edited this',
|
||||
});
|
||||
// No persistence and no audit on a conflict.
|
||||
expect(appliedPatch(commentRepo)).toBeUndefined();
|
||||
// No delete and no audit on a conflict.
|
||||
expect(commentRepo.deleteComment).not.toHaveBeenCalled();
|
||||
expect(auditService.log).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('already-applied AND already-resolved → idempotent success, no collab call, no re-resolve (#315 double-click)', async () => {
|
||||
it('already-applied WITH replies → idempotent success, no re-apply, resolve branch', async () => {
|
||||
const { service, collaborationGateway, commentRepo, auditService } =
|
||||
makeService({ applied: true, currentText: 'new text' });
|
||||
makeService({ applied: true, currentText: 'new text' }, true);
|
||||
|
||||
const result = await service.applySuggestion(
|
||||
suggestionComment({
|
||||
@@ -171,17 +220,20 @@ describe('CommentService — applySuggestion', () => {
|
||||
user(),
|
||||
);
|
||||
|
||||
// Idempotent SUCCESS, not a 409. The suggestion is already applied, so the
|
||||
// collaborative document is never touched again and nothing is re-stamped
|
||||
// or re-resolved.
|
||||
expect(result).toBe(UPDATED);
|
||||
expect(collaborationGateway.handleYjsEvent).not.toHaveBeenCalled();
|
||||
expect(commentRepo.updateComment).not.toHaveBeenCalled();
|
||||
// Same success shape as the applied path (broadcast + audit).
|
||||
// Idempotent SUCCESS. The suggestion is already applied, so the document is
|
||||
// never re-mutated (no applyCommentSuggestion) and nothing is re-stamped.
|
||||
expect(collaborationGateway.handleYjsEvent).not.toHaveBeenCalledWith(
|
||||
'applyCommentSuggestion',
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(appliedPatch(commentRepo)).toBeUndefined();
|
||||
expect(commentRepo.deleteComment).not.toHaveBeenCalled();
|
||||
expect(auditService.log).toHaveBeenCalledTimes(1);
|
||||
expect(result.outcome).toBe('resolved');
|
||||
});
|
||||
|
||||
it('already-applied but NOT resolved (crash window) → idempotent success, self-heals resolve, no re-apply', async () => {
|
||||
it('already-applied, no replies (double-click after a delete) → deletes idempotently', async () => {
|
||||
const { service, collaborationGateway, commentRepo } = makeService({
|
||||
applied: true,
|
||||
currentText: 'new text',
|
||||
@@ -192,28 +244,43 @@ describe('CommentService — applySuggestion', () => {
|
||||
user(),
|
||||
);
|
||||
|
||||
expect(result).toBe(UPDATED);
|
||||
|
||||
// The suggestion is NOT re-applied to the document…
|
||||
// No re-apply to the document; the childless applied comment is removed.
|
||||
expect(collaborationGateway.handleYjsEvent).not.toHaveBeenCalledWith(
|
||||
'applyCommentSuggestion',
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
// …but the open thread is self-healed to resolved via resolveComment, which
|
||||
// writes the resolve patch and updates the resolve mark.
|
||||
expect(commentRepo.deleteCommentIfChildless).toHaveBeenCalledWith('c-1');
|
||||
expect(result.outcome).toBe('deleted');
|
||||
});
|
||||
|
||||
it('applied=true, no replies at read time but a reply races in (conditional delete → 0 rows) → resolves instead, no hard-delete, outcome=resolved (#338 F1)', async () => {
|
||||
// The suggested text is already applied to the document, but between the
|
||||
// hasChildren read and the atomic delete a reply landed. The parent must NOT
|
||||
// be hard-deleted (cascade would destroy the reply); resolve the thread.
|
||||
const { service, commentRepo, wsService, collaborationGateway } =
|
||||
makeService({ applied: true, currentText: 'new text' }, false, 0);
|
||||
|
||||
const result = await service.applySuggestion(suggestionComment(), user());
|
||||
|
||||
expect(commentRepo.deleteCommentIfChildless).toHaveBeenCalledWith('c-1');
|
||||
// No deletion broadcast — the row + the racing reply survive.
|
||||
expect(wsService.emitCommentEvent).not.toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.objectContaining({ operation: 'commentDeleted' }),
|
||||
);
|
||||
// Fell back to resolving.
|
||||
const resolvePatch = commentRepo.updateComment.mock.calls
|
||||
.map((c: any[]) => c[0])
|
||||
.find((p: any) => 'resolvedAt' in p);
|
||||
expect(resolvePatch.resolvedAt).toBeInstanceOf(Date);
|
||||
expect(resolvePatch.resolvedById).toBe('user-1');
|
||||
expect(collaborationGateway.handleYjsEvent).toHaveBeenCalledWith(
|
||||
'resolveCommentMark',
|
||||
'page.page-1',
|
||||
expect.objectContaining({ commentId: 'c-1', resolved: true }),
|
||||
);
|
||||
// The applied stamps are NOT re-written (already stamped).
|
||||
expect(appliedPatch(commentRepo)).toBeUndefined();
|
||||
expect(result.outcome).toBe('resolved');
|
||||
});
|
||||
|
||||
it('rejects a comment with no suggestedText', async () => {
|
||||
@@ -238,8 +305,8 @@ describe('CommentService — applySuggestion', () => {
|
||||
service.applySuggestion(suggestionComment(), user()),
|
||||
).rejects.toThrow(InternalServerErrorException);
|
||||
|
||||
// Nothing persisted, nothing audited.
|
||||
expect(appliedPatch(commentRepo)).toBeUndefined();
|
||||
// Nothing deleted, nothing audited.
|
||||
expect(commentRepo.deleteComment).not.toHaveBeenCalled();
|
||||
expect(auditService.log).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { CommentService } from './comment.service';
|
||||
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
|
||||
|
||||
/**
|
||||
* Coverage for CommentService.dismissSuggestion (#329). Dismiss ("Не применять")
|
||||
* removes a suggested edit WITHOUT changing the page text: the comment
|
||||
* disappears (hard-delete + strip the inline anchor mark) unless the thread has
|
||||
* replies, in which case it is resolved to preserve the discussion.
|
||||
*
|
||||
* The permission gate (canComment, NOT canEdit) lives in the controller and is
|
||||
* covered in comment.controller.spec.ts; here we pin the service's own state
|
||||
* guards and the delete-vs-resolve fork.
|
||||
*/
|
||||
describe('CommentService — dismissSuggestion', () => {
|
||||
const UPDATED = { id: 'c-1', __updated: true } as any;
|
||||
|
||||
function makeService(hasChildren = false, deletedRows = 1) {
|
||||
const commentRepo: any = {
|
||||
findById: jest.fn(async () => UPDATED),
|
||||
updateComment: jest.fn(async () => undefined),
|
||||
hasChildren: jest.fn(async () => hasChildren),
|
||||
deleteComment: jest.fn(async () => undefined),
|
||||
// #338 F1: the childless ephemeral delete is now atomic-conditional and
|
||||
// returns the number of rows removed (1 = deleted, 0 = a reply raced in).
|
||||
deleteCommentIfChildless: jest.fn(async () => deletedRows),
|
||||
};
|
||||
const pageRepo: any = {};
|
||||
const wsService: any = { emitCommentEvent: jest.fn() };
|
||||
const collaborationGateway: any = {
|
||||
handleYjsEvent: jest.fn(async () => undefined),
|
||||
};
|
||||
const generalQueue: any = { add: jest.fn(() => Promise.resolve()) };
|
||||
const notificationQueue: any = { add: jest.fn(async () => undefined) };
|
||||
const auditService: any = { log: jest.fn() };
|
||||
|
||||
const service = new CommentService(
|
||||
commentRepo,
|
||||
pageRepo,
|
||||
wsService,
|
||||
collaborationGateway,
|
||||
generalQueue,
|
||||
notificationQueue,
|
||||
auditService,
|
||||
);
|
||||
|
||||
return { service, commentRepo, wsService, collaborationGateway, auditService };
|
||||
}
|
||||
|
||||
const suggestionComment = (over?: Partial<any>): any => ({
|
||||
id: 'c-1',
|
||||
pageId: 'page-1',
|
||||
spaceId: 'space-1',
|
||||
workspaceId: 'ws-1',
|
||||
creatorId: 'user-1',
|
||||
parentCommentId: null,
|
||||
selection: 'old text',
|
||||
suggestedText: 'new text',
|
||||
suggestionAppliedAt: null,
|
||||
resolvedAt: null,
|
||||
...over,
|
||||
});
|
||||
const user = (over?: Partial<any>): any => ({ id: 'user-1', ...over });
|
||||
|
||||
it('no replies → hard-deletes, strips the anchor mark, does NOT touch page text, audits DISMISSED, outcome=deleted', async () => {
|
||||
const { service, commentRepo, wsService, collaborationGateway, auditService } =
|
||||
makeService(false);
|
||||
|
||||
const result = await service.dismissSuggestion(suggestionComment(), user());
|
||||
|
||||
// Never applies the suggestion to the document.
|
||||
expect(collaborationGateway.handleYjsEvent).not.toHaveBeenCalledWith(
|
||||
'applyCommentSuggestion',
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
// Hard-delete (atomic-conditional) + strip mark.
|
||||
expect(commentRepo.deleteCommentIfChildless).toHaveBeenCalledWith('c-1');
|
||||
expect(collaborationGateway.handleYjsEvent).toHaveBeenCalledWith(
|
||||
'deleteCommentMark',
|
||||
'page.page-1',
|
||||
expect.objectContaining({ commentId: 'c-1', user: expect.any(Object) }),
|
||||
);
|
||||
expect(wsService.emitCommentEvent).toHaveBeenCalledWith(
|
||||
'space-1',
|
||||
'page-1',
|
||||
expect.objectContaining({ operation: 'commentDeleted', commentId: 'c-1' }),
|
||||
);
|
||||
expect(auditService.log).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
event: AuditEvent.COMMENT_SUGGESTION_DISMISSED,
|
||||
resourceType: AuditResource.COMMENT,
|
||||
resourceId: 'c-1',
|
||||
}),
|
||||
);
|
||||
expect(result.outcome).toBe('deleted');
|
||||
});
|
||||
|
||||
it('no replies → if the anchor-mark removal FAILS, the row is NOT deleted and the error propagates (#329: no orphan anchor)', async () => {
|
||||
const { service, commentRepo, wsService, collaborationGateway } =
|
||||
makeService(false);
|
||||
// Mark removal is FATAL and runs BEFORE the irreversible row delete: a collab
|
||||
// failure (e.g. COLLAB_DISABLE_REDIS "no live instance") must abort the whole
|
||||
// operation, leaving row + mark consistent — never a deleted row with an
|
||||
// orphan anchor left in the document reporting success.
|
||||
collaborationGateway.handleYjsEvent = jest.fn(async () => {
|
||||
throw new Error('requires a live collaboration instance');
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.dismissSuggestion(suggestionComment(), user()),
|
||||
).rejects.toThrow(/live collaboration/);
|
||||
|
||||
expect(commentRepo.deleteCommentIfChildless).not.toHaveBeenCalled();
|
||||
expect(wsService.emitCommentEvent).not.toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.objectContaining({ operation: 'commentDeleted' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('WITH replies → resolves (not delete), does NOT apply, audits DISMISSED, outcome=resolved', async () => {
|
||||
const { service, commentRepo, wsService, collaborationGateway, auditService } =
|
||||
makeService(true);
|
||||
|
||||
const result = await service.dismissSuggestion(suggestionComment(), user());
|
||||
|
||||
// Resolved via resolveComment (resolve patch + resolve mark), NOT deleted.
|
||||
const resolvePatch = commentRepo.updateComment.mock.calls
|
||||
.map((c: any[]) => c[0])
|
||||
.find((p: any) => 'resolvedAt' in p);
|
||||
expect(resolvePatch.resolvedAt).toBeInstanceOf(Date);
|
||||
expect(resolvePatch.resolvedById).toBe('user-1');
|
||||
expect(commentRepo.deleteComment).not.toHaveBeenCalled();
|
||||
expect(collaborationGateway.handleYjsEvent).toHaveBeenCalledWith(
|
||||
'resolveCommentMark',
|
||||
'page.page-1',
|
||||
expect.objectContaining({ commentId: 'c-1', resolved: true }),
|
||||
);
|
||||
// No applied stamp — dismiss does not apply the edit.
|
||||
const appliedPatch = commentRepo.updateComment.mock.calls
|
||||
.map((c: any[]) => c[0])
|
||||
.find((p: any) => 'suggestionAppliedAt' in p);
|
||||
expect(appliedPatch).toBeUndefined();
|
||||
|
||||
expect(auditService.log).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
event: AuditEvent.COMMENT_SUGGESTION_DISMISSED,
|
||||
}),
|
||||
);
|
||||
expect(result.outcome).toBe('resolved');
|
||||
});
|
||||
|
||||
it('reply races in after the childless read (conditional delete → 0 rows) → resolves instead, does NOT hard-delete, reply survives, outcome=resolved (#338 F1)', async () => {
|
||||
// hasChildren=false selects the ephemeral branch (the read saw no replies),
|
||||
// but the atomic delete matches 0 rows because a reply landed in the window
|
||||
// between that read and the delete. The parent must NOT be hard-deleted
|
||||
// (a cascade would destroy the just-added reply); the thread is resolved.
|
||||
const { service, commentRepo, wsService, collaborationGateway } =
|
||||
makeService(false, 0);
|
||||
|
||||
const result = await service.dismissSuggestion(suggestionComment(), user());
|
||||
|
||||
// The conditional delete was attempted (and matched nothing).
|
||||
expect(commentRepo.deleteCommentIfChildless).toHaveBeenCalledWith('c-1');
|
||||
// No commentDeleted broadcast — the row (and the racing reply) survive.
|
||||
expect(wsService.emitCommentEvent).not.toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.objectContaining({ operation: 'commentDeleted' }),
|
||||
);
|
||||
// Fell back to resolving the thread.
|
||||
const resolvePatch = commentRepo.updateComment.mock.calls
|
||||
.map((c: any[]) => c[0])
|
||||
.find((p: any) => 'resolvedAt' in p);
|
||||
expect(resolvePatch.resolvedAt).toBeInstanceOf(Date);
|
||||
expect(resolvePatch.resolvedById).toBe('user-1');
|
||||
expect(collaborationGateway.handleYjsEvent).toHaveBeenCalledWith(
|
||||
'resolveCommentMark',
|
||||
'page.page-1',
|
||||
expect.objectContaining({ commentId: 'c-1', resolved: true }),
|
||||
);
|
||||
expect(result.outcome).toBe('resolved');
|
||||
});
|
||||
|
||||
it('rejects a reply (non-top-level) comment', async () => {
|
||||
const { service, commentRepo } = makeService();
|
||||
await expect(
|
||||
service.dismissSuggestion(
|
||||
suggestionComment({ parentCommentId: 'parent-1' }),
|
||||
user(),
|
||||
),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
expect(commentRepo.deleteComment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a comment without a suggested edit', async () => {
|
||||
const { service, commentRepo } = makeService();
|
||||
await expect(
|
||||
service.dismissSuggestion(
|
||||
suggestionComment({ suggestedText: null }),
|
||||
user(),
|
||||
),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
expect(commentRepo.deleteComment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an already-applied suggestion', async () => {
|
||||
const { service, commentRepo } = makeService();
|
||||
await expect(
|
||||
service.dismissSuggestion(
|
||||
suggestionComment({ suggestionAppliedAt: new Date() }),
|
||||
user(),
|
||||
),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
expect(commentRepo.deleteComment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an already-resolved thread', async () => {
|
||||
const { service, commentRepo } = makeService();
|
||||
await expect(
|
||||
service.dismissSuggestion(
|
||||
suggestionComment({ resolvedAt: new Date() }),
|
||||
user(),
|
||||
),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
expect(commentRepo.deleteComment).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -35,6 +35,12 @@ import {
|
||||
IAuditService,
|
||||
} from '../../integrations/audit/audit.service';
|
||||
|
||||
// Ephemeral-suggestion settle result (#329): 'deleted' → the comment vanished
|
||||
// (hard-delete + anchor mark stripped); 'resolved' → the thread had replies and
|
||||
// was resolved instead. Returned to the client so it can pick the optimistic
|
||||
// cache action.
|
||||
export type SuggestionOutcome = 'deleted' | 'resolved';
|
||||
|
||||
@Injectable()
|
||||
export class CommentService {
|
||||
private readonly logger = new Logger(CommentService.name);
|
||||
@@ -362,7 +368,7 @@ export class CommentService {
|
||||
comment: Comment,
|
||||
user: User,
|
||||
provenance?: AuthProvenanceData,
|
||||
): Promise<Comment> {
|
||||
): Promise<Comment & { outcome: SuggestionOutcome }> {
|
||||
// Structural guards.
|
||||
if (comment.parentCommentId) {
|
||||
throw new BadRequestException(
|
||||
@@ -449,42 +455,148 @@ export class CommentService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the applied stamps (idempotently), auto-resolve the thread and
|
||||
* broadcast + audit the applied suggestion. Shared by the applied and the
|
||||
* Dismiss ("Не применять") a suggested edit without touching the page text:
|
||||
* the suggestion disappears. Ephemeral rule (#329) — a top-level suggestion
|
||||
* comment is transient UI, so dismissing it hard-deletes the comment AND strips
|
||||
* its inline anchor mark UNLESS the thread has replies, in which case the
|
||||
* discussion is preserved by resolving it instead.
|
||||
*
|
||||
* Dismiss does NOT change the document text, so the controller authorizes it
|
||||
* with canComment (NOT canEdit). This re-checks the comment's own state so the
|
||||
* invariant holds regardless of caller.
|
||||
*/
|
||||
async dismissSuggestion(
|
||||
comment: Comment,
|
||||
user: User,
|
||||
provenance?: AuthProvenanceData,
|
||||
): Promise<Comment & { outcome: SuggestionOutcome }> {
|
||||
// Structural guards (mirror applySuggestion).
|
||||
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 to dismiss',
|
||||
);
|
||||
}
|
||||
// State guards: dismissing an already-applied or already-resolved thread is
|
||||
// meaningless. On an apply↔dismiss race the loser sees the comment already
|
||||
// gone (404 at the controller) or already resolved (this 400); the client
|
||||
// treats both as "already resolved".
|
||||
if (comment.suggestionAppliedAt) {
|
||||
throw new BadRequestException(
|
||||
'Cannot dismiss a suggested edit that was already applied',
|
||||
);
|
||||
}
|
||||
if (comment.resolvedAt) {
|
||||
throw new BadRequestException(
|
||||
'Cannot dismiss a suggested edit on a resolved comment thread',
|
||||
);
|
||||
}
|
||||
|
||||
const hasChildren = await this.commentRepo.hasChildren(comment.id);
|
||||
|
||||
if (hasChildren) {
|
||||
// Preserve the discussion: resolve (never delete) a thread with replies.
|
||||
const updatedComment = await this.resolveComment(
|
||||
comment,
|
||||
true,
|
||||
user,
|
||||
provenance,
|
||||
);
|
||||
this.auditService.log({
|
||||
event: AuditEvent.COMMENT_SUGGESTION_DISMISSED,
|
||||
resourceType: AuditResource.COMMENT,
|
||||
resourceId: comment.id,
|
||||
spaceId: comment.spaceId,
|
||||
metadata: { pageId: comment.pageId },
|
||||
});
|
||||
return { ...updatedComment, outcome: 'resolved' };
|
||||
}
|
||||
|
||||
// Ephemeral: no replies → the suggestion vanishes entirely. The atomic
|
||||
// conditional delete may still fall back to a resolve if a reply raced in
|
||||
// (see deleteEphemeralSuggestion), so the outcome is whatever it settled on.
|
||||
const settled = await this.deleteEphemeralSuggestion(comment, user, provenance);
|
||||
this.auditService.log({
|
||||
event: AuditEvent.COMMENT_SUGGESTION_DISMISSED,
|
||||
resourceType: AuditResource.COMMENT,
|
||||
resourceId: comment.id,
|
||||
spaceId: comment.spaceId,
|
||||
metadata: { pageId: comment.pageId },
|
||||
});
|
||||
return settled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the applied stamps (idempotently), then settle the suggestion under
|
||||
* the ephemeral rule (#329): a suggestion whose thread has NO replies
|
||||
* DISAPPEARS after apply (hard-delete + strip the inline anchor mark), since
|
||||
* the suggested text is now in the document and a stand-alone resolved thread
|
||||
* would only pile up an orphan anchor. A thread WITH replies is preserved by
|
||||
* auto-resolving it (the historical behaviour). Shared by the applied and the
|
||||
* idempotent "already-applied" branches of applySuggestion.
|
||||
*
|
||||
* Returns the comment augmented with `outcome` so the client can pick the
|
||||
* optimistic action ('deleted' → drop it, 'resolved' → move to the resolved
|
||||
* tab).
|
||||
*/
|
||||
private async finalizeAppliedSuggestion(
|
||||
comment: Comment,
|
||||
user: User,
|
||||
provenance?: AuthProvenanceData,
|
||||
): Promise<Comment> {
|
||||
if (!comment.suggestionAppliedAt) {
|
||||
await this.commentRepo.updateComment(
|
||||
{
|
||||
suggestionAppliedAt: new Date(),
|
||||
suggestionAppliedById: user.id,
|
||||
},
|
||||
comment.id,
|
||||
);
|
||||
): Promise<Comment & { outcome: SuggestionOutcome }> {
|
||||
const hasChildren = await this.commentRepo.hasChildren(comment.id);
|
||||
|
||||
if (hasChildren) {
|
||||
// Thread has replies → preserve the discussion: stamp applied + resolve.
|
||||
if (!comment.suggestionAppliedAt) {
|
||||
await this.commentRepo.updateComment(
|
||||
{
|
||||
suggestionAppliedAt: new Date(),
|
||||
suggestionAppliedById: user.id,
|
||||
},
|
||||
comment.id,
|
||||
);
|
||||
}
|
||||
|
||||
// Auto-resolve the thread. resolveComment handles the resolve mark, its ws
|
||||
// broadcast and the resolve notification. Stay defensive on re-entry.
|
||||
if (!comment.resolvedAt) {
|
||||
await this.resolveComment(comment, true, user, provenance);
|
||||
}
|
||||
|
||||
const updatedComment = await this.commentRepo.findById(comment.id, {
|
||||
includeCreator: true,
|
||||
includeResolvedBy: true,
|
||||
});
|
||||
|
||||
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 },
|
||||
});
|
||||
|
||||
return { ...updatedComment, outcome: 'resolved' };
|
||||
}
|
||||
|
||||
// Auto-resolve the thread. resolveComment handles the resolve mark, its ws
|
||||
// broadcast and the resolve notification. The guard above guarantees the
|
||||
// thread was open when we entered, but stay defensive on re-entry.
|
||||
if (!comment.resolvedAt) {
|
||||
await this.resolveComment(comment, true, user, provenance);
|
||||
}
|
||||
|
||||
const updatedComment = await this.commentRepo.findById(comment.id, {
|
||||
includeCreator: true,
|
||||
includeResolvedBy: true,
|
||||
});
|
||||
|
||||
this.wsService.emitCommentEvent(comment.spaceId, comment.pageId, {
|
||||
operation: 'commentUpdated',
|
||||
pageId: comment.pageId,
|
||||
comment: updatedComment,
|
||||
});
|
||||
// No replies → ephemeral: the suggested text is already in the document, so
|
||||
// the comment is redundant. Hard-delete it and strip its inline anchor. We
|
||||
// deliberately do NOT write the applied stamps first (the row is about to be
|
||||
// deleted); the audit event still records that the suggestion was applied.
|
||||
// The delete is atomic-conditional: if a reply raced in after the
|
||||
// hasChildren read, it falls back to resolving instead (outcome 'resolved').
|
||||
const settled = await this.deleteEphemeralSuggestion(comment, user, provenance);
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.COMMENT_SUGGESTION_APPLIED,
|
||||
@@ -494,7 +606,86 @@ export class CommentService {
|
||||
metadata: { pageId: comment.pageId },
|
||||
});
|
||||
|
||||
return updatedComment;
|
||||
return settled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Settle an ephemeral suggestion whose thread looked childless: remove its
|
||||
* 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.
|
||||
*
|
||||
* 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
|
||||
* ON DELETE CASCADE, so an unconditional delete here would cascade-destroy the
|
||||
* just-added reply forever. Instead we use `deleteCommentIfChildless`, which
|
||||
* re-checks childlessness under a FOR UPDATE lock inside a transaction (a plain
|
||||
* anti-join DELETE is NOT race-safe under READ COMMITTED — see the repo method
|
||||
* docstring). If it removes the row (outcome 'deleted') we broadcast the
|
||||
* deletion as before. If it removes 0 rows (a reply interleaved) we do NOT
|
||||
* hard-delete — we resolve the thread instead (outcome 'resolved'), preserving
|
||||
* the discussion and the new reply. The anchor mark is already gone by then, an
|
||||
* accepted degradation: the thread lands in the resolved tab without its inline
|
||||
* highlight — far better than losing a reply.
|
||||
*/
|
||||
private async deleteEphemeralSuggestion(
|
||||
comment: Comment,
|
||||
user: User,
|
||||
provenance?: AuthProvenanceData,
|
||||
): Promise<Comment & { outcome: SuggestionOutcome }> {
|
||||
await this.deleteCommentMark(comment, user);
|
||||
|
||||
const deletedRows = await this.commentRepo.deleteCommentIfChildless(
|
||||
comment.id,
|
||||
);
|
||||
|
||||
if (deletedRows > 0) {
|
||||
this.wsService.emitCommentEvent(comment.spaceId, comment.pageId, {
|
||||
operation: 'commentDeleted',
|
||||
pageId: comment.pageId,
|
||||
commentId: comment.id,
|
||||
});
|
||||
return { ...comment, outcome: 'deleted' };
|
||||
}
|
||||
|
||||
// A reply interleaved between the hasChildren read and this delete, so the
|
||||
// conditional delete matched nothing. Preserve the discussion + the new
|
||||
// reply by resolving the thread instead of hard-deleting it. resolveComment
|
||||
// handles the resolve patch, its ws broadcast and the resolve notification;
|
||||
// its collab call is best-effort, so the already-stripped mark is fine.
|
||||
const resolvedComment = await this.resolveComment(
|
||||
comment,
|
||||
true,
|
||||
user,
|
||||
provenance,
|
||||
);
|
||||
return { ...resolvedComment, outcome: 'resolved' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the inline `comment` mark for a comment from the collaborative
|
||||
* document. FATAL, NOT best-effort: unlike resolveComment (which keeps the row,
|
||||
* so a failed mark update is recoverable), this is used before an irreversible
|
||||
* hard-delete, so the mark removal MUST succeed or throw. Under
|
||||
* COLLAB_DISABLE_REDIS the gateway invokes the deleteCommentMark handler
|
||||
* directly (never a silent no-op) and a missing live instance surfaces as a
|
||||
* thrown error, which we let propagate so the caller aborts before deleting.
|
||||
*/
|
||||
private async deleteCommentMark(comment: Comment, user: User): Promise<void> {
|
||||
const documentName = `page.${comment.pageId}`;
|
||||
await this.collaborationGateway.handleYjsEvent(
|
||||
'deleteCommentMark',
|
||||
documentName,
|
||||
{ commentId: comment.id, user },
|
||||
);
|
||||
}
|
||||
|
||||
private async queueCommentNotification(
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { IsUUID } from 'class-validator';
|
||||
|
||||
export class DismissSuggestionDto {
|
||||
@IsUUID()
|
||||
commentId: string;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Server-side whitelist + limits for POST /api/telemetry/vitals (#355).
|
||||
*
|
||||
* The endpoint is PUBLIC (browsers post it, no auth) so it is a privacy and
|
||||
* abuse surface: everything not on these lists is silently DROPPED and the
|
||||
* request still returns 200 (never 400 — a 400 would make browsers retry).
|
||||
*/
|
||||
|
||||
// The only metric names accepted. Anything else is dropped.
|
||||
export const ALLOWED_METRIC_NAMES = new Set<string>([
|
||||
'INP',
|
||||
'LCP',
|
||||
'CLS',
|
||||
'TTFB',
|
||||
'editor_tx_ms',
|
||||
'page_open_ms',
|
||||
'longtask_ms',
|
||||
]);
|
||||
|
||||
// The only rating values accepted (web-vitals). Anything else -> null.
|
||||
export const ALLOWED_RATINGS = new Set<string>([
|
||||
'good',
|
||||
'needs-improvement',
|
||||
'poor',
|
||||
]);
|
||||
|
||||
// Max events accepted per batch; the rest are ignored.
|
||||
export const MAX_EVENTS_PER_BATCH = 50;
|
||||
|
||||
// Defence-in-depth body cap (~16KB). Fastify's global bodyLimit is far larger,
|
||||
// so we re-check the parsed payload size here and drop oversized batches.
|
||||
export const MAX_BODY_BYTES = 16 * 1024;
|
||||
|
||||
// attr is truncated to this many characters (attribution target only, no PII).
|
||||
export const MAX_ATTR_LENGTH = 120;
|
||||
|
||||
// route label sanity cap (client sends a template like /s/:space/p/:slug).
|
||||
export const MAX_ROUTE_LENGTH = 200;
|
||||
|
||||
// `client_metrics.doc_size` is a Postgres `int` (int4). A garbage/huge docSize
|
||||
// on a single event would overflow int4 and make Postgres reject the WHOLE
|
||||
// batch INSERT, losing every event in it. Values outside this range are DROPPED
|
||||
// to null (the event is still kept) so one bad field never loses the batch.
|
||||
export const DOC_SIZE_MAX = 2147483647; // 2^31 - 1 (int4 max)
|
||||
|
||||
export interface ClientMetricRow {
|
||||
name: string;
|
||||
value: number;
|
||||
rating: string | null;
|
||||
route: string | null;
|
||||
attr: string | null;
|
||||
docSize: number | null;
|
||||
workspaceId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate + normalise a single incoming event into a DB row, or return null to
|
||||
* DROP it. Pure so it is directly unit-testable. Enforces the name whitelist,
|
||||
* numeric value, rating whitelist, attr truncation and doc_size (int) coercion.
|
||||
*/
|
||||
export function sanitizeVitalEvent(
|
||||
raw: unknown,
|
||||
workspaceId: string | null,
|
||||
): ClientMetricRow | null {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const e = raw as Record<string, unknown>;
|
||||
|
||||
const name = e.name;
|
||||
if (typeof name !== 'string' || !ALLOWED_METRIC_NAMES.has(name)) return null;
|
||||
|
||||
const value =
|
||||
typeof e.value === 'number' && Number.isFinite(e.value) ? e.value : null;
|
||||
if (value === null) return null;
|
||||
|
||||
const rating =
|
||||
typeof e.rating === 'string' && ALLOWED_RATINGS.has(e.rating)
|
||||
? e.rating
|
||||
: null;
|
||||
|
||||
let route: string | null = null;
|
||||
if (typeof e.route === 'string' && e.route.length > 0) {
|
||||
route = e.route.slice(0, MAX_ROUTE_LENGTH);
|
||||
}
|
||||
|
||||
let attr: string | null = null;
|
||||
if (typeof e.attr === 'string' && e.attr.length > 0) {
|
||||
attr = e.attr.slice(0, MAX_ATTR_LENGTH);
|
||||
}
|
||||
|
||||
let docSize: number | null = null;
|
||||
if (typeof e.docSize === 'number' && Number.isFinite(e.docSize)) {
|
||||
docSize = Math.trunc(e.docSize);
|
||||
} else if (typeof e.doc_size === 'number' && Number.isFinite(e.doc_size)) {
|
||||
// Accept snake_case too, in case a client sends the raw column name.
|
||||
docSize = Math.trunc(e.doc_size as number);
|
||||
}
|
||||
// Guard the int4 column: an out-of-range docSize would overflow int4 and make
|
||||
// Postgres reject the whole batch INSERT. Drop the field (keep the event)
|
||||
// rather than lose every other event in the batch.
|
||||
if (docSize !== null && (docSize < 0 || docSize > DOC_SIZE_MAX)) {
|
||||
docSize = null;
|
||||
}
|
||||
|
||||
return { name, value, rating, route, attr, docSize, workspaceId };
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { ClientTelemetryModule } from './client-telemetry.module';
|
||||
import { VitalsController } from './vitals.controller';
|
||||
import { VitalsService } from './vitals.service';
|
||||
|
||||
// The register() gate is the CORE of the maintainer's E1=B decision: the public,
|
||||
// unauthenticated /api/telemetry/vitals endpoint must be OFF by default, so a
|
||||
// self-host deploy has no anonymous disk-fill surface into `client_metrics`. A
|
||||
// regression that inverts the flag (or a truthiness bug where "" / "false"
|
||||
// registers the route) would silently reopen that surface — pin it here.
|
||||
describe('ClientTelemetryModule.register (E1=B gate)', () => {
|
||||
const original = process.env.CLIENT_TELEMETRY_ENABLED;
|
||||
afterEach(() => {
|
||||
if (original === undefined) delete process.env.CLIENT_TELEMETRY_ENABLED;
|
||||
else process.env.CLIENT_TELEMETRY_ENABLED = original;
|
||||
});
|
||||
|
||||
it('OFF by default (flag unset) — no controller, no provider (endpoint absent)', () => {
|
||||
delete process.env.CLIENT_TELEMETRY_ENABLED;
|
||||
const mod = ClientTelemetryModule.register();
|
||||
expect(mod.controllers).toEqual([]);
|
||||
expect(mod.providers).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(['false', 'False', '0', '', 'yes', '1'])(
|
||||
'stays OFF for non-"true" value %p (no route)',
|
||||
(val) => {
|
||||
process.env.CLIENT_TELEMETRY_ENABLED = val;
|
||||
const mod = ClientTelemetryModule.register();
|
||||
expect(mod.controllers).toEqual([]);
|
||||
expect(mod.providers).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it('ON only for "true" — registers VitalsController + VitalsService', () => {
|
||||
process.env.CLIENT_TELEMETRY_ENABLED = 'true';
|
||||
const mod = ClientTelemetryModule.register();
|
||||
expect(mod.controllers).toContain(VitalsController);
|
||||
expect(mod.providers).toContain(VitalsService);
|
||||
});
|
||||
|
||||
it('ON is case-insensitive ("TRUE")', () => {
|
||||
process.env.CLIENT_TELEMETRY_ENABLED = 'TRUE';
|
||||
const mod = ClientTelemetryModule.register();
|
||||
expect(mod.controllers).toContain(VitalsController);
|
||||
expect(mod.providers).toContain(VitalsService);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { DynamicModule, Module } from '@nestjs/common';
|
||||
import { VitalsController } from './vitals.controller';
|
||||
import { VitalsService } from './vitals.service';
|
||||
|
||||
/**
|
||||
* Client perf-telemetry (#355): the public /api/telemetry/vitals sink that
|
||||
* persists web-vitals + custom client metrics into `client_metrics`.
|
||||
* Named ClientTelemetryModule to avoid confusion with the unrelated
|
||||
* integrations/telemetry (product usage ping) module.
|
||||
*
|
||||
* GATED OFF BY DEFAULT (maintainer decision E1=B). The public, unauthenticated
|
||||
* endpoint is only registered when CLIENT_TELEMETRY_ENABLED=true — otherwise the
|
||||
* route does NOT exist at all (no anonymous disk-fill surface, and no unbounded
|
||||
* `client_metrics` growth on a self-host deploy without an external pruner). The
|
||||
* client is told the same flag via window.CONFIG and skips sending when off.
|
||||
*/
|
||||
@Module({})
|
||||
export class ClientTelemetryModule {
|
||||
static register(): DynamicModule {
|
||||
// Read process.env directly (not EnvironmentService) so the toggle is
|
||||
// resolved at module-registration time, identical to how the metrics
|
||||
// subsystem reads METRICS_PORT. Absent/anything-but-"true" => OFF.
|
||||
const enabled =
|
||||
(process.env.CLIENT_TELEMETRY_ENABLED ?? '').toLowerCase() === 'true';
|
||||
|
||||
return {
|
||||
module: ClientTelemetryModule,
|
||||
controllers: enabled ? [VitalsController] : [],
|
||||
providers: enabled ? [VitalsService] : [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
HttpCode,
|
||||
Post,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { SkipThrottle, Throttle, ThrottlerGuard } from '@nestjs/throttler';
|
||||
import { FastifyRequest } from 'fastify';
|
||||
import { Public } from '../../common/decorators/public.decorator';
|
||||
import {
|
||||
AI_CHAT_THROTTLER,
|
||||
AUTH_THROTTLER,
|
||||
PAGE_TEMPLATE_THROTTLER,
|
||||
PUBLIC_SHARE_AI_THROTTLER,
|
||||
VITALS_THROTTLER,
|
||||
} from '../../integrations/throttle/throttler-names';
|
||||
import { VitalsService } from './vitals.service';
|
||||
|
||||
/**
|
||||
* POST /api/telemetry/vitals (#355) — public client perf-metrics sink.
|
||||
*
|
||||
* PUBLIC (browsers post via sendBeacon, no session) but IP-throttled. Always
|
||||
* returns 200 with no body of interest: invalid/foreign/oversized payloads are
|
||||
* silently dropped by the service rather than 400'd, so browsers never retry.
|
||||
*/
|
||||
@Controller('telemetry')
|
||||
export class VitalsController {
|
||||
constructor(private readonly vitalsService: VitalsService) {}
|
||||
|
||||
@Public()
|
||||
@UseGuards(ThrottlerGuard)
|
||||
// The global ThrottlerGuard applies ALL named throttlers to every route, so
|
||||
// every OTHER bucket must be skipped here — otherwise the strictest of them
|
||||
// (public-share AI at 5/min) would override the intended vitals limit and cap
|
||||
// this route at 5/min instead of 120/min. Skip them all so ONLY the VITALS
|
||||
// bucket below applies.
|
||||
@SkipThrottle({
|
||||
[AUTH_THROTTLER]: true,
|
||||
[AI_CHAT_THROTTLER]: true,
|
||||
[PAGE_TEMPLATE_THROTTLER]: true,
|
||||
[PUBLIC_SHARE_AI_THROTTLER]: true,
|
||||
})
|
||||
@Throttle({ [VITALS_THROTTLER]: { limit: 120, ttl: 60_000 } })
|
||||
@Post('vitals')
|
||||
@HttpCode(200)
|
||||
async vitals(
|
||||
@Body() body: unknown,
|
||||
@Req() req: FastifyRequest,
|
||||
): Promise<{ ok: true }> {
|
||||
// workspaceId is resolved by the workspace-host middleware onto req.raw when
|
||||
// the browser posts from a workspace host; null otherwise. No other PII.
|
||||
const workspaceId =
|
||||
((req.raw as unknown as { workspaceId?: string })?.workspaceId ?? null) ||
|
||||
null;
|
||||
try {
|
||||
await this.vitalsService.ingest(body, workspaceId);
|
||||
} catch {
|
||||
// Never surface storage errors to the browser; telemetry is best-effort.
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { VitalsService } from './vitals.service';
|
||||
import { MAX_ATTR_LENGTH } from './client-metrics.constants';
|
||||
|
||||
// buildRows is pure (no DB access), so a null db is fine here.
|
||||
const svc = new VitalsService(null as any);
|
||||
|
||||
describe('VitalsService.buildRows', () => {
|
||||
const WS = 'ws-uuid';
|
||||
|
||||
it('accepts a valid batch and maps whitelisted fields to rows', () => {
|
||||
const body = {
|
||||
events: [
|
||||
{ name: 'INP', value: 123.4, rating: 'good', route: '/s/:space/p/:slug' },
|
||||
{ name: 'editor_tx_ms', value: 12, route: '/s/:space/p/:slug', docSize: 4096 },
|
||||
],
|
||||
};
|
||||
const rows = svc.buildRows(body, WS);
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0]).toEqual({
|
||||
name: 'INP',
|
||||
value: 123.4,
|
||||
rating: 'good',
|
||||
route: '/s/:space/p/:slug',
|
||||
attr: null,
|
||||
docSize: null,
|
||||
workspaceId: WS,
|
||||
});
|
||||
expect(rows[1].name).toBe('editor_tx_ms');
|
||||
expect(rows[1].docSize).toBe(4096);
|
||||
expect(rows[1].workspaceId).toBe(WS);
|
||||
});
|
||||
|
||||
it('accepts a bare array body', () => {
|
||||
const rows = svc.buildRows([{ name: 'LCP', value: 1 }], WS);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].name).toBe('LCP');
|
||||
});
|
||||
|
||||
it('drops events with foreign metric names', () => {
|
||||
const rows = svc.buildRows(
|
||||
{ events: [{ name: 'evil_metric', value: 1 }, { name: 'LCP', value: 2 }] },
|
||||
WS,
|
||||
);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].name).toBe('LCP');
|
||||
});
|
||||
|
||||
it('drops events with a non-numeric or missing value', () => {
|
||||
const rows = svc.buildRows(
|
||||
{
|
||||
events: [
|
||||
{ name: 'CLS', value: 'nan' },
|
||||
{ name: 'CLS' },
|
||||
{ name: 'CLS', value: 0.1 },
|
||||
],
|
||||
},
|
||||
WS,
|
||||
);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].value).toBe(0.1);
|
||||
});
|
||||
|
||||
it('strips foreign fields and only keeps whitelisted columns', () => {
|
||||
const rows = svc.buildRows(
|
||||
{ events: [{ name: 'TTFB', value: 5, secret: 'drop-me', title: 'my page' }] },
|
||||
WS,
|
||||
);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(Object.keys(rows[0]).sort()).toEqual(
|
||||
['attr', 'docSize', 'name', 'rating', 'route', 'value', 'workspaceId'].sort(),
|
||||
);
|
||||
expect((rows[0] as any).secret).toBeUndefined();
|
||||
expect((rows[0] as any).title).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects a rating outside the allowed set (-> null)', () => {
|
||||
const rows = svc.buildRows(
|
||||
{ events: [{ name: 'INP', value: 1, rating: 'terrible' }] },
|
||||
WS,
|
||||
);
|
||||
expect(rows[0].rating).toBeNull();
|
||||
});
|
||||
|
||||
it('truncates attr to 120 chars', () => {
|
||||
const longAttr = 'a'.repeat(500);
|
||||
const rows = svc.buildRows(
|
||||
{ events: [{ name: 'INP', value: 1, attr: longAttr }] },
|
||||
WS,
|
||||
);
|
||||
expect(rows[0].attr).toHaveLength(MAX_ATTR_LENGTH);
|
||||
});
|
||||
|
||||
it('caps the batch at 50 events', () => {
|
||||
const events = Array.from({ length: 200 }, () => ({ name: 'CLS', value: 1 }));
|
||||
const rows = svc.buildRows({ events }, WS);
|
||||
expect(rows).toHaveLength(50);
|
||||
});
|
||||
|
||||
it('drops an oversized (>16KB) payload wholesale', () => {
|
||||
const events = Array.from({ length: 50 }, () => ({
|
||||
name: 'INP',
|
||||
value: 1,
|
||||
attr: 'x'.repeat(400),
|
||||
route: '/s/:space/p/:slug',
|
||||
}));
|
||||
// Serialised body far exceeds 16KB.
|
||||
const rows = svc.buildRows({ events }, WS);
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns [] for malformed bodies', () => {
|
||||
expect(svc.buildRows(null, WS)).toEqual([]);
|
||||
expect(svc.buildRows('nope', WS)).toEqual([]);
|
||||
expect(svc.buildRows({ notEvents: 1 }, WS)).toEqual([]);
|
||||
expect(svc.buildRows(42, WS)).toEqual([]);
|
||||
});
|
||||
|
||||
it('carries a null workspaceId through', () => {
|
||||
const rows = svc.buildRows({ events: [{ name: 'LCP', value: 1 }] }, null);
|
||||
expect(rows[0].workspaceId).toBeNull();
|
||||
});
|
||||
|
||||
it('drops an out-of-int4-range docSize to null without losing the batch', () => {
|
||||
const rows = svc.buildRows(
|
||||
{
|
||||
events: [
|
||||
// Garbage docSize overflowing int4 must NOT reject the whole batch:
|
||||
// the field is dropped to null and the event is kept.
|
||||
{ name: 'editor_tx_ms', value: 10, docSize: 9_999_999_999 },
|
||||
{ name: 'editor_tx_ms', value: 20, docSize: -5 },
|
||||
{ name: 'editor_tx_ms', value: 30, docSize: 4096 },
|
||||
],
|
||||
},
|
||||
WS,
|
||||
);
|
||||
expect(rows).toHaveLength(3);
|
||||
expect(rows[0].docSize).toBeNull();
|
||||
expect(rows[1].docSize).toBeNull();
|
||||
expect(rows[2].docSize).toBe(4096);
|
||||
});
|
||||
|
||||
it('keeps a docSize exactly at the int4 max', () => {
|
||||
const rows = svc.buildRows(
|
||||
{ events: [{ name: 'editor_tx_ms', value: 1, docSize: 2147483647 }] },
|
||||
WS,
|
||||
);
|
||||
expect(rows[0].docSize).toBe(2147483647);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import {
|
||||
ClientMetricRow,
|
||||
MAX_BODY_BYTES,
|
||||
MAX_EVENTS_PER_BATCH,
|
||||
sanitizeVitalEvent,
|
||||
} from './client-metrics.constants';
|
||||
|
||||
@Injectable()
|
||||
export class VitalsService {
|
||||
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
||||
|
||||
/**
|
||||
* Turn a raw request body into the (bounded, whitelisted) rows to persist.
|
||||
* Pure/synchronous so it is unit-testable without a DB. Returns [] for any
|
||||
* malformed / oversized / foreign input — the caller still responds 200.
|
||||
*/
|
||||
buildRows(body: unknown, workspaceId: string | null): ClientMetricRow[] {
|
||||
if (!body || typeof body !== 'object') return [];
|
||||
|
||||
// Defence-in-depth body cap (~16KB): drop oversized batches wholesale.
|
||||
try {
|
||||
if (JSON.stringify(body).length > MAX_BODY_BYTES) return [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Accept either a bare array or `{ events: [...] }`.
|
||||
const events = Array.isArray(body)
|
||||
? body
|
||||
: Array.isArray((body as { events?: unknown }).events)
|
||||
? ((body as { events: unknown[] }).events as unknown[])
|
||||
: null;
|
||||
if (!events) return [];
|
||||
|
||||
const rows: ClientMetricRow[] = [];
|
||||
for (const event of events) {
|
||||
if (rows.length >= MAX_EVENTS_PER_BATCH) break;
|
||||
const row = sanitizeVitalEvent(event, workspaceId);
|
||||
if (row) rows.push(row);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Batch-insert the sanitised rows in a single statement. No-op on []. */
|
||||
async insertRows(rows: ClientMetricRow[]): Promise<void> {
|
||||
if (rows.length === 0) return;
|
||||
await this.db
|
||||
.insertInto('clientMetrics')
|
||||
.values(
|
||||
rows.map((r) => ({
|
||||
name: r.name,
|
||||
value: r.value,
|
||||
rating: r.rating,
|
||||
route: r.route,
|
||||
attr: r.attr,
|
||||
docSize: r.docSize,
|
||||
workspaceId: r.workspaceId,
|
||||
})),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async ingest(body: unknown, workspaceId: string | null): Promise<void> {
|
||||
const rows = this.buildRows(body, workspaceId);
|
||||
await this.insertRows(rows);
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,11 @@ import { PageListener } from '@docmost/db/listeners/page.listener';
|
||||
import { PostgresJSDialect } from 'kysely-postgres-js';
|
||||
import * as postgres from 'postgres';
|
||||
import { normalizePostgresUrl } from '../common/helpers';
|
||||
import {
|
||||
observeDbQuery,
|
||||
isMetricsEnabled,
|
||||
} from '../integrations/metrics/metrics.registry';
|
||||
import { firstSqlToken } from '../integrations/metrics/metrics.constants';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
@@ -67,6 +72,18 @@ import { normalizePostgresUrl } from '../common/helpers';
|
||||
}),
|
||||
plugins: [new CamelCasePlugin()],
|
||||
log: (event: LogEvent) => {
|
||||
// #355 — db_query_duration_seconds, labelled by the leading SQL token
|
||||
// (bounded cardinality). Gated on isMetricsEnabled() so the token work
|
||||
// (regex + Set lookup) is skipped entirely when metrics are OFF — not
|
||||
// just observeDbQuery no-op'd — so a non-metrics deployment pays nothing
|
||||
// per query. Runs independent of the dev-only debug logging below.
|
||||
if (isMetricsEnabled()) {
|
||||
observeDbQuery(
|
||||
firstSqlToken(event.query.sql),
|
||||
event.queryDurationMillis / 1000,
|
||||
);
|
||||
}
|
||||
|
||||
if (environmentService.getNodeEnv() !== 'development') return;
|
||||
const logger = new Logger(DatabaseModule.name);
|
||||
if (process.env.DEBUG_DB?.toLowerCase() === 'true') {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { type Kysely, sql } from 'kysely';
|
||||
|
||||
/**
|
||||
* #355 — `client_metrics`: raw sink for client-side perf telemetry (web-vitals
|
||||
* + custom editor/page metrics) posted to /api/telemetry/vitals.
|
||||
*
|
||||
* The table/columns/indexes here are a FIXED contract shared with the deployed
|
||||
* Grafana infra (the `grafana_ro` role reads this table; a separate maintenance
|
||||
* container prunes rows >90d and re-GRANTs daily). No app-side retention is
|
||||
* added on purpose. Written as raw SQL to match that contract 1:1 (identity PK,
|
||||
* conditional GRANT).
|
||||
*/
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await sql`
|
||||
CREATE TABLE client_metrics (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
name text NOT NULL, -- INP|LCP|CLS|TTFB|editor_tx_ms|page_open_ms|longtask_ms
|
||||
value double precision NOT NULL,
|
||||
rating text, -- good|needs-improvement|poor (web-vitals only)
|
||||
route text, -- templated: /s/:space/p/:slug — never raw slugs
|
||||
attr text, -- attribution target, truncated to 120 chars
|
||||
doc_size int, -- editor_tx_ms only
|
||||
workspace_id uuid
|
||||
)
|
||||
`.execute(db);
|
||||
|
||||
await sql`
|
||||
CREATE INDEX idx_client_metrics_name_created
|
||||
ON client_metrics (name, created_at)
|
||||
`.execute(db);
|
||||
|
||||
await sql`
|
||||
CREATE INDEX idx_client_metrics_created
|
||||
ON client_metrics (created_at)
|
||||
`.execute(db);
|
||||
|
||||
// The read-only Grafana role only exists in the deployed environment; guard so
|
||||
// the migration still applies cleanly in dev/CI where the role is absent.
|
||||
await sql`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'grafana_ro') THEN
|
||||
GRANT SELECT ON client_metrics TO grafana_ro;
|
||||
END IF;
|
||||
END $$;
|
||||
`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await sql`DROP TABLE IF EXISTS client_metrics`.execute(db);
|
||||
}
|
||||
@@ -139,6 +139,65 @@ export class CommentRepo {
|
||||
await this.db.deleteFrom('comments').where('id', '=', commentId).execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an ephemeral suggestion row ONLY if it is still childless, returning
|
||||
* the number of rows removed (0 or 1). Closes the data-loss race in
|
||||
* dismiss/apply (#338 F4): the service reads `hasChildren`, then removes the
|
||||
* anchor mark (a collab round-trip of tens-to-hundreds of ms), then calls this.
|
||||
* `comments.parent_comment_id` is ON DELETE CASCADE, so a reply landing in that
|
||||
* window would be cascade-destroyed by a blind delete.
|
||||
*
|
||||
* A single anti-join `DELETE … WHERE NOT EXISTS(child)` is NOT sufficient under
|
||||
* READ COMMITTED: if a reply INSERT (holding FOR KEY SHARE on the parent, not
|
||||
* yet committed) interleaves, the DELETE's snapshot does not see the
|
||||
* uncommitted child, so `NOT EXISTS` is true and the parent qualifies; the
|
||||
* DELETE then blocks on the child's key-share lock, and when it wakes the row
|
||||
* was only LOCKED (not modified), so EvalPlanQual does NOT re-evaluate the
|
||||
* predicate → the parent is deleted and the just-committed reply cascades away.
|
||||
*
|
||||
* So we do a lock-then-recheck in ONE transaction:
|
||||
* 1. `SELECT id … FOR UPDATE` on the parent. FOR UPDATE conflicts with the
|
||||
* FOR KEY SHARE a concurrent reply INSERT takes on its parent (FK), so a
|
||||
* reply in the window serializes against us: it either commits before we
|
||||
* acquire the lock, or it must wait until this tx ends.
|
||||
* 2. Re-read childlessness with a FRESH statement in the SAME tx. Under RC a
|
||||
* new statement gets a new snapshot, so a reply that committed while we
|
||||
* waited on the lock is now visible.
|
||||
* 3. Delete only if still childless (return 1); otherwise return 0 so the
|
||||
* caller resolves the thread instead. The FOR UPDATE lock is held to
|
||||
* end-of-tx, so no new reply can insert between the re-check and the delete.
|
||||
*/
|
||||
async deleteCommentIfChildless(commentId: string): Promise<number> {
|
||||
return this.db.transaction().execute(async (trx) => {
|
||||
const parent = await trx
|
||||
.selectFrom('comments')
|
||||
.select('id')
|
||||
.where('id', '=', commentId)
|
||||
.forUpdate()
|
||||
.executeTakeFirst();
|
||||
|
||||
// Already gone (e.g. a racing delete won) → nothing to remove.
|
||||
if (!parent) return 0;
|
||||
|
||||
const child = await trx
|
||||
.selectFrom('comments')
|
||||
.select('id')
|
||||
.where('parentCommentId', '=', commentId)
|
||||
.limit(1)
|
||||
.executeTakeFirst();
|
||||
|
||||
// A reply exists (possibly one that just committed) → do NOT hard-delete;
|
||||
// the cascade would destroy it. Caller falls back to resolving the thread.
|
||||
if (child) return 0;
|
||||
|
||||
await trx
|
||||
.deleteFrom('comments')
|
||||
.where('id', '=', commentId)
|
||||
.execute();
|
||||
return 1;
|
||||
});
|
||||
}
|
||||
|
||||
async hasChildren(commentId: string): Promise<boolean> {
|
||||
const result = await this.db
|
||||
.selectFrom('comments')
|
||||
|
||||
+13
@@ -156,6 +156,18 @@ export interface Billing {
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export interface ClientMetrics {
|
||||
id: Generated<Int8>;
|
||||
createdAt: Generated<Timestamp>;
|
||||
name: string;
|
||||
value: number;
|
||||
rating: string | null;
|
||||
route: string | null;
|
||||
attr: string | null;
|
||||
docSize: number | null;
|
||||
workspaceId: string | null;
|
||||
}
|
||||
|
||||
export interface Comments {
|
||||
aiChatId: string | null;
|
||||
content: Json | null;
|
||||
@@ -691,6 +703,7 @@ export interface DB {
|
||||
authProviders: AuthProviders;
|
||||
backlinks: Backlinks;
|
||||
billing: Billing;
|
||||
clientMetrics: ClientMetrics;
|
||||
comments: Comments;
|
||||
favorites: Favorites;
|
||||
fileTasks: FileTasks;
|
||||
|
||||
@@ -227,6 +227,22 @@ export class EnvironmentService {
|
||||
return compactTree === 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
* Operator toggle for the public client-telemetry sink (#355). DEFAULT OFF:
|
||||
* the unauthenticated POST /api/telemetry/vitals endpoint + client vitals
|
||||
* collection are only wired when this is explicitly true. Kept SEPARATE from
|
||||
* METRICS_PORT (the server Prometheus half) because Grafana reads the
|
||||
* `client_metrics` table directly, independent of the scrape port — and
|
||||
* because `client_metrics` has no app-side retention, so an operator must opt
|
||||
* in and run an external pruner.
|
||||
*/
|
||||
isClientTelemetryEnabled(): boolean {
|
||||
const enabled = this.configService
|
||||
.get<string>('CLIENT_TELEMETRY_ENABLED', 'false')
|
||||
.toLowerCase();
|
||||
return enabled === 'true';
|
||||
}
|
||||
|
||||
getStripePublishableKey(): string {
|
||||
return this.configService.get<string>('STRIPE_PUBLISHABLE_KEY');
|
||||
}
|
||||
@@ -261,6 +277,21 @@ export class EnvironmentService {
|
||||
return disable === 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
* Deferred tool loading for the in-app AI chat (#332). When enabled, the agent
|
||||
* sees a compact <tool_catalog> and only CORE tools + the loadTools meta-tool
|
||||
* are active each step; deferred tools (the fat/rare ones + all external MCP
|
||||
* tools) load on demand. Defaults to ENABLED — the issue treats deferred
|
||||
* loading as the new behavior; set AI_CHAT_DEFERRED_TOOLS=false to restore the
|
||||
* old "all tools always active" behavior.
|
||||
*/
|
||||
isAiChatDeferredToolsEnabled(): boolean {
|
||||
const enabled = this.configService
|
||||
.get<string>('AI_CHAT_DEFERRED_TOOLS', 'true')
|
||||
.toLowerCase();
|
||||
return enabled === 'true';
|
||||
}
|
||||
|
||||
getPostHogHost(): string {
|
||||
return this.configService.get<string>('POSTHOG_HOST');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { isStreamingResponse } from './metrics.constants';
|
||||
import { observeHttp } from './metrics.registry';
|
||||
|
||||
/**
|
||||
* Resolve the BOUNDED route label for an HTTP response.
|
||||
*
|
||||
* HARD REQUIREMENT (#355): use the ROUTE TEMPLATE (`/pages/:id`), NEVER the raw
|
||||
* URL (`/pages/abc-123`), so label cardinality stays finite. Fastify exposes the
|
||||
* matched template on `req.routeOptions.url`. On 404s (no route matched) that is
|
||||
* missing → collapse to the literal `unknown`.
|
||||
*/
|
||||
export function resolveRouteLabel(req: FastifyRequest): string {
|
||||
const url = req.routeOptions?.url;
|
||||
return typeof url === 'string' && url.length > 0 ? url : 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Fastify onResponse handler that records http_request_duration_seconds.
|
||||
* No-op when metrics are disabled (the hook is only registered when enabled,
|
||||
* but the observe helpers are also guarded). Never throws into the response
|
||||
* pipeline — telemetry must not break request handling.
|
||||
*/
|
||||
export function recordHttpResponse(
|
||||
req: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
): void {
|
||||
try {
|
||||
const route = resolveRouteLabel(req);
|
||||
|
||||
// Exclude SSE/streaming responses: onResponse fires at connection close for
|
||||
// those, so it would record the stream lifetime and poison p95/p99.
|
||||
const contentType = reply.getHeader('content-type');
|
||||
if (isStreamingResponse(contentType, route)) return;
|
||||
|
||||
observeHttp(
|
||||
req.method,
|
||||
route,
|
||||
reply.statusCode,
|
||||
// Fastify measures elapsed time in ms; the metric is in seconds.
|
||||
reply.elapsedTime / 1000,
|
||||
);
|
||||
} catch {
|
||||
// Swallow: a telemetry failure must never affect the served response.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
OnModuleDestroy,
|
||||
OnModuleInit,
|
||||
} from '@nestjs/common';
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { Queue, QueueEvents } from 'bullmq';
|
||||
import { QueueName } from '../queue/constants';
|
||||
import { EnvironmentService } from '../environment/environment.service';
|
||||
import { parseRedisUrl } from '../../common/helpers';
|
||||
import {
|
||||
isMetricsEnabled,
|
||||
observeJobDuration,
|
||||
setQueueDepth,
|
||||
} from './metrics.registry';
|
||||
|
||||
const POLL_INTERVAL_MS = 15_000;
|
||||
// Cap the in-flight start-time map so a job that never emits completed/failed
|
||||
// (worker crash) cannot leak memory unbounded. Well above realistic concurrency.
|
||||
const MAX_INFLIGHT = 10_000;
|
||||
|
||||
/**
|
||||
* BullMQ instrumentation for #355:
|
||||
* - `bullmq_queue_depth{queue}`: polled from getJobCounts() every 15s.
|
||||
* - `bullmq_job_duration_seconds{queue}`: wall-clock time between a job going
|
||||
* `active` and `completed`/`failed`, observed via per-queue QueueEvents.
|
||||
*
|
||||
* Queue names are a FINITE list (the QueueName enum), so labels are bounded — no
|
||||
* job ids ever enter a label. Everything is gated on METRICS_PORT: when metrics
|
||||
* are off, onModuleInit does nothing (no interval, no QueueEvents connections).
|
||||
*/
|
||||
@Injectable()
|
||||
export class MetricsBullService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(MetricsBullService.name);
|
||||
private readonly queues: { label: string; queue: Queue }[];
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
private queueEvents: QueueEvents[] = [];
|
||||
// jobId -> start timestamp (ms). Bounded by MAX_INFLIGHT.
|
||||
private readonly inflight = new Map<string, number>();
|
||||
|
||||
constructor(
|
||||
private readonly environmentService: EnvironmentService,
|
||||
@InjectQueue(QueueName.EMAIL_QUEUE) emailQueue: Queue,
|
||||
@InjectQueue(QueueName.ATTACHMENT_QUEUE) attachmentQueue: Queue,
|
||||
@InjectQueue(QueueName.GENERAL_QUEUE) generalQueue: Queue,
|
||||
@InjectQueue(QueueName.BILLING_QUEUE) billingQueue: Queue,
|
||||
@InjectQueue(QueueName.FILE_TASK_QUEUE) fileTaskQueue: Queue,
|
||||
@InjectQueue(QueueName.SEARCH_QUEUE) searchQueue: Queue,
|
||||
@InjectQueue(QueueName.AI_QUEUE) aiQueue: Queue,
|
||||
@InjectQueue(QueueName.HISTORY_QUEUE) historyQueue: Queue,
|
||||
@InjectQueue(QueueName.NOTIFICATION_QUEUE) notificationQueue: Queue,
|
||||
@InjectQueue(QueueName.AUDIT_QUEUE) auditQueue: Queue,
|
||||
) {
|
||||
this.queues = [
|
||||
{ label: 'email', queue: emailQueue },
|
||||
{ label: 'attachment', queue: attachmentQueue },
|
||||
{ label: 'general', queue: generalQueue },
|
||||
{ label: 'billing', queue: billingQueue },
|
||||
{ label: 'file-task', queue: fileTaskQueue },
|
||||
{ label: 'search', queue: searchQueue },
|
||||
{ label: 'ai', queue: aiQueue },
|
||||
{ label: 'history', queue: historyQueue },
|
||||
{ label: 'notification', queue: notificationQueue },
|
||||
{ label: 'audit', queue: auditQueue },
|
||||
];
|
||||
}
|
||||
|
||||
onModuleInit(): void {
|
||||
if (!isMetricsEnabled()) return;
|
||||
|
||||
// Poll queue depth.
|
||||
this.timer = setInterval(() => {
|
||||
void this.pollDepths();
|
||||
}, POLL_INTERVAL_MS);
|
||||
// Do not keep the event loop alive solely for polling.
|
||||
this.timer.unref?.();
|
||||
void this.pollDepths();
|
||||
|
||||
// Wire per-queue job-duration events.
|
||||
const redisConfig = parseRedisUrl(this.environmentService.getRedisUrl());
|
||||
const connection = {
|
||||
host: redisConfig.host,
|
||||
port: redisConfig.port,
|
||||
password: redisConfig.password,
|
||||
db: redisConfig.db,
|
||||
family: redisConfig.family,
|
||||
};
|
||||
|
||||
for (const { label, queue } of this.queues) {
|
||||
const events = new QueueEvents(queue.name, { connection });
|
||||
events.on('active', ({ jobId }) => {
|
||||
if (this.inflight.size >= MAX_INFLIGHT) {
|
||||
// Drop the oldest tracked start to keep the map bounded.
|
||||
const oldest = this.inflight.keys().next().value;
|
||||
if (oldest !== undefined) this.inflight.delete(oldest);
|
||||
}
|
||||
this.inflight.set(jobId, Date.now());
|
||||
});
|
||||
const finalize = ({ jobId }: { jobId: string }) => {
|
||||
const start = this.inflight.get(jobId);
|
||||
if (start === undefined) return;
|
||||
this.inflight.delete(jobId);
|
||||
observeJobDuration(label, (Date.now() - start) / 1000);
|
||||
};
|
||||
events.on('completed', finalize);
|
||||
events.on('failed', finalize);
|
||||
events.on('error', (err) => {
|
||||
this.logger.debug(`QueueEvents error (${label}): ${err?.message}`);
|
||||
});
|
||||
this.queueEvents.push(events);
|
||||
}
|
||||
}
|
||||
|
||||
private async pollDepths(): Promise<void> {
|
||||
for (const { label, queue } of this.queues) {
|
||||
try {
|
||||
const counts = await queue.getJobCounts();
|
||||
// "Depth" = jobs not yet finished (backlog + in-flight).
|
||||
const depth =
|
||||
(counts.waiting ?? 0) +
|
||||
(counts.active ?? 0) +
|
||||
(counts.delayed ?? 0) +
|
||||
(counts.prioritized ?? 0) +
|
||||
(counts.paused ?? 0);
|
||||
setQueueDepth(label, depth);
|
||||
} catch (err) {
|
||||
this.logger.debug(
|
||||
`Failed to read job counts for ${label}: ${(err as Error)?.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
await Promise.all(
|
||||
this.queueEvents.map((e) => e.close().catch(() => undefined)),
|
||||
);
|
||||
this.queueEvents = [];
|
||||
this.inflight.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Injectable, OnModuleDestroy } from '@nestjs/common';
|
||||
import { closeMetricsServer } from './metrics.server';
|
||||
|
||||
/**
|
||||
* Ties the bare node:http metrics scrape server (started in main.ts after the
|
||||
* Fastify app is up, outside the DI container) into Nest's shutdown lifecycle.
|
||||
* With `app.enableShutdownHooks()`, onModuleDestroy fires on SIGTERM/SIGINT and
|
||||
* closes the listener so it is not left dangling (jest/e2e never exits, and a
|
||||
* prod restart doesn't leak the port). No-op when metrics are disabled.
|
||||
*/
|
||||
@Injectable()
|
||||
export class MetricsServerLifecycle implements OnModuleDestroy {
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
await closeMetricsServer();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Perf-metrics contract (#355). These names/labels are FIXED by the already
|
||||
* deployed scrape+dashboard infra (VictoriaMetrics scraping docmost:9464,
|
||||
* Grafana dashboards, alerts). Do NOT rename them.
|
||||
*/
|
||||
export const METRIC_HTTP_REQUEST_DURATION = 'http_request_duration_seconds';
|
||||
export const METRIC_DB_QUERY_DURATION = 'db_query_duration_seconds';
|
||||
export const METRIC_BULLMQ_QUEUE_DEPTH = 'bullmq_queue_depth';
|
||||
export const METRIC_BULLMQ_JOB_DURATION = 'bullmq_job_duration_seconds';
|
||||
export const METRIC_COLLAB_STORE_DURATION = 'collab_store_duration_seconds';
|
||||
|
||||
// Histogram buckets (seconds). Chosen to give useful p50/p95/p99 resolution
|
||||
// for typical web/DB latencies without exploding series cardinality.
|
||||
export const HTTP_BUCKETS = [
|
||||
0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10,
|
||||
];
|
||||
export const DB_BUCKETS = [
|
||||
0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5,
|
||||
];
|
||||
export const COLLAB_BUCKETS = [
|
||||
0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5,
|
||||
];
|
||||
export const JOB_BUCKETS = [
|
||||
0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60, 120,
|
||||
];
|
||||
|
||||
/**
|
||||
* Extract the first SQL token (select/insert/update/delete/...) from a query,
|
||||
* lower-cased, to use as a BOUNDED label for db_query_duration_seconds. Using
|
||||
* the full query text would blow up label cardinality; the leading keyword is a
|
||||
* finite set. Unknown/empty queries collapse to `other`.
|
||||
*/
|
||||
// The bounded set of SQL leading keywords used as db_query_duration_seconds
|
||||
// labels. Module-const so it is built ONCE, not per query (this runs on every DB
|
||||
// query when metrics are enabled).
|
||||
const KNOWN_SQL_TOKENS = new Set([
|
||||
'select',
|
||||
'insert',
|
||||
'update',
|
||||
'delete',
|
||||
'with',
|
||||
'begin',
|
||||
'commit',
|
||||
'rollback',
|
||||
'alter',
|
||||
'create',
|
||||
'drop',
|
||||
'truncate',
|
||||
'explain',
|
||||
]);
|
||||
|
||||
export function firstSqlToken(sql: string | undefined): string {
|
||||
if (!sql) return 'other';
|
||||
// Skip leading whitespace / comments and grab the first word.
|
||||
const match = /^[\s(]*([a-zA-Z]+)/.exec(sql);
|
||||
if (!match) return 'other';
|
||||
const token = match[1].toLowerCase();
|
||||
return KNOWN_SQL_TOKENS.has(token) ? token : 'other';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an HTTP response must be EXCLUDED from http_request_duration_seconds.
|
||||
*
|
||||
* SSE/streaming responses (the AI-chat `text/event-stream`) keep the connection
|
||||
* open for the whole conversation, so Fastify's onResponse fires only when the
|
||||
* client disconnects — recording the connection lifetime, not a response time,
|
||||
* which would poison p95/p99. We skip by content-type (authoritative) with a
|
||||
* route-suffix fallback for the two known stream endpoints.
|
||||
*/
|
||||
export function isStreamingResponse(
|
||||
contentType: unknown,
|
||||
route: string | undefined,
|
||||
): boolean {
|
||||
if (
|
||||
typeof contentType === 'string' &&
|
||||
contentType.toLowerCase().includes('text/event-stream')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// Fallback: the AI-chat stream routes (/api/ai-chat/stream,
|
||||
// /api/shares/ai/stream) both end in `/stream`.
|
||||
if (route && route.endsWith('/stream')) return true;
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MetricsBullService } from './metrics-bull.service';
|
||||
import { MetricsServerLifecycle } from './metrics-server.lifecycle';
|
||||
|
||||
/**
|
||||
* Wires the BullMQ collectors (#355). The queues are provided by the @Global
|
||||
* QueueModule (which exports BullModule), so no re-registration is needed here.
|
||||
* The HTTP histogram, DB-query and collab-store collectors live in module-level
|
||||
* singletons (metrics.registry) and are wired directly at their call sites.
|
||||
* MetricsServerLifecycle closes the scrape server on shutdown.
|
||||
*/
|
||||
@Module({
|
||||
providers: [MetricsBullService, MetricsServerLifecycle],
|
||||
})
|
||||
export class MetricsModule {}
|
||||
@@ -0,0 +1,126 @@
|
||||
import {
|
||||
collectDefaultMetrics,
|
||||
Histogram,
|
||||
Gauge,
|
||||
Registry,
|
||||
} from 'prom-client';
|
||||
import {
|
||||
COLLAB_BUCKETS,
|
||||
DB_BUCKETS,
|
||||
HTTP_BUCKETS,
|
||||
JOB_BUCKETS,
|
||||
METRIC_BULLMQ_JOB_DURATION,
|
||||
METRIC_BULLMQ_QUEUE_DEPTH,
|
||||
METRIC_COLLAB_STORE_DURATION,
|
||||
METRIC_DB_QUERY_DURATION,
|
||||
METRIC_HTTP_REQUEST_DURATION,
|
||||
} from './metrics.constants';
|
||||
|
||||
/**
|
||||
* Process-wide perf-metrics registry (#355).
|
||||
*
|
||||
* This is a plain module singleton (NOT a Nest provider) because the collectors
|
||||
* are cross-cutting: the Kysely `log` callback (built in a DI factory), the
|
||||
* Fastify onResponse hook (main.ts, before the Nest container hands out
|
||||
* providers) and the collab persistence extension all need the SAME instruments
|
||||
* without threading DI through them.
|
||||
*
|
||||
* HARD CONTRACT: when `METRICS_PORT` is unset the whole subsystem is OFF — the
|
||||
* registry is never created, `collectDefaultMetrics` never runs, and every
|
||||
* observe/set helper is a cheap no-op. Nothing is exposed on :3000.
|
||||
*/
|
||||
|
||||
// Decided once at process start. Deliberately read here (not via
|
||||
// EnvironmentService) so the toggle is identical for the DI and non-DI callers.
|
||||
const enabled = Boolean(process.env.METRICS_PORT);
|
||||
|
||||
let registry: Registry | null = null;
|
||||
let httpHist: Histogram<'method' | 'route' | 'status'> | null = null;
|
||||
let dbHist: Histogram<'op'> | null = null;
|
||||
let queueDepthGauge: Gauge<'queue'> | null = null;
|
||||
let jobHist: Histogram<'queue'> | null = null;
|
||||
let collabHist: Histogram | null = null;
|
||||
|
||||
function init(): void {
|
||||
if (registry || !enabled) return;
|
||||
|
||||
registry = new Registry();
|
||||
|
||||
// Node/runtime metrics: gives nodejs_eventloop_lag_p99_seconds, GC, heap, etc.
|
||||
collectDefaultMetrics({ register: registry });
|
||||
|
||||
httpHist = new Histogram({
|
||||
name: METRIC_HTTP_REQUEST_DURATION,
|
||||
help: 'HTTP request duration in seconds, by method, route template and status',
|
||||
labelNames: ['method', 'route', 'status'],
|
||||
buckets: HTTP_BUCKETS,
|
||||
registers: [registry],
|
||||
});
|
||||
|
||||
dbHist = new Histogram({
|
||||
name: METRIC_DB_QUERY_DURATION,
|
||||
help: 'Database query duration in seconds, by leading SQL keyword',
|
||||
labelNames: ['op'],
|
||||
buckets: DB_BUCKETS,
|
||||
registers: [registry],
|
||||
});
|
||||
|
||||
queueDepthGauge = new Gauge({
|
||||
name: METRIC_BULLMQ_QUEUE_DEPTH,
|
||||
help: 'Number of not-yet-finished BullMQ jobs per queue',
|
||||
labelNames: ['queue'],
|
||||
registers: [registry],
|
||||
});
|
||||
|
||||
jobHist = new Histogram({
|
||||
name: METRIC_BULLMQ_JOB_DURATION,
|
||||
help: 'BullMQ job processing duration in seconds, per queue',
|
||||
labelNames: ['queue'],
|
||||
buckets: JOB_BUCKETS,
|
||||
registers: [registry],
|
||||
});
|
||||
|
||||
collabHist = new Histogram({
|
||||
name: METRIC_COLLAB_STORE_DURATION,
|
||||
help: 'Collaboration onStoreDocument duration in seconds',
|
||||
buckets: COLLAB_BUCKETS,
|
||||
registers: [registry],
|
||||
});
|
||||
}
|
||||
|
||||
// Runs once when this module is first imported. Safe to call again (idempotent).
|
||||
init();
|
||||
|
||||
export function isMetricsEnabled(): boolean {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
/** The prom-client registry, or null when metrics are disabled. */
|
||||
export function getMetricsRegistry(): Registry | null {
|
||||
return registry;
|
||||
}
|
||||
|
||||
export function observeHttp(
|
||||
method: string,
|
||||
route: string,
|
||||
status: number,
|
||||
seconds: number,
|
||||
): void {
|
||||
httpHist?.observe({ method, route, status }, seconds);
|
||||
}
|
||||
|
||||
export function observeDbQuery(op: string, seconds: number): void {
|
||||
dbHist?.observe({ op }, seconds);
|
||||
}
|
||||
|
||||
export function setQueueDepth(queue: string, depth: number): void {
|
||||
queueDepthGauge?.set({ queue }, depth);
|
||||
}
|
||||
|
||||
export function observeJobDuration(queue: string, seconds: number): void {
|
||||
jobHist?.observe({ queue }, seconds);
|
||||
}
|
||||
|
||||
export function observeCollabStore(seconds: number): void {
|
||||
collabHist?.observe(seconds);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { createServer, Server } from 'node:http';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { getMetricsRegistry, isMetricsEnabled } from './metrics.registry';
|
||||
|
||||
/**
|
||||
* Start the Prometheus scrape endpoint on a SEPARATE port, taken from
|
||||
* `METRICS_PORT`. There is NO default port: when `METRICS_PORT` is unset the
|
||||
* whole metrics subsystem is OFF and this returns null. This is a bare node:http
|
||||
* server, NOT part of the Fastify app, so `/metrics` never exists on the public
|
||||
* :3000 listener.
|
||||
*
|
||||
* Returns the http.Server (so callers can close it on shutdown) or null when
|
||||
* metrics are disabled. The reference is also kept module-side so the Nest
|
||||
* lifecycle (see MetricsModule) can close it on application shutdown without
|
||||
* threading the handle back through the non-DI bootstrap.
|
||||
*/
|
||||
let metricsServer: Server | null = null;
|
||||
|
||||
export function startMetricsServer(): Server | null {
|
||||
if (!isMetricsEnabled()) return null;
|
||||
|
||||
const logger = new Logger('MetricsServer');
|
||||
const register = getMetricsRegistry();
|
||||
if (!register) return null;
|
||||
|
||||
const port = Number(process.env.METRICS_PORT);
|
||||
if (!Number.isInteger(port) || port <= 0) {
|
||||
logger.warn(
|
||||
`Invalid METRICS_PORT="${process.env.METRICS_PORT}", metrics endpoint not started`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
if (req.method === 'GET' && req.url === '/metrics') {
|
||||
try {
|
||||
const body = await register.metrics();
|
||||
res.setHeader('Content-Type', register.contentType);
|
||||
res.statusCode = 200;
|
||||
res.end(body);
|
||||
} catch (err) {
|
||||
res.statusCode = 500;
|
||||
res.end(String((err as Error)?.message ?? 'error'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
res.statusCode = 404;
|
||||
res.end();
|
||||
});
|
||||
|
||||
// Bind on all interfaces: the scraper (VictoriaMetrics) reaches this from
|
||||
// another container as docmost:9464. The port is not published to the host.
|
||||
server.listen(port, '0.0.0.0', () => {
|
||||
logger.log(`Metrics endpoint listening on :${port}/metrics`);
|
||||
});
|
||||
|
||||
server.on('error', (err) => {
|
||||
logger.error(`Metrics server error: ${err?.message}`);
|
||||
});
|
||||
|
||||
metricsServer = server;
|
||||
return server;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the metrics scrape server if one is running. Idempotent and safe to call
|
||||
* when metrics are disabled (no server was ever started). Wired into Nest's
|
||||
* shutdown lifecycle so the listener is not left dangling on shutdown.
|
||||
*/
|
||||
export function closeMetricsServer(): Promise<void> {
|
||||
const server = metricsServer;
|
||||
metricsServer = null;
|
||||
if (!server) return Promise.resolve();
|
||||
return new Promise((resolve) => {
|
||||
server.close(() => resolve());
|
||||
// server.close() stops accepting NEW connections but its callback does not
|
||||
// fire until existing keep-alive sockets drain. The scraper (VictoriaMetrics/
|
||||
// vmagent) holds an idle HTTP keep-alive socket, so without this the callback
|
||||
// — and thus shutdown — would hang until the scraper disconnects or the
|
||||
// orchestrator escalates to SIGKILL on the kill-grace window. Force-close idle
|
||||
// keep-alive sockets so close() completes immediately, and unref so this
|
||||
// server never keeps the event loop alive on its own.
|
||||
server.closeIdleConnections();
|
||||
server.unref();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { FastifyRequest } from 'fastify';
|
||||
import { resolveRouteLabel } from './http-metrics.hook';
|
||||
import { firstSqlToken, isStreamingResponse } from './metrics.constants';
|
||||
|
||||
describe('resolveRouteLabel (histogram route label)', () => {
|
||||
it('uses the ROUTE TEMPLATE, never the raw URL', () => {
|
||||
// routeOptions.url is the matched template; url is the raw path with the id.
|
||||
const req = {
|
||||
url: '/api/pages/abc-123-def',
|
||||
routeOptions: { url: '/api/pages/:id' },
|
||||
} as unknown as FastifyRequest;
|
||||
expect(resolveRouteLabel(req)).toBe('/api/pages/:id');
|
||||
expect(resolveRouteLabel(req)).not.toContain('abc-123-def');
|
||||
});
|
||||
|
||||
it('falls back to "unknown" on a 404 (no matched route template)', () => {
|
||||
const req = {
|
||||
url: '/totally/unmatched/path',
|
||||
routeOptions: {},
|
||||
} as unknown as FastifyRequest;
|
||||
expect(resolveRouteLabel(req)).toBe('unknown');
|
||||
});
|
||||
|
||||
it('falls back to "unknown" when routeOptions is missing', () => {
|
||||
const req = { url: '/x' } as unknown as FastifyRequest;
|
||||
expect(resolveRouteLabel(req)).toBe('unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isStreamingResponse (SSE exclusion)', () => {
|
||||
it('excludes text/event-stream responses by content-type', () => {
|
||||
expect(isStreamingResponse('text/event-stream', '/api/ai-chat/stream')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isStreamingResponse('text/event-stream; charset=utf-8', '/x')).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('excludes known /stream routes by suffix as a fallback', () => {
|
||||
expect(isStreamingResponse('application/json', '/api/ai-chat/stream')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isStreamingResponse(undefined, '/api/shares/ai/stream')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not exclude ordinary JSON responses', () => {
|
||||
expect(isStreamingResponse('application/json', '/api/pages/:id')).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isStreamingResponse(undefined, '/api/pages/:id')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('firstSqlToken (bounded db label)', () => {
|
||||
it('returns the lower-cased leading keyword', () => {
|
||||
expect(firstSqlToken('SELECT * FROM pages')).toBe('select');
|
||||
expect(firstSqlToken(' insert into x values (1)')).toBe('insert');
|
||||
expect(firstSqlToken('UPDATE pages SET a=1')).toBe('update');
|
||||
expect(firstSqlToken('delete from pages')).toBe('delete');
|
||||
expect(firstSqlToken('(SELECT 1)')).toBe('select');
|
||||
});
|
||||
|
||||
it('collapses unknown/empty queries to "other"', () => {
|
||||
expect(firstSqlToken('')).toBe('other');
|
||||
expect(firstSqlToken(undefined)).toBe('other');
|
||||
expect(firstSqlToken('123 not sql')).toBe('other');
|
||||
expect(firstSqlToken('vacuum analyze')).toBe('other');
|
||||
});
|
||||
});
|
||||
@@ -50,6 +50,10 @@ export class StaticModule implements OnModuleInit {
|
||||
: undefined,
|
||||
POSTHOG_HOST: this.environmentService.getPostHogHost(),
|
||||
POSTHOG_KEY: this.environmentService.getPostHogKey(),
|
||||
// #355 — mirrors the server-side CLIENT_TELEMETRY_ENABLED gate so the
|
||||
// client only collects/sends vitals when the operator opts in.
|
||||
CLIENT_TELEMETRY_ENABLED:
|
||||
this.environmentService.isClientTelemetryEnabled(),
|
||||
};
|
||||
|
||||
const windowScriptContent = `<script>window.CONFIG=${JSON.stringify(configString)};</script>`;
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
AI_CHAT_THROTTLER,
|
||||
PAGE_TEMPLATE_THROTTLER,
|
||||
PUBLIC_SHARE_AI_THROTTLER,
|
||||
VITALS_THROTTLER,
|
||||
} from './throttler-names';
|
||||
|
||||
@Module({
|
||||
@@ -29,6 +30,8 @@ import {
|
||||
{ name: PAGE_TEMPLATE_THROTTLER, ttl: 60_000, limit: 30 },
|
||||
// Anonymous public-share assistant: ~5 req/min per IP.
|
||||
{ name: PUBLIC_SHARE_AI_THROTTLER, ttl: 60_000, limit: 5 },
|
||||
// Anonymous client perf-telemetry sink: 120 batched posts/min per IP.
|
||||
{ name: VITALS_THROTTLER, ttl: 60_000, limit: 120 },
|
||||
],
|
||||
errorMessage: 'Too many requests',
|
||||
// Pass ioredis options (not a pre-built Redis instance) so
|
||||
|
||||
@@ -6,3 +6,7 @@ export const PAGE_TEMPLATE_THROTTLER = 'page-template';
|
||||
// ThrottlerGuard tracker) to bound anonymous abuse — the workspace owner pays
|
||||
// for the tokens.
|
||||
export const PUBLIC_SHARE_AI_THROTTLER = 'public-share-ai';
|
||||
// IP-keyed throttler for the anonymous client perf-telemetry sink
|
||||
// (POST /api/telemetry/vitals). Browsers batch metrics, so the limit is
|
||||
// generous; it only exists to bound abuse of the public, unauthenticated route.
|
||||
export const VITALS_THROTTLER = 'vitals';
|
||||
|
||||
@@ -16,6 +16,9 @@ import { EnvironmentService } from './integrations/environment/environment.servi
|
||||
import { SANDBOX_API_PATH } from './integrations/sandbox/sandbox.constants';
|
||||
import { resolveFrameHeader } from './common/helpers';
|
||||
import { resolveTrustProxy } from './integrations/environment/trust-proxy.util';
|
||||
import { isMetricsEnabled } from './integrations/metrics/metrics.registry';
|
||||
import { recordHttpResponse } from './integrations/metrics/http-metrics.hook';
|
||||
import { startMetricsServer } from './integrations/metrics/metrics.server';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create<NestFastifyApplication>(
|
||||
@@ -91,6 +94,19 @@ async function bootstrap() {
|
||||
done();
|
||||
});
|
||||
|
||||
// #355 — HTTP request-duration histogram. Registered ONLY when METRICS_PORT is
|
||||
// set (otherwise no collector runs at all). Uses the bounded route template
|
||||
// label and excludes SSE/streaming responses (see recordHttpResponse).
|
||||
if (isMetricsEnabled()) {
|
||||
app
|
||||
.getHttpAdapter()
|
||||
.getInstance()
|
||||
.addHook('onResponse', (req, reply, done) => {
|
||||
recordHttpResponse(req, reply);
|
||||
done();
|
||||
});
|
||||
}
|
||||
|
||||
app
|
||||
.getHttpAdapter()
|
||||
.getInstance()
|
||||
@@ -127,6 +143,9 @@ async function bootstrap() {
|
||||
'/api/workspace/create',
|
||||
'/api/workspace/joined',
|
||||
'/api/workspace/find-by-email',
|
||||
// Public client perf-telemetry sink: browsers post it without a
|
||||
// resolved workspace host, so the workspace-resolution gate must not 404 it.
|
||||
'/api/telemetry/vitals',
|
||||
// Anonymous in-RAM blob sandbox: a remote consumer fetches blobs by an
|
||||
// unguessable UUID without any workspace host context, so the
|
||||
// workspace-resolution gate must not apply.
|
||||
@@ -175,6 +194,11 @@ async function bootstrap() {
|
||||
`Listening on http://127.0.0.1:${port} / ${process.env.APP_URL}`,
|
||||
);
|
||||
});
|
||||
|
||||
// #355 — Prometheus scrape endpoint on a SEPARATE port (METRICS_PORT),
|
||||
// started after the app is up. No default port: a no-op when METRICS_PORT is
|
||||
// unset. Closed on shutdown by MetricsServerLifecycle (MetricsModule).
|
||||
startMetricsServer();
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import * as http from 'node:http';
|
||||
import { Kysely } from 'kysely';
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { MockLanguageModelV3, convertArrayToReadableStream } from 'ai/test';
|
||||
import { AiChatRepo } from '@docmost/db/repos/ai-chat/ai-chat.repo';
|
||||
import { AiChatMessageRepo } from '@docmost/db/repos/ai-chat/ai-chat-message.repo';
|
||||
@@ -146,6 +148,9 @@ describe('AiChatService.stream [integration]', () => {
|
||||
{} as any, // aiAgentRoleRepo (role is pre-resolved + passed in)
|
||||
{} as any, // pageRepo (only used when body.openPage is set)
|
||||
{} as any, // pageAccess (idem)
|
||||
// environment (#332): keep deferred tool loading OFF for this lifecycle
|
||||
// harness so the toolset/behavior is exactly as before.
|
||||
{ isAiChatDeferredToolsEnabled: () => false } as any,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -315,4 +320,174 @@ describe('AiChatService.stream [integration]', () => {
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* #332 deferred tool loading, the ON path. The riskiest property is that the
|
||||
* per-turn `activatedTools` Set is created FRESH inside each stream() call, so a
|
||||
* tool a previous turn activated via loadTools is NOT still active when the next
|
||||
* turn starts — the new turn begins "cold" (CORE + loadTools only). The unit
|
||||
* tests only exercise pure prepareAgentStep with hand-fed Sets; this pins the
|
||||
* real wiring end-to-end (loadTools.execute -> activatedTools -> prepareStep ->
|
||||
* per-step activeTools) against the real streamText loop, and proves there is no
|
||||
* cross-turn leak. We drive a MockLanguageModelV3 whose step 1 calls
|
||||
* loadTools(['createPage']) and assert, via the model's recorded per-step
|
||||
* CallOptions.tools (the AI SDK filters the provider tool list by activeTools),
|
||||
* that the deferred tool becomes active on the SAME turn's next step but NOT on a
|
||||
* fresh turn's first step.
|
||||
*/
|
||||
describe('deferred tool loading ON — per-turn activation, no leak (#332)', () => {
|
||||
// A stub deferred (non-core) tool the agent can activate. Its execute is never
|
||||
// called — the model only needs to SEE it become active — but it must be a
|
||||
// valid AI-SDK tool so the SDK includes it in a step's tool list once active.
|
||||
const createPageStub = tool({
|
||||
description: 'create a new page',
|
||||
inputSchema: z.object({ title: z.string() }),
|
||||
execute: async () => ({ id: 'p-stub' }),
|
||||
});
|
||||
|
||||
// A CORE tool in the toolset, so a cold step shows CORE tools ARE active while
|
||||
// the deferred createPage is not. `searchPages` is in CORE_TOOL_SET.
|
||||
const searchPagesStub = tool({
|
||||
description: 'search the wiki',
|
||||
inputSchema: z.object({ query: z.string() }),
|
||||
execute: async () => [],
|
||||
});
|
||||
|
||||
// Same lifecycle harness as buildService() above, but with deferred loading ON
|
||||
// and a toolset that exposes exactly one deferred tool (createPage) so it is
|
||||
// catalogued + loadable-by-name. Kept separate so the OFF scenarios are
|
||||
// untouched.
|
||||
function buildDeferredService(): AiChatService {
|
||||
return new AiChatService(
|
||||
{ getChatModel: async () => null } as any,
|
||||
aiChatRepo,
|
||||
msgRepo,
|
||||
{} as any,
|
||||
{ resolve: async () => null } as any,
|
||||
{
|
||||
forUser: async () => ({
|
||||
searchPages: searchPagesStub,
|
||||
createPage: createPageStub,
|
||||
}),
|
||||
getInAppDeferredCatalog: async () => [
|
||||
{ name: 'createPage', catalogLine: 'createPage — create a new page.' },
|
||||
],
|
||||
} as any,
|
||||
mcpClients as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
// #332: deferred tool loading ON — the property under test.
|
||||
{ isAiChatDeferredToolsEnabled: () => true } as any,
|
||||
);
|
||||
}
|
||||
|
||||
// Drive ONE stream() turn against `model` and wait for the assistant row to
|
||||
// settle (mirrors runStream, but builds the deferred-ON service).
|
||||
async function runDeferredTurn(
|
||||
model: MockLanguageModelV3,
|
||||
chatId: string,
|
||||
body: any,
|
||||
): Promise<void> {
|
||||
closeCalls = 0;
|
||||
const service = buildDeferredService();
|
||||
const { res, cleanup } = await makeRealResponse();
|
||||
try {
|
||||
await service.stream({
|
||||
user: { id: userId, workspaceId } as any,
|
||||
workspace: { id: workspaceId, name: 'WS' } as any,
|
||||
sessionId: 'sess-1',
|
||||
body,
|
||||
res: { raw: res } as any,
|
||||
signal: new AbortController().signal,
|
||||
model: model as any,
|
||||
role: null,
|
||||
} as any);
|
||||
await waitFor(async () => {
|
||||
const rows = await msgRepo.findAllByChat(chatId, workspaceId);
|
||||
return rows.some(
|
||||
(r) =>
|
||||
r.role === 'assistant' &&
|
||||
['completed', 'error', 'aborted'].includes(r.status as string),
|
||||
);
|
||||
});
|
||||
await waitFor(() => closeCalls > 0, { timeoutMs: 5_000 });
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
// Tool names the provider actually received for a recorded step (activeTools
|
||||
// filters this list, so it reflects what was active that step).
|
||||
const toolNames = (call: any): string[] =>
|
||||
((call?.tools ?? []) as any[]).map((t) => t?.name).filter(Boolean);
|
||||
|
||||
// A model that, on step 1, calls loadTools(['createPage']); on step 2, answers.
|
||||
function loadThenAnswerModel(): MockLanguageModelV3 {
|
||||
let step = 0;
|
||||
return new MockLanguageModelV3({
|
||||
doStream: async () => {
|
||||
const n = step++;
|
||||
if (n === 0) {
|
||||
return {
|
||||
stream: convertArrayToReadableStream([
|
||||
{ type: 'stream-start', warnings: [] },
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'lt1',
|
||||
toolName: 'loadTools',
|
||||
input: JSON.stringify({ names: ['createPage'] }),
|
||||
},
|
||||
{
|
||||
type: 'finish',
|
||||
finishReason: 'tool-calls',
|
||||
usage: { inputTokens: 5, outputTokens: 3, totalTokens: 8 },
|
||||
},
|
||||
] as any),
|
||||
};
|
||||
}
|
||||
return { stream: successStream() };
|
||||
},
|
||||
} as any);
|
||||
}
|
||||
|
||||
it('activates a deferred tool for the SAME turn, and a NEW turn starts cold (no leak)', async () => {
|
||||
const chatId = (await createChat(db, { workspaceId, creatorId: userId })).id;
|
||||
|
||||
// --- Turn 1: loadTools(createPage) on step 1, then answer on step 2. ---
|
||||
const model1 = loadThenAnswerModel();
|
||||
await runDeferredTurn(model1, chatId, {
|
||||
chatId,
|
||||
messages: [userUiMessage('Make me a page')],
|
||||
});
|
||||
|
||||
// The turn ran at least two steps (the load round-trip + the answer).
|
||||
expect(model1.doStreamCalls.length).toBeGreaterThanOrEqual(2);
|
||||
const step1Tools = toolNames(model1.doStreamCalls[0]);
|
||||
const step2Tools = toolNames(model1.doStreamCalls[1]);
|
||||
|
||||
// Step 1 starts cold: CORE tools + the loadTools meta-tool are active, but
|
||||
// the deferred createPage is NOT yet.
|
||||
expect(step1Tools).toContain('loadTools');
|
||||
expect(step1Tools).toContain('searchPages'); // a CORE tool, always active
|
||||
expect(step1Tools).not.toContain('createPage');
|
||||
// Step 2 of the SAME turn sees the just-activated deferred tool.
|
||||
expect(step2Tools).toContain('createPage');
|
||||
|
||||
// --- Turn 2 on the SAME chat: must start cold again. ---
|
||||
const model2 = new MockLanguageModelV3({
|
||||
doStream: async () => ({ stream: successStream() }),
|
||||
} as any);
|
||||
await runDeferredTurn(model2, chatId, {
|
||||
chatId,
|
||||
messages: [userUiMessage('And another thing')],
|
||||
});
|
||||
|
||||
const nextTurnFirstStep = toolNames(model2.doStreamCalls[0]);
|
||||
expect(nextTurnFirstStep).toContain('loadTools');
|
||||
// The activated set is per-turn: the prior turn's createPage did NOT leak,
|
||||
// so the fresh turn's first step sees it deferred again.
|
||||
expect(nextTurnFirstStep).not.toContain('createPage');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { Kysely } from 'kysely';
|
||||
import { CommentRepo } from '../../src/database/repos/comment/comment.repo';
|
||||
import {
|
||||
getTestDb,
|
||||
destroyTestDb,
|
||||
buildTestDb,
|
||||
createWorkspace,
|
||||
createSpace,
|
||||
createPage,
|
||||
createUser,
|
||||
createComment,
|
||||
} from './db';
|
||||
|
||||
/**
|
||||
* Real-DB coverage for CommentRepo.deleteCommentIfChildless (#338 F4/F6).
|
||||
*
|
||||
* This is the guard that keeps an ephemeral-suggestion hard-delete from
|
||||
* cascade-destroying a reply (`comments.parent_comment_id` is ON DELETE CASCADE).
|
||||
* The unit tests MOCK this method to 0/1, so only an int-spec actually exercises
|
||||
* the SQL — the FOR UPDATE lock-then-recheck transaction — against Postgres.
|
||||
*
|
||||
* The concurrency case is the whole point: a plain anti-join
|
||||
* `DELETE … WHERE NOT EXISTS(child)` passes (a) and (b) but SILENTLY loses a
|
||||
* reply that commits mid-operation under READ COMMITTED (EvalPlanQual does not
|
||||
* re-check a merely-locked row). Test (c) reproduces exactly that interleaving
|
||||
* and asserts the row + reply both survive.
|
||||
*/
|
||||
describe('CommentRepo.deleteCommentIfChildless [integration]', () => {
|
||||
let db: Kysely<any>;
|
||||
let repo: CommentRepo;
|
||||
let workspaceId: string;
|
||||
let spaceId: string;
|
||||
let pageId: string;
|
||||
let userId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
db = getTestDb();
|
||||
repo = new CommentRepo(db as any);
|
||||
workspaceId = (await createWorkspace(db)).id;
|
||||
spaceId = (await createSpace(db, workspaceId)).id;
|
||||
pageId = (await createPage(db, { workspaceId, spaceId })).id;
|
||||
userId = (await createUser(db, workspaceId)).id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await destroyTestDb();
|
||||
});
|
||||
|
||||
async function rowExists(id: string): Promise<boolean> {
|
||||
const row = await db
|
||||
.selectFrom('comments')
|
||||
.select('id')
|
||||
.where('id', '=', id)
|
||||
.executeTakeFirst();
|
||||
return Boolean(row);
|
||||
}
|
||||
|
||||
function seedTopLevel() {
|
||||
return createComment(db, {
|
||||
workspaceId,
|
||||
spaceId,
|
||||
pageId,
|
||||
creatorId: userId,
|
||||
selection: 'old text',
|
||||
suggestedText: 'new text',
|
||||
});
|
||||
}
|
||||
|
||||
function seedReply(parentId: string) {
|
||||
return createComment(db, {
|
||||
workspaceId,
|
||||
spaceId,
|
||||
pageId,
|
||||
creatorId: userId,
|
||||
parentCommentId: parentId,
|
||||
});
|
||||
}
|
||||
|
||||
it('(a) childless top-level → returns 1 and the row is gone', async () => {
|
||||
const parent = await seedTopLevel();
|
||||
expect(await rowExists(parent.id)).toBe(true);
|
||||
|
||||
const deleted = await repo.deleteCommentIfChildless(parent.id);
|
||||
|
||||
expect(deleted).toBe(1);
|
||||
expect(await rowExists(parent.id)).toBe(false);
|
||||
});
|
||||
|
||||
it('(b) top-level WITH a committed reply → returns 0, parent AND reply survive (gate blocks the cascade)', async () => {
|
||||
const parent = await seedTopLevel();
|
||||
const reply = await seedReply(parent.id);
|
||||
|
||||
const deleted = await repo.deleteCommentIfChildless(parent.id);
|
||||
|
||||
expect(deleted).toBe(0);
|
||||
expect(await rowExists(parent.id)).toBe(true);
|
||||
expect(await rowExists(reply.id)).toBe(true);
|
||||
});
|
||||
|
||||
it('(c) reply COMMITS mid-operation (FOR UPDATE path) → returns 0, parent + reply survive; a blind anti-join would lose the reply', async () => {
|
||||
const parent = await seedTopLevel();
|
||||
|
||||
// Second connection holds an open transaction that inserts a reply (taking
|
||||
// FOR KEY SHARE on the parent via the FK) and does NOT commit until we open
|
||||
// the gate — reproducing the "reply not yet committed" window.
|
||||
const conn2 = buildTestDb();
|
||||
let openGate!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
openGate = resolve;
|
||||
});
|
||||
let replyId: string | undefined;
|
||||
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
try {
|
||||
const replyTx = conn2.transaction().execute(async (trx) => {
|
||||
const row = await trx
|
||||
.insertInto('comments')
|
||||
.values({
|
||||
workspaceId,
|
||||
spaceId,
|
||||
pageId,
|
||||
creatorId: userId,
|
||||
parentCommentId: parent.id,
|
||||
})
|
||||
.returning(['id'])
|
||||
.executeTakeFirstOrThrow();
|
||||
replyId = row.id as string;
|
||||
// Hold the FOR KEY SHARE lock on the parent until the gate opens.
|
||||
await gate;
|
||||
});
|
||||
|
||||
// Let the reply INSERT acquire its lock before the delete starts.
|
||||
await sleep(250);
|
||||
|
||||
// deleteCommentIfChildless does SELECT ... FOR UPDATE on the parent, which
|
||||
// conflicts with the reply's FOR KEY SHARE, so it BLOCKS here.
|
||||
const deletePromise = repo.deleteCommentIfChildless(parent.id);
|
||||
|
||||
// Give the delete time to reach (and block on) its FOR UPDATE, then let the
|
||||
// reply commit. The delete then wakes, re-checks under the lock, sees the
|
||||
// now-committed reply, and returns 0.
|
||||
await sleep(250);
|
||||
openGate();
|
||||
await replyTx;
|
||||
|
||||
const deleted = await deletePromise;
|
||||
|
||||
expect(deleted).toBe(0);
|
||||
expect(await rowExists(parent.id)).toBe(true);
|
||||
expect(replyId).toBeDefined();
|
||||
expect(await rowExists(replyId!)).toBe(true);
|
||||
} finally {
|
||||
// Always release the gate (in case an assertion threw before openGate) and
|
||||
// close the extra connection so global-teardown can DROP the database.
|
||||
openGate();
|
||||
await conn2.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -132,6 +132,62 @@ export async function createUser(
|
||||
return { id: row.id as string };
|
||||
}
|
||||
|
||||
// The default group every workspace has; `groupUserRepo.addUserToDefaultGroup`
|
||||
// (invoked by acceptInvitation) looks it up by `isDefault = true`, so a
|
||||
// workspace under test must have exactly one for the accept path to complete.
|
||||
export async function createDefaultGroup(
|
||||
db: Kysely<any>,
|
||||
workspaceId: string,
|
||||
overrides: { name?: string } = {},
|
||||
): Promise<{ id: string }> {
|
||||
const id = randomUUID();
|
||||
const suffix = shortId(id);
|
||||
const row = await db
|
||||
.insertInto('groups')
|
||||
.values({
|
||||
id,
|
||||
// name is unique per workspace + NOT NULL.
|
||||
name: overrides.name ?? `group-${suffix}`,
|
||||
isDefault: true,
|
||||
workspaceId,
|
||||
})
|
||||
.returning(['id'])
|
||||
.executeTakeFirstOrThrow();
|
||||
return { id: row.id as string };
|
||||
}
|
||||
|
||||
// A pending workspace invitation. `role`/`token` are NOT NULL; `groupIds` is a
|
||||
// nullable uuid[] and `invitedById` a nullable FK to users. Returns the fields a
|
||||
// spec needs to drive acceptInvitation (id + token + the invited email).
|
||||
export async function createInvitation(
|
||||
db: Kysely<any>,
|
||||
args: {
|
||||
workspaceId: string;
|
||||
email: string;
|
||||
invitedById?: string | null;
|
||||
role?: string;
|
||||
token?: string;
|
||||
groupIds?: string[] | null;
|
||||
},
|
||||
): Promise<{ id: string; token: string; email: string }> {
|
||||
const id = randomUUID();
|
||||
const token = args.token ?? `tok-${shortId(id)}`;
|
||||
const row = await db
|
||||
.insertInto('workspaceInvitations')
|
||||
.values({
|
||||
id,
|
||||
email: args.email,
|
||||
role: args.role ?? 'member',
|
||||
token,
|
||||
groupIds: (args.groupIds ?? null) as any,
|
||||
invitedById: args.invitedById ?? null,
|
||||
workspaceId: args.workspaceId,
|
||||
})
|
||||
.returning(['id'])
|
||||
.executeTakeFirstOrThrow();
|
||||
return { id: row.id as string, token, email: args.email };
|
||||
}
|
||||
|
||||
export async function createSpace(
|
||||
db: Kysely<any>,
|
||||
workspaceId: string,
|
||||
@@ -174,6 +230,40 @@ export async function createPage(
|
||||
return { id: row.id as string };
|
||||
}
|
||||
|
||||
export async function createComment(
|
||||
db: Kysely<any>,
|
||||
args: {
|
||||
workspaceId: string;
|
||||
spaceId: string;
|
||||
pageId: string;
|
||||
creatorId?: string | null;
|
||||
parentCommentId?: string | null;
|
||||
content?: unknown;
|
||||
selection?: string | null;
|
||||
suggestedText?: string | null;
|
||||
type?: string | null;
|
||||
},
|
||||
): Promise<{ id: string }> {
|
||||
const id = randomUUID();
|
||||
const row = await db
|
||||
.insertInto('comments')
|
||||
.values({
|
||||
id,
|
||||
workspaceId: args.workspaceId,
|
||||
spaceId: args.spaceId,
|
||||
pageId: args.pageId,
|
||||
creatorId: args.creatorId ?? null,
|
||||
parentCommentId: args.parentCommentId ?? null,
|
||||
content: (args.content ?? null) as any,
|
||||
selection: args.selection ?? null,
|
||||
suggestedText: args.suggestedText ?? null,
|
||||
type: args.type ?? 'page',
|
||||
})
|
||||
.returning(['id'])
|
||||
.executeTakeFirstOrThrow();
|
||||
return { id: row.id as string };
|
||||
}
|
||||
|
||||
export async function createRole(
|
||||
db: Kysely<any>,
|
||||
args: {
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { Kysely } from 'kysely';
|
||||
import { Workspace } from '@docmost/db/types/entity.types';
|
||||
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
||||
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
|
||||
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
|
||||
import { WorkspaceInvitationService } from 'src/core/workspace/services/workspace-invitation.service';
|
||||
import {
|
||||
getTestDb,
|
||||
destroyTestDb,
|
||||
createWorkspace,
|
||||
createUser,
|
||||
createDefaultGroup,
|
||||
createInvitation,
|
||||
} from './db';
|
||||
|
||||
/**
|
||||
* acceptInvitation atomicity (issue #324, tail of #244).
|
||||
*
|
||||
* acceptInvitation() reads the invitation OUTSIDE the transaction, then inside a
|
||||
* single tx: inserts the invited user, adds them to the default group, and
|
||||
* deletes the invitation. Two accepts of the SAME invitation therefore race to
|
||||
* insert a user with the same (email, workspaceId) — which the
|
||||
* `users_email_workspace_id_unique` constraint forbids. The service catches that
|
||||
* violation and reports "Invitation already accepted".
|
||||
*
|
||||
* These specs pin the INVARIANT that path protects: no matter how many times the
|
||||
* invitation is accepted (concurrently or repeatedly), the workspace ends up
|
||||
* with exactly ONE membership for the invited email and the invitation is
|
||||
* consumed exactly once — never a duplicate user and never a half-applied state.
|
||||
*
|
||||
* The service is wired with the REAL repos (UserRepo / GroupRepo / GroupUserRepo)
|
||||
* against the test Kysely; only the peripheral collaborators that acceptInvitation
|
||||
* touches AFTER the transaction (mail, session token, billing, audit, env) are
|
||||
* stubbed, so the exercised DB write path is the production one.
|
||||
*/
|
||||
describe('WorkspaceInvitationService.acceptInvitation atomicity [integration]', () => {
|
||||
let db: Kysely<any>;
|
||||
let service: WorkspaceInvitationService;
|
||||
|
||||
// Count the memberships (user rows) for an email within a workspace — the
|
||||
// quantity the atomicity guarantee is about.
|
||||
async function membershipCount(
|
||||
workspaceId: string,
|
||||
email: string,
|
||||
): Promise<number> {
|
||||
const rows = await db
|
||||
.selectFrom('users')
|
||||
.select('id')
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.where('email', '=', email.toLowerCase())
|
||||
.execute();
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async function invitationExists(invitationId: string): Promise<boolean> {
|
||||
const row = await db
|
||||
.selectFrom('workspaceInvitations')
|
||||
.select('id')
|
||||
.where('id', '=', invitationId)
|
||||
.executeTakeFirst();
|
||||
return !!row;
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
db = getTestDb();
|
||||
|
||||
const userRepo = new UserRepo(db as any);
|
||||
const groupRepo = new GroupRepo(db as any);
|
||||
const groupUserRepo = new GroupUserRepo(db as any, groupRepo, userRepo);
|
||||
|
||||
// Collaborators used only on the post-commit success tail; safe to stub.
|
||||
const mailService = { sendToQueue: jest.fn().mockResolvedValue(undefined) };
|
||||
const domainService = {} as any;
|
||||
const tokenService = {} as any;
|
||||
const sessionService = {
|
||||
createSessionAndToken: jest.fn().mockResolvedValue('test-auth-token'),
|
||||
};
|
||||
const billingQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||
const environmentService = { isCloud: () => false };
|
||||
const auditService = { log: jest.fn() };
|
||||
|
||||
service = new WorkspaceInvitationService(
|
||||
userRepo,
|
||||
groupUserRepo,
|
||||
mailService as any,
|
||||
domainService,
|
||||
tokenService,
|
||||
sessionService as any,
|
||||
db as any,
|
||||
billingQueue as any,
|
||||
environmentService as any,
|
||||
auditService as any,
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await destroyTestDb();
|
||||
});
|
||||
|
||||
// A workspace with its default group, an inviter, and a pending invitation.
|
||||
async function seedInvite(): Promise<{
|
||||
workspace: Workspace;
|
||||
invitationId: string;
|
||||
token: string;
|
||||
email: string;
|
||||
}> {
|
||||
const { id: workspaceId } = await createWorkspace(db);
|
||||
await createDefaultGroup(db, workspaceId);
|
||||
const inviter = await createUser(db, workspaceId);
|
||||
// Distinct address per invite so specs never collide across the suite.
|
||||
const email = `invitee-${workspaceId.slice(0, 8)}@example.test`;
|
||||
const invite = await createInvitation(db, {
|
||||
workspaceId,
|
||||
email,
|
||||
invitedById: inviter.id,
|
||||
});
|
||||
|
||||
// acceptInvitation only reads id/hostname/enforceSso/emailDomains/enforceMfa
|
||||
// off the workspace; a minimal plain object is sufficient.
|
||||
const workspace = {
|
||||
id: workspaceId,
|
||||
hostname: `host-${workspaceId.slice(0, 8)}`,
|
||||
enforceSso: false,
|
||||
enforceMfa: false,
|
||||
emailDomains: [] as string[],
|
||||
} as unknown as Workspace;
|
||||
|
||||
return { workspace, invitationId: invite.id, token: invite.token, email };
|
||||
}
|
||||
|
||||
it('concurrent accepts create a single membership and consume the invitation once', async () => {
|
||||
const { workspace, invitationId, token, email } = await seedInvite();
|
||||
|
||||
const dto = { invitationId, token, name: 'Invited User', password: 'password123' };
|
||||
|
||||
// Fire two accepts of the SAME invitation at once. They race to insert the
|
||||
// same (email, workspaceId); the unique constraint lets exactly one win.
|
||||
const results = await Promise.allSettled([
|
||||
service.acceptInvitation({ ...dto }, workspace),
|
||||
service.acceptInvitation({ ...dto }, workspace),
|
||||
]);
|
||||
|
||||
const fulfilled = results.filter((r) => r.status === 'fulfilled');
|
||||
const rejected = results.filter(
|
||||
(r): r is PromiseRejectedResult => r.status === 'rejected',
|
||||
);
|
||||
|
||||
// Exactly one accept succeeds; the other is rejected.
|
||||
expect(fulfilled).toHaveLength(1);
|
||||
expect(rejected).toHaveLength(1);
|
||||
|
||||
// The loser fails via the caught unique-constraint path with the specific
|
||||
// "already accepted" message — not a half-state / generic failure.
|
||||
expect(rejected[0].reason).toBeInstanceOf(BadRequestException);
|
||||
expect(rejected[0].reason.message).toBe('Invitation already accepted');
|
||||
|
||||
// Invariant: exactly one membership, and the invitation is gone.
|
||||
expect(await membershipCount(workspace.id, email)).toBe(1);
|
||||
expect(await invitationExists(invitationId)).toBe(false);
|
||||
});
|
||||
|
||||
it('a repeated (sequential) accept does not create a duplicate membership', async () => {
|
||||
const { workspace, invitationId, token, email } = await seedInvite();
|
||||
const dto = { invitationId, token, name: 'Invited User', password: 'password123' };
|
||||
|
||||
// First accept succeeds and returns an auth token.
|
||||
const first = await service.acceptInvitation({ ...dto }, workspace);
|
||||
expect(first?.authToken).toBe('test-auth-token');
|
||||
expect(await membershipCount(workspace.id, email)).toBe(1);
|
||||
expect(await invitationExists(invitationId)).toBe(false);
|
||||
|
||||
// Re-accepting the (now consumed) invitation must be rejected and must NOT
|
||||
// add a second membership. The invitation row is gone, so this hits the
|
||||
// "Invitation not found" guard rather than the unique-constraint path.
|
||||
await expect(
|
||||
service.acceptInvitation({ ...dto }, workspace),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(await membershipCount(workspace.id, email)).toBe(1);
|
||||
});
|
||||
|
||||
it('the single created membership is added to the default group (no partial state)', async () => {
|
||||
const { workspace, invitationId, token, email } = await seedInvite();
|
||||
const dto = { invitationId, token, name: 'Invited User', password: 'password123' };
|
||||
|
||||
await Promise.allSettled([
|
||||
service.acceptInvitation({ ...dto }, workspace),
|
||||
service.acceptInvitation({ ...dto }, workspace),
|
||||
]);
|
||||
|
||||
// Resolve the one surviving user and assert the whole tx applied: they exist
|
||||
// AND are in the workspace default group (the mid-transaction step), proving
|
||||
// the winning accept committed as a whole rather than leaving a torn state.
|
||||
const user = await db
|
||||
.selectFrom('users')
|
||||
.select(['id'])
|
||||
.where('workspaceId', '=', workspace.id)
|
||||
.where('email', '=', email.toLowerCase())
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
const defaultGroup = await db
|
||||
.selectFrom('groups')
|
||||
.select(['id'])
|
||||
.where('workspaceId', '=', workspace.id)
|
||||
.where('isDefault', '=', true)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
const membership = await db
|
||||
.selectFrom('groupUsers')
|
||||
.select(['userId'])
|
||||
.where('groupId', '=', defaultGroup.id)
|
||||
.where('userId', '=', user.id)
|
||||
.execute();
|
||||
|
||||
expect(membership).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -13,5 +13,9 @@
|
||||
"types": "dist/index.d.ts",
|
||||
"dependencies": {
|
||||
"marked": "17.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitest/coverage-v8": "4.1.6",
|
||||
"vitest": "4.1.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,5 +5,21 @@ export default defineConfig({
|
||||
environment: "jsdom",
|
||||
globals: true,
|
||||
include: ["src/**/*.{test,spec}.ts"],
|
||||
// Coverage gate (issue #324). v8 provider avoids the istanbul AST-rewrite
|
||||
// that broke on this package's ESM barrel. Thresholds sit a few points
|
||||
// below the level measured on develop, over the files the suite exercises
|
||||
// (`all: false`), so the gate passes today and catches a real regression.
|
||||
coverage: {
|
||||
enabled: true,
|
||||
provider: "v8",
|
||||
reporter: ["text-summary", "text"],
|
||||
all: false,
|
||||
thresholds: {
|
||||
statements: 54,
|
||||
branches: 44,
|
||||
functions: 60,
|
||||
lines: 54,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@docmost/prosemirror-markdown": "workspace:*",
|
||||
"@tiptap/core": "3.20.4",
|
||||
"@tiptap/extension-highlight": "3.20.4",
|
||||
"@tiptap/extension-image": "3.20.4",
|
||||
@@ -38,6 +39,7 @@
|
||||
"@docmost/editor-ext": "workspace:*",
|
||||
"@types/jsdom": "^21.1.7",
|
||||
"@types/node": "^20.0.0",
|
||||
"@vitest/coverage-v8": "4.1.6",
|
||||
"fast-check": "^4.8.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vitest": "4.1.6"
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
*/
|
||||
import { dirname } from "node:path";
|
||||
import { sep } from "node:path";
|
||||
import { parsePageFile, serializePageFile } from "../lib/page-file.js";
|
||||
import { parsePageFile, serializePageFile } from "@docmost/prosemirror-markdown";
|
||||
import type { GitSyncClient } from "./client.types.js";
|
||||
import { buildVaultLayout, type PageNode } from "./layout.js";
|
||||
import {
|
||||
|
||||
@@ -26,8 +26,11 @@
|
||||
* the gitmost server drives the engine in-process (there is no standalone CLI
|
||||
* entry point).
|
||||
*/
|
||||
import { type DocmostMdMeta } from "../lib/index.js";
|
||||
import { parsePageFile, serializePageFile } from "../lib/page-file.js";
|
||||
import {
|
||||
type DocmostMdMeta,
|
||||
parsePageFile,
|
||||
serializePageFile,
|
||||
} from "@docmost/prosemirror-markdown";
|
||||
import type { GitSyncClient } from "./client.types.js";
|
||||
import type { DiffEntry } from "./git.js";
|
||||
import { VaultGit, DEFAULT_BRANCH } from "./git.js";
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
markdownToProseMirror,
|
||||
serializeDocmostMarkdownBody,
|
||||
type DocmostMdMeta,
|
||||
} from "../lib/index.js";
|
||||
} from "@docmost/prosemirror-markdown";
|
||||
|
||||
/**
|
||||
* Meta object as `exportPageBody` builds it (SPEC §4). Kept byte-for-byte
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
*/
|
||||
|
||||
// Pure converter (markdown <-> ProseMirror, file envelope, canonicalization).
|
||||
// Re-exported from the standalone `@docmost/prosemirror-markdown` package,
|
||||
// which is the single source of truth for the converter core; git-sync keeps
|
||||
// only the engine (vault/git/orchestrator) and re-surfaces the converter for
|
||||
// in-process consumers of the git-sync barrel.
|
||||
export {
|
||||
serializeDocmostMarkdown,
|
||||
serializeDocmostMarkdownBody,
|
||||
@@ -16,8 +20,8 @@ export {
|
||||
markdownToProseMirror,
|
||||
canonicalizeContent,
|
||||
docsCanonicallyEqual,
|
||||
} from "./lib/index.js";
|
||||
export type { DocmostMdMeta } from "./lib/index.js";
|
||||
} from "@docmost/prosemirror-markdown";
|
||||
export type { DocmostMdMeta } from "@docmost/prosemirror-markdown";
|
||||
|
||||
// Pure engine (no IO): reconcile planner, vault layout, sanitize, stabilize,
|
||||
// loop-guard body hash.
|
||||
@@ -123,4 +127,4 @@ export {
|
||||
} from "./engine/path-guard.js";
|
||||
export type { PathGuardIo, VaultPathUnsafeReason } from "./engine/path-guard.js";
|
||||
|
||||
export { parsePageFile, serializePageFile } from "./lib/page-file.js";
|
||||
export { parsePageFile, serializePageFile } from "@docmost/prosemirror-markdown";
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,365 +0,0 @@
|
||||
/**
|
||||
* Pure markdown -> ProseMirror conversion.
|
||||
*
|
||||
* The converter path is `markdownToProseMirror` (marked -> HTML ->
|
||||
* generateJSON) plus the two pre/post processors it needs (`preprocessCallouts`,
|
||||
* `bridgeTaskLists`). The gitmost server writes the resulting page bodies
|
||||
* natively through the collab gateway, so no websocket/Yjs write-path lives
|
||||
* here.
|
||||
*/
|
||||
import { generateJSON } from "@tiptap/html";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { marked } from "marked";
|
||||
import { docmostExtensions } from "./docmost-schema.js";
|
||||
|
||||
// Setup DOM environment for Tiptap HTML parsing in Node.js
|
||||
const dom = new JSDOM("<!DOCTYPE html><html><body></body></html>");
|
||||
global.window = dom.window as any;
|
||||
global.document = dom.window.document;
|
||||
// @ts-ignore
|
||||
global.Element = dom.window.Element;
|
||||
|
||||
/**
|
||||
* Hard ceiling above which we skip callout preprocessing entirely. The linear
|
||||
* scanner below has no quadratic blow-up, but we still cap input defensively so
|
||||
* a pathological multi-megabyte payload cannot tie up the event loop; in that
|
||||
* case the markdown is passed through verbatim (callouts are simply not
|
||||
* detected) rather than risking a slow scan.
|
||||
*/
|
||||
const MAX_CALLOUT_PREPROCESS_BYTES = 4 * 1024 * 1024; // 4 MB
|
||||
|
||||
/** Matches an opening callout fence: `:::type` (type captured, lower-cased). */
|
||||
const CALLOUT_OPEN_RE = /^:::\s*(\w+)\s*$/;
|
||||
/** Matches a bare closing callout fence: `:::`. */
|
||||
const CALLOUT_CLOSE_RE = /^:::\s*$/;
|
||||
/**
|
||||
* Matches an Obsidian-native callout opener: `> [!type]` (type captured). An
|
||||
* optional title after the type is allowed but ignored (the Docmost callout
|
||||
* schema has no title). The body is the following contiguous blockquote lines.
|
||||
*/
|
||||
const CALLOUT_BQ_OPEN_RE = /^>\s*\[!(\w+)\]/;
|
||||
/** Matches any blockquote continuation line (`>` … ). */
|
||||
const BLOCKQUOTE_LINE_RE = /^>/;
|
||||
/** Matches the start/end of a code fence (``` or ~~~), capturing the marker. */
|
||||
const CODE_FENCE_RE = /^(\s*)(`{3,}|~{3,})/;
|
||||
|
||||
/**
|
||||
* Pre-process Docmost-flavoured markdown: convert `:::type ... :::`
|
||||
* callout blocks (the syntax our markdown export produces) into HTML
|
||||
* divs that the callout extension parses. The inner content is rendered
|
||||
* through marked as regular markdown.
|
||||
*
|
||||
* Implemented as a single linear pass over the lines (no quadratic regex
|
||||
* rescan). It:
|
||||
* - tracks fenced code regions (```...``` and ~~~...~~~) and never treats a
|
||||
* `:::` line that lives inside a code fence as a callout delimiter, so a
|
||||
* callout body that itself contains a fenced code block with a `:::` line is
|
||||
* no longer corrupted;
|
||||
* - matches an opening `:::type` line with the next CLOSING `:::` at the SAME
|
||||
* nesting level, supporting NESTED callouts via a depth counter (an inner
|
||||
* `:::type` opens a deeper level and consumes a matching `:::`);
|
||||
* - emits the same `<div data-type="callout" data-callout-type="TYPE">` output
|
||||
* (inner rendered through marked) as the previous regex implementation.
|
||||
*/
|
||||
async function preprocessCallouts(markdown: string): Promise<string> {
|
||||
// Defensive cap: skip preprocessing for pathologically large inputs.
|
||||
if (markdown.length > MAX_CALLOUT_PREPROCESS_BYTES) {
|
||||
return markdown;
|
||||
}
|
||||
|
||||
// Recursively transform a slice of lines, converting top-level callouts in
|
||||
// that slice into <div> blocks and rendering their inner content (which may
|
||||
// itself contain nested callouts) through this same function.
|
||||
const transform = async (lines: string[]): Promise<string> => {
|
||||
const out: string[] = [];
|
||||
let inCodeFence = false;
|
||||
let codeFenceMarker = ""; // the exact run of backticks/tildes that opened it
|
||||
let i = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
|
||||
// Inside a code fence, only its matching closing fence is significant;
|
||||
// everything else (including `:::` lines) is copied through verbatim.
|
||||
if (inCodeFence) {
|
||||
out.push(line);
|
||||
const fence = line.match(CODE_FENCE_RE);
|
||||
if (fence && fence[2].startsWith(codeFenceMarker[0]) &&
|
||||
fence[2].length >= codeFenceMarker.length) {
|
||||
inCodeFence = false;
|
||||
codeFenceMarker = "";
|
||||
}
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// A code fence opening outside any callout body: enter code-fence mode.
|
||||
const fenceOpen = line.match(CODE_FENCE_RE);
|
||||
if (fenceOpen) {
|
||||
inCodeFence = true;
|
||||
codeFenceMarker = fenceOpen[2];
|
||||
out.push(line);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// An opening callout fence: scan forward (with code-fence and nested
|
||||
// callout awareness) for its matching closing `:::` at the same level.
|
||||
const open = line.match(CALLOUT_OPEN_RE);
|
||||
if (open) {
|
||||
const type = open[1].toLowerCase();
|
||||
const bodyLines: string[] = [];
|
||||
let depth = 1;
|
||||
let innerInCodeFence = false;
|
||||
let innerCodeFenceMarker = "";
|
||||
let j = i + 1;
|
||||
for (; j < lines.length; j++) {
|
||||
const bl = lines[j];
|
||||
if (innerInCodeFence) {
|
||||
const f = bl.match(CODE_FENCE_RE);
|
||||
if (f && f[2].startsWith(innerCodeFenceMarker[0]) &&
|
||||
f[2].length >= innerCodeFenceMarker.length) {
|
||||
innerInCodeFence = false;
|
||||
innerCodeFenceMarker = "";
|
||||
}
|
||||
bodyLines.push(bl);
|
||||
continue;
|
||||
}
|
||||
const innerFence = bl.match(CODE_FENCE_RE);
|
||||
if (innerFence) {
|
||||
innerInCodeFence = true;
|
||||
innerCodeFenceMarker = innerFence[2];
|
||||
bodyLines.push(bl);
|
||||
continue;
|
||||
}
|
||||
if (CALLOUT_OPEN_RE.test(bl)) {
|
||||
depth++;
|
||||
bodyLines.push(bl);
|
||||
continue;
|
||||
}
|
||||
if (CALLOUT_CLOSE_RE.test(bl)) {
|
||||
depth--;
|
||||
if (depth === 0) break; // matching close for THIS callout
|
||||
bodyLines.push(bl);
|
||||
continue;
|
||||
}
|
||||
bodyLines.push(bl);
|
||||
}
|
||||
|
||||
if (j < lines.length) {
|
||||
// Found the matching closing fence: render the body (recursively, so
|
||||
// nested callouts are handled) and emit the callout div.
|
||||
const inner = await transform(bodyLines);
|
||||
const renderedInner = await marked.parse(inner);
|
||||
out.push(
|
||||
`\n<div data-type="callout" data-callout-type="${type}">${renderedInner}</div>\n`,
|
||||
);
|
||||
i = j + 1; // skip past the closing `:::`
|
||||
continue;
|
||||
}
|
||||
// No matching close (unterminated callout): treat the opener as a
|
||||
// literal line and continue, preserving the original text.
|
||||
out.push(line);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// An Obsidian-native callout: `> [!type]` opener; the body is the following
|
||||
// CONTIGUOUS blockquote (`>`-prefixed) lines. Strip ONE blockquote level and
|
||||
// recurse so nested callouts (`> > [!type]`) are handled, then emit the same
|
||||
// callout div the `:::` path produces. A normal blockquote (no `[!type]` on
|
||||
// its first line) does not match and stays a blockquote.
|
||||
const bqOpen = line.match(CALLOUT_BQ_OPEN_RE);
|
||||
if (bqOpen) {
|
||||
const type = bqOpen[1].toLowerCase();
|
||||
const bodyLines: string[] = [];
|
||||
let j = i + 1;
|
||||
for (; j < lines.length; j++) {
|
||||
if (!BLOCKQUOTE_LINE_RE.test(lines[j])) break;
|
||||
bodyLines.push(lines[j].replace(/^>\s?/, ""));
|
||||
}
|
||||
const inner = await transform(bodyLines);
|
||||
const renderedInner = await marked.parse(inner);
|
||||
out.push(
|
||||
`\n<div data-type="callout" data-callout-type="${type}">${renderedInner}</div>\n`,
|
||||
);
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
|
||||
out.push(line);
|
||||
i++;
|
||||
}
|
||||
|
||||
return out.join("\n");
|
||||
};
|
||||
|
||||
return transform(markdown.split("\n"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridge marked's checkbox lists to TipTap task lists.
|
||||
*
|
||||
* marked renders GitHub task list items (`- [x] done`) as a plain
|
||||
* `<ul><li><p><input type="checkbox" checked> text</p></li></ul>` WITHOUT the
|
||||
* markup TipTap's TaskList/TaskItem extensions parse. This rewrites such lists
|
||||
* into the shape those extensions expect:
|
||||
* TaskList parseHTML matches `ul[data-type="taskList"]`,
|
||||
* TaskItem matches `li[data-type="taskItem"]`,
|
||||
* the checked state is read from `data-checked === "true"`.
|
||||
*
|
||||
* A list is only converted when it has at least one `<li>` and EVERY direct
|
||||
* `<li>` contains a checkbox input. Both `<ul>` and `<ol>` are considered: a
|
||||
* numbered checklist (`1. [x] a`, which marked renders as an `<ol>` of checkbox
|
||||
* `<li>`s) would otherwise lose its task state. TipTap task lists are unordered,
|
||||
* so a matching `<ol>` is emitted as `data-type="taskList"` exactly like a
|
||||
* `<ul>`. Mixed or ordinary lists (including ordinary `<ol>` lists) are left
|
||||
* untouched so they keep rendering as bullet/numbered lists. The marked `<p>`
|
||||
* wrapper is kept inside the `<li>` because TaskItem content allows paragraphs.
|
||||
*/
|
||||
function bridgeTaskLists(html: string): string {
|
||||
// Cheap early-out: if the markup contains no checkbox input at all there is
|
||||
// nothing to bridge, so skip the expensive JSDOM parse entirely. This is the
|
||||
// common case (most pages have no task lists).
|
||||
if (!/type=["']?checkbox/i.test(html)) {
|
||||
return html;
|
||||
}
|
||||
// Defensive cap (consistent with preprocessCallouts): skip the bridge for
|
||||
// pathologically large inputs rather than running a second expensive JSDOM
|
||||
// parse on a multi-megabyte payload. The markup is passed through verbatim.
|
||||
if (html.length > MAX_CALLOUT_PREPROCESS_BYTES) {
|
||||
return html;
|
||||
}
|
||||
const dom = new JSDOM(html);
|
||||
const document = dom.window.document;
|
||||
// Collect the checkbox(es) that belong to THIS <li> directly: either direct
|
||||
// child <input type="checkbox"> elements or ones inside the <li>'s direct <p>
|
||||
// child (the shape marked emits: `<li><p><input type="checkbox"> text</p></li>`).
|
||||
// Checkboxes nested deeper (e.g. inside a child <ul>/<ol>) are excluded so a
|
||||
// bullet <li> that merely contains a nested task sublist is not misdetected.
|
||||
// Raw inline HTML can put more than one checkbox in a single <li>; we gather
|
||||
// ALL of them so none survive into the converted item.
|
||||
const directCheckboxes = (li: Element): Element[] => {
|
||||
const found: Element[] = [];
|
||||
for (const child of Array.from(li.children)) {
|
||||
if (
|
||||
child.tagName === "INPUT" &&
|
||||
child.getAttribute("type") === "checkbox"
|
||||
) {
|
||||
found.push(child);
|
||||
continue;
|
||||
}
|
||||
if (child.tagName === "P") {
|
||||
for (const inp of Array.from(
|
||||
child.querySelectorAll(":scope > input[type='checkbox']"),
|
||||
)) {
|
||||
found.push(inp);
|
||||
}
|
||||
}
|
||||
}
|
||||
return found;
|
||||
};
|
||||
// Both <ul> and <ol> are candidates: an <ol> whose every direct <li> carries
|
||||
// its own checkbox is a numbered checklist that must also become a taskList.
|
||||
const lists = Array.from(document.querySelectorAll("ul, ol"));
|
||||
for (const list of lists) {
|
||||
// Only consider DIRECT child <li> elements; nested lists are handled by
|
||||
// their own iteration of the outer loop.
|
||||
const items = Array.from(list.children).filter(
|
||||
(child) => child.tagName === "LI",
|
||||
);
|
||||
if (items.length === 0) continue;
|
||||
const itemCheckboxes = items.map((li) => directCheckboxes(li));
|
||||
// Convert only when every direct <li> carries at least one OWN checkbox.
|
||||
if (!itemCheckboxes.every((boxes) => boxes.length > 0)) continue;
|
||||
|
||||
// A numbered checklist arrives as an <ol>. We must NOT leave the tag as
|
||||
// <ol> while tagging it data-type="taskList": generateJSON would then match
|
||||
// BOTH the orderedList rule (tag ol) and the taskList rule (data-type),
|
||||
// emitting a phantom empty orderedList beside the real taskList. So rename a
|
||||
// qualifying <ol> to a <ul> — move its <li> children over and replace it —
|
||||
// leaving only the taskList rule to match. Already-<ul> lists are unchanged.
|
||||
let target: Element = list;
|
||||
if (list.tagName === "OL") {
|
||||
const ul = document.createElement("ul");
|
||||
// Carry over existing attributes (e.g. class) so nothing is silently lost.
|
||||
for (const attr of Array.from(list.attributes)) {
|
||||
ul.setAttribute(attr.name, attr.value);
|
||||
}
|
||||
// Move every child node (including the <li>s we collected) into the <ul>.
|
||||
while (list.firstChild) {
|
||||
ul.appendChild(list.firstChild);
|
||||
}
|
||||
list.replaceWith(ul);
|
||||
target = ul;
|
||||
}
|
||||
|
||||
target.setAttribute("data-type", "taskList");
|
||||
items.forEach((li, index) => {
|
||||
const boxes = itemCheckboxes[index];
|
||||
// The first checkbox determines the checked state (matches the previous
|
||||
// single-checkbox behaviour); any extras only need removing.
|
||||
const input = boxes[0] ?? null;
|
||||
li.setAttribute("data-type", "taskItem");
|
||||
const checked =
|
||||
input != null &&
|
||||
(input.hasAttribute("checked") || (input as any).checked);
|
||||
li.setAttribute("data-checked", checked ? "true" : "false");
|
||||
// Remove ALL direct checkbox inputs so none survive into the content
|
||||
// (a raw-inline-HTML <li> may carry more than one).
|
||||
for (const box of boxes) {
|
||||
box.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
return document.body.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively strip content-less paragraph nodes from a generated doc.
|
||||
*
|
||||
* A block-level atom whose markdown form is INLINE (e.g. the block `image`'s
|
||||
* ``, or a bare media element) is wrapped by marked in a <p>; the schema
|
||||
* then HOISTS the block atom out of that paragraph, leaving an EMPTY paragraph
|
||||
* sibling. On the next export that empty `<p>` renders to "" and the doc "\n\n"
|
||||
* join injects a phantom blank gap, so the markdown is not byte-stable.
|
||||
*
|
||||
* Markdown blank lines are separators, never content, so generateJSON only ever
|
||||
* produces an empty paragraph as such a hoist artifact — removing them is safe
|
||||
* and general (it also subsumes the <div>-wrapper workaround the `video` case
|
||||
* uses). We remove ONLY `type === 'paragraph'` nodes whose `content` is absent
|
||||
* or an empty array; every other node (including atoms without `content`) is
|
||||
* preserved, and we recurse into the content of any node that has children.
|
||||
*/
|
||||
function stripEmptyParagraphs(node: any): any {
|
||||
if (!node || !Array.isArray(node.content)) {
|
||||
// Atom / leaf node (no children to recurse into): keep as-is.
|
||||
return node;
|
||||
}
|
||||
const mapped = node.content.map((child: any) => stripEmptyParagraphs(child));
|
||||
const isEmptyParagraph = (child: any): boolean =>
|
||||
!!child &&
|
||||
child.type === "paragraph" &&
|
||||
(!Array.isArray(child.content) || child.content.length === 0);
|
||||
const filtered = mapped.filter((child: any) => !isEmptyParagraph(child));
|
||||
// Schema-validity guard: several nodes require NON-empty block content
|
||||
// (`content: "block+"` — tableCell, tableHeader, blockquote, column, callout,
|
||||
// and the doc root). For an empty one of those, generateJSON materializes a
|
||||
// single empty paragraph as its OBLIGATORY content — that is not a hoist
|
||||
// artifact. If stripping would empty the container, keep ONE empty paragraph
|
||||
// so the result stays schema-valid (an empty cell/quote must not become `[]`).
|
||||
const cleaned =
|
||||
filtered.length === 0 && mapped.length > 0 ? [mapped[0]] : filtered;
|
||||
return { ...node, content: cleaned };
|
||||
}
|
||||
|
||||
/** Convert markdown to a ProseMirror doc using the full Docmost schema. */
|
||||
export async function markdownToProseMirror(
|
||||
markdownContent: string,
|
||||
): Promise<any> {
|
||||
const withCallouts = await preprocessCallouts(markdownContent);
|
||||
const html = await marked.parse(withCallouts);
|
||||
const bridged = bridgeTaskLists(html);
|
||||
const doc = generateJSON(bridged, docmostExtensions);
|
||||
return stripEmptyParagraphs(doc);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { applyPushActions, LAST_PUSHED_REF } from '../src/engine/push';
|
||||
import { bodyHash } from '../src/engine/loop-guard';
|
||||
import type { ApplyPushDeps, PushActions } from '../src/engine/push';
|
||||
import { parsePageFile, serializePageFile } from '../src/lib/page-file';
|
||||
import { parsePageFile, serializePageFile } from '@docmost/prosemirror-markdown';
|
||||
|
||||
// The Docmost space this vault mirrors (native files carry no spaceId; the run
|
||||
// supplies it). A CREATE targets this space.
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
MetaSide,
|
||||
RenameMoveAction,
|
||||
} from '../src/engine/push';
|
||||
import type { DocmostMdMeta } from '../src/lib/index';
|
||||
import type { DocmostMdMeta } from '@docmost/prosemirror-markdown';
|
||||
|
||||
// FS→Docmost push #3 (SPEC §5/§6/§16). `classifyRenameMoves` is the PURE half of
|
||||
// the move/rename apply: it resolves each `{pageId, oldPath, newPath}` into the
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { computePushActions } from '../src/engine/push';
|
||||
import type { DiffEntry, MetaSide } from '../src/engine/push';
|
||||
import type { DocmostMdMeta } from '../src/lib/index';
|
||||
import type { DocmostMdMeta } from '@docmost/prosemirror-markdown';
|
||||
|
||||
// FS→Docmost push, FIRST increment (SPEC §6). `computePushActions` is the PURE
|
||||
// half: it classifies each `git diff --name-status` row into a Docmost action by
|
||||
|
||||
@@ -8,7 +8,7 @@ import { runCycle } from "../src/engine/cycle";
|
||||
import type { CycleFs } from "../src/engine/cycle";
|
||||
import { VaultGit } from "../src/engine/git";
|
||||
import type { Settings } from "../src/engine/settings";
|
||||
import { serializeDocmostMarkdownBody } from "../src/lib/index";
|
||||
import { serializeDocmostMarkdownBody } from "@docmost/prosemirror-markdown";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { firstDivergence } from './roundtrip-helpers';
|
||||
import { applyPullActions } from '../src/engine/pull';
|
||||
import type { PullActions, ApplyPullActionsDeps } from '../src/engine/pull';
|
||||
import type { DeletionDecision } from '../src/engine/reconcile';
|
||||
import { serializePageFile, parsePageFile } from '../src/lib/page-file';
|
||||
import { serializePageFile, parsePageFile } from '@docmost/prosemirror-markdown';
|
||||
|
||||
// Engine-layer coverage gaps flagged by the PR #119 reviewers (test-strategy
|
||||
// report, Module 2 `src/engine`). Each block targets a specific under-covered
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user