Compare commits
104 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f7d96e09d | |||
| d218b3a39e | |||
| f0778cb85a | |||
| 8f7da77939 | |||
| e6a861bdaf | |||
| 5b835fc185 | |||
| cec50c3ce4 | |||
| f36a2def73 | |||
| 67dca8c10e | |||
| 5d45f5a85e | |||
| 320b200ac8 | |||
| 539512c4c8 | |||
| edc5dae103 | |||
| 67fa0d1a28 | |||
| 91d674fea6 | |||
| bb5bb52244 | |||
| f9cd3e6318 | |||
| c838fdeebe | |||
| f2d12fd2cd | |||
| fcc9ae0c24 | |||
| e4ff146ab0 | |||
| c7e034cab9 | |||
| 123e981808 | |||
| 7abce93543 | |||
| 0750a6fd34 | |||
| d833e5adb1 | |||
| f3dbcec0fd | |||
| 63f948df10 | |||
| fbaaa84419 | |||
| 32cb9eb1e3 | |||
| b47751349f | |||
| b7e5cb6970 | |||
| 906733b5c8 | |||
| f020739bfd | |||
| 22e3fcdeba | |||
| 7179f8a5b2 | |||
| fe4adf23a0 | |||
| eefe17600c | |||
| 32e99c6e42 | |||
| e48d7720e9 | |||
| 42e618ec7f | |||
| 857a0064f7 | |||
| daf6c9ea16 | |||
| 9e69d917ee | |||
| 2594828758 | |||
| b5ce63a956 | |||
| e777ebcf4f | |||
| abd6e3948b | |||
| 5125296bfa | |||
| 452a752264 | |||
| a40a00d5c5 | |||
| 81c0226be7 | |||
| d5079aa1d8 | |||
| b536a41ad3 | |||
| 28d2560dfd | |||
| 52959de2f3 | |||
| 5da12e89f9 | |||
| 3a91e0eca9 | |||
| 2e83c9cebf | |||
| f6d22a59a6 | |||
| 6baad935f9 | |||
| d255afa611 | |||
| 73c5c44301 | |||
| 8c42c4f0d6 | |||
| 071eae4e2a | |||
| a91405632e | |||
| 5d4eb8ede2 | |||
| aa1ee64b7a | |||
| 53febfd5b9 | |||
| a2ac08c04c | |||
| 40ca04eb08 | |||
| 393875d910 | |||
| c3dbee9fbf | |||
| ea1f8da906 | |||
| 9baaf1ea58 | |||
| 71375e25ee | |||
| e528988d71 | |||
| dc7a0ec9f5 | |||
| 969c00aaf1 | |||
| 085a30575f | |||
| 95bc9fe98d | |||
| cca0bfe306 | |||
| 0dbf85b129 | |||
| fb357cd52e | |||
| 177d8a31d4 | |||
| 8fa32e8438 | |||
| 807ff1f5f5 | |||
| fa89cba023 | |||
| 3386bf2865 | |||
| 98253cf614 | |||
| 181a8330f3 | |||
| 02daccc453 | |||
| d06cf97ed6 | |||
| 04032ae677 | |||
| d9d1d54aaa | |||
| 593f181bbc | |||
| 582e1976cc | |||
| e0e01157c2 | |||
| 8373360a67 | |||
| e2493cafa9 | |||
| 5a4d9f84d7 | |||
| 70bd0dba4d | |||
| b0cd4bd6cf | |||
| 56ab17fbc2 |
@@ -223,3 +223,45 @@ 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
|
||||
|
||||
# --- GIT-SYNC (native two-way Docmost <-> git Markdown sync) ---
|
||||
# Master switch. Off by default. When 'true', GIT_SYNC_SERVICE_USER_ID below is
|
||||
# REQUIRED (the service account that git-originated create/move/rename/delete are
|
||||
# attributed to) — the server refuses to boot with sync enabled and no user id.
|
||||
# GIT_SYNC_ENABLED=false
|
||||
#
|
||||
# Serve the per-space vaults over smart-HTTP (the /git host). Defaults to
|
||||
# GIT_SYNC_ENABLED when unset.
|
||||
# GIT_SYNC_HTTP_ENABLED=false
|
||||
#
|
||||
# REQUIRED when GIT_SYNC_ENABLED=true: id of the user that git-originated page
|
||||
# operations (create / move / rename / delete) are attributed to.
|
||||
# GIT_SYNC_SERVICE_USER_ID=
|
||||
#
|
||||
# Where the per-space working vaults live (non-bare repos; the engine needs a
|
||||
# working tree).
|
||||
# Defaults to "<DATA_DIR or ./data>/git-sync".
|
||||
# GIT_SYNC_DATA_DIR=
|
||||
#
|
||||
# SCAFFOLDING for the DEFERRED remote-push feature (SPEC §7) — NOT yet
|
||||
# implemented and currently INERT. The vendored sync engine does not consume
|
||||
# this value anywhere (git push to a remote is deferred), so setting it has NO
|
||||
# effect today: vaults remain local-only regardless. It is validated and carried
|
||||
# only so the wiring is ready for when remote push lands. The intended future
|
||||
# shape is a per-space URL template where the literal "{spaceId}" is substituted
|
||||
# per space (e.g. git@host:vault-{spaceId}.git).
|
||||
# GIT_SYNC_REMOTE_TEMPLATE=
|
||||
#
|
||||
# Poll-safety interval in ms — the cadence of the background reconcile cycle
|
||||
# (default: 15000).
|
||||
# GIT_SYNC_POLL_INTERVAL_MS=15000
|
||||
#
|
||||
# Debounce window in ms for collapsing bursts of page edits into one sync cycle
|
||||
# (default: 2000).
|
||||
# GIT_SYNC_DEBOUNCE_MS=2000
|
||||
#
|
||||
# Watchdog timeout in ms for the spawned `git http-backend` process serving a
|
||||
# git smart-HTTP push (default: 120000). A stalled/hung receive-pack is killed
|
||||
# after this deadline so it cannot hold the per-space lock forever.
|
||||
# GIT_SYNC_BACKEND_TIMEOUT_MS=120000
|
||||
#
|
||||
|
||||
@@ -72,6 +72,13 @@ jobs:
|
||||
- name: Build editor-ext
|
||||
run: pnpm --filter @docmost/editor-ext build
|
||||
|
||||
# git-sync and mcp are no longer committed in built form (build/ is
|
||||
# gitignored), so CI must compile them: the server resolves both via their
|
||||
# built build/index.js. The server pretest also builds them, but building
|
||||
# here keeps it explicit and independent of pnpm lifecycle ordering.
|
||||
- name: Build git-sync and mcp
|
||||
run: pnpm --filter @docmost/git-sync build && pnpm --filter @docmost/mcp build
|
||||
|
||||
- name: Run unit tests
|
||||
run: pnpm -r test
|
||||
|
||||
|
||||
@@ -5,6 +5,12 @@ data
|
||||
# compiled output
|
||||
/dist
|
||||
/node_modules
|
||||
# workspace package node_modules (pnpm symlinks — never commit; they bake
|
||||
# machine-local store paths) and the git-sync compiled output (built in CI/Docker
|
||||
# via `pnpm build`, never committed, so src/ and prod can never silently diverge).
|
||||
packages/*/node_modules/
|
||||
packages/git-sync/build/
|
||||
packages/mcp/build/
|
||||
|
||||
# Logs
|
||||
logs
|
||||
|
||||
@@ -72,10 +72,7 @@ git log -1 --format='Author: %an <%ae>%nCommitter: %cn <%ce>'
|
||||
|
||||
### 4. Push and PR to develop
|
||||
|
||||
PRs always target `develop`. Two different mechanisms are involved: **pushing
|
||||
commits is git-native** (the Gitea MCP cannot push local git history, so the
|
||||
branch is still pushed with `git push`), while **the PR itself is opened through
|
||||
the Gitea MCP** (see below). The `claude_code` password lives in the macOS
|
||||
PRs always target `develop`. The `claude_code` password lives in the macOS
|
||||
keychain as a **generic password** under service `gitea-claude-code` (do not
|
||||
duplicate it as an internet-password for `gitea.vvzvlad.xyz` — that creates a
|
||||
conflict with the owner's account in the git credential helper):
|
||||
@@ -97,24 +94,18 @@ git remote set-url gitea "$ORIG_URL"
|
||||
unset AGENT_PASS SAFE_PASS
|
||||
```
|
||||
|
||||
The PR is opened through the **Gitea MCP** (server `gitea`), not `curl`/`tea` —
|
||||
the MCP authenticates in-process, so no keychain lookup or Basic-Auth is needed.
|
||||
Call `pull_request_write` with:
|
||||
The PR is created via the Gitea REST API (Basic Auth as `claude_code`):
|
||||
|
||||
- `method: "create"`
|
||||
- `owner: "vvzvlad"`, `repo: "gitmost"`
|
||||
- `base: "develop"`, `head: "<branch>"`
|
||||
- `title`, `body` — in the body: what was done, what is out of scope,
|
||||
verification results (tsc/lint/tests).
|
||||
```bash
|
||||
curl -s -X POST \
|
||||
-u "claude_code:$(security find-generic-password -s gitea-claude-code -w)" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d @pr_body.json \
|
||||
"https://gitea.vvzvlad.xyz/api/v1/repos/vvzvlad/gitmost/pulls"
|
||||
```
|
||||
|
||||
Manage and read PRs through the same server: `list_pull_requests`,
|
||||
`pull_request_read` (`get`, `get_diff`, `get_files`, `get_status`),
|
||||
`pull_request_review_write`.
|
||||
|
||||
**Identity note:** the MCP acts under its **own** configured Gitea token (verify
|
||||
with `get_me`), a different account from the `claude_code` used for git
|
||||
commits/pushes in §3. Only the forge API calls (PR / issue / review) go through
|
||||
the MCP account; the commits themselves stay authored as `claude_code`.
|
||||
`base: develop`, `head: <branch>`. In the PR body: what was done, what is out
|
||||
of scope, verification results (tsc/lint/tests).
|
||||
|
||||
> If push fails with `User permission denied for writing`, then `claude_code`
|
||||
> lacks collaborator rights on the repo. Ask the owner to add them (once, via
|
||||
@@ -161,25 +152,23 @@ below.
|
||||
| Agent user (Gitea/git) | `claude_code` |
|
||||
| Agent email | `claude_code@vvzvlad.xyz` |
|
||||
| Keychain password | `security find-generic-password -s gitea-claude-code -w` |
|
||||
| Forge API (PR / issue / review / reads) | **Gitea MCP** — server `gitea` (`pull_request_write`, `issue_write`, `list_pull_requests`, `pull_request_read`, `label_read`, …). Authenticated in-process; acts under its own token — check with `get_me`. Repo slug on the server is `gitmost`. |
|
||||
| PR API | `https://gitea.vvzvlad.xyz/api/v1/repos/vvzvlad/gitmost/pulls` (here `gitmost` is the repo's real slug on the server) |
|
||||
| Base branch | `develop` |
|
||||
| `origin` | GitHub mirror `vvzvlad/gitmost` — **do not push**, updated by the owner's CI |
|
||||
| `upstream` | The original Docmost — **never push** |
|
||||
|
||||
## Creating issues (Gitea MCP)
|
||||
## Creating issues (Gitea `tea` CLI)
|
||||
|
||||
File issues through the **Gitea MCP** (server `gitea`), not a CLI — call
|
||||
`issue_write` with:
|
||||
Issues are filed with the official Gitea CLI `tea`, already logged in as
|
||||
`claude_code` (`tea logins list` shows the `gitea` login as default):
|
||||
|
||||
- `method: "create"`
|
||||
- `owner: "vvzvlad"`, `repo: "gitmost"`
|
||||
- `title`, `body`
|
||||
- `labels` — an array of label **IDs** (numbers), *not* names. Resolve a name
|
||||
such as `feature` to its id first with `label_read` (`method: "list"`), then
|
||||
pass e.g. `labels: [<id>]`.
|
||||
```bash
|
||||
tea issues create --repo vvzvlad/gitmost --labels feature \
|
||||
--title '<title>' --description "$(cat body.md)"
|
||||
```
|
||||
|
||||
Read issues with `list_issues`, `issue_read`, or `search_issues`. The MCP is
|
||||
authenticated in-process, so no `tea`/`curl` and no keychain lookup are needed.
|
||||
> Gotcha (tea 0.14.1): the issue body flag is `--description`/`-d`, **not**
|
||||
> `--body` — passing `--body` fails with `flag provided but not defined: -body`.
|
||||
|
||||
---
|
||||
|
||||
@@ -193,7 +182,7 @@ authenticated in-process, so no `tea`/`curl` and no keychain lookup are needed.
|
||||
|
||||
## Monorepo layout
|
||||
|
||||
pnpm workspace (`pnpm@10.4.0`) orchestrated by **Nx**. Four workspace packages:
|
||||
pnpm workspace (`pnpm@10.4.0`) orchestrated by **Nx**. Five workspace packages:
|
||||
|
||||
| Path | Name | Stack | Role |
|
||||
| --- | --- | --- | --- |
|
||||
@@ -201,6 +190,7 @@ pnpm workspace (`pnpm@10.4.0`) orchestrated by **Nx**. Four workspace packages:
|
||||
| `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/git-sync` | `@docmost/git-sync` | Tiptap/ProseMirror, Yjs, git | Pure ProseMirror↔Markdown converter plus the two-way Docmost↔git Markdown sync engine. Bundled into the server (loaded over the ESM bridge), built in CI and the Dockerfile. Does **not** import `editor-ext` — it keeps its own vendored mirror of the document schema (kept in sync with `editor-ext`). |
|
||||
|
||||
`build` targets are Nx-cached and dependency-ordered (`dependsOn: ["^build"]`), so `editor-ext` builds before the apps. `nx.json` sets `affected.defaultBase: main`.
|
||||
|
||||
@@ -262,8 +252,10 @@ Migration files live in `apps/server/src/database/migrations/` and are named `YY
|
||||
|
||||
The API server is a Fastify app with a global `/api` prefix (`main.ts` excludes `robots.txt`, public share pages, and `mcp` from the prefix). A `preHandler` hook enforces that a resolved `workspaceId` exists for most `/api` routes (multi-tenant by hostname/subdomain via `DomainMiddleware`). `GET /api/sb/:id` (the anonymous blob-sandbox read route) is listed in that preHandler's `excludedPaths`, so it is exempt from workspace resolution and carries no session auth at all (its capability is the unguessable UUID + TTL + TLS) — unlike `/api/files/public/...`, which still resolves a workspace and requires a workspace-bound attachment JWT. Auth is JWT (cookie + bearer); authorization is **CASL** (`core/casl`) — every data access is scoped to the user's abilities.
|
||||
|
||||
Two routes are mounted **outside** the `/api` prefix at the root, as raw Fastify routes that bypass the Nest pipeline (so neither `DomainMiddleware` nor `ThrottlerGuard` runs for them — each resolves the workspace and throttles itself): `/mcp` (the embedded MCP server, see below) and `/git/<spaceId>.git/...` (the git-sync smart-HTTP host, see below). Both share `mcp-auth.helpers.ts` (HTTP-Basic parsing, `FailedLoginLimiter`, `clientIp`) and the common `resolveRequestWorkspace` helper.
|
||||
|
||||
### Module structure (server)
|
||||
`AppModule` wires integration modules (`integrations/*`: storage [local/S3/Azure], mail, queue [BullMQ on Redis], security, telemetry, throttle, `mcp`, `ai`) plus `CoreModule`, `DatabaseModule`, and `CollaborationModule`. `CoreModule` (`core/*`) holds the domain modules: `page`, `space`, `comment`, `workspace`, `user`, `auth`, `group`, `attachment`, `search`, `share`, `ai-chat`, etc. Each domain module follows NestJS controller → service → repo layering; DB repos live under `database/repos` and are injected app-wide from the global `DatabaseModule`.
|
||||
`AppModule` wires integration modules (`integrations/*`: storage [local/S3/Azure], mail, queue [BullMQ on Redis], security, telemetry, throttle, `mcp`, `ai`, `git-sync`) plus `CoreModule`, `DatabaseModule`, and `CollaborationModule`. `CoreModule` (`core/*`) holds the domain modules: `page`, `space`, `comment`, `workspace`, `user`, `auth`, `group`, `attachment`, `search`, `share`, `ai-chat`, etc. Each domain module follows NestJS controller → service → repo layering; DB repos live under `database/repos` and are injected app-wide from the global `DatabaseModule`.
|
||||
|
||||
**EE removal artifact:** `app.module.ts` still contains a `try/require('./ee/ee.module')` stub. That path no longer exists, so the require fails and is swallowed (it only hard-exits when `CLOUD === 'true'`). Treat EE as gone — do not add code that depends on it.
|
||||
|
||||
@@ -279,10 +271,16 @@ The API server is a Fastify app with a global `/api` prefix (`main.ts` excludes
|
||||
- `core/ai-chat/embedding/` — RAG indexer + a BullMQ consumer on `AI_QUEUE` that embeds pages into `page_embeddings` (vector search), complementing Postgres full-text search. Pages are (re)indexed on edit; `AI_EMBEDDING_TIMEOUT_MS` bounds a hung embeddings endpoint.
|
||||
- `core/ai-chat/external-mcp/` — admins can attach external MCP servers (e.g. Tavily) to give the agent web access. **`ssrf-guard.ts` validates outbound MCP URLs against SSRF** — keep that guard in the path when touching external-MCP connection logic.
|
||||
|
||||
### Git-sync (native two-way Docmost ↔ git Markdown sync)
|
||||
`integrations/git-sync/` (`GitSyncModule`) + the vendored pure engine in `packages/git-sync`. Off by default; gated by the `GIT_SYNC_ENABLED` master switch (and `GIT_SYNC_SERVICE_USER_ID`, the account git-originated writes are attributed to). Per-space opt-in via `space.settings.gitSync.enabled`, with a second per-space toggle `space.settings.gitSync.autoMergeConflicts` that changes PUSH behavior for a still-conflicted page (one carrying `<<<<<<<`/`>>>>>>>` markers): **off (the safe default)** records a per-page failure and holds the refs so the user resolves the git conflict first (markers never reach Docmost); **on** strips the marker lines and pushes both sides' content. Each enabled space gets an on-disk working "vault" repo; the `GitSyncOrchestrator` runs a debounced + poll-backstop reconcile cycle (PULL Docmost→vault, PUSH vault→Docmost) under a per-space Redis leader lock + in-process mutex (`SpaceLockService`). Writes go through the collaboration layer (so concurrent human edits aren't clobbered) and are stamped `lastUpdatedSource = 'git-sync'` for the listener loop-guard. The in-process `setInterval` orchestration + best-effort lock (no fencing tokens) is a known multi-replica limitation — BullMQ + fencing is the documented future direction.
|
||||
|
||||
- **`/git` smart-HTTP host** (`integrations/git-sync/http/`, gated additionally by `GIT_SYNC_HTTP_ENABLED`, which defaults to `GIT_SYNC_ENABLED`): a raw root-mounted Fastify route `/git/<spaceId>.git/...` (registered in `main.ts`, NOT under `/api`) that bridges `git clone`/`fetch`/`push` to `git http-backend`. It authenticates HTTP Basic against `AuthService` (throttled by a `FailedLoginLimiter` mirroring the `/mcp` path), authorizes via `SpaceAbilityFactory` (read = fetch, Manage = push), and gates existence so a non-member gets the SAME 404 as a missing/sync-disabled space (never 403 — that would leak space existence). A push runs the receive-pack under the space lock, then a reconcile cycle.
|
||||
- **Schema mirror:** `packages/git-sync/src/lib/docmost-schema.ts` is one of the **three** hand-synced copies of the Tiptap document schema (see Client structure) — keep it in lockstep with `editor-ext` (canonical) and `packages/mcp`.
|
||||
|
||||
### 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. Note neither `packages/mcp` nor `packages/git-sync` depends on `editor-ext`; each carries its own mirrored copy of the schema. There are now **three** independent copies (`editor-ext` is canonical, plus `packages/mcp` and `packages/git-sync`), so keep all three in sync manually when the document schema changes.
|
||||
- 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`.
|
||||
|
||||
|
||||
+16
-70
@@ -12,12 +12,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- **Native two-way Docmost ↔ git Markdown sync.** Opt-in per space (Space
|
||||
settings → a git-sync toggle, plus an `autoMergeConflicts` toggle that controls
|
||||
whether a still-conflicted page is held back or pushed with its conflict
|
||||
markers stripped): each enabled space is mirrored to an on-disk git "vault" of
|
||||
Markdown files and reconciled in both directions (Docmost → vault and vault →
|
||||
Docmost) on a debounced + poll-backstop cycle, under a per-space lock, writing
|
||||
through the collaboration layer so concurrent human edits aren't clobbered.
|
||||
Git-originated changes are attributed to a configurable service account and
|
||||
carry a "git-sync" provenance badge in page history. Optionally exposes a `/git`
|
||||
smart-HTTP host so you can `git clone`/`fetch`/`push` a space directly (HTTP
|
||||
Basic auth, space-permission authorized). Off by default and configured via the
|
||||
`GIT_SYNC_*` environment variables, including `GIT_SYNC_ENABLED`,
|
||||
`GIT_SYNC_SERVICE_USER_ID`, and `GIT_SYNC_HTTP_ENABLED` (see `.env.example`).
|
||||
(#119)
|
||||
- **Place several images side by side in a row.** A new "Inline (side by
|
||||
side)" alignment mode in the image bubble menu renders consecutive inline
|
||||
images as a row that wraps onto the next line on narrow screens. The row is
|
||||
centered horizontally by default in modern browsers (CSS `:has()`), falling
|
||||
back to start-aligned rows in browsers without support. Unlike the float
|
||||
modes, text does not wrap around inline images. The mode round-trips
|
||||
images as a row that wraps onto the next line on narrow screens. Unlike the
|
||||
float modes, text does not wrap around inline images. The mode round-trips
|
||||
losslessly through markdown as `data-align`, like the other alignment
|
||||
values.
|
||||
|
||||
@@ -86,53 +98,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
with the `||text||` input rule; the rendered span blurs until clicked to reveal.
|
||||
The mark is preserved losslessly through Markdown export/import (as a raw
|
||||
`<span data-spoiler="true">…</span>`) and on public shares. (#259)
|
||||
- **Dock the AI chat window into the side menu.** The floating chat window can
|
||||
be pinned to the sidebar — drag it onto the navbar (a drop-zone highlight
|
||||
shows where it lands) or use the new "Dock to sidebar" header button; while
|
||||
docked it fills the sidebar area and follows its live size. "Undock" (or
|
||||
dragging it back out) restores the floating window, a collapsed/absent
|
||||
sidebar falls back to floating, and the docked state survives a reload.
|
||||
(#276, #282)
|
||||
- **Hovering commented text shows the comment thread in a tooltip.** Pointing
|
||||
at a highlighted comment mark pops a small card with the author and plain
|
||||
text of the root comment and its replies, so a thread can be skimmed without
|
||||
opening the side panel. The card appears after a short delay (no flicker on a
|
||||
passing glance), skips resolved and text-less threads, and dismisses on
|
||||
scroll or click — clicking a mark still opens the comments panel. (#268,
|
||||
#271)
|
||||
- **"Move to trash" button in the temporary-note banner.** Besides "Make
|
||||
permanent", the banner on an open temporary note now also offers to trash the
|
||||
note immediately instead of waiting out its lifetime. It reuses the regular
|
||||
soft-delete path, so the "Page moved to trash" undo toast is the safety net —
|
||||
no confirmation dialog. (#273, #277)
|
||||
- **Code-block controls float as an overlay instead of taking a row above the
|
||||
code.** The language selector and copy button now sit in the block's top-right
|
||||
corner, and the selector stays invisible until the block is hovered or the
|
||||
selector is focused, so reading code is chrome-free. In read-only views only
|
||||
the copy button renders. (#275, #278)
|
||||
- **The AI agent is told about your page edits between turns.** The server
|
||||
snapshots the open page's Markdown at the end of every agent turn and, on the
|
||||
next turn, injects a unified diff of what changed in between, so the agent
|
||||
knows its earlier copy of the page is stale and builds on the user's edits
|
||||
instead of reverting or overwriting them. The diff is whitespace-normalized
|
||||
(pure formatting churn injects nothing) and size-capped, with a hint to
|
||||
re-read the full page via `getPage` when truncated. (#274, #281)
|
||||
- **Stress-accent button (U+0301) in the bubble menu.** Select a vowel and
|
||||
toggle a combining acute accent over it — a Russian-style stress mark. The
|
||||
accent is stored as plain text (no custom mark), so it survives Markdown/HTML
|
||||
export, full-text search and public shares unchanged; the toggle is a single
|
||||
undo step and re-clicking removes the accent. (#270, #280)
|
||||
- **Reading position survives a reload.** The editor remembers how far you
|
||||
scrolled in each page (per tab, in `sessionStorage`) and restores that
|
||||
position after an F5 or reopening the document, waiting for the collaborative
|
||||
content to finish laying out first. A URL `#hash` anchor still wins — restore
|
||||
is a no-op then. (#266, #267)
|
||||
- **The slash menu finds commands typed in the wrong keyboard layout.** A query
|
||||
typed with the wrong layout active (e.g. `/сщву` for `/code`, or `/cyjcrf`
|
||||
for the Cyrillic «сноска» → Footnote) is additionally remapped ЙЦУКЕН↔QWERTY
|
||||
by physical key position and matched against the commands; genuine Cyrillic
|
||||
search terms keep priority over remapped candidates, and short wrong-layout
|
||||
prefixes match by command title. (#283, #285, #287)
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -198,25 +163,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
emits a single-use "intentional clear" signal that lets exactly that one empty
|
||||
write through the guard, so genuinely emptying a page is persisted while
|
||||
accidental empties are blocked. (#248, #251)
|
||||
- **Ctrl+Z works again right after using a table menu.** Closing a table
|
||||
row/column menu (grip or chevron) left focus on the menu's portaled target
|
||||
outside the editor, so undo keystrokes went nowhere until you clicked back
|
||||
into a cell. The editor is now refocused after the menu closes — unless you
|
||||
deliberately moved focus to another input or editable (e.g. the page title).
|
||||
(#269, #279)
|
||||
- **The AI reindex progress counter no longer freezes at 0.** Right after
|
||||
"Reindex now" the client could read the stale pre-reindex snapshot of an
|
||||
already-indexed workspace (`reindexing=false`, all pages counted) as
|
||||
"finished" and stop polling on the very first tick, leaving the counter
|
||||
frozen until a manual reload. Polling now keeps going until it has actually
|
||||
observed the active run. (#262, #264)
|
||||
- **An MCP edit can no longer be silently lost to a duplicate collab document.**
|
||||
When the agent addressed a page by its short slugId, the MCP opened a
|
||||
collaboration document named after that slugId while the web editor always
|
||||
uses the page's canonical UUID — two independent live documents for one page,
|
||||
whose debounced stores clobbered each other. The MCP now resolves every page
|
||||
id to the canonical UUID before opening the collab doc (a UUID input
|
||||
short-circuits locally; a slugId is resolved once and cached). (#260, #265)
|
||||
|
||||
### Security
|
||||
|
||||
|
||||
+10
-1
@@ -17,8 +17,9 @@ RUN pnpm build
|
||||
|
||||
FROM base AS installer
|
||||
|
||||
# git: required by the git-sync VaultGit (shells out to git)
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl bash \
|
||||
&& apt-get install -y --no-install-recommends curl bash git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
@@ -38,6 +39,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
|
||||
# git-sync: the server loads @docmost/git-sync at runtime via the loader
|
||||
# (git-sync.loader.ts), which deliberately does NOT `require()` it — the package is
|
||||
# ESM-only, so the loader uses `require.resolve` + a dynamic `import()`. Without
|
||||
# these copied build artifacts that resolve/import fails and the server crashes on
|
||||
# first use. Built fresh by the builder's `pnpm build` (nx builds the package's tsc
|
||||
# `build` target).
|
||||
COPY --from=builder /app/packages/git-sync/build /app/packages/git-sync/build
|
||||
COPY --from=builder /app/packages/git-sync/package.json /app/packages/git-sync/package.json
|
||||
|
||||
# Copy root package files
|
||||
COPY --from=builder /app/package.json /app/package.json
|
||||
|
||||
@@ -104,7 +104,7 @@ community feature, with no enterprise license. Open it from the page header; the
|
||||
- ✅ **Page templates** — flag a page as a template and embed its whole content live into other pages; edits to the template propagate to every place it is inserted (whole-page transclusion on top of the existing synced blocks).
|
||||
- ✅ **Public-share AI assistant** — anonymous visitors of a shared page can ask the AI agent, scoped strictly to that share's page tree (read-only, share-scoped search), behind a workspace toggle.
|
||||
- ✅ **Footnotes** — academic-style footnotes: a numbered superscript reference inline (read it in place via a hover popover), with the note text living as a real, editable block at the bottom of the page; auto-numbered, collaboration-safe, and round-trips through Markdown export/import and the AI agent / MCP.
|
||||
- ✅ **Temporary notes** — create a note as temporary and it auto-moves to Trash after a configurable per-workspace lifetime (default 24h) unless made permanent first; create one in a click from the Home screen, any space overview.
|
||||
- ✅ **Temporary notes** — mark a note as temporary and it auto-moves to Trash after a configurable per-workspace lifetime (default 24h) unless made permanent first; create one in a click from the Home screen, any space overview, or the space sidebar, with a "Make permanent" rescue banner on the open note.
|
||||
|
||||
### In progress
|
||||
|
||||
@@ -187,17 +187,14 @@ start the new migrations apply on top of your existing schema (`CREATE EXTENSION
|
||||
- Spaces
|
||||
- Permissions management
|
||||
- Groups
|
||||
- Comments (with resolve / re-open and hover tooltips showing the comment text)
|
||||
- Comments (with resolve / re-open)
|
||||
- Page history
|
||||
- Search
|
||||
- File attachments
|
||||
- Embeds (Airtable, Loom, Miro and more)
|
||||
- Translations (10+ languages)
|
||||
- Embedded MCP server (`/mcp`)
|
||||
- AI agent chat over your wiki (read + write, RAG search, external MCP / web access); the chat window docks into the side menu, and the agent is told about your in-page edits between turns
|
||||
- Code-block buttons as an overlay, with the language selector revealed on hover
|
||||
- Stress-accent button (U+0301) in the bubble menu
|
||||
- Reading scroll position restored on reload
|
||||
- AI agent chat over your wiki (read + write, RAG search, external MCP / web access)
|
||||
|
||||
### Screenshots
|
||||
|
||||
|
||||
+3
-7
@@ -105,7 +105,7 @@ real-time-коллаборации Docmost, поэтому запись нико
|
||||
- ✅ **Шаблоны страниц** — пометить страницу шаблоном и вставлять её содержимое живой ссылкой в другие страницы; правки шаблона распространяются на все места вставки (whole-page-транслюзия поверх существующих synced-блоков).
|
||||
- ✅ **AI-ассистент на публичных шарах** — анонимный зритель расшаренной страницы может спросить AI-агента, который ищет строго по дереву этой шары (read-only, share-scoped поиск), за тумблером воркспейса.
|
||||
- ✅ **Сноски** — сноски академического вида: нумерованная ссылка-надстрочник прямо в тексте (читается на месте во всплывающем окне по наведению), а текст сноски живёт реальным редактируемым блоком внизу страницы; авто-нумерация, безопасна для совместного редактирования, переживает экспорт/импорт Markdown и доступна AI-агенту / MCP.
|
||||
- ✅ **Временные заметки** — создайте временную заметку, и она автоматически уедет в корзину по истечении настраиваемого срока жизни (по умолчанию 24 ч); создать такую можно в один клик с домашнего экрана, с обзора любого пространства или из сайдбара пространства.
|
||||
- ✅ **Временные заметки** — пометьте заметку временной, и она автоматически уедет в корзину по истечении настраиваемого срока жизни воркспейса (по умолчанию 24 ч), если её предварительно не сделать постоянной; создать такую можно в один клик с домашнего экрана, с обзора любого пространства или из сайдбара пространства, а на открытой заметке есть баннер «Сделать постоянной».
|
||||
|
||||
### В процессе
|
||||
|
||||
@@ -174,18 +174,14 @@ dump/restore, существующий каталог данных переис
|
||||
- Пространства (Spaces)
|
||||
- Управление правами доступа
|
||||
- Группы
|
||||
- Комментарии (с резолвом / переоткрытием и всплывающими подсказками с текстом комментария при наведении)
|
||||
- Комментарии (с резолвом / переоткрытием)
|
||||
- История страниц
|
||||
- Поиск
|
||||
- Вложения файлов
|
||||
- Встраивания (Airtable, Loom, Miro и другие)
|
||||
- Переводы (10+ языков)
|
||||
- Встроенный MCP-сервер (`/mcp`)
|
||||
- Чат с AI-агентом по вики (чтение + запись, RAG-поиск, внешние MCP / доступ в интернет); окно чата закрепляется в боковом меню, а агент узнаёт о ваших правках страницы между ходами
|
||||
- Кнопки код-блока оверлеем, селектор языка появляется при наведении
|
||||
- Кнопка «Ударение» (U+0301) в bubble-меню
|
||||
- Позиция чтения (прокрутка) восстанавливается после перезагрузки
|
||||
- Slash-меню терпимо к неправильной раскладке (ЙЦУКЕН↔QWERTY)
|
||||
- Чат с AI-агентом по вики (чтение + запись, RAG-поиск, внешние MCP / доступ в интернет)
|
||||
|
||||
### Скриншоты
|
||||
|
||||
|
||||
@@ -1222,8 +1222,10 @@
|
||||
"Commented": "Commented",
|
||||
"Resolved comment": "Resolved comment",
|
||||
"Ran tool {{name}}": "Ran tool {{name}}",
|
||||
"AI agent «{{role}}» on behalf of {{person}}": "AI agent «{{role}}» on behalf of {{person}}",
|
||||
"AI agent {{name}}": "AI agent {{name}}",
|
||||
"AI-agent": "AI-agent",
|
||||
"Edited by AI agent on behalf of {{name}}": "Edited by AI agent on behalf of {{name}}",
|
||||
"Git sync": "Git sync",
|
||||
"Synced from Git on behalf of {{name}}": "Synced from Git on behalf of {{name}}",
|
||||
"Endpoints": "Endpoints",
|
||||
"where we fetch models": "where we fetch models",
|
||||
"All endpoints are OpenAI-compatible. Point the Base URL at OpenAI, OpenRouter, a local Ollama, or any self-hosted server.": "All endpoints are OpenAI-compatible. Point the Base URL at OpenAI, OpenRouter, a local Ollama, or any self-hosted server.",
|
||||
@@ -1248,6 +1250,10 @@
|
||||
"MCP server": "MCP server",
|
||||
"expose the workspace": "expose the workspace",
|
||||
"Enable MCP server": "Enable MCP server",
|
||||
"Enable Git sync": "Enable Git sync",
|
||||
"Sync this space's pages to a Git repository.": "Sync this space's pages to a Git repository.",
|
||||
"Auto-merge conflicts on push": "Auto-merge conflicts on push",
|
||||
"When off (recommended), a page whose content still has unresolved Git conflict markers is skipped on push until you resolve the conflict in Git. When on, the markers are stripped and both sides' content is pushed.": "When off (recommended), a page whose content still has unresolved Git conflict markers is skipped on push until you resolve the conflict in Git. When on, the markers are stripped and both sides' content is pushed.",
|
||||
"Exposes the workspace as an MCP server at /mcp — this provides a capability, it doesn't consume a model.": "Exposes the workspace as an MCP server at /mcp — this provides a capability, it doesn't consume a model.",
|
||||
"Resolves to {{url}}": "Resolves to {{url}}",
|
||||
"Model": "Model",
|
||||
|
||||
@@ -724,8 +724,7 @@
|
||||
"Shown as used / total in the chat header. Leave empty to hide the limit.": "Показывается в шапке чата как использовано / всего. Пусто — лимит скрыт.",
|
||||
"Delete this chat?": "Удалить этот чат?",
|
||||
"Deleted successfully": "Успешно удалено",
|
||||
"AI agent «{{role}}» on behalf of {{person}}": "AI-агент «{{role}}» от имени {{person}}",
|
||||
"AI agent {{name}}": "AI-агент {{name}}",
|
||||
"Edited by AI agent on behalf of {{name}}": "Отредактировано AI-агентом от имени {{name}}",
|
||||
"Failed to delete chat": "Не удалось удалить чат",
|
||||
"Failed to rename chat": "Не удалось переименовать чат",
|
||||
"Failed": "Ошибка",
|
||||
|
||||
@@ -14,22 +14,6 @@ import { notifications } from "@mantine/notifications";
|
||||
import { exportSpace } from "@/features/space/services/space-service";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
// The export request uses `responseType: "blob"`, so a server error body arrives
|
||||
// as a Blob rather than parsed JSON — `err.response?.data.message` is therefore
|
||||
// always undefined. Read and parse the blob to surface the real error message.
|
||||
async function extractExportError(err: any): Promise<string> {
|
||||
const data = err?.response?.data;
|
||||
if (data instanceof Blob) {
|
||||
try {
|
||||
const json = JSON.parse(await data.text());
|
||||
return json?.message ?? "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
return data?.message ?? err?.message ?? "";
|
||||
}
|
||||
|
||||
interface ExportModalProps {
|
||||
id: string;
|
||||
type: "space" | "page";
|
||||
@@ -68,9 +52,8 @@ export default function ExportModal({
|
||||
});
|
||||
onClose();
|
||||
} catch (err) {
|
||||
const message = await extractExportError(err);
|
||||
notifications.show({
|
||||
message: t("Export failed") + (message ? `: ${message}` : ""),
|
||||
message: "Export failed:" + err.response?.data.message,
|
||||
color: "red",
|
||||
});
|
||||
console.error("export error", err);
|
||||
|
||||
@@ -12,7 +12,6 @@ import TopMenu from "@/components/layouts/global/top-menu.tsx";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useAtom } from "jotai";
|
||||
import {
|
||||
NAVBAR_COLLAPSE_BREAKPOINT,
|
||||
desktopSidebarAtom,
|
||||
mobileSidebarAtom,
|
||||
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||
@@ -54,13 +53,7 @@ export function AppHeader() {
|
||||
aria-label={t("Sidebar toggle")}
|
||||
opened={mobileOpened}
|
||||
onClick={toggleMobile}
|
||||
// Must match the AppShell navbar breakpoint (md). The navbar
|
||||
// collapses to the MOBILE drawer below md, so the mobile toggle
|
||||
// (which flips mobileOpened) must be the one visible across the
|
||||
// whole <md band — otherwise at 768-991 the desktop toggle showed
|
||||
// but flipped the wrong atom, leaving the drawer unopenable (the
|
||||
// regression from the initial sm->md navbar change).
|
||||
hiddenFrom={NAVBAR_COLLAPSE_BREAKPOINT}
|
||||
hiddenFrom="sm"
|
||||
size="sm"
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -70,7 +63,7 @@ export function AppHeader() {
|
||||
aria-label={t("Sidebar toggle")}
|
||||
opened={desktopOpened}
|
||||
onClick={toggleDesktop}
|
||||
visibleFrom={NAVBAR_COLLAPSE_BREAKPOINT}
|
||||
visibleFrom="sm"
|
||||
size="sm"
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
@@ -6,7 +6,6 @@ import SettingsSidebar from "@/components/settings/settings-sidebar.tsx";
|
||||
import { useAtom } from "jotai";
|
||||
import {
|
||||
APP_NAVBAR_ID,
|
||||
NAVBAR_COLLAPSE_BREAKPOINT,
|
||||
asideStateAtom,
|
||||
desktopSidebarAtom,
|
||||
mobileSidebarAtom,
|
||||
@@ -89,13 +88,7 @@ export default function GlobalAppShell({
|
||||
header={{ height: 45 }}
|
||||
navbar={{
|
||||
width: isSpaceRoute ? sidebarWidth : 300,
|
||||
// `md` (not `sm`): below 992px the fixed ~300px sidebar leaves too little
|
||||
// room for content — the settings tables (Members/…) overflow the offset
|
||||
// content area on tablet (~768px) and clip the Role/actions columns
|
||||
// off-screen with no horizontal scroll. Collapsing the navbar to a toggle
|
||||
// drawer across the whole tablet band frees the full width for content
|
||||
// (the mobile drawer is closed by default, so nothing overlaps on load).
|
||||
breakpoint: NAVBAR_COLLAPSE_BREAKPOINT,
|
||||
breakpoint: "sm",
|
||||
collapsed: {
|
||||
mobile: !mobileOpened,
|
||||
desktop: !desktopOpened,
|
||||
@@ -104,7 +97,7 @@ export default function GlobalAppShell({
|
||||
aside={
|
||||
isPageRoute && {
|
||||
width: 420,
|
||||
breakpoint: "md",
|
||||
breakpoint: "sm",
|
||||
collapsed: { mobile: !isAsideOpen, desktop: !isAsideOpen },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,13 +7,6 @@ import { atom } from "jotai";
|
||||
// would create a shell -> chat-window -> shell import cycle).
|
||||
export const APP_NAVBAR_ID = "app-shell-navbar";
|
||||
|
||||
// Single source of truth for the navbar collapse breakpoint. The AppShell navbar
|
||||
// `breakpoint` and BOTH burger toggles' `hiddenFrom`/`visibleFrom` MUST use this
|
||||
// exact value: if they drift, the sidebar becomes unreachable on tablet widths
|
||||
// (the round-1 regression of #292). Kept here so the shell and the header share
|
||||
// one constant the compiler enforces, instead of three hand-synced string literals.
|
||||
export const NAVBAR_COLLAPSE_BREAKPOINT = "md";
|
||||
|
||||
export const mobileSidebarAtom = atom<boolean>(false);
|
||||
|
||||
export const desktopSidebarAtom = atomWithWebStorage<boolean>(
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { Provider, createStore } from "jotai";
|
||||
import { AgentAvatarStack } from "./agent-avatar-stack";
|
||||
import {
|
||||
activeAiChatIdAtom,
|
||||
aiChatWindowOpenAtom,
|
||||
aiChatDraftAtom,
|
||||
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
|
||||
|
||||
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
||||
|
||||
type Props = React.ComponentProps<typeof AgentAvatarStack>;
|
||||
|
||||
function renderStack(props: Props) {
|
||||
const store = createStore();
|
||||
store.set(aiChatDraftAtom, "leftover draft from another chat");
|
||||
const utils = render(
|
||||
<Provider store={store}>
|
||||
<MantineProvider>
|
||||
<AgentAvatarStack {...props} />
|
||||
</MantineProvider>
|
||||
</Provider>,
|
||||
);
|
||||
return { store, ...utils };
|
||||
}
|
||||
|
||||
describe("AgentAvatarStack", () => {
|
||||
it("internal chat WITH role: emoji glyph in front + human launcher behind", () => {
|
||||
const { container } = renderStack({
|
||||
agent: { name: "Researcher", emoji: "🔬", avatarUrl: null },
|
||||
launcher: { name: "Alice", avatarUrl: null },
|
||||
aiChatId: "chat-1",
|
||||
});
|
||||
|
||||
// Emoji is used as the glyph (priority 2), NOT the sparkles fallback.
|
||||
expect(screen.getByText("🔬")).toBeDefined();
|
||||
expect(container.querySelector(".tabler-icon-sparkles")).toBeNull();
|
||||
// Label: bold role name + dimmed "· launcher".
|
||||
expect(screen.getByText("Researcher")).toBeDefined();
|
||||
expect(screen.getByText(/·/)).toBeDefined();
|
||||
expect(screen.getByText("Alice")).toBeDefined();
|
||||
});
|
||||
|
||||
it("showName=false: renders only the avatars, no inline name label", () => {
|
||||
renderStack({
|
||||
agent: { name: "Researcher", emoji: "🔬", avatarUrl: null },
|
||||
launcher: { name: "Alice", avatarUrl: null },
|
||||
aiChatId: "chat-1",
|
||||
showName: false,
|
||||
});
|
||||
|
||||
// The agent glyph is still rendered...
|
||||
expect(screen.getByText("🔬")).toBeDefined();
|
||||
// ...but neither the agent NOR the launcher inline name label is rendered
|
||||
// (they live only in the hover tooltip, which is not mounted in the initial
|
||||
// DOM) — guards against suppressing only the agent name and leaking the
|
||||
// launcher name.
|
||||
expect(screen.queryByText("Researcher")).toBeNull();
|
||||
expect(screen.queryByText("Alice")).toBeNull();
|
||||
});
|
||||
|
||||
it("internal chat WITHOUT role: sparkles fallback + 'AI agent' + launcher", () => {
|
||||
const { container } = renderStack({
|
||||
agent: { name: "AI agent", avatarUrl: null },
|
||||
launcher: { name: "Bob", avatarUrl: null },
|
||||
aiChatId: "chat-2",
|
||||
});
|
||||
|
||||
// No avatarUrl and no emoji => sparkles glyph (priority 3).
|
||||
expect(container.querySelector(".tabler-icon-sparkles")).not.toBeNull();
|
||||
expect(screen.getByText("AI agent")).toBeDefined();
|
||||
expect(screen.getByText("Bob")).toBeDefined();
|
||||
});
|
||||
|
||||
it("external MCP: agent avatar in front, NO launcher behind", () => {
|
||||
const { container } = renderStack({
|
||||
agent: { name: "MCP Bot", avatarUrl: "http://example.test/a.png" },
|
||||
launcher: null,
|
||||
aiChatId: null,
|
||||
});
|
||||
|
||||
// avatarUrl provided (priority 1) => not the sparkles fallback.
|
||||
expect(container.querySelector(".tabler-icon-sparkles")).toBeNull();
|
||||
expect(screen.getByText("MCP Bot")).toBeDefined();
|
||||
// No human behind => no "·" separator is rendered.
|
||||
expect(screen.queryByText(/·/)).toBeNull();
|
||||
// No internal chat => the stack is not an interactive deep-link button.
|
||||
expect(screen.queryByRole("button")).toBeNull();
|
||||
});
|
||||
|
||||
it("click deep-links into the chat when aiChatId is present", () => {
|
||||
const { store } = renderStack({
|
||||
agent: { name: "Researcher", emoji: "🔬", avatarUrl: null },
|
||||
launcher: { name: "Alice", avatarUrl: null },
|
||||
aiChatId: "chat-1",
|
||||
});
|
||||
|
||||
const button = screen.getByRole("button");
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(store.get(activeAiChatIdAtom)).toBe("chat-1");
|
||||
expect(store.get(aiChatWindowOpenAtom)).toBe(true);
|
||||
expect(store.get(aiChatDraftAtom)).toBe(""); // draft cleared on switch
|
||||
});
|
||||
|
||||
it("click is a no-op / not interactive without a chat target", () => {
|
||||
const onActivate = vi.fn();
|
||||
renderStack({
|
||||
agent: { name: "MCP Bot", avatarUrl: "http://example.test/a.png" },
|
||||
launcher: null,
|
||||
aiChatId: null,
|
||||
onActivate,
|
||||
});
|
||||
expect(screen.queryByRole("button")).toBeNull();
|
||||
expect(onActivate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,209 +0,0 @@
|
||||
import { Avatar, Box, Group, Text, Tooltip } from "@mantine/core";
|
||||
import { IconSparkles } from "@tabler/icons-react";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import {
|
||||
activeAiChatIdAtom,
|
||||
aiChatWindowOpenAtom,
|
||||
aiChatDraftAtom,
|
||||
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
|
||||
|
||||
// The FRONT identity (the acting agent) and the BEHIND identity (the human who
|
||||
// launched it). Both are computed server-side (#300) so the client never branches
|
||||
// on the internal-vs-MCP provenance — it just renders whatever it is handed.
|
||||
export interface AgentInfo {
|
||||
name: string;
|
||||
emoji?: string | null;
|
||||
avatarUrl?: string | null;
|
||||
}
|
||||
export interface LauncherInfo {
|
||||
name: string;
|
||||
avatarUrl?: string | null;
|
||||
}
|
||||
|
||||
// Same violet token as the former AiAgentBadge (which used color="violet").
|
||||
const AGENT_COLOR = "violet";
|
||||
const GLYPH_SIZE = 38;
|
||||
const LAUNCHER_SIZE = 22;
|
||||
// How far the launcher avatar sticks out past the agent's bottom-right corner, so
|
||||
// the "human behind" reads as behind (lower z-index) yet stays clearly visible.
|
||||
const LAUNCHER_OVERHANG = 8;
|
||||
|
||||
/**
|
||||
* The front avatar. Image-source priority (#300):
|
||||
* 1. agent.avatarUrl -> a real avatar image (external MCP agent account).
|
||||
* 2. agent.emoji -> the role emoji on a violet circle.
|
||||
* 3. otherwise -> the IconSparkles glyph on a violet circle (fallback).
|
||||
*/
|
||||
function AgentGlyph({ agent }: { agent: AgentInfo }) {
|
||||
if (agent.avatarUrl) {
|
||||
return (
|
||||
<CustomAvatar
|
||||
size={GLYPH_SIZE}
|
||||
avatarUrl={agent.avatarUrl}
|
||||
name={agent.name}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (agent.emoji) {
|
||||
return (
|
||||
<Avatar size={GLYPH_SIZE} radius="xl" color={AGENT_COLOR} variant="filled">
|
||||
<span style={{ fontSize: Math.round(GLYPH_SIZE * 0.5) }} aria-hidden>
|
||||
{agent.emoji}
|
||||
</span>
|
||||
</Avatar>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Avatar size={GLYPH_SIZE} radius="xl" color={AGENT_COLOR} variant="filled">
|
||||
<IconSparkles size={Math.round(GLYPH_SIZE * 0.55)} stroke={2} />
|
||||
</Avatar>
|
||||
);
|
||||
}
|
||||
|
||||
export interface AgentAvatarStackProps {
|
||||
agent: AgentInfo;
|
||||
// null/absent => external MCP (front agent avatar only, no human behind).
|
||||
launcher?: LauncherInfo | null;
|
||||
// Deep-links into the internal AI chat when present (null for external MCP).
|
||||
aiChatId?: string | null;
|
||||
// Fired after the stack deep-links into its chat, so the caller can react
|
||||
// (e.g. the page-history row closes the history modal). Keeps this ui/ primitive
|
||||
// free of cross-feature coupling (inherited from the old AiAgentBadge, #143).
|
||||
onActivate?: () => void;
|
||||
// Whether to render the inline name label next to the avatars (default true).
|
||||
// Set false when the caller renders the name itself (e.g. the comment row).
|
||||
showName?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The "agent avatar stack" (#300): the AGENT glyph in front, and — for an
|
||||
* internal AI chat — the HUMAN who launched it as a smaller avatar offset behind.
|
||||
* Replaces the old text `AI-agent` badge. When the item carries an `aiChatId` the
|
||||
* whole stack is a deep-link into that chat (the click the old badge owned moved
|
||||
* here); the click is contained (stopPropagation) so it does not also trigger an
|
||||
* enclosing row handler.
|
||||
*/
|
||||
export function AgentAvatarStack({
|
||||
agent,
|
||||
launcher,
|
||||
aiChatId,
|
||||
onActivate,
|
||||
showName = true,
|
||||
}: AgentAvatarStackProps) {
|
||||
const { t } = useTranslation();
|
||||
const setAiChatWindowOpen = useSetAtom(aiChatWindowOpenAtom);
|
||||
const setActiveChatId = useSetAtom(activeAiChatIdAtom);
|
||||
const setDraft = useSetAtom(aiChatDraftAtom);
|
||||
|
||||
const clickable = !!aiChatId;
|
||||
|
||||
const openChat = useCallback(
|
||||
(event: React.SyntheticEvent) => {
|
||||
event.stopPropagation();
|
||||
if (!aiChatId) return;
|
||||
setActiveChatId(aiChatId);
|
||||
// Switching chats must start with a clean composer — clear any unsent draft
|
||||
// so it does not leak from the previously open chat.
|
||||
setDraft("");
|
||||
setAiChatWindowOpen(true);
|
||||
onActivate?.();
|
||||
},
|
||||
[aiChatId, setActiveChatId, setDraft, setAiChatWindowOpen, onActivate],
|
||||
);
|
||||
|
||||
// Internal chat => "role on behalf of person"; external MCP => just the agent.
|
||||
const tooltip = launcher
|
||||
? t("AI agent «{{role}}» on behalf of {{person}}", {
|
||||
role: agent.name,
|
||||
person: launcher.name,
|
||||
})
|
||||
: t("AI agent {{name}}", { name: agent.name });
|
||||
|
||||
// The container is only enlarged when there is a launcher to overhang; with no
|
||||
// human behind it stays tight at the agent glyph size.
|
||||
const stackSize = launcher ? GLYPH_SIZE + LAUNCHER_OVERHANG : GLYPH_SIZE;
|
||||
|
||||
const stack = (
|
||||
<Box
|
||||
pos="relative"
|
||||
style={{
|
||||
width: stackSize,
|
||||
height: stackSize,
|
||||
flexShrink: 0,
|
||||
// Center the (in-flow) agent glyph vertically so it lines up with its
|
||||
// name label; the absolutely-positioned launcher is unaffected by flex.
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
cursor: clickable ? "pointer" : undefined,
|
||||
}}
|
||||
{...(clickable
|
||||
? {
|
||||
role: "button",
|
||||
tabIndex: 0,
|
||||
onClick: openChat,
|
||||
onKeyDown: (event: React.KeyboardEvent) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
openChat(event);
|
||||
}
|
||||
},
|
||||
}
|
||||
: {})}
|
||||
>
|
||||
{launcher && (
|
||||
<Box pos="absolute" bottom={0} right={0} style={{ zIndex: 0 }}>
|
||||
<CustomAvatar
|
||||
size={LAUNCHER_SIZE}
|
||||
avatarUrl={launcher.avatarUrl}
|
||||
name={launcher.name}
|
||||
style={{ border: "2px solid var(--mantine-color-body)" }}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
{/* Pin the agent glyph to the top-left at its own size; the launcher then
|
||||
overhangs it by LAUNCHER_OVERHANG at the bottom-right and stays visible. */}
|
||||
<Box
|
||||
style={{
|
||||
position: "relative",
|
||||
zIndex: 1,
|
||||
width: GLYPH_SIZE,
|
||||
height: GLYPH_SIZE,
|
||||
}}
|
||||
>
|
||||
<AgentGlyph agent={agent} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Tooltip label={tooltip} withArrow>
|
||||
{stack}
|
||||
</Tooltip>
|
||||
{showName && (
|
||||
<Group gap={4} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Text size="xs" fw={600} lineClamp={1} lh={1.2}>
|
||||
{agent.name}
|
||||
</Text>
|
||||
{launcher && (
|
||||
<>
|
||||
<Text size="xs" c="dimmed" fw={400} aria-hidden>
|
||||
·
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" fw={400} lineClamp={1} lh={1.2}>
|
||||
{launcher.name}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export default AgentAvatarStack;
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { Provider, createStore } from "jotai";
|
||||
import { AiAgentBadge } from "./ai-agent-badge";
|
||||
import {
|
||||
activeAiChatIdAtom,
|
||||
aiChatWindowOpenAtom,
|
||||
aiChatDraftAtom,
|
||||
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
|
||||
|
||||
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
||||
|
||||
function renderBadge(props: { authorName?: string; aiChatId?: string | null }) {
|
||||
return render(
|
||||
<MantineProvider>
|
||||
<AiAgentBadge {...props} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
// Render a clickable badge inside an explicit jotai store, with a leftover draft
|
||||
// and an onActivate + parent-click spy, so the deep-link side effects are
|
||||
// assertable. Returns the store and spies.
|
||||
function setupClickable() {
|
||||
const store = createStore();
|
||||
store.set(aiChatDraftAtom, "leftover draft from another chat");
|
||||
const onActivate = vi.fn();
|
||||
const onParentClick = vi.fn();
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<MantineProvider>
|
||||
<div onClick={onParentClick}>
|
||||
<AiAgentBadge authorName="Bot" aiChatId="chat-1" onActivate={onActivate} />
|
||||
</div>
|
||||
</MantineProvider>
|
||||
</Provider>,
|
||||
);
|
||||
return { store, onActivate, onParentClick, badge: screen.getByRole("button") };
|
||||
}
|
||||
|
||||
function expectDeepLinked(store: ReturnType<typeof createStore>, onActivate: ReturnType<typeof vi.fn>) {
|
||||
expect(store.get(activeAiChatIdAtom)).toBe("chat-1");
|
||||
expect(store.get(aiChatWindowOpenAtom)).toBe(true);
|
||||
expect(store.get(aiChatDraftAtom)).toBe(""); // draft cleared
|
||||
expect(onActivate).toHaveBeenCalledTimes(1); // caller closes its own modal etc.
|
||||
}
|
||||
|
||||
describe("AiAgentBadge", () => {
|
||||
it("renders the AI-agent label", () => {
|
||||
renderBadge({ authorName: "Bot" });
|
||||
expect(screen.getByText("AI-agent")).toBeDefined();
|
||||
});
|
||||
|
||||
it("is clickable (accessible button) when aiChatId is present", () => {
|
||||
renderBadge({ authorName: "Bot", aiChatId: "chat-1" });
|
||||
const badge = screen.getByRole("button");
|
||||
expect(badge).toBeDefined();
|
||||
expect(badge.textContent).toContain("AI-agent");
|
||||
});
|
||||
|
||||
it("click deep-links: sets active chat, clears draft, opens window, fires onActivate, stops propagation", () => {
|
||||
const { store, onActivate, onParentClick, badge } = setupClickable();
|
||||
fireEvent.click(badge);
|
||||
expectDeepLinked(store, onActivate);
|
||||
expect(onParentClick).not.toHaveBeenCalled(); // stopPropagation contained the click
|
||||
});
|
||||
|
||||
it.each(["Enter", " "])(
|
||||
"keyboard %j activates the deep-link (same side effects as click)",
|
||||
(key) => {
|
||||
const { store, onActivate, badge } = setupClickable();
|
||||
fireEvent.keyDown(badge, { key });
|
||||
expectDeepLinked(store, onActivate);
|
||||
},
|
||||
);
|
||||
|
||||
it("an unrelated key does NOT activate the badge", () => {
|
||||
const { store, onActivate, badge } = setupClickable();
|
||||
fireEvent.keyDown(badge, { key: "Tab" });
|
||||
expect(store.get(activeAiChatIdAtom)).toBeNull();
|
||||
expect(store.get(aiChatWindowOpenAtom)).toBe(false);
|
||||
expect(store.get(aiChatDraftAtom)).toBe("leftover draft from another chat");
|
||||
expect(onActivate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([{ aiChatId: null }, {}])(
|
||||
"is a plain non-clickable label without a chat target (%o)",
|
||||
(props) => {
|
||||
renderBadge({ authorName: "Bot", ...props });
|
||||
expect(screen.getByText("AI-agent")).toBeDefined();
|
||||
// No interactive role is exposed when there is no chat to deep-link into.
|
||||
expect(screen.queryByRole("button")).toBeNull();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Badge, Tooltip } from "@mantine/core";
|
||||
import { IconSparkles } from "@tabler/icons-react";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSetAtom } from "jotai";
|
||||
import {
|
||||
activeAiChatIdAtom,
|
||||
aiChatWindowOpenAtom,
|
||||
aiChatDraftAtom,
|
||||
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
|
||||
|
||||
interface AiAgentBadgeProps {
|
||||
authorName?: string;
|
||||
aiChatId?: string | null;
|
||||
// Fired after the badge deep-links into its chat. The caller handles its own
|
||||
// context (e.g. the page-history row closes the history modal) so this generic
|
||||
// ui/ primitive stays free of cross-feature coupling (#143 review Arch B).
|
||||
onActivate?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Badge marking content written by the AI agent (provenance C3 / §7.4). It is
|
||||
* ADDITIVE — shown next to the human author, never replacing them. Reused by the
|
||||
* page-history list and the comments sidebar.
|
||||
*
|
||||
* When the item carries an `aiChatId` (an internal AI-chat edit), clicking the
|
||||
* badge deep-links into that chat: it sets the active-chat atom and opens the
|
||||
* floating AI-chat window, then invokes `onActivate` so the caller can react
|
||||
* (e.g. the history modal closes itself). When `aiChatId` is null/absent (an
|
||||
* external MCP write with no internal ai_chats row), the badge is a plain
|
||||
* non-clickable label. The click is contained (stopPropagation) so it does not
|
||||
* also trigger an enclosing row's click handler.
|
||||
*/
|
||||
export function AiAgentBadge({
|
||||
authorName,
|
||||
aiChatId,
|
||||
onActivate,
|
||||
}: AiAgentBadgeProps) {
|
||||
const { t } = useTranslation();
|
||||
const setAiChatWindowOpen = useSetAtom(aiChatWindowOpenAtom);
|
||||
const setActiveChatId = useSetAtom(activeAiChatIdAtom);
|
||||
const setDraft = useSetAtom(aiChatDraftAtom);
|
||||
|
||||
const tooltip = t("Edited by AI agent on behalf of {{name}}", {
|
||||
name: authorName ?? "",
|
||||
});
|
||||
|
||||
const openChat = useCallback(
|
||||
(event: React.SyntheticEvent) => {
|
||||
event.stopPropagation();
|
||||
if (!aiChatId) return;
|
||||
setActiveChatId(aiChatId);
|
||||
// Switching to another chat must start with a clean composer — clear any
|
||||
// unsent draft so it does not leak from the previously open chat.
|
||||
setDraft("");
|
||||
setAiChatWindowOpen(true);
|
||||
onActivate?.();
|
||||
},
|
||||
[aiChatId, setActiveChatId, setDraft, setAiChatWindowOpen, onActivate],
|
||||
);
|
||||
|
||||
const badge = (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="violet"
|
||||
radius="sm"
|
||||
leftSection={<IconSparkles size={12} stroke={2} />}
|
||||
style={aiChatId ? { cursor: "pointer" } : undefined}
|
||||
{...(aiChatId
|
||||
? {
|
||||
// Keep the default Badge root element (not a <button>) to avoid an
|
||||
// invalid <button>-in-<button> nesting inside a row's
|
||||
// UnstyledButton; expose it as an accessible button via
|
||||
// role/keyboard.
|
||||
role: "button",
|
||||
tabIndex: 0,
|
||||
onClick: openChat,
|
||||
onKeyDown: (event: React.KeyboardEvent) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
openChat(event);
|
||||
}
|
||||
},
|
||||
}
|
||||
: {})}
|
||||
>
|
||||
{t("AI-agent")}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip label={tooltip} withArrow>
|
||||
{badge}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export default AiAgentBadge;
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Badge, Tooltip } from "@mantine/core";
|
||||
import { IconGitMerge } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface GitSyncBadgeProps {
|
||||
authorName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Badge marking a version produced by git-sync (provenance §8.1). The history
|
||||
* version is created on the PUSH path — when an incoming git body is written back
|
||||
* into the Docmost doc — not by the pull itself. Like {@link AiAgentBadge} it is
|
||||
* ADDITIVE — shown next to the human author, never replacing them — but a git-sync
|
||||
* edit is NOT an agent edit and has no chat to deep-link into, so it is a small,
|
||||
* neutral, non-clickable label.
|
||||
*/
|
||||
export function GitSyncBadge({ authorName }: GitSyncBadgeProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const tooltip = t("Synced from Git on behalf of {{name}}", {
|
||||
name: authorName ?? "",
|
||||
});
|
||||
|
||||
return (
|
||||
<Tooltip label={tooltip} withArrow>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="gray"
|
||||
radius="sm"
|
||||
leftSection={<IconGitMerge size={12} stroke={2} />}
|
||||
>
|
||||
{t("Git sync")}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -23,7 +23,6 @@ import { acceptInvitation } from "@/features/workspace/services/workspace-servic
|
||||
import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route.ts";
|
||||
import { RESET } from "jotai/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { clearPersistedTreeCaches } from "@/features/page/tree/atoms/tree-data-atom";
|
||||
|
||||
export default function useAuth() {
|
||||
const { t } = useTranslation();
|
||||
@@ -123,11 +122,6 @@ export default function useAuth() {
|
||||
|
||||
const handleLogout = async () => {
|
||||
setCurrentUser(RESET);
|
||||
// Purge the persisted sidebar tree caches (they contain page titles) so the
|
||||
// cached page titles aren't left readable in localStorage on a shared
|
||||
// machine. (Only the tree caches are swept; other localStorage entries
|
||||
// remain.)
|
||||
clearPersistedTreeCaches();
|
||||
await logout();
|
||||
window.location.replace(`${APP_ROUTE.AUTH.LOGIN}?logout=1`);
|
||||
};
|
||||
|
||||
@@ -40,50 +40,20 @@ function renderItem(comment: IComment) {
|
||||
);
|
||||
}
|
||||
|
||||
describe("CommentListItem — agent avatar stack", () => {
|
||||
it('flips the hierarchy for an agent comment: agent primary, launcher shown once', () => {
|
||||
// Internal-chat shape with DISTINCT names so absence-of-duplication is
|
||||
// assertable: creator is the human "Alice", the acting agent is "Researcher".
|
||||
renderItem(
|
||||
baseComment({
|
||||
creator: { id: "user-1", name: "Alice", avatarUrl: null } as any,
|
||||
createdSource: "agent",
|
||||
aiChatId: "chat-1",
|
||||
agent: { name: "Researcher", emoji: "🔬", avatarUrl: null },
|
||||
launcher: { name: "Alice", avatarUrl: null },
|
||||
}),
|
||||
);
|
||||
// The AGENT is the primary label (the flipped hierarchy).
|
||||
expect(screen.getByText("Researcher")).toBeDefined();
|
||||
// The human launcher name shows exactly once — it is no longer duplicated as
|
||||
// a separate creator name (that duplication is the bug this fixes).
|
||||
expect(screen.getAllByText("Alice").length).toBe(1);
|
||||
});
|
||||
|
||||
it('external MCP agent comment (no launcher): shows the agent name, no separator', () => {
|
||||
// aiChatId null => external MCP: the agent IS the account, no human behind.
|
||||
renderItem(
|
||||
baseComment({
|
||||
creator: { id: "bot-1", name: "MCP Bot", avatarUrl: null } as any,
|
||||
createdSource: "agent",
|
||||
aiChatId: null,
|
||||
agent: { name: "MCP Bot", avatarUrl: null },
|
||||
launcher: null,
|
||||
}),
|
||||
);
|
||||
expect(screen.getByText("MCP Bot")).toBeDefined();
|
||||
// No launcher => no dimmed "·" separator in the header.
|
||||
expect(screen.queryByText("·")).toBeNull();
|
||||
});
|
||||
|
||||
it('does NOT render the stack for a normal user comment (createdSource "user")', () => {
|
||||
const { container } = renderItem(baseComment({ createdSource: "user" }));
|
||||
// No agent glyph (sparkles) is present for a plain human comment.
|
||||
expect(container.querySelector(".tabler-icon-sparkles")).toBeNull();
|
||||
describe("CommentListItem — AI badge", () => {
|
||||
it('renders the AI-agent badge when createdSource === "agent"', () => {
|
||||
renderItem(baseComment({ createdSource: "agent", aiChatId: null }));
|
||||
expect(screen.getByText("AI-agent")).toBeDefined();
|
||||
expect(screen.getByText("Service Bot")).toBeDefined();
|
||||
});
|
||||
|
||||
// The stack's own behaviors (glyph priority, launcher-behind, deep-link click)
|
||||
// are covered directly in agent-avatar-stack.test.tsx; this integration suite
|
||||
// only guards the insertion gate (agent → stack, user → no stack).
|
||||
it('does NOT render the badge for a normal user comment (createdSource "user")', () => {
|
||||
renderItem(baseComment({ createdSource: "user" }));
|
||||
expect(screen.queryByText("AI-agent")).toBeNull();
|
||||
expect(screen.getByText("Service Bot")).toBeDefined();
|
||||
});
|
||||
|
||||
// The non-clickable (null aiChatId) branch is a property of AiAgentBadge itself
|
||||
// and is covered in ai-agent-badge.test.tsx; this integration suite only needs
|
||||
// the insertion gate (agent → badge, user → no badge) above (#143 review).
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Group, Text, Box } from "@mantine/core";
|
||||
import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx";
|
||||
import { AiAgentBadge } from "@/components/ui/ai-agent-badge.tsx";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import classes from "./comment.module.css";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
@@ -119,44 +119,24 @@ function CommentListItem({
|
||||
return (
|
||||
<Box ref={ref} pb={6}>
|
||||
<Group gap="xs">
|
||||
{comment.createdSource === "agent" && comment.agent ? (
|
||||
<AgentAvatarStack
|
||||
agent={comment.agent}
|
||||
launcher={comment.launcher}
|
||||
aiChatId={comment.aiChatId}
|
||||
showName={false}
|
||||
/>
|
||||
) : (
|
||||
<CustomAvatar
|
||||
size="sm"
|
||||
avatarUrl={comment.creator.avatarUrl}
|
||||
name={comment.creator.name}
|
||||
/>
|
||||
)}
|
||||
<CustomAvatar
|
||||
size="sm"
|
||||
avatarUrl={comment.creator.avatarUrl}
|
||||
name={comment.creator.name}
|
||||
/>
|
||||
|
||||
<div style={{ flex: 1 }}>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
{comment.createdSource === "agent" && comment.agent ? (
|
||||
<>
|
||||
<Text size="xs" fw={600} lineClamp={1} lh={1.2}>
|
||||
{comment.agent.name}
|
||||
</Text>
|
||||
{comment.launcher && (
|
||||
<>
|
||||
<Text size="xs" c="dimmed" fw={400} aria-hidden>
|
||||
·
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" fw={400} lineClamp={1} lh={1.2}>
|
||||
{comment.launcher.name}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Text size="xs" fw={500} lineClamp={1} lh={1.2}>
|
||||
{comment.creator.name}
|
||||
</Text>
|
||||
<Text size="xs" fw={500} lineClamp={1} lh={1.2}>
|
||||
{comment.creator.name}
|
||||
</Text>
|
||||
|
||||
{comment.createdSource === "agent" && (
|
||||
<AiAgentBadge
|
||||
authorName={comment.creator?.name}
|
||||
aiChatId={comment.aiChatId}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { IUser } from "@/features/user/types/user.types";
|
||||
import { QueryParams } from "@/lib/types.ts";
|
||||
import type {
|
||||
AgentInfo,
|
||||
LauncherInfo,
|
||||
} from "@/components/ui/agent-avatar-stack.tsx";
|
||||
|
||||
export interface IComment {
|
||||
id: string;
|
||||
@@ -28,11 +24,6 @@ export interface IComment {
|
||||
createdSource?: string;
|
||||
aiChatId?: string | null;
|
||||
resolvedSource?: string | null;
|
||||
// Server-normalized "agent avatar stack" provenance (#300), present only when
|
||||
// createdSource === "agent": `agent` is the front identity, `launcher` the
|
||||
// human behind it (null for an external MCP agent).
|
||||
agent?: AgentInfo | null;
|
||||
launcher?: LauncherInfo | null;
|
||||
yjsSelection?: {
|
||||
anchor: any;
|
||||
head: any;
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { Document } from "@tiptap/extension-document";
|
||||
import { Paragraph } from "@tiptap/extension-paragraph";
|
||||
import { Text } from "@tiptap/extension-text";
|
||||
import { ySyncPluginKey } from "@tiptap/y-tiptap";
|
||||
import {
|
||||
CustomTypography,
|
||||
undoGuardKey,
|
||||
findChangedRange,
|
||||
mapRangeThroughChange,
|
||||
} from "./custom-typography";
|
||||
|
||||
/**
|
||||
* PR #296 — the collab-safe typography undo-guard is exercised through the REAL
|
||||
* editor path: a fresh Editor with the CustomTypography extension, transactions
|
||||
* tagged exactly the way prosemirror-history / y-tiptap tag undo & remote
|
||||
* changes (`setMeta("history$", …)` and `setMeta(ySyncPluginKey, …)`), plus
|
||||
* direct unit tests of the two pure diff helpers. No hand-poke of plugin state.
|
||||
*
|
||||
* ARMING MECHANISM (verified against custom-typography.ts source):
|
||||
* - A transaction arms the guard only when it is BOTH history/remote
|
||||
* (`getMeta("history$")` truthy, or `isChangeOrigin` via the ySync meta)
|
||||
* AND an undo/redo (`getMeta("history$")` truthy, or ySync
|
||||
* `isUndoRedoOperation`), AND its whole-doc diff is a REPLACE
|
||||
* (change.oldTo > change.from && change.newTo > change.from).
|
||||
* - `history$` is the stringified PluginKey of the single prosemirror-history
|
||||
* plugin; ProseMirror stores meta under `key.key`, so setMeta("history$")
|
||||
* in a test is read identically by the extension's getMeta("history$").
|
||||
*/
|
||||
|
||||
const singlePara = (text: string) => ({
|
||||
type: "doc",
|
||||
content: [{ type: "paragraph", content: [{ type: "text", text }] }],
|
||||
});
|
||||
|
||||
const makeEditor = (text: string) =>
|
||||
new Editor({
|
||||
extensions: [Document, Paragraph, Text, CustomTypography],
|
||||
content: singlePara(text),
|
||||
});
|
||||
|
||||
// Build a before/after EditorState pair by applying one plain transaction.
|
||||
const mutate = (text: string, apply: (tr: any, schema: any) => void) => {
|
||||
const editor = new Editor({
|
||||
extensions: [Document, Paragraph, Text],
|
||||
content: singlePara(text),
|
||||
});
|
||||
const before = editor.state;
|
||||
const tr = before.tr;
|
||||
apply(tr, before.schema);
|
||||
editor.view.dispatch(tr);
|
||||
const after = editor.state;
|
||||
return { before, after, editor };
|
||||
};
|
||||
|
||||
describe("findChangedRange", () => {
|
||||
it("returns null for identical docs", () => {
|
||||
const editor = new Editor({
|
||||
extensions: [Document, Paragraph, Text],
|
||||
content: singlePara("hello"),
|
||||
});
|
||||
expect(findChangedRange(editor.state, editor.state)).toBeNull();
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("returns the minimal range for a normal middle insertion", () => {
|
||||
// "hello world" (text at 1..12); insert "there " at pos 6.
|
||||
const { before, after, editor } = mutate("hello world", (tr) =>
|
||||
tr.insertText("there ", 6),
|
||||
);
|
||||
expect(findChangedRange(before, after)).toEqual({
|
||||
from: 6,
|
||||
oldTo: 6,
|
||||
newTo: 12,
|
||||
});
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("normalizes the INSERTION overlapping-bounds branch (repeated content)", () => {
|
||||
// Insert one more 'a' into "aaaaa" at pos 3. findDiffStart lands at the end
|
||||
// (6) while findDiffEnd reports an end BEFORE it ({a:1,b:2}); both ends must
|
||||
// be pushed forward by the same delta -> a non-degenerate range.
|
||||
const { before, after, editor } = mutate("aaaaa", (tr) =>
|
||||
tr.insertText("a", 3),
|
||||
);
|
||||
const change = findChangedRange(before, after)!;
|
||||
expect(change).toEqual({ from: 6, oldTo: 6, newTo: 7 });
|
||||
// Invariant the guard logic relies on: never degenerate.
|
||||
expect(change.from).toBeLessThanOrEqual(change.oldTo);
|
||||
expect(change.from).toBeLessThanOrEqual(change.newTo);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("normalizes the DELETION overlapping-bounds branch (F2 fix)", () => {
|
||||
// Delete one repeated 'a' from the middle of "aaaaa" ([3,4)). Here
|
||||
// findDiffEnd reports newTo < start, the symmetric case the old one-sided
|
||||
// normalization missed -> it used to yield a degenerate range (newTo < from).
|
||||
const { before, after, editor } = mutate("aaaaa", (tr) => tr.delete(3, 4));
|
||||
const change = findChangedRange(before, after)!;
|
||||
expect(change).toEqual({ from: 5, oldTo: 6, newTo: 5 });
|
||||
// The whole point of F2: from <= newTo (and from <= oldTo) still holds.
|
||||
expect(change.from).toBeLessThanOrEqual(change.newTo);
|
||||
expect(change.from).toBeLessThanOrEqual(change.oldTo);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("normalizes a multi-char repeated deletion (F2 fix)", () => {
|
||||
const { before, after, editor } = mutate("aaaaa", (tr) => tr.delete(2, 4));
|
||||
const change = findChangedRange(before, after)!;
|
||||
expect(change).toEqual({ from: 4, oldTo: 6, newTo: 4 });
|
||||
expect(change.from).toBeLessThanOrEqual(change.newTo);
|
||||
editor.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapRangeThroughChange", () => {
|
||||
const range = { from: 5, to: 10 };
|
||||
|
||||
it("RELEASES on a strict intersection (edit inside the guarded range)", () => {
|
||||
// change straddles the interior of the guard.
|
||||
expect(
|
||||
mapRangeThroughChange(range, { from: 6, oldTo: 8, newTo: 7 }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("does NOT release on a boundary touch at the guard END", () => {
|
||||
// Edit begins exactly at range.to (10): from < to is false -> no intersect.
|
||||
expect(
|
||||
mapRangeThroughChange(range, { from: 10, oldTo: 10, newTo: 12 }),
|
||||
).toEqual(range);
|
||||
});
|
||||
|
||||
it("does NOT release on a boundary touch at the guard START", () => {
|
||||
// Edit ends exactly at range.from (5): oldTo > from is false -> no intersect;
|
||||
// it is treated as a change fully before, shifting the guard.
|
||||
expect(
|
||||
mapRangeThroughChange(range, { from: 3, oldTo: 5, newTo: 8 }),
|
||||
).toEqual({ from: 8, to: 13 });
|
||||
});
|
||||
|
||||
it("SHIFTS the guard for a change fully before it", () => {
|
||||
// Insert 2 chars entirely before the range (oldTo 3 <= from 5): +2 delta.
|
||||
expect(
|
||||
mapRangeThroughChange(range, { from: 2, oldTo: 3, newTo: 5 }),
|
||||
).toEqual({ from: 7, to: 12 });
|
||||
});
|
||||
|
||||
it("leaves the guard untouched for a change fully after it", () => {
|
||||
expect(
|
||||
mapRangeThroughChange(range, { from: 12, oldTo: 14, newTo: 16 }),
|
||||
).toBe(range);
|
||||
});
|
||||
});
|
||||
|
||||
describe("undo-guard arming (integration)", () => {
|
||||
it("arms {from, to:newTo} on a LOCAL undo-replace (history meta)", () => {
|
||||
// Undo of an em-dash substitution: "a—b" restored to "a--b" — the em-dash
|
||||
// (pos 2..3) is REPLACED by "--", tagged with the history plugin's meta.
|
||||
const editor = makeEditor("a—b");
|
||||
const { state } = editor;
|
||||
const tr = state.tr
|
||||
.replaceWith(2, 3, state.schema.text("--"))
|
||||
.setMeta("history$", { redo: false });
|
||||
editor.view.dispatch(tr);
|
||||
|
||||
expect(editor.state.doc.textContent).toBe("a--b");
|
||||
// from = diff start (2), to = newTo = end of the inserted "--" (4).
|
||||
expect(undoGuardKey.getState(editor.state)).toEqual({ from: 2, to: 4 });
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("does NOT arm on a REMOTE change-origin replace (no undo meta)", () => {
|
||||
// Same replace, but tagged only as a y-sync remote change: history/remote
|
||||
// yes, undo/redo NO -> must not arm.
|
||||
const editor = makeEditor("a—b");
|
||||
const { state } = editor;
|
||||
const tr = state.tr
|
||||
.replaceWith(2, 3, state.schema.text("--"))
|
||||
.setMeta(ySyncPluginKey, { isChangeOrigin: true });
|
||||
editor.view.dispatch(tr);
|
||||
|
||||
expect(editor.state.doc.textContent).toBe("a--b");
|
||||
expect(undoGuardKey.getState(editor.state)).toBeNull();
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("does NOT arm on an ordinary local edit", () => {
|
||||
const editor = makeEditor("a—b");
|
||||
editor.view.dispatch(
|
||||
editor.state.tr.replaceWith(2, 3, editor.state.schema.text("--")),
|
||||
);
|
||||
expect(undoGuardKey.getState(editor.state)).toBeNull();
|
||||
editor.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("undo-guard release / shift (integration)", () => {
|
||||
it("RELEASES when a later edit lands inside the guarded region", () => {
|
||||
const editor = makeEditor("a—b");
|
||||
editor.view.dispatch(
|
||||
editor.state.tr
|
||||
.replaceWith(2, 3, editor.state.schema.text("--"))
|
||||
.setMeta("history$", { redo: false }),
|
||||
);
|
||||
const guard = undoGuardKey.getState(editor.state)!;
|
||||
expect(guard).toEqual({ from: 2, to: 4 });
|
||||
|
||||
// Type a character inside the restored region -> guard is dropped.
|
||||
editor.view.dispatch(editor.state.tr.insertText("x", guard.from + 1));
|
||||
expect(undoGuardKey.getState(editor.state)).toBeNull();
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("keeps and SHIFTS the guard when a later edit lands before it", () => {
|
||||
const editor = makeEditor("zz a—b");
|
||||
// "zz a—b": em-dash at pos 5; replace the 'a' at 4..5 with "--" to arm.
|
||||
editor.view.dispatch(
|
||||
editor.state.tr
|
||||
.replaceWith(4, 5, editor.state.schema.text("--"))
|
||||
.setMeta("history$", { redo: false }),
|
||||
);
|
||||
const guard = undoGuardKey.getState(editor.state)!;
|
||||
expect(guard).toEqual({ from: 4, to: 6 });
|
||||
|
||||
// Insert one char at the very start (before the guard) -> guard shifts +1.
|
||||
editor.view.dispatch(editor.state.tr.insertText("Q", 1));
|
||||
expect(undoGuardKey.getState(editor.state)).toEqual({ from: 5, to: 7 });
|
||||
editor.destroy();
|
||||
});
|
||||
});
|
||||
@@ -1,193 +0,0 @@
|
||||
import { InputRule } from "@tiptap/core";
|
||||
import {
|
||||
Plugin,
|
||||
PluginKey,
|
||||
type EditorState,
|
||||
type Transaction,
|
||||
} from "@tiptap/pm/state";
|
||||
import { Typography } from "@tiptap/extension-typography";
|
||||
import { isChangeOrigin } from "@tiptap/extension-collaboration";
|
||||
import { ySyncPluginKey } from "@tiptap/y-tiptap";
|
||||
|
||||
// Region restored by the latest undo — while it is intact, typography
|
||||
// input rules overlapping it must not fire again.
|
||||
interface UndoGuardRange {
|
||||
from: number;
|
||||
to: number;
|
||||
}
|
||||
|
||||
// Exported for tests: the plugin key lets a test read the armed guard state,
|
||||
// and the two pure helpers below are unit-tested directly.
|
||||
export const undoGuardKey = new PluginKey<UndoGuardRange | null>(
|
||||
"typographyUndoGuard",
|
||||
);
|
||||
|
||||
// prosemirror-history does not export its plugin key, so template-editor
|
||||
// undo/redo is detected via the stable stringified key. Only one
|
||||
// PluginKey("history") exists in the dependency tree, so "history$" is stable.
|
||||
const HISTORY_META = "history$";
|
||||
|
||||
const isUndoRedoTransaction = (tr: Transaction): boolean => {
|
||||
if (tr.getMeta(HISTORY_META)) {
|
||||
return true;
|
||||
}
|
||||
// Read yjs undo/redo meta via the real ySyncPluginKey object (imported, not
|
||||
// a fragile stringified key), which y-tiptap sets on Y.UndoManager changes.
|
||||
const ySyncMeta = tr.getMeta(ySyncPluginKey) as
|
||||
| { isUndoRedoOperation?: boolean }
|
||||
| undefined;
|
||||
return !!ySyncMeta?.isUndoRedoOperation;
|
||||
};
|
||||
|
||||
interface DocChange {
|
||||
from: number;
|
||||
oldTo: number;
|
||||
newTo: number;
|
||||
}
|
||||
|
||||
// Compute the minimal changed region between two docs. yjs undo/redo (and any
|
||||
// remote change) arrives as a whole-document replace step, so the transaction
|
||||
// step maps are useless — diff the docs to recover the real minimal change.
|
||||
// Returns null when the docs are identical.
|
||||
export const findChangedRange = (
|
||||
oldState: EditorState,
|
||||
newState: EditorState,
|
||||
): DocChange | null => {
|
||||
const start = oldState.doc.content.findDiffStart(newState.doc.content);
|
||||
const end = oldState.doc.content.findDiffEnd(newState.doc.content);
|
||||
if (start == null || end == null) {
|
||||
return null;
|
||||
}
|
||||
let { a: oldTo, b: newTo } = end;
|
||||
// findDiffEnd can report an end BEFORE the diff start when the changed text
|
||||
// abuts repeated content (insertion -> oldTo<start, deletion -> newTo<start).
|
||||
// Push both ends forward by the same delta so the range stays non-degenerate
|
||||
// (from <= oldTo and from <= newTo), matching ProseMirror's own diff bounds.
|
||||
const minTo = Math.min(oldTo, newTo);
|
||||
if (minTo < start) {
|
||||
const delta = start - minTo;
|
||||
oldTo += delta;
|
||||
newTo += delta;
|
||||
}
|
||||
return { from: start, oldTo, newTo };
|
||||
};
|
||||
|
||||
// Map an armed guard range across a single document change described by a diff.
|
||||
// Returns null when the change touches the guarded text itself (the restored
|
||||
// substitution was edited, so the guard must be released).
|
||||
export const mapRangeThroughChange = (
|
||||
range: UndoGuardRange,
|
||||
change: DocChange,
|
||||
): UndoGuardRange | null => {
|
||||
// Strict intersection: an edit exactly at a guard boundary (e.g. the user
|
||||
// typing the suppressed space right after the restored text, or deleting it)
|
||||
// must NOT drop the guard.
|
||||
if (change.from < range.to && change.oldTo > range.from) {
|
||||
return null;
|
||||
}
|
||||
// Change fully before the guard: shift the guard by the length delta.
|
||||
if (change.oldTo <= range.from) {
|
||||
const delta = change.newTo - change.oldTo;
|
||||
return { from: range.from + delta, to: range.to + delta };
|
||||
}
|
||||
// Change fully after the guard: positions are unaffected.
|
||||
return range;
|
||||
};
|
||||
|
||||
// Detect history/remote transactions that may arrive as a whole-document
|
||||
// replace step: prosemirror-history undo/redo, or any yjs remote-origin change
|
||||
// (isChangeOrigin is the canonical predicate already used across the app).
|
||||
const isHistoryOrRemoteTransaction = (tr: Transaction): boolean =>
|
||||
!!tr.getMeta(HISTORY_META) || isChangeOrigin(tr);
|
||||
|
||||
export const CustomTypography = Typography.extend({
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
...(this.parent?.() ?? []),
|
||||
new Plugin({
|
||||
key: undoGuardKey,
|
||||
state: {
|
||||
init: () => null,
|
||||
apply(tr, prev, oldState, newState): UndoGuardRange | null {
|
||||
if (tr.docChanged && isHistoryOrRemoteTransaction(tr)) {
|
||||
const change = findChangedRange(oldState, newState);
|
||||
if (change == null) {
|
||||
// Attribute-only or otherwise content-neutral change: keep the
|
||||
// guard.
|
||||
return prev;
|
||||
}
|
||||
// Arm the guard only when the LOCAL user's undo/redo REPLACED text
|
||||
// (deleted + inserted) — the signature of reverting an input-rule
|
||||
// substitution. Pure insertions/deletions and remote peer edits
|
||||
// must not arm it.
|
||||
if (
|
||||
isUndoRedoTransaction(tr) &&
|
||||
change.oldTo > change.from &&
|
||||
change.newTo > change.from
|
||||
) {
|
||||
return { from: change.from, to: change.newTo };
|
||||
}
|
||||
// Non-arming history/remote change: map the existing guard through
|
||||
// the real diff instead of the (whole-document) step map.
|
||||
if (!prev) {
|
||||
return null;
|
||||
}
|
||||
return mapRangeThroughChange(prev, change);
|
||||
}
|
||||
if (!prev) {
|
||||
return null;
|
||||
}
|
||||
if (!tr.docChanged) {
|
||||
return prev;
|
||||
}
|
||||
// Ordinary local edit: minimal step maps are accurate and cheap.
|
||||
let range: UndoGuardRange | null = prev;
|
||||
for (const stepMap of tr.mapping.maps) {
|
||||
const { from: rangeFrom, to: rangeTo } = range;
|
||||
let touched = false;
|
||||
stepMap.forEach((fromA, toA) => {
|
||||
if (fromA < rangeTo && toA > rangeFrom) {
|
||||
touched = true;
|
||||
}
|
||||
});
|
||||
if (touched) {
|
||||
range = null;
|
||||
break;
|
||||
}
|
||||
range = {
|
||||
from: stepMap.map(rangeFrom, 1),
|
||||
to: stepMap.map(rangeTo, -1),
|
||||
};
|
||||
}
|
||||
return range && range.to > range.from ? range : null;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
|
||||
addInputRules() {
|
||||
// Wrap every typography rule: skip it when its match overlaps the text
|
||||
// just restored by undo, so an undone substitution is not re-applied.
|
||||
return (this.parent?.() ?? []).map(
|
||||
(rule) =>
|
||||
new InputRule({
|
||||
find: rule.find,
|
||||
undoable: rule.undoable,
|
||||
handler: (props) => {
|
||||
const guard = undoGuardKey.getState(props.state);
|
||||
if (
|
||||
guard &&
|
||||
props.range.from < guard.to &&
|
||||
props.range.to > guard.from
|
||||
) {
|
||||
// Returning null skips this rule and lets the typed character
|
||||
// be inserted as plain text.
|
||||
return null;
|
||||
}
|
||||
return rule.handler(props);
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import { TaskList, TaskItem } from "@tiptap/extension-list";
|
||||
import { Placeholder, CharacterCount, UndoRedo } from "@tiptap/extensions";
|
||||
import { Superscript } from "@tiptap/extension-superscript";
|
||||
import SubScript from "@tiptap/extension-subscript";
|
||||
import { CustomTypography } from "./custom-typography";
|
||||
import { Typography } from "@tiptap/extension-typography";
|
||||
import { TextStyle } from "@tiptap/extension-text-style";
|
||||
import { Color } from "@tiptap/extension-color";
|
||||
import { Youtube } from "@tiptap/extension-youtube";
|
||||
@@ -245,9 +245,7 @@ export const mainExtensions = [
|
||||
return ReactMarkViewRenderer(SpoilerView);
|
||||
},
|
||||
}),
|
||||
// Typography with an undo guard: does not re-apply a substitution the user
|
||||
// just undid (e.g. Ctrl+Z on "1/2" -> "½" followed by another space).
|
||||
CustomTypography,
|
||||
Typography,
|
||||
TrailingNode,
|
||||
GlobalDragHandle.configure({
|
||||
customNodes: ["transclusionSource", "transclusionReference", "pageEmbed"],
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { htmlToMarkdown } from "@docmost/editor-ext";
|
||||
import {
|
||||
normalizeTableColumnWidths,
|
||||
classifyClipboardSelection,
|
||||
} from "./markdown-clipboard";
|
||||
import { normalizeTableColumnWidths } from "./markdown-clipboard";
|
||||
|
||||
// normalizeTableColumnWidths mutates a DOM subtree (jsdom provides document).
|
||||
function root(html: string): HTMLElement {
|
||||
@@ -128,171 +124,3 @@ describe("normalizeTableColumnWidths", () => {
|
||||
).toEqual([null, null]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyClipboardSelection", () => {
|
||||
it("serializes a list of 2+ items as markdown", () => {
|
||||
expect(
|
||||
classifyClipboardSelection([{ name: "bulletList", childCount: 2 }]),
|
||||
).toEqual({ asMarkdown: true, wrapBareRows: false });
|
||||
});
|
||||
|
||||
it("leaves a single-item list as plain text", () => {
|
||||
expect(
|
||||
classifyClipboardSelection([{ name: "bulletList", childCount: 1 }]),
|
||||
).toEqual({ asMarkdown: false, wrapBareRows: false });
|
||||
});
|
||||
|
||||
it("serializes a whole table without wrapping bare rows", () => {
|
||||
expect(
|
||||
classifyClipboardSelection([{ name: "table", childCount: 3 }]),
|
||||
).toEqual({ asMarkdown: true, wrapBareRows: false });
|
||||
});
|
||||
|
||||
it("serializes a partial cell selection (bare rows) and flags wrapping", () => {
|
||||
expect(
|
||||
classifyClipboardSelection([
|
||||
{ name: "tableRow", childCount: 2 },
|
||||
{ name: "tableRow", childCount: 2 },
|
||||
]),
|
||||
).toEqual({ asMarkdown: true, wrapBareRows: true });
|
||||
});
|
||||
|
||||
it("leaves plain paragraphs as plain text", () => {
|
||||
expect(
|
||||
classifyClipboardSelection([{ name: "paragraph", childCount: 1 }]),
|
||||
).toEqual({ asMarkdown: false, wrapBareRows: false });
|
||||
});
|
||||
|
||||
it("does not wrap when rows are mixed with other block types", () => {
|
||||
expect(
|
||||
classifyClipboardSelection([
|
||||
{ name: "tableRow", childCount: 2 },
|
||||
{ name: "paragraph", childCount: 1 },
|
||||
]),
|
||||
).toEqual({ asMarkdown: false, wrapBareRows: false });
|
||||
});
|
||||
});
|
||||
|
||||
// Output-level tests for the table clipboard regression: copying a table must
|
||||
// yield a real GFM pipe table, NOT one-value-per-line concatenated cells.
|
||||
// These exercise the actual markdown produced by htmlToMarkdown (the same
|
||||
// serializer step the clipboardTextSerializer runs), so they pin the OUTPUT
|
||||
// shape that the classifier-flag tests above do not cover.
|
||||
describe("table clipboard markdown output (htmlToMarkdown)", () => {
|
||||
// Trim each line and drop blanks so structural assertions are whitespace-robust.
|
||||
function lines(md: string): string[] {
|
||||
return md
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.length > 0);
|
||||
}
|
||||
|
||||
// A GFM separator row like "| --- | --- |" (any number of columns), tolerant
|
||||
// of the padding turndown emits.
|
||||
function isSeparatorRow(line: string): boolean {
|
||||
const compact = line.replace(/\s+/g, "");
|
||||
return /^\|(?:-{3,}\|)+$/.test(compact);
|
||||
}
|
||||
|
||||
// Split a pipe-delimited row into trimmed cell values.
|
||||
function cells(line: string): string[] {
|
||||
return line
|
||||
.replace(/^\|/, "")
|
||||
.replace(/\|$/, "")
|
||||
.split("|")
|
||||
.map((c) => c.trim());
|
||||
}
|
||||
|
||||
it("serializes a header-less partial cell selection (bare rows) as a valid GFM pipe table", () => {
|
||||
// Mirror the serializer's `wrapBareRows` branch exactly: bare <tr> nodes are
|
||||
// wrapped in <table><tbody> and htmlToMarkdown(div.innerHTML) is called.
|
||||
// See markdown-clipboard.ts clipboardTextSerializer:
|
||||
// const table = document.createElement("table");
|
||||
// const tbody = document.createElement("tbody");
|
||||
// tbody.appendChild(fragment); table.appendChild(tbody);
|
||||
// div.appendChild(table);
|
||||
// return htmlToMarkdown(div.innerHTML);
|
||||
const div = document.createElement("div");
|
||||
const table = document.createElement("table");
|
||||
const tbody = document.createElement("tbody");
|
||||
for (const [c1, c2] of [
|
||||
["a", "b"],
|
||||
["c", "d"],
|
||||
]) {
|
||||
const tr = document.createElement("tr");
|
||||
const td1 = document.createElement("td");
|
||||
td1.textContent = c1;
|
||||
const td2 = document.createElement("td");
|
||||
td2.textContent = c2;
|
||||
tr.appendChild(td1);
|
||||
tr.appendChild(td2);
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
table.appendChild(tbody);
|
||||
div.appendChild(table);
|
||||
|
||||
const md = htmlToMarkdown(div.innerHTML);
|
||||
const ls = lines(md);
|
||||
|
||||
// Valid GFM: a header/data separator row is present (an empty header is
|
||||
// synthesized by the GFM turndown plugin for a header-less table — fine).
|
||||
expect(ls.some(isSeparatorRow)).toBe(true);
|
||||
// NOT the old broken "one value per line" shape: every line is pipe-delimited
|
||||
// and no line is a bare cell value on its own.
|
||||
expect(ls.every((l) => l.includes("|"))).toBe(true);
|
||||
expect(md).not.toMatch(/^\s*(a|b|c|d)\s*$/m);
|
||||
// The cell values land in real pipe-delimited data rows.
|
||||
const dataRows = ls.filter((l) => !isSeparatorRow(l)).map(cells);
|
||||
expect(dataRows).toContainEqual(["a", "b"]);
|
||||
expect(dataRows).toContainEqual(["c", "d"]);
|
||||
});
|
||||
|
||||
it("serializes a whole table with a header row as a proper GFM table (headline regression)", () => {
|
||||
// Mirror the serializer's non-wrap branch: the full <table> node is appended
|
||||
// directly (div.appendChild(fragment)) and htmlToMarkdown(div.innerHTML) runs.
|
||||
const div = document.createElement("div");
|
||||
const table = document.createElement("table");
|
||||
|
||||
const thead = document.createElement("thead");
|
||||
const headerRow = document.createElement("tr");
|
||||
for (const h of ["Name", "Age"]) {
|
||||
const th = document.createElement("th");
|
||||
th.textContent = h;
|
||||
headerRow.appendChild(th);
|
||||
}
|
||||
thead.appendChild(headerRow);
|
||||
table.appendChild(thead);
|
||||
|
||||
const tbody = document.createElement("tbody");
|
||||
for (const [name, age] of [
|
||||
["Alice", "30"],
|
||||
["Bob", "25"],
|
||||
]) {
|
||||
const tr = document.createElement("tr");
|
||||
const td1 = document.createElement("td");
|
||||
td1.textContent = name;
|
||||
const td2 = document.createElement("td");
|
||||
td2.textContent = age;
|
||||
tr.appendChild(td1);
|
||||
tr.appendChild(td2);
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
table.appendChild(tbody);
|
||||
div.appendChild(table);
|
||||
|
||||
const md = htmlToMarkdown(div.innerHTML);
|
||||
const ls = lines(md);
|
||||
|
||||
// Proper GFM structure: separator row + all rows pipe-delimited.
|
||||
expect(ls.some(isSeparatorRow)).toBe(true);
|
||||
expect(ls.every((l) => l.includes("|"))).toBe(true);
|
||||
|
||||
const rows = ls.filter((l) => !isSeparatorRow(l)).map(cells);
|
||||
// Header row comes first, followed by both data rows.
|
||||
expect(rows[0]).toEqual(["Name", "Age"]);
|
||||
expect(rows).toContainEqual(["Alice", "30"]);
|
||||
expect(rows).toContainEqual(["Bob", "25"]);
|
||||
// Headline regression: the table is NOT concatenated one-value-per-line.
|
||||
expect(md).not.toMatch(/^\s*(Name|Age|Alice|Bob|30|25)\s*$/m);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,36 +27,24 @@ export const MarkdownClipboard = Extension.create({
|
||||
key: new PluginKey("markdownClipboard"),
|
||||
props: {
|
||||
clipboardTextSerializer: (slice) => {
|
||||
const topLevelNodes: { name: string; childCount: number }[] = [];
|
||||
const listTypes = ["bulletList", "orderedList", "taskList"];
|
||||
let topLevelCount = 0;
|
||||
let hasList = false;
|
||||
slice.content.forEach((node) => {
|
||||
topLevelNodes.push({
|
||||
name: node.type.name,
|
||||
childCount: node.childCount,
|
||||
});
|
||||
if (listTypes.includes(node.type.name)) {
|
||||
hasList = true;
|
||||
topLevelCount += node.childCount;
|
||||
} else {
|
||||
topLevelCount++;
|
||||
}
|
||||
});
|
||||
|
||||
const { asMarkdown, wrapBareRows } =
|
||||
classifyClipboardSelection(topLevelNodes);
|
||||
if (!asMarkdown) return null;
|
||||
if (!hasList || topLevelCount < 2) return null;
|
||||
|
||||
const div = document.createElement("div");
|
||||
const serializer = DOMSerializer.fromSchema(this.editor.schema);
|
||||
const fragment = serializer.serializeFragment(slice.content);
|
||||
|
||||
if (wrapBareRows) {
|
||||
// A partial table cell-selection serializes to bare <tr> nodes
|
||||
// (prosemirror-tables returns the whole `table` node only when the
|
||||
// entire table is selected). Bare <tr> would be foster-parented
|
||||
// away by the HTML parser inside htmlToMarkdown, so wrap them in
|
||||
// <table><tbody> first for the GFM turndown rule to detect them.
|
||||
const table = document.createElement("table");
|
||||
const tbody = document.createElement("tbody");
|
||||
tbody.appendChild(fragment);
|
||||
table.appendChild(tbody);
|
||||
div.appendChild(table);
|
||||
} else {
|
||||
div.appendChild(fragment);
|
||||
}
|
||||
div.appendChild(fragment);
|
||||
return htmlToMarkdown(div.innerHTML);
|
||||
},
|
||||
handlePaste: (view, event, slice) => {
|
||||
@@ -165,55 +153,6 @@ export const MarkdownClipboard = Extension.create({
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Decide whether a copied slice's plain-text clipboard payload should be
|
||||
* serialized as Markdown (instead of ProseMirror's default text serializer,
|
||||
* which joins block leaves with newlines — the "one value per line" bug for
|
||||
* tables).
|
||||
*
|
||||
* Serialize as Markdown for structured content:
|
||||
* - lists with 2+ total items (a single copied bullet stays literal text);
|
||||
* - a whole table (top-level `table` node);
|
||||
* - a partial table cell-selection, which prosemirror-tables copies as bare
|
||||
* `tableRow` nodes (only a full-table selection yields a `table` node).
|
||||
*
|
||||
* `wrapBareRows` flags the bare-rows case so the caller wraps the serialized
|
||||
* <tr> nodes in <table><tbody> before the HTML->Markdown step. Plain paragraphs
|
||||
* return asMarkdown=false so a simple text copy stays literal, and internal
|
||||
* copy/paste keeps using the richer text/html clipboard payload.
|
||||
*/
|
||||
export function classifyClipboardSelection(
|
||||
nodes: { name: string; childCount: number }[],
|
||||
): { asMarkdown: boolean; wrapBareRows: boolean } {
|
||||
const listTypes = ["bulletList", "orderedList", "taskList"];
|
||||
let topLevelCount = 0;
|
||||
let hasList = false;
|
||||
let hasTable = false;
|
||||
let tableRowCount = 0;
|
||||
let nonRowCount = 0;
|
||||
|
||||
for (const node of nodes) {
|
||||
if (listTypes.includes(node.name)) {
|
||||
hasList = true;
|
||||
topLevelCount += node.childCount;
|
||||
nonRowCount++;
|
||||
} else {
|
||||
if (node.name === "table") hasTable = true;
|
||||
if (node.name === "tableRow") tableRowCount++;
|
||||
else nonRowCount++;
|
||||
topLevelCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Bare tableRow nodes at the top level only occur for a partial cell
|
||||
// selection; a slice never mixes bare rows with other block types, so
|
||||
// "every top-level node is a row" is a safe signal to wrap-and-serialize.
|
||||
const wrapBareRows = tableRowCount > 0 && nonRowCount === 0;
|
||||
const asMarkdown =
|
||||
(hasList && topLevelCount >= 2) || hasTable || wrapBareRows;
|
||||
return { asMarkdown, wrapBareRows };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder/dedup the footnotes of a SELF-CONTAINED pasted markdown block to the
|
||||
* canonical invariant (the live footnoteSyncPlugin never reorders an existing
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useScrollPosition, hasSavedReadingPosition } from "./use-scroll-position";
|
||||
import { useScrollPosition } from "./use-scroll-position";
|
||||
|
||||
const KEY_PREFIX = "gitmost:scroll-position:";
|
||||
|
||||
@@ -93,35 +93,28 @@ describe("useScrollPosition", () => {
|
||||
// Restore still scrolls to 500 (the captured target), NOT the clobbered 0.
|
||||
// If the capture were moved into an effect (after handlers register), it
|
||||
// would read the clobbered 0 and this assertion would fail.
|
||||
setScrollHeight(2000); // maxScroll = 1200 >= 500, held steady -> settles
|
||||
setScrollHeight(2000); // maxScroll = 1200 >= 500
|
||||
act(() => {
|
||||
result.current.restoreScrollPosition();
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
expect(window.scrollTo).toHaveBeenCalledWith({ top: 500, behavior: "auto" });
|
||||
});
|
||||
|
||||
it("(a3) is idempotent: re-asserting the same target does not scroll again", () => {
|
||||
it("(a3) restores at most once per mount even if called again", () => {
|
||||
vi.useFakeTimers();
|
||||
window.sessionStorage.setItem(`${KEY_PREFIX}once`, "500");
|
||||
setScrollHeight(2000); // tall enough + steady -> settles
|
||||
setScrollHeight(2000); // tall enough to restore synchronously
|
||||
|
||||
const { result } = renderHook(() => useScrollPosition("once"));
|
||||
act(() => {
|
||||
result.current.restoreScrollPosition();
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
expect(window.scrollTo).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Simulate the browser now being at the restored position.
|
||||
setScrollY(500);
|
||||
|
||||
// A second call (e.g. the wiring effect re-running on [showStatic, editor,
|
||||
// restoreScrollPosition]) must NOT scroll again: the redundancy guard sees
|
||||
// the window is already at the target and does nothing.
|
||||
// restoreScrollPosition]) must NOT scroll again and yank the reader.
|
||||
act(() => {
|
||||
result.current.restoreScrollPosition();
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
expect(window.scrollTo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -129,10 +122,10 @@ describe("useScrollPosition", () => {
|
||||
it("(b) does not restore when the URL has a #hash anchor", () => {
|
||||
vi.useFakeTimers();
|
||||
window.sessionStorage.setItem(`${KEY_PREFIX}p2`, "500");
|
||||
// Content is ALREADY tall enough (maxScroll = 2000 - 800 = 1200 >= 500) and
|
||||
// steady, so without the hash guard the wait would settle and scroll to the
|
||||
// target. The assertion below therefore genuinely proves the hash guard
|
||||
// short-circuits before any scroll (not just that the wait has not fired).
|
||||
// Content is ALREADY tall enough (maxScroll = 2000 - 800 = 1200 >= 500), so
|
||||
// without the hash guard tryRestore would call scrollTo synchronously on the
|
||||
// first tick. The assertion below therefore genuinely proves the hash guard
|
||||
// short-circuits before any scroll (not just that the poll has not fired).
|
||||
setScrollHeight(2000);
|
||||
window.location.hash = "#some-heading";
|
||||
|
||||
@@ -169,86 +162,6 @@ describe("useScrollPosition", () => {
|
||||
expect(window.scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("(g) does not restore if the reader scrolled (wheel) before restore fires", () => {
|
||||
window.sessionStorage.setItem(`${KEY_PREFIX}g1`, "500");
|
||||
setScrollHeight(2000); // tall enough (would settle and restore, absent the wheel)
|
||||
|
||||
const { result } = renderHook(() => useScrollPosition("g1"));
|
||||
|
||||
// The reader shows scroll intent before restore is triggered.
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("wheel"));
|
||||
});
|
||||
act(() => {
|
||||
result.current.restoreScrollPosition();
|
||||
});
|
||||
|
||||
expect(window.scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("(h) aborts an in-flight restore poll when the reader scrolls", () => {
|
||||
vi.useFakeTimers();
|
||||
window.sessionStorage.setItem(`${KEY_PREFIX}h1`, "500");
|
||||
setInnerHeight(800);
|
||||
setScrollHeight(100); // maxScroll = -700: target not reachable yet, so it polls.
|
||||
|
||||
const { result } = renderHook(() => useScrollPosition("h1"));
|
||||
act(() => {
|
||||
result.current.restoreScrollPosition();
|
||||
});
|
||||
expect(window.scrollTo).not.toHaveBeenCalled(); // still polling
|
||||
|
||||
// The reader takes over mid-poll: this cancels the in-flight poll.
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("wheel"));
|
||||
});
|
||||
|
||||
// Content of the page grows tall enough and time passes: the cancelled poll
|
||||
// must NOT resurrect and yank the reader.
|
||||
setScrollHeight(2000);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(5000);
|
||||
});
|
||||
expect(window.scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("(i) a non-scroll keydown does NOT abort restore", () => {
|
||||
vi.useFakeTimers();
|
||||
window.sessionStorage.setItem(`${KEY_PREFIX}i1`, "500");
|
||||
setScrollHeight(2000); // tall enough + steady -> settles
|
||||
|
||||
const { result } = renderHook(() => useScrollPosition("i1"));
|
||||
|
||||
// A non-scroll key (e.g. typing, a shortcut) must NOT count as scroll intent.
|
||||
act(() => {
|
||||
window.dispatchEvent(new KeyboardEvent("keydown", { key: "a" }));
|
||||
});
|
||||
act(() => {
|
||||
result.current.restoreScrollPosition();
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
// Restore still happens: the innocuous keypress did not disable it.
|
||||
expect(window.scrollTo).toHaveBeenCalledWith({ top: 500, behavior: "auto" });
|
||||
});
|
||||
|
||||
it("(j) a scroll keydown (Space) DOES abort restore", () => {
|
||||
window.sessionStorage.setItem(`${KEY_PREFIX}j1`, "500");
|
||||
setScrollHeight(2000); // tall enough (would settle and restore, absent the scroll key)
|
||||
|
||||
const { result } = renderHook(() => useScrollPosition("j1"));
|
||||
|
||||
// Space scrolls the page: this is real scroll intent and must abort restore.
|
||||
act(() => {
|
||||
window.dispatchEvent(new KeyboardEvent("keydown", { key: " " }));
|
||||
});
|
||||
act(() => {
|
||||
result.current.restoreScrollPosition();
|
||||
});
|
||||
|
||||
expect(window.scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("(c) does nothing when nothing is saved or the saved value is <= 0", () => {
|
||||
// Nothing saved.
|
||||
const a = renderHook(() => useScrollPosition("nope"));
|
||||
@@ -266,7 +179,7 @@ describe("useScrollPosition", () => {
|
||||
expect(window.scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("(d) scrolls to the saved Y once the height settles tall enough", () => {
|
||||
it("(d) scrolls to the saved Y once the content is tall enough", () => {
|
||||
vi.useFakeTimers();
|
||||
window.sessionStorage.setItem(`${KEY_PREFIX}p4`, "500");
|
||||
setInnerHeight(800);
|
||||
@@ -275,50 +188,17 @@ describe("useScrollPosition", () => {
|
||||
const { result } = renderHook(() => useScrollPosition("p4"));
|
||||
act(() => {
|
||||
result.current.restoreScrollPosition();
|
||||
vi.advanceTimersByTime(300);
|
||||
});
|
||||
|
||||
// Still waiting: content not laid out tall enough yet.
|
||||
// Still polling: content not laid out yet.
|
||||
expect(window.scrollTo).not.toHaveBeenCalled();
|
||||
|
||||
// Content becomes tall enough and then holds steady past the stable window:
|
||||
// maxScroll = 2000 - 800 = 1200 >= 500.
|
||||
// Content becomes tall enough: maxScroll = 2000 - 800 = 1200 >= 500.
|
||||
setScrollHeight(2000);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
expect(window.scrollTo).toHaveBeenCalledWith({ top: 500, behavior: "auto" });
|
||||
});
|
||||
|
||||
it("(d3) waits for the height to STOP changing before restoring", () => {
|
||||
vi.useFakeTimers();
|
||||
window.sessionStorage.setItem(`${KEY_PREFIX}p4b`, "500");
|
||||
setInnerHeight(800);
|
||||
setScrollHeight(2000); // reachable from the start (maxScroll 1200 >= 500)...
|
||||
|
||||
const { result } = renderHook(() => useScrollPosition("p4b"));
|
||||
act(() => {
|
||||
result.current.restoreScrollPosition();
|
||||
});
|
||||
|
||||
// ...but the height keeps changing every tick, so it never settles.
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(100);
|
||||
setScrollHeight(2500);
|
||||
vi.advanceTimersByTime(100);
|
||||
setScrollHeight(3000);
|
||||
vi.advanceTimersByTime(100);
|
||||
setScrollHeight(3500);
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
expect(window.scrollTo).not.toHaveBeenCalled(); // reachable, but not settled
|
||||
|
||||
// Height now holds steady past HEIGHT_STABLE_MS -> restore fires (to the
|
||||
// fixed target, unaffected by the taller document).
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
expect(window.scrollTo).toHaveBeenCalledWith({ top: 500, behavior: "auto" });
|
||||
});
|
||||
|
||||
@@ -341,41 +221,6 @@ describe("useScrollPosition", () => {
|
||||
expect(window.scrollTo).toHaveBeenCalledWith({ top: 200, behavior: "auto" });
|
||||
});
|
||||
|
||||
it("(k) a re-trigger while the wait is running does not start a second concurrent poll", () => {
|
||||
// Both triggers (early on-mount + post-swap) call restore. The
|
||||
// `if (pollTimerRef.current !== null) return` guard makes a re-trigger during
|
||||
// an in-flight wait a no-op, so exactly ONE poll runs and scrolls exactly once.
|
||||
// A mutant dropping that guard would start a second parallel poll; since the
|
||||
// stubbed scrollTo never moves window.scrollY, the second poll would scroll
|
||||
// again (redundancy guard sees scrollY still 0 != target) -> two calls, and
|
||||
// this assertion would fail.
|
||||
vi.useFakeTimers();
|
||||
window.sessionStorage.setItem(`${KEY_PREFIX}k1`, "500");
|
||||
setInnerHeight(800);
|
||||
setScrollHeight(100); // too short -> the first wait keeps polling (no scroll yet)
|
||||
|
||||
const { result } = renderHook(() => useScrollPosition("k1"));
|
||||
|
||||
// First trigger: starts the wait.
|
||||
act(() => {
|
||||
result.current.restoreScrollPosition();
|
||||
});
|
||||
// Second trigger while the first wait is still running: the guard suppresses it.
|
||||
act(() => {
|
||||
result.current.restoreScrollPosition();
|
||||
});
|
||||
|
||||
// Content becomes reachable and holds steady past the stable window.
|
||||
setScrollHeight(2000);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
// Exactly one scroll — the guard prevented a second concurrent poll.
|
||||
expect(window.scrollTo).toHaveBeenCalledTimes(1);
|
||||
expect(window.scrollTo).toHaveBeenCalledWith({ top: 500, behavior: "auto" });
|
||||
});
|
||||
|
||||
it("(e) never throws when storage access throws", () => {
|
||||
const err = new Error("storage denied");
|
||||
vi.spyOn(window.sessionStorage, "getItem").mockImplementation(() => {
|
||||
@@ -396,23 +241,3 @@ describe("useScrollPosition", () => {
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasSavedReadingPosition", () => {
|
||||
beforeEach(() => {
|
||||
window.sessionStorage.clear();
|
||||
});
|
||||
|
||||
it("returns false when nothing is saved for the page", () => {
|
||||
expect(hasSavedReadingPosition("none")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when the saved value is 0 (page stays at the top)", () => {
|
||||
window.sessionStorage.setItem(`${KEY_PREFIX}zero`, "0");
|
||||
expect(hasSavedReadingPosition("zero")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when a positive position is saved", () => {
|
||||
window.sessionStorage.setItem(`${KEY_PREFIX}deep`, "500");
|
||||
expect(hasSavedReadingPosition("deep")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
// Throttle interval for persisting the scroll position while the user reads.
|
||||
const SAVE_THROTTLE_MS = 250;
|
||||
@@ -8,29 +7,12 @@ const SAVE_THROTTLE_MS = 250;
|
||||
const MAX_RESTORE_WAIT_MS = 5000;
|
||||
// How often to re-check the document height while waiting for content to load.
|
||||
const RESTORE_POLL_MS = 100;
|
||||
// The document height must stay unchanged this long before we treat the layout
|
||||
// as settled and safe to restore against — restoring while content is still
|
||||
// rendering in lets scroll-anchoring drift the saved offset (and re-fire, which
|
||||
// is the residual reload "jitter" this replaces).
|
||||
const HEIGHT_STABLE_MS = 400;
|
||||
|
||||
// sessionStorage key prefix. sessionStorage survives an F5 in the same tab and
|
||||
// is cleared on tab close, which is exactly the lifetime we want for an MVP
|
||||
// "remember where I was reading" feature (self-limiting, no cross-tab leak).
|
||||
const STORAGE_PREFIX = "gitmost:scroll-position:";
|
||||
|
||||
// Keys that scroll the window. Only these count as scroll intent for keydown;
|
||||
// other keys (shortcuts, modifiers, typing) must NOT disable scroll restore.
|
||||
const SCROLL_KEYS = new Set([
|
||||
"ArrowUp",
|
||||
"ArrowDown",
|
||||
"PageUp",
|
||||
"PageDown",
|
||||
"Home",
|
||||
"End",
|
||||
" ", // Space (and Shift+Space) scroll the page
|
||||
]);
|
||||
|
||||
function storageKey(pageId: string): string {
|
||||
return `${STORAGE_PREFIX}${pageId}`;
|
||||
}
|
||||
@@ -62,52 +44,32 @@ function writeStorage(pageId: string, scrollY: number): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a positive reading position is saved for this page — i.e. the page
|
||||
* will be scrolled away from the top on load. Used by the title editor to avoid
|
||||
* auto-focusing (and thus placing the caret in) the now-off-screen title.
|
||||
* Returns false when nothing is saved or storage is unavailable.
|
||||
*/
|
||||
export function hasSavedReadingPosition(pageId: string): boolean {
|
||||
const y = readStorage(pageId);
|
||||
return typeof y === "number" && y > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists and restores the window scroll position per page so a reader keeps
|
||||
* their place across a reload (F5) or reopening the document.
|
||||
*
|
||||
* Returns `restoreScrollPosition`, which the page editor calls from two triggers
|
||||
* (early on mount + after the static->live editor swap). It WAITS for the
|
||||
* document height to stop changing (the layout to settle) and then scrolls once
|
||||
* to the saved offset — so it never fires mid-render, where scroll-anchoring
|
||||
* would drift the position. It is idempotent: a running wait suppresses a second
|
||||
* trigger, and once positioned re-asserting is a no-op. The two scroll mechanisms
|
||||
* are mutually exclusive: if the URL has a `#hash` anchor, the existing
|
||||
* anchor-scroll logic wins and restore is a no-op.
|
||||
* Returns `restoreScrollPosition`, which the page editor calls once the live
|
||||
* (non-static) content is laid out. The two scroll mechanisms are mutually
|
||||
* exclusive: if the URL has a `#hash` anchor, the existing anchor-scroll logic
|
||||
* wins and restore is a no-op.
|
||||
*/
|
||||
export function useScrollPosition(pageId: string): {
|
||||
restoreScrollPosition: () => void;
|
||||
} {
|
||||
// CONTRACT: this hook assumes PageEditor REMOUNTS per page — page.tsx renders
|
||||
// `<MemoizedFullEditor key={page.id} ...>`, so switching pages creates a fresh
|
||||
// hook instance with fresh refs. Restore is idempotent and interaction-gated
|
||||
// (not single-shot): it may be called from several triggers and re-asserts the
|
||||
// SAME captured target, which is a no-op once the window is already positioned.
|
||||
// The per-mount refs that latch are `initialTargetRef` (the captured target)
|
||||
// and `userInteractedRef` (the reader has taken over scrolling). They are NOT
|
||||
// reset when `pageId` changes in place (only the effect re-runs on [pageId]).
|
||||
// If that `key={page.id}` is ever removed, restore would silently break on the
|
||||
// 2nd page (refs would hold the first page's target / interaction flag) — in
|
||||
// that case the refs must be reset on a pageId change.
|
||||
// hook instance with fresh refs. These refs latch per-mount and are NOT reset
|
||||
// when `pageId` changes in place (only the effect re-runs on [pageId]). If that
|
||||
// `key={page.id}` is ever removed, restore would silently break on the 2nd page
|
||||
// (refs would hold the first page's target / already-restored flag) — in that
|
||||
// case the refs must be reset on a pageId change.
|
||||
//
|
||||
// The target Y captured synchronously at mount, BEFORE any scroll/visibility
|
||||
// handler can overwrite the stored value with a fresh 0 (the page starts
|
||||
// scrolled to top on load). `null` means "not yet captured".
|
||||
const initialTargetRef = useRef<number | null>(null);
|
||||
// Set once the reader shows unambiguous scroll intent; restore must never yank
|
||||
// a reader who has already started scrolling.
|
||||
const userInteractedRef = useRef(false);
|
||||
// Guards so restore runs at most once per page mount.
|
||||
const hasRestoredRef = useRef(false);
|
||||
// Holds the in-flight restore poll timer so the cleanup can cancel it: without
|
||||
// this, a fast SPA navigation away mid-poll would let the old page's poll fire
|
||||
// window.scrollTo against the NEW page's document (visible wrong-page scroll).
|
||||
@@ -152,43 +114,14 @@ export function useScrollPosition(pageId: string): {
|
||||
}
|
||||
};
|
||||
|
||||
// User scroll-intent signals. wheel and touch are unconditional scroll
|
||||
// intent; keydown is filtered to actual scroll keys only (SCROLL_KEYS) so
|
||||
// shortcuts, lone modifiers, and typing do not abort restore. Our own
|
||||
// window.scrollTo does NOT emit these, so restore can never self-abort via
|
||||
// them. Once the reader shows intent we mark it and cancel any in-flight
|
||||
// restore poll so restore can never yank them back. (Scrollbar-drag via
|
||||
// pointer is an accepted small gap — it is not covered here.)
|
||||
const onUserIntent = (event: Event) => {
|
||||
// wheel/touchstart are unambiguous scroll intent; for keydown, only real
|
||||
// scroll keys count — a shortcut or typing must not abort restore.
|
||||
if (
|
||||
event.type === "keydown" &&
|
||||
!SCROLL_KEYS.has((event as KeyboardEvent).key)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
userInteractedRef.current = true;
|
||||
if (pollTimerRef.current !== null) {
|
||||
window.clearTimeout(pollTimerRef.current);
|
||||
pollTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
window.addEventListener("pagehide", onPageHide);
|
||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||
window.addEventListener("wheel", onUserIntent, { passive: true });
|
||||
window.addEventListener("touchstart", onUserIntent, { passive: true });
|
||||
window.addEventListener("keydown", onUserIntent);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("scroll", onScroll);
|
||||
window.removeEventListener("pagehide", onPageHide);
|
||||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||
window.removeEventListener("wheel", onUserIntent);
|
||||
window.removeEventListener("touchstart", onUserIntent);
|
||||
window.removeEventListener("keydown", onUserIntent);
|
||||
if (throttleTimer !== null) {
|
||||
window.clearTimeout(throttleTimer);
|
||||
throttleTimer = null;
|
||||
@@ -204,8 +137,9 @@ export function useScrollPosition(pageId: string): {
|
||||
}, [pageId]);
|
||||
|
||||
const restoreScrollPosition = useCallback(() => {
|
||||
// The reader took over — never yank them back.
|
||||
if (userInteractedRef.current) return;
|
||||
// Run at most once per page mount.
|
||||
if (hasRestoredRef.current) return;
|
||||
hasRestoredRef.current = true;
|
||||
|
||||
// Anchor priority: a `#hash` in the URL is handled by useEditorScroll.
|
||||
if (window.location.hash) return;
|
||||
@@ -214,93 +148,30 @@ export function useScrollPosition(pageId: string): {
|
||||
// Nothing meaningful to restore to.
|
||||
if (targetY <= 0) return;
|
||||
|
||||
// Idempotent: if a restore poll is already running, do not start a second.
|
||||
// Both triggers (early + post-swap) share it; a running poll suppresses the
|
||||
// second, and once positioned the redundancy guard makes it a no-op.
|
||||
if (pollTimerRef.current !== null) return;
|
||||
|
||||
const start = Date.now();
|
||||
let lastHeight = -1;
|
||||
let stableSince = start;
|
||||
|
||||
// Restore ONCE the document height has been stable for HEIGHT_STABLE_MS AND
|
||||
// the target is reachable, so the saved pixel offset lands on the same content
|
||||
// the reader left. Restoring earlier — while the doc is still rendering in
|
||||
// (progressive content / static->live swap) — lets scroll-anchoring drift the
|
||||
// position and makes the restore re-fire (the reload jitter). The timeout is
|
||||
// the only fallback that clamps to the furthest reachable position.
|
||||
const tick = () => {
|
||||
// Bail mid-wait if the reader started scrolling.
|
||||
if (userInteractedRef.current) {
|
||||
pollTimerRef.current = null;
|
||||
return;
|
||||
}
|
||||
const tryRestore = () => {
|
||||
const maxScroll =
|
||||
document.documentElement.scrollHeight - window.innerHeight;
|
||||
const timedOut = Date.now() - start >= MAX_RESTORE_WAIT_MS;
|
||||
|
||||
const now = Date.now();
|
||||
const height = document.documentElement.scrollHeight;
|
||||
if (height !== lastHeight) {
|
||||
lastHeight = height;
|
||||
stableSince = now;
|
||||
}
|
||||
const maxScroll = height - window.innerHeight;
|
||||
const settled = now - stableSince >= HEIGHT_STABLE_MS;
|
||||
const reachable = maxScroll >= targetY;
|
||||
const timedOut = now - start >= MAX_RESTORE_WAIT_MS;
|
||||
|
||||
if ((settled && reachable) || timedOut) {
|
||||
const top = Math.min(targetY, Math.max(maxScroll, 0));
|
||||
// No-op when already there — avoids a redundant scroll and keeps the two
|
||||
// triggers from double-scrolling.
|
||||
if (Math.abs(window.scrollY - top) > 1) {
|
||||
window.scrollTo({ top, behavior: "auto" });
|
||||
}
|
||||
// Restore once the content is tall enough to reach the target, or bail out
|
||||
// after the timeout and scroll as far as currently possible.
|
||||
if (maxScroll >= targetY || timedOut) {
|
||||
window.scrollTo({
|
||||
top: Math.min(targetY, Math.max(maxScroll, 0)),
|
||||
behavior: "auto",
|
||||
});
|
||||
pollTimerRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Stored in a ref so the effect cleanup can cancel it on unmount.
|
||||
pollTimerRef.current = window.setTimeout(tick, RESTORE_POLL_MS);
|
||||
pollTimerRef.current = window.setTimeout(tryRestore, RESTORE_POLL_MS);
|
||||
};
|
||||
|
||||
tick();
|
||||
tryRestore();
|
||||
}, []);
|
||||
|
||||
return { restoreScrollPosition };
|
||||
}
|
||||
|
||||
/**
|
||||
* Wires `useScrollPosition` to the page editor's static->live swap lifecycle.
|
||||
*
|
||||
* Extracted from PageEditor so the exact restore triggers (their deps and the
|
||||
* post-swap `&& editor` guard) are directly unit-testable rather than mirrored.
|
||||
* `restoreScrollPosition` waits for the layout to settle and is idempotent, so
|
||||
* both triggers together produce a single restore (a running wait suppresses the
|
||||
* second; once positioned re-asserting is a no-op).
|
||||
*
|
||||
* @param pageId the page whose scroll position is persisted/restored.
|
||||
* @param editor the tiptap editor instance, or `null` until it is ready.
|
||||
* @param showStatic whether the static (cached) content is still shown.
|
||||
*/
|
||||
export function useScrollRestoreOnSwap(
|
||||
pageId: string,
|
||||
editor: Editor | null,
|
||||
showStatic: boolean,
|
||||
): void {
|
||||
const { restoreScrollPosition } = useScrollPosition(pageId);
|
||||
|
||||
// Early trigger: start the restore wait on mount, so a reload that never
|
||||
// reaches the live editor (offline / collab never syncs — the static cache
|
||||
// stays shown) still restores once the static layout settles. The wait itself
|
||||
// holds off scrolling until the height is stable, and aborts if the reader
|
||||
// starts scrolling (handled inside the hook).
|
||||
useLayoutEffect(() => {
|
||||
restoreScrollPosition();
|
||||
}, [restoreScrollPosition]);
|
||||
|
||||
// Post-swap trigger: after the static -> live swap, (re)start the wait so it
|
||||
// measures the final live layout. Idempotent: a no-op while the early wait is
|
||||
// still running, and a no-op once already positioned or the reader interacted.
|
||||
useLayoutEffect(() => {
|
||||
if (!showStatic && editor) restoreScrollPosition();
|
||||
}, [showStatic, editor, restoreScrollPosition]);
|
||||
}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useTitleAutofocus } from "./use-title-autofocus";
|
||||
|
||||
const KEY_PREFIX = "gitmost:scroll-position:";
|
||||
|
||||
function fakeEditor(overrides = {}) {
|
||||
return { isInitialized: true, commands: { focus: vi.fn() }, ...overrides } as any;
|
||||
}
|
||||
|
||||
describe("useTitleAutofocus", () => {
|
||||
beforeEach(() => {
|
||||
window.sessionStorage.clear();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("skips auto-focus when a saved reading position exists", () => {
|
||||
window.sessionStorage.setItem(`${KEY_PREFIX}saved`, "500");
|
||||
const editor = fakeEditor();
|
||||
renderHook(() => useTitleAutofocus(editor, "saved"));
|
||||
act(() => vi.advanceTimersByTime(300));
|
||||
expect(editor.commands.focus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("auto-focuses a new page (no saved position) with scrollIntoView: false", () => {
|
||||
const editor = fakeEditor();
|
||||
renderHook(() => useTitleAutofocus(editor, "fresh"));
|
||||
act(() => vi.advanceTimersByTime(300));
|
||||
expect(editor.commands.focus).toHaveBeenCalledWith("end", { scrollIntoView: false });
|
||||
});
|
||||
|
||||
it("does not focus before initialization", () => {
|
||||
const editor = fakeEditor({ isInitialized: false });
|
||||
renderHook(() => useTitleAutofocus(editor, "fresh2"));
|
||||
act(() => vi.advanceTimersByTime(300));
|
||||
expect(editor.commands.focus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cancels the pending focus on unmount", () => {
|
||||
const editor = fakeEditor();
|
||||
const { unmount } = renderHook(() => useTitleAutofocus(editor, "fresh3"));
|
||||
unmount();
|
||||
act(() => vi.advanceTimersByTime(300));
|
||||
expect(editor.commands.focus).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,45 +0,0 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { hasSavedReadingPosition } from "./use-scroll-position";
|
||||
|
||||
// Delay before auto-focusing the title on load — guards a tiptap init race
|
||||
// ("Cannot access view['hasFocus']" if focused too early).
|
||||
const TITLE_AUTOFOCUS_DELAY_MS = 300;
|
||||
|
||||
/**
|
||||
* Auto-focus the page title shortly after mount — UNLESS a saved reading position
|
||||
* will be restored (then the viewport scrolls away from the top, and focusing the
|
||||
* top-of-page title would drop the caret off-screen). When it does focus, it uses
|
||||
* `{ scrollIntoView: false }` so placing the caret never moves the viewport
|
||||
* (tiptap's focus scrolls the focused node into view by default, which otherwise
|
||||
* yanks the window to the top and fights scroll-position restoration).
|
||||
*
|
||||
* Extracted from TitleEditor so this exact decision is unit-testable.
|
||||
*
|
||||
* CONTRACT: relies on TitleEditor remounting per page (page.tsx renders
|
||||
* `<MemoizedFullEditor key={page.id}>`), so `hasSavedScrollRef` is captured fresh
|
||||
* per page. It is read synchronously on first render, before any scroll-save
|
||||
* handler can clobber the stored value to 0 — matching `useScrollPosition`'s own
|
||||
* synchronous capture of `initialTargetRef`.
|
||||
*/
|
||||
export function useTitleAutofocus(
|
||||
titleEditor: Editor | null,
|
||||
pageId: string,
|
||||
): void {
|
||||
const hasSavedScrollRef = useRef<boolean | null>(null);
|
||||
if (hasSavedScrollRef.current === null) {
|
||||
hasSavedScrollRef.current = hasSavedReadingPosition(pageId);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (hasSavedScrollRef.current) return;
|
||||
const timer = setTimeout(() => {
|
||||
// guard against "Cannot access view['hasFocus']" before init
|
||||
if (!titleEditor?.isInitialized) return;
|
||||
titleEditor?.commands?.focus("end", { scrollIntoView: false });
|
||||
}, TITLE_AUTOFOCUS_DELAY_MS);
|
||||
// Clear the pending focus if the editor changes or the component unmounts
|
||||
// (also fixes the previously-uncancelled timer).
|
||||
return () => clearTimeout(timer);
|
||||
}, [titleEditor]);
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, act } from "@testing-library/react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { useScrollRestoreOnSwap } from "./hooks/use-scroll-position";
|
||||
|
||||
const KEY_PREFIX = "gitmost:scroll-position:";
|
||||
|
||||
// NOTE ON SCOPE (F2 — reviewer-approved lighter variant).
|
||||
//
|
||||
// The real UX wiring lives in the exported `useScrollRestoreOnSwap` hook (two
|
||||
// useLayoutEffects around useScrollPosition), which PageEditor calls with the
|
||||
// same signature. A FULL PageEditor component test is impractical here and has no
|
||||
// precedent in this client: PageEditor directly constructs a
|
||||
// HocuspocusProviderWebsocket + IndexeddbPersistence, a tiptap `useEditor` with
|
||||
// collab extensions, reads jotai atoms, react-router params, the shared
|
||||
// `queryClient` from main.tsx, i18n, and mounts ~12 editor menu children. Worse,
|
||||
// the static->live swap (`showStatic` -> false) is gated on
|
||||
// `isCollabSynced(status, isLocalSynced && isRemoteSynced)`, which can only flip
|
||||
// by driving the mocked collab provider's async sync callbacks. The heaviest
|
||||
// component-test precedent in the repo (comment-hover-preview.test.tsx) mounts a
|
||||
// single leaf component with ONE mocked query; nothing mounts a feature root of
|
||||
// this weight. Reproducing all of that would test the mocks, not the wiring.
|
||||
//
|
||||
// So this file tests the REAL `useScrollRestoreOnSwap` hook — the exact code
|
||||
// PageEditor imports and calls — driving its `showStatic`/`editor` inputs the way
|
||||
// the swap does. Because it exercises the real hook (not a copy), dropping the
|
||||
// `&& editor` guard or changing the effect deps makes these tests fail; they
|
||||
// guard the production code directly (verified: removing `&& editor` reddens the
|
||||
// first test).
|
||||
//
|
||||
// Both tests observe the real effect via `window.scrollTo`. Restore is NOT
|
||||
// synchronous: it waits for the document height to settle (HEIGHT_STABLE_MS)
|
||||
// before scrolling, so the tests use fake timers and advance them with a steady,
|
||||
// reachable height to let the wait fire. The stubbed `window.scrollTo` never
|
||||
// mutates `window.scrollY`, so every restore that settles yields exactly one
|
||||
// `scrollTo` call — making the call count a faithful proxy for restore invocations.
|
||||
|
||||
function setScrollY(value: number): void {
|
||||
Object.defineProperty(window, "scrollY", { configurable: true, value });
|
||||
}
|
||||
function setScrollHeight(value: number): void {
|
||||
Object.defineProperty(document.documentElement, "scrollHeight", {
|
||||
configurable: true,
|
||||
value,
|
||||
});
|
||||
}
|
||||
function setInnerHeight(value: number): void {
|
||||
Object.defineProperty(window, "innerHeight", { configurable: true, value });
|
||||
}
|
||||
|
||||
// Minimal stand-in for the tiptap editor: the hook only truthiness-checks it.
|
||||
const fakeEditor = { id: "editor" } as unknown as Editor;
|
||||
|
||||
// Thin host that calls the REAL hook so a rerender drives showStatic/editor
|
||||
// exactly like the page-editor swap does.
|
||||
function Host({
|
||||
pageId,
|
||||
showStatic,
|
||||
editor,
|
||||
}: {
|
||||
pageId: string;
|
||||
showStatic: boolean;
|
||||
editor: Editor | null;
|
||||
}) {
|
||||
useScrollRestoreOnSwap(pageId, editor, showStatic);
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("PageEditor scroll-restore wiring (useScrollRestoreOnSwap)", () => {
|
||||
beforeEach(() => {
|
||||
window.sessionStorage.clear();
|
||||
setScrollY(0);
|
||||
setScrollHeight(0);
|
||||
setInnerHeight(800);
|
||||
window.scrollTo = vi.fn();
|
||||
window.location.hash = "";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
window.location.hash = "";
|
||||
});
|
||||
|
||||
it("early trigger restores once the layout settles; post-swap re-assert gated by && editor", () => {
|
||||
// Restore WAITS for the document height to settle (HEIGHT_STABLE_MS), so tests
|
||||
// advance fake timers. `window.scrollY` stays 0 (stubbed scrollTo never updates
|
||||
// it), so scrollTo's call count proxies the number of effective restores.
|
||||
vi.useFakeTimers();
|
||||
window.sessionStorage.setItem(`${KEY_PREFIX}guard`, "500");
|
||||
setInnerHeight(800);
|
||||
setScrollHeight(2000); // reachable + held steady -> the wait settles
|
||||
|
||||
// Pre-swap: the early on-mount trigger's wait settles and restores once — this
|
||||
// is the offline / collab-never-syncs path (no swap needed).
|
||||
const { rerender } = render(
|
||||
<Host pageId="guard" showStatic={true} editor={null} />,
|
||||
);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
expect(window.scrollTo).toHaveBeenCalledTimes(1);
|
||||
|
||||
// showStatic flips false but the editor is still null: the post-swap effect
|
||||
// re-runs (deps [showStatic, editor] changed) but its `&& editor` guard must
|
||||
// keep it a no-op. (Dropping `&& editor` would start a fresh wait against a
|
||||
// null editor and produce a 2nd scrollTo, failing this expectation.)
|
||||
rerender(<Host pageId="guard" showStatic={false} editor={null} />);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
expect(window.scrollTo).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The static -> live swap completes (showStatic false AND editor present): the
|
||||
// post-swap effect re-invokes restore, whose fresh wait settles and re-asserts.
|
||||
rerender(<Host pageId="guard" showStatic={false} editor={fakeEditor} />);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
expect(window.scrollTo).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("restore waits for the height to settle before scrolling (end-to-end via the hook)", () => {
|
||||
vi.useFakeTimers();
|
||||
window.sessionStorage.setItem(`${KEY_PREFIX}peg`, "500");
|
||||
setInnerHeight(800);
|
||||
setScrollHeight(100); // maxScroll = -700: target not reachable yet.
|
||||
|
||||
// Mount + swap while the content is still too short: nothing scrolls, even as
|
||||
// time passes — restore never fires against an unsettled/unreachable layout.
|
||||
const { rerender } = render(
|
||||
<Host pageId="peg" showStatic={true} editor={null} />,
|
||||
);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
act(() => {
|
||||
rerender(<Host pageId="peg" showStatic={false} editor={fakeEditor} />);
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
expect(window.scrollTo).not.toHaveBeenCalled();
|
||||
|
||||
// The live content finally lays out tall enough and holds steady past the
|
||||
// stable window -> restore fires exactly to the saved target.
|
||||
setScrollHeight(2000);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
expect(window.scrollTo).toHaveBeenCalledWith({ top: 500, behavior: "auto" });
|
||||
});
|
||||
});
|
||||
@@ -78,7 +78,7 @@ import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||
import { jwtDecode } from "jwt-decode";
|
||||
import { searchSpotlight } from "@/features/search/constants.ts";
|
||||
import { useEditorScroll } from "./hooks/use-editor-scroll";
|
||||
import { useScrollRestoreOnSwap } from "./hooks/use-scroll-position";
|
||||
import { useScrollPosition } from "./hooks/use-scroll-position";
|
||||
import { EditorLinkMenu } from "@/features/editor/components/link/link-menu";
|
||||
import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx";
|
||||
import { TransclusionLookupProvider } from "@/features/editor/components/transclusion/transclusion-lookup-context";
|
||||
@@ -143,6 +143,7 @@ export default function PageEditor({
|
||||
[isComponentMounted],
|
||||
);
|
||||
const { handleScrollTo } = useEditorScroll({ canScroll });
|
||||
const { restoreScrollPosition } = useScrollPosition(pageId);
|
||||
// Providers only created once per pageId
|
||||
const providersRef = useRef<{
|
||||
local: IndexeddbPersistence;
|
||||
@@ -481,10 +482,10 @@ export default function PageEditor({
|
||||
}
|
||||
}, [yjsConnectionStatus, isSynced]);
|
||||
|
||||
// Restore the reader's scroll position across the static -> live editor swap.
|
||||
// The wiring (early pre-paint restore + post-swap re-assert) lives in the hook
|
||||
// so its triggers/guard are directly unit-testable.
|
||||
useScrollRestoreOnSwap(pageId, editor, showStatic);
|
||||
// Restore the saved reading position once the live content is laid out.
|
||||
useEffect(() => {
|
||||
if (!showStatic && editor) restoreScrollPosition();
|
||||
}, [showStatic, editor, restoreScrollPosition]);
|
||||
|
||||
return (
|
||||
<TransclusionLookupProvider>
|
||||
|
||||
@@ -71,22 +71,3 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Inline image rows (#284): center the anonymous line boxes formed by
|
||||
consecutive [data-image-align="inline"] node-view containers. A row has no
|
||||
DOM wrapper of its own, so its horizontal placement is controlled by the
|
||||
text-align of the nearest block ancestor (the editor root or a nested
|
||||
block container: blockquote, callout, list item, table cell, details).
|
||||
Centering is enabled only in containers that actually hold an inline
|
||||
image (:has), and every other child of such a container gets its default
|
||||
alignment back so ordinary text is unaffected. Explicit per-block
|
||||
alignment from the toolbar is an inline style and still wins. Browsers
|
||||
without :has() degrade to left-pinned rows. */
|
||||
.ProseMirror:has(> [data-image-align="inline"]),
|
||||
.ProseMirror :has(> [data-image-align="inline"]) {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ProseMirror:has(> [data-image-align="inline"]) > :not([data-image-align="inline"]),
|
||||
.ProseMirror :has(> [data-image-align="inline"]) > :not([data-image-align="inline"]) {
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ import localEmitter from "@/lib/local-emitter.ts";
|
||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||
import { searchSpotlight } from "@/features/search/constants.ts";
|
||||
import { platformModifierKey } from "@/lib";
|
||||
import { useTitleAutofocus } from "@/features/editor/hooks/use-title-autofocus";
|
||||
|
||||
export interface TitleEditorProps {
|
||||
pageId: string;
|
||||
@@ -168,7 +167,13 @@ export function TitleEditor({
|
||||
}
|
||||
}, [pageId, title, titleEditor]);
|
||||
|
||||
useTitleAutofocus(titleEditor, pageId);
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
// guard against Cannot access view['hasFocus'] error
|
||||
if (!titleEditor?.isInitialized) return;
|
||||
titleEditor?.commands?.focus("end");
|
||||
}, 300);
|
||||
}, [titleEditor]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
|
||||
import { render, screen, cleanup, within } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
|
||||
// Mantine Tooltip mounts its label lazily on hover via Floating UI, which is
|
||||
// flaky under jsdom. Replace ONLY the Tooltip with a thin wrapper that renders
|
||||
// the label inline (keeping Badge/Switch/etc. real), so the provenance label —
|
||||
// the contract we care about — is deterministically queryable.
|
||||
vi.mock("@mantine/core", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@mantine/core")>("@mantine/core");
|
||||
const Tooltip = ({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
}) => (
|
||||
<>
|
||||
{children}
|
||||
<span data-testid="tooltip-label">{label}</span>
|
||||
</>
|
||||
);
|
||||
Tooltip.Group = ({ children }: { children?: React.ReactNode }) => (
|
||||
<>{children}</>
|
||||
);
|
||||
return { ...actual, Tooltip };
|
||||
});
|
||||
|
||||
// jsdom lacks matchMedia, which MantineProvider's color-scheme hook needs.
|
||||
beforeAll(() => {
|
||||
if (!window.matchMedia) {
|
||||
window.matchMedia = (query: string) =>
|
||||
({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
}) as unknown as MediaQueryList;
|
||||
}
|
||||
});
|
||||
|
||||
// --- Mocks for the heavy / networked module graph ---------------------------
|
||||
// HistoryItem pulls in i18n, jotai atoms (ai-chat / history), a config-backed
|
||||
// avatar and a time formatter. The provenance-badge contract is the unit under
|
||||
// test, so we stub everything else down to inert, deterministic renders and
|
||||
// keep the real Mantine Badge/Tooltip so role/label queries are meaningful.
|
||||
|
||||
// i18n: interpolate {{name}} so the git-sync tooltip carries the author name,
|
||||
// letting us assert provenance attribution without a real i18n backend.
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, vars?: Record<string, unknown>) =>
|
||||
vars && typeof vars.name !== "undefined"
|
||||
? key.replace("{{name}}", String(vars.name))
|
||||
: key,
|
||||
}),
|
||||
}));
|
||||
|
||||
// jotai setters: the badges call useSetAtom; return inert setters so a click on
|
||||
// the (deep-linkable) AiAgentBadge would fire these — proving the git-sync badge
|
||||
// does NOT wire any of them.
|
||||
const setAiChatWindowOpen = vi.fn();
|
||||
const setActiveChatId = vi.fn();
|
||||
const setDraft = vi.fn();
|
||||
const setHistoryModalOpen = vi.fn();
|
||||
vi.mock("jotai", async () => {
|
||||
const actual = await vi.importActual<typeof import("jotai")>("jotai");
|
||||
return {
|
||||
...actual,
|
||||
useSetAtom: (atom: unknown) => {
|
||||
switch (atom) {
|
||||
case aiChatWindowOpenAtom:
|
||||
return setAiChatWindowOpen;
|
||||
case activeAiChatIdAtom:
|
||||
return setActiveChatId;
|
||||
case aiChatDraftAtom:
|
||||
return setDraft;
|
||||
case historyAtoms:
|
||||
return setHistoryModalOpen;
|
||||
default:
|
||||
return vi.fn();
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Atoms are imported only as identity tokens for the useSetAtom switch above.
|
||||
vi.mock("@/features/ai-chat/atoms/ai-chat-atom.ts", () => ({
|
||||
activeAiChatIdAtom: { __tag: "activeAiChatIdAtom" },
|
||||
aiChatWindowOpenAtom: { __tag: "aiChatWindowOpenAtom" },
|
||||
aiChatDraftAtom: { __tag: "aiChatDraftAtom" },
|
||||
}));
|
||||
vi.mock("@/features/page-history/atoms/history-atoms.ts", () => ({
|
||||
historyAtoms: { __tag: "historyAtoms" },
|
||||
}));
|
||||
|
||||
// Avatar reaches into config (getAvatarUrl) — stub to a plain element.
|
||||
vi.mock("@/components/ui/custom-avatar.tsx", () => ({
|
||||
CustomAvatar: ({ name }: { name?: string }) => (
|
||||
<span data-testid="avatar">{name}</span>
|
||||
),
|
||||
}));
|
||||
|
||||
// Deterministic, locale-free date string.
|
||||
vi.mock("@/lib/time", () => ({
|
||||
formattedDate: () => "2026-06-21",
|
||||
}));
|
||||
|
||||
import HistoryItem from "./history-item";
|
||||
import {
|
||||
activeAiChatIdAtom,
|
||||
aiChatWindowOpenAtom,
|
||||
aiChatDraftAtom,
|
||||
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
|
||||
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
||||
import type { IPageHistory } from "@/features/page-history/types/page.types";
|
||||
|
||||
function makeItem(overrides: Partial<IPageHistory> = {}): IPageHistory {
|
||||
return {
|
||||
id: "h1",
|
||||
pageId: "p1",
|
||||
title: "Title",
|
||||
slug: "slug",
|
||||
icon: "",
|
||||
coverPhoto: "",
|
||||
version: 1,
|
||||
lastUpdatedById: "u1",
|
||||
workspaceId: "w1",
|
||||
createdAt: "2026-06-21T00:00:00.000Z",
|
||||
updatedAt: "2026-06-21T00:00:00.000Z",
|
||||
lastUpdatedBy: { id: "u1", name: "Alice", avatarUrl: "" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderItem(item: IPageHistory) {
|
||||
return render(
|
||||
<MantineProvider>
|
||||
<HistoryItem
|
||||
historyItem={item}
|
||||
index={0}
|
||||
onSelect={vi.fn()}
|
||||
isActive={false}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("HistoryItem git-sync provenance badge", () => {
|
||||
// Test 1: the git-sync badge renders ONLY for lastUpdatedSource === 'git-sync'.
|
||||
it("renders the Git sync badge only when lastUpdatedSource is 'git-sync'", () => {
|
||||
renderItem(makeItem({ lastUpdatedSource: "git-sync" }));
|
||||
expect(screen.getByText("Git sync")).toBeTruthy();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["agent", "agent"],
|
||||
["user", "user"],
|
||||
["undefined", undefined],
|
||||
])(
|
||||
"does NOT render the Git sync badge when lastUpdatedSource is %s",
|
||||
(_label, source) => {
|
||||
renderItem(makeItem({ lastUpdatedSource: source }));
|
||||
expect(screen.queryByText("Git sync")).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
// Test 2: provenance attribution + the git-sync badge is NOT interactive.
|
||||
it("attributes the git-sync provenance to the correct author and is not clickable", () => {
|
||||
renderItem(
|
||||
makeItem({
|
||||
lastUpdatedSource: "git-sync",
|
||||
lastUpdatedBy: { id: "u2", name: "Bob", avatarUrl: "" },
|
||||
}),
|
||||
);
|
||||
|
||||
const badge = screen.getByText("Git sync");
|
||||
|
||||
// Provenance attribution: the tooltip label carries the author name (the
|
||||
// git-sync badge passes authorName -> "Synced from Git on behalf of {{name}}").
|
||||
expect(screen.getByText("Synced from Git on behalf of Bob")).toBeTruthy();
|
||||
|
||||
// The git-sync badge must NOT behave like AiAgentBadge: the badge element
|
||||
// itself is not a button, carries no role=button and no tabIndex, and
|
||||
// clicking it must not trigger any ai-chat deep-link. (The surrounding
|
||||
// history-row IS an UnstyledButton — that is the row's own select affordance,
|
||||
// not the badge — so we scope these checks to the badge element.)
|
||||
const badgeRoot = (badge.closest("[class*='mantine-Badge-root']") ??
|
||||
badge) as HTMLElement;
|
||||
expect(badgeRoot.getAttribute("role")).not.toBe("button");
|
||||
expect(badgeRoot.getAttribute("tabindex")).toBeNull();
|
||||
expect(badgeRoot.tagName.toLowerCase()).not.toBe("button");
|
||||
// No interactive descendant button lives inside the badge itself.
|
||||
expect(within(badgeRoot).queryByRole("button")).toBeNull();
|
||||
|
||||
badgeRoot.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(setActiveChatId).not.toHaveBeenCalled();
|
||||
expect(setAiChatWindowOpen).not.toHaveBeenCalled();
|
||||
expect(setDraft).not.toHaveBeenCalled();
|
||||
expect(setHistoryModalOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Sanity contrast: the agent badge (the copy-paste source) IS interactive when
|
||||
// it carries an aiChatId — proving the not-clickable assertion above is real.
|
||||
it("contrast: the AI-agent badge is a deep-link button when it has an aiChatId", () => {
|
||||
renderItem(
|
||||
makeItem({
|
||||
lastUpdatedSource: "agent",
|
||||
lastUpdatedAiChatId: "chat-1",
|
||||
}),
|
||||
);
|
||||
const agentBadge = screen.getByText("AI-agent");
|
||||
const root = agentBadge.closest("[role='button']");
|
||||
expect(root).not.toBeNull();
|
||||
within(root as HTMLElement).getByText("AI-agent");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Text, Group, UnstyledButton, Avatar, Tooltip } from "@mantine/core";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx";
|
||||
import { AiAgentBadge } from "@/components/ui/ai-agent-badge.tsx";
|
||||
import { GitSyncBadge } from "@/components/ui/git-sync-badge.tsx";
|
||||
import { formattedDate } from "@/lib/time";
|
||||
import classes from "./css/history.module.css";
|
||||
import clsx from "clsx";
|
||||
@@ -41,6 +42,7 @@ const HistoryItem = memo(function HistoryItem({
|
||||
const contributors = historyItem.contributors;
|
||||
const hasContributors = contributors && contributors.length > 0;
|
||||
const isAgentEdit = historyItem.lastUpdatedSource === "agent";
|
||||
const isGitSyncEdit = historyItem.lastUpdatedSource === "git-sync";
|
||||
|
||||
return (
|
||||
<UnstyledButton
|
||||
@@ -99,16 +101,19 @@ const HistoryItem = memo(function HistoryItem({
|
||||
</>
|
||||
)}
|
||||
|
||||
{isAgentEdit && historyItem.agent && (
|
||||
<AgentAvatarStack
|
||||
agent={historyItem.agent}
|
||||
launcher={historyItem.launcher}
|
||||
{isAgentEdit && (
|
||||
<AiAgentBadge
|
||||
authorName={historyItem.lastUpdatedBy?.name}
|
||||
aiChatId={historyItem.lastUpdatedAiChatId}
|
||||
// The history row owns the modal: close it when the stack deep-links
|
||||
// into the chat (the stack no longer reaches into page-history).
|
||||
// The history row owns the modal: close it when the badge deep-links
|
||||
// into the chat (the badge no longer reaches into page-history).
|
||||
onActivate={() => setHistoryModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isGitSyncEdit && (
|
||||
<GitSyncBadge authorName={historyItem.lastUpdatedBy?.name} />
|
||||
)}
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
import type {
|
||||
AgentInfo,
|
||||
LauncherInfo,
|
||||
} from "@/components/ui/agent-avatar-stack.tsx";
|
||||
|
||||
interface IPageHistoryUser {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -29,9 +24,4 @@ export interface IPageHistory {
|
||||
// (when present) deep-links to the chat that produced the edit.
|
||||
lastUpdatedSource?: string;
|
||||
lastUpdatedAiChatId?: string | null;
|
||||
// Server-normalized "agent avatar stack" provenance (#300), present only when
|
||||
// lastUpdatedSource === "agent": `agent` is the front identity, `launcher` the
|
||||
// human behind it (null for an external MCP agent).
|
||||
agent?: AgentInfo | null;
|
||||
launcher?: LauncherInfo | null;
|
||||
}
|
||||
|
||||
@@ -13,30 +13,20 @@ export type OpenMap = Record<string, boolean>;
|
||||
// `OpenMap | Promise<OpenMap>` and break the functional-updater setter below).
|
||||
const openTreeNodesStorage = createJSONStorage<OpenMap>(() => localStorage);
|
||||
|
||||
// Single source of truth for the open-map localStorage key prefix. Exported so
|
||||
// the logout cache sweep (tree-data-atom.ts) removes keys by the SAME prefix
|
||||
// used to write them — a rename here can never silently desync the cleanup.
|
||||
export const OPEN_TREE_NODES_KEY_PREFIX = "openTreeNodes:";
|
||||
|
||||
// One persisted open/closed map per (workspace, user). Scoping the localStorage
|
||||
// key prevents accounts that share a browser origin from leaking tree state.
|
||||
// `getOnInit: true` reads localStorage synchronously at atom init (not on mount),
|
||||
// so the first render already has the saved state — no collapse-then-expand
|
||||
// flicker on reload, and writes never run against an un-hydrated empty map.
|
||||
const openTreeNodesFamily = atomFamily((scopeKey: string) =>
|
||||
atomWithStorage<OpenMap>(
|
||||
`${OPEN_TREE_NODES_KEY_PREFIX}${scopeKey}`,
|
||||
{},
|
||||
openTreeNodesStorage,
|
||||
{ getOnInit: true },
|
||||
),
|
||||
atomWithStorage<OpenMap>(`openTreeNodes:${scopeKey}`, {}, openTreeNodesStorage, {
|
||||
getOnInit: true,
|
||||
}),
|
||||
);
|
||||
|
||||
// Resolve the storage scope from the current user. Fall back to "anon" for the
|
||||
// workspace/user parts when nothing is loaded yet (logged out / first paint).
|
||||
// Shared by the open-map atom below and the persisted tree-data atom
|
||||
// (tree-data-atom.ts) so both caches are scoped identically.
|
||||
export const scopeKeyAtom = atom((get) => {
|
||||
const scopeKeyAtom = atom((get) => {
|
||||
const currentUser = get(currentUserAtom);
|
||||
const workspaceId = currentUser?.workspace?.id ?? "anon";
|
||||
const userId = currentUser?.user?.id ?? "anon";
|
||||
|
||||
@@ -1,265 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import type { SpaceTreeNode } from "@/features/page/tree/types";
|
||||
import type { ICurrentUser } from "@/features/user/types/user.types";
|
||||
|
||||
// The persisted tree-data atom hydrates from localStorage ONCE, at family-atom
|
||||
// creation (`getOnInit: true`). To exercise hydration deterministically each
|
||||
// test imports a FRESH module instance (fresh atomFamily) after seeding the
|
||||
// storage stub from vitest.setup.ts. jotai itself is externalized by vitest, so
|
||||
// `createStore` can stay a static import — atoms are plain objects and any
|
||||
// store works with any module instance.
|
||||
import { createStore } from "jotai";
|
||||
|
||||
// Storage key for the default scope: no currentUser -> "anon:anon" (see
|
||||
// scopeKeyAtom in open-tree-nodes-atom.ts) with the `v1` cache-shape version.
|
||||
const ANON_KEY = "treeData:v1:anon:anon";
|
||||
const DEBOUNCE_MS = 500;
|
||||
|
||||
async function freshImport() {
|
||||
vi.resetModules();
|
||||
const treeDataModule = await import("./tree-data-atom");
|
||||
const userModule = await import(
|
||||
"@/features/user/atoms/current-user-atom"
|
||||
);
|
||||
return {
|
||||
treeDataAtom: treeDataModule.treeDataAtom,
|
||||
flushPendingTreeDataWrites: treeDataModule.flushPendingTreeDataWrites,
|
||||
clearPersistedTreeCaches: treeDataModule.clearPersistedTreeCaches,
|
||||
currentUserAtom: userModule.currentUserAtom,
|
||||
};
|
||||
}
|
||||
|
||||
function node(id: string): SpaceTreeNode {
|
||||
return {
|
||||
id,
|
||||
slugId: `slug-${id}`,
|
||||
name: id,
|
||||
position: "a0",
|
||||
spaceId: "space-1",
|
||||
parentPageId: null as unknown as string,
|
||||
hasChildren: false,
|
||||
children: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Every persisted tree key currently in storage — asserting on the whole
|
||||
// prefix (not one known key) catches writes that resurrect under ANY scope.
|
||||
function persistedTreeDataKeys(): string[] {
|
||||
const keys: string[] = [];
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key !== null && key.startsWith("treeData:v1:")) keys.push(key);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
function currentUser(workspaceId: string, userId: string): ICurrentUser {
|
||||
return {
|
||||
user: { id: userId },
|
||||
workspace: { id: workspaceId },
|
||||
} as unknown as ICurrentUser;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("treeDataAtom (localStorage-persisted)", () => {
|
||||
it("reads [] from a fresh store with empty storage", async () => {
|
||||
const { treeDataAtom } = await freshImport();
|
||||
const store = createStore();
|
||||
|
||||
expect(store.get(treeDataAtom)).toEqual([]);
|
||||
});
|
||||
|
||||
it("persists through the debounced setItem and hydrates a fresh module back", async () => {
|
||||
vi.useFakeTimers();
|
||||
const setItemSpy = vi.spyOn(localStorage, "setItem");
|
||||
|
||||
const { treeDataAtom } = await freshImport();
|
||||
const store = createStore();
|
||||
|
||||
store.set(treeDataAtom, [node("a")]);
|
||||
// Second write inside the debounce window — must coalesce into ONE flush
|
||||
// carrying only the latest value.
|
||||
vi.advanceTimersByTime(DEBOUNCE_MS / 2);
|
||||
store.set(treeDataAtom, [node("a"), node("b")]);
|
||||
|
||||
// Nothing flushed yet: the write is trailing-debounced.
|
||||
expect(localStorage.getItem(ANON_KEY)).toBeNull();
|
||||
|
||||
vi.advanceTimersByTime(DEBOUNCE_MS + 100);
|
||||
|
||||
expect(setItemSpy).toHaveBeenCalledTimes(1);
|
||||
expect(JSON.parse(localStorage.getItem(ANON_KEY)!)).toEqual([
|
||||
node("a"),
|
||||
node("b"),
|
||||
]);
|
||||
|
||||
// A fresh module (fresh atom family -> getOnInit re-reads storage) and a
|
||||
// fresh store hydrate the persisted tree back — the reload scenario.
|
||||
const second = await freshImport();
|
||||
const store2 = createStore();
|
||||
expect(store2.get(second.treeDataAtom)).toEqual([node("a"), node("b")]);
|
||||
});
|
||||
|
||||
it("reads [] (without throwing) when storage holds corrupted JSON", async () => {
|
||||
localStorage.setItem(ANON_KEY, "{definitely not JSON!!!");
|
||||
|
||||
const { treeDataAtom } = await freshImport();
|
||||
const store = createStore();
|
||||
|
||||
expect(store.get(treeDataAtom)).toEqual([]);
|
||||
});
|
||||
|
||||
it("reads [] when storage holds valid JSON of a non-array shape", async () => {
|
||||
localStorage.setItem(ANON_KEY, JSON.stringify({ id: "not-a-tree" }));
|
||||
|
||||
const { treeDataAtom } = await freshImport();
|
||||
const store = createStore();
|
||||
|
||||
expect(store.get(treeDataAtom)).toEqual([]);
|
||||
});
|
||||
|
||||
it("supports functional-updater writes", async () => {
|
||||
const { treeDataAtom } = await freshImport();
|
||||
const store = createStore();
|
||||
|
||||
store.set(treeDataAtom, [node("a")]);
|
||||
store.set(treeDataAtom, (prev) => [...prev, node("b")]);
|
||||
|
||||
expect(store.get(treeDataAtom).map((n) => n.id)).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("isolates trees between (workspace, user) scopes", async () => {
|
||||
const { treeDataAtom, currentUserAtom } = await freshImport();
|
||||
const store = createStore();
|
||||
|
||||
store.set(currentUserAtom, currentUser("w1", "u1"));
|
||||
store.set(treeDataAtom, [node("a")]);
|
||||
expect(store.get(treeDataAtom).map((n) => n.id)).toEqual(["a"]);
|
||||
|
||||
// Another account on the same browser origin must NOT see u1's tree.
|
||||
store.set(currentUserAtom, currentUser("w2", "u2"));
|
||||
expect(store.get(treeDataAtom)).toEqual([]);
|
||||
|
||||
store.set(treeDataAtom, [node("b")]);
|
||||
expect(store.get(treeDataAtom).map((n) => n.id)).toEqual(["b"]);
|
||||
|
||||
// Switching back resolves the original scope's tree untouched.
|
||||
store.set(currentUserAtom, currentUser("w1", "u1"));
|
||||
expect(store.get(treeDataAtom).map((n) => n.id)).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("clearPersistedTreeCaches removes all tree keys and discards pending writes", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
// Stale caches across scopes plus an UNRELATED key that must survive.
|
||||
localStorage.setItem("treeData:v1:a:b", JSON.stringify([node("stale")]));
|
||||
localStorage.setItem("openTreeNodes:a:b", JSON.stringify({ p1: true }));
|
||||
localStorage.setItem("currentUser", JSON.stringify({ user: { id: "b" } }));
|
||||
|
||||
const { treeDataAtom, clearPersistedTreeCaches } = await freshImport();
|
||||
const store = createStore();
|
||||
|
||||
// Queue a debounced write (not flushed yet) for the anon scope.
|
||||
store.set(treeDataAtom, [node("pending")]);
|
||||
expect(localStorage.getItem(ANON_KEY)).toBeNull();
|
||||
|
||||
clearPersistedTreeCaches();
|
||||
|
||||
// Both prefixed caches are swept; the unrelated key is untouched.
|
||||
expect(localStorage.getItem("treeData:v1:a:b")).toBeNull();
|
||||
expect(localStorage.getItem("openTreeNodes:a:b")).toBeNull();
|
||||
expect(localStorage.getItem("currentUser")).toBe(
|
||||
JSON.stringify({ user: { id: "b" } }),
|
||||
);
|
||||
|
||||
// The queued write was DISCARDED, not merely delayed: the debounce timer
|
||||
// firing later must not resurrect a tree key after logout.
|
||||
vi.advanceTimersByTime(DEBOUNCE_MS + 100);
|
||||
expect(localStorage.getItem(ANON_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it("clearPersistedTreeCaches discards queued writes even when flushed DIRECTLY", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const { treeDataAtom, clearPersistedTreeCaches, flushPendingTreeDataWrites } =
|
||||
await freshImport();
|
||||
const store = createStore();
|
||||
|
||||
// Queue a debounced write, then clear. Calling the flush directly (not via
|
||||
// the debounce timer) isolates the pending-queue discard from the timer
|
||||
// cancel: if the queue survived, this flush would resurrect the key even
|
||||
// though the timer never fired.
|
||||
store.set(treeDataAtom, [node("pending")]);
|
||||
clearPersistedTreeCaches();
|
||||
flushPendingTreeDataWrites();
|
||||
|
||||
expect(localStorage.getItem(ANON_KEY)).toBeNull();
|
||||
expect(persistedTreeDataKeys()).toEqual([]);
|
||||
});
|
||||
|
||||
it("skips persisting a tree over the size cap and warns exactly once", async () => {
|
||||
vi.useFakeTimers();
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const setItemSpy = vi.spyOn(localStorage, "setItem");
|
||||
|
||||
const { treeDataAtom, flushPendingTreeDataWrites } = await freshImport();
|
||||
const store = createStore();
|
||||
|
||||
// One node whose name alone serializes to > MAX_SERIALIZED_LENGTH (~4M).
|
||||
const huge = node("big");
|
||||
huge.name = "x".repeat(4_000_001);
|
||||
|
||||
store.set(treeDataAtom, [huge]);
|
||||
vi.advanceTimersByTime(DEBOUNCE_MS + 100);
|
||||
|
||||
// The oversized serialization is skipped: the key is never written.
|
||||
expect(localStorage.getItem(ANON_KEY)).toBeNull();
|
||||
expect(setItemSpy).not.toHaveBeenCalled();
|
||||
|
||||
// Editing the still-oversized tree fires another debounced write, but the
|
||||
// "too large" warn is gated by the once-flag — no per-tick console spam.
|
||||
store.set(treeDataAtom, [huge, node("big2")]);
|
||||
vi.advanceTimersByTime(DEBOUNCE_MS + 100);
|
||||
flushPendingTreeDataWrites();
|
||||
|
||||
expect(localStorage.getItem(ANON_KEY)).toBeNull();
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
"[tree] cached tree too large to persist; skipping",
|
||||
ANON_KEY,
|
||||
);
|
||||
});
|
||||
|
||||
it("disables persistence after clearPersistedTreeCaches: NEW writes never reach storage", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const { treeDataAtom, clearPersistedTreeCaches, flushPendingTreeDataWrites } =
|
||||
await freshImport();
|
||||
const store = createStore();
|
||||
|
||||
clearPersistedTreeCaches();
|
||||
|
||||
// The resurrection scenario: a websocket tree event lands while `await
|
||||
// logout()` is still in flight, AFTER the sweep. The write must not be
|
||||
// queued, must not arm a new debounce timer, and must not survive the
|
||||
// beforeunload flush fired by the logout redirect.
|
||||
store.set(treeDataAtom, [node("late")]);
|
||||
|
||||
vi.advanceTimersByTime(DEBOUNCE_MS + 100);
|
||||
flushPendingTreeDataWrites(); // what the beforeunload handler runs
|
||||
|
||||
expect(persistedTreeDataKeys()).toEqual([]);
|
||||
|
||||
// Only PERSISTENCE is disabled: the in-memory atom keeps working, so the
|
||||
// UI stays intact during the brief pre-redirect window.
|
||||
expect(store.get(treeDataAtom).map((n) => n.id)).toEqual(["late"]);
|
||||
});
|
||||
});
|
||||
@@ -1,206 +1,8 @@
|
||||
import { atom } from "jotai";
|
||||
import { atomFamily, atomWithStorage } from "jotai/utils";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types";
|
||||
import { appendNodeChildren } from "../utils";
|
||||
import {
|
||||
OPEN_TREE_NODES_KEY_PREFIX,
|
||||
scopeKeyAtom,
|
||||
} from "./open-tree-nodes-atom";
|
||||
|
||||
// The sidebar tree is persisted to localStorage so a page reload can paint the
|
||||
// last-known tree IMMEDIATELY (no blank sidebar while the root query runs) and
|
||||
// then reconcile with the server in the background. localStorage is a BOOT
|
||||
// CACHE only — the in-memory atom stays the source of truth while the app runs.
|
||||
|
||||
// Trailing-debounce machinery for the localStorage writes. The tree is
|
||||
// rewritten on every lazy load / drag / socket event; serializing a large tree
|
||||
// on each update would burn CPU and thrash the storage quota, so writes are
|
||||
// coalesced (~500 ms per burst) and only the latest value per key is flushed.
|
||||
const WRITE_DEBOUNCE_MS = 500;
|
||||
|
||||
// Single source of truth for the tree-cache localStorage key prefix. The `v1`
|
||||
// segment versions the cached node shape (bump it when SpaceTreeNode changes
|
||||
// incompatibly). Shared by the storage key construction below AND the logout
|
||||
// sweep in clearPersistedTreeCaches() so the two can never drift apart.
|
||||
export const TREE_DATA_KEY_PREFIX = "treeData:v1:";
|
||||
|
||||
// Size guard: skip persisting trees whose JSON exceeds ~4M chars. localStorage
|
||||
// quota is typically ~5 MB per origin; a huge tree must not evict everything
|
||||
// else or spam QuotaExceededError on every debounce tick.
|
||||
const MAX_SERIALIZED_LENGTH = 4_000_000;
|
||||
|
||||
const pendingWrites = new Map<string, SpaceTreeNode[]>();
|
||||
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let writeFailureWarned = false;
|
||||
|
||||
// Persistence kill-switch, armed by clearPersistedTreeCaches(). Once set, the
|
||||
// debounced setItem and the flush become no-ops so nothing can be written back
|
||||
// to localStorage AFTER the logout sweep: a websocket tree event landing while
|
||||
// `await logout()` is still in flight would otherwise re-queue a write that
|
||||
// the `beforeunload` flush (fired by the redirect) silently resurrects.
|
||||
// Intentionally never reset: every caller of clearPersistedTreeCaches()
|
||||
// immediately navigates away with a full page load
|
||||
// (window.location.replace/href), so this module instance is torn down anyway.
|
||||
// Only PERSISTENCE stops — the in-memory atoms keep working, so the UI stays
|
||||
// intact during the brief pre-redirect window.
|
||||
let persistenceDisabled = false;
|
||||
|
||||
function writeNow(key: string, value: SpaceTreeNode[]): void {
|
||||
try {
|
||||
const serialized = JSON.stringify(value);
|
||||
if (serialized.length > MAX_SERIALIZED_LENGTH) {
|
||||
// Warn ONCE, like the quota branch below: a >4M-char tree re-serializes on
|
||||
// every ~500ms debounce tick while it's edited, so an un-gated warn would
|
||||
// spam the console on each flush.
|
||||
if (!writeFailureWarned) {
|
||||
writeFailureWarned = true;
|
||||
console.warn("[tree] cached tree too large to persist; skipping", key);
|
||||
}
|
||||
return;
|
||||
}
|
||||
localStorage.setItem(key, serialized);
|
||||
} catch (err) {
|
||||
// QuotaExceededError, private mode, jsdom shims without working storage…
|
||||
// The cache is best-effort: warn once, keep the in-memory tree working.
|
||||
if (!writeFailureWarned) {
|
||||
writeFailureWarned = true;
|
||||
console.warn("[tree] failed to persist tree cache", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Exported so tests can force the debounced write synchronously; production
|
||||
// code must never need it (the beforeunload hook below covers reloads).
|
||||
export function flushPendingTreeDataWrites(): void {
|
||||
if (flushTimer !== null) {
|
||||
clearTimeout(flushTimer);
|
||||
flushTimer = null;
|
||||
}
|
||||
if (persistenceDisabled) {
|
||||
// Belt-and-braces: after logout nothing may reach localStorage, even via
|
||||
// the beforeunload flush racing the redirect. Drop anything queued.
|
||||
pendingWrites.clear();
|
||||
return;
|
||||
}
|
||||
for (const [key, value] of pendingWrites) {
|
||||
writeNow(key, value);
|
||||
}
|
||||
pendingWrites.clear();
|
||||
}
|
||||
|
||||
// Logout hygiene: the tree cache stores PAGE TITLES, so leaving it behind
|
||||
// would keep them readable in localStorage on a shared machine after logout.
|
||||
// Sweep by key prefix (not just the current scope) so stale scopes — old
|
||||
// users, the `anon:anon` fallback — are purged too. Pending debounced writes
|
||||
// are DISCARDED first (not flushed): a queued write firing after the sweep
|
||||
// would silently resurrect a removed key.
|
||||
export function clearPersistedTreeCaches(): void {
|
||||
// Disable persistence FIRST so no write can be queued (or flushed) between
|
||||
// the sweep below and the full-page navigation every caller performs next.
|
||||
persistenceDisabled = true;
|
||||
if (flushTimer !== null) {
|
||||
clearTimeout(flushTimer);
|
||||
flushTimer = null;
|
||||
}
|
||||
pendingWrites.clear();
|
||||
try {
|
||||
// Collect matching keys BEFORE removing: deleting while iterating
|
||||
// `localStorage.key(i)` shifts the indices and skips entries.
|
||||
const keysToRemove: string[] = [];
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (
|
||||
key !== null &&
|
||||
(key.startsWith(TREE_DATA_KEY_PREFIX) ||
|
||||
key.startsWith(OPEN_TREE_NODES_KEY_PREFIX))
|
||||
) {
|
||||
keysToRemove.push(key);
|
||||
}
|
||||
}
|
||||
for (const key of keysToRemove) {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort: disabled storage / jsdom shims must never break logout.
|
||||
}
|
||||
}
|
||||
|
||||
// Flush the pending debounced write on unload so a reload right after a tree
|
||||
// change doesn't lose the newest state (the debounce would otherwise eat it).
|
||||
if (
|
||||
typeof window !== "undefined" &&
|
||||
typeof window.addEventListener === "function"
|
||||
) {
|
||||
window.addEventListener("beforeunload", flushPendingTreeDataWrites);
|
||||
}
|
||||
|
||||
// Custom sync storage for the tree cache. Deliberately NO `subscribe` key:
|
||||
// cross-tab sync would REPLACE this tab's tree wholesale and clobber in-flight
|
||||
// lazy loads; websockets already keep every open tab live. Each tab keeps its
|
||||
// own in-memory tree — localStorage only seeds the next boot.
|
||||
const treeDataStorage = {
|
||||
getItem: (key: string, initialValue: SpaceTreeNode[]): SpaceTreeNode[] => {
|
||||
// Defensive: jsdom test shims may lack methods, stored JSON may be
|
||||
// corrupted or of a wrong shape. Any failure falls back to the empty tree.
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (raw === null) return initialValue;
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? (parsed as SpaceTreeNode[]) : initialValue;
|
||||
} catch {
|
||||
return initialValue;
|
||||
}
|
||||
},
|
||||
setItem: (key: string, newValue: SpaceTreeNode[]): void => {
|
||||
// After logout the cache must stay purged: neither queue the write nor arm
|
||||
// a new flush timer (see persistenceDisabled above). The in-memory atom
|
||||
// value is unaffected — only the localStorage mirror is frozen.
|
||||
if (persistenceDisabled) return;
|
||||
pendingWrites.set(key, newValue);
|
||||
if (flushTimer !== null) clearTimeout(flushTimer);
|
||||
flushTimer = setTimeout(flushPendingTreeDataWrites, WRITE_DEBOUNCE_MS);
|
||||
},
|
||||
removeItem: (key: string): void => {
|
||||
pendingWrites.delete(key);
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
} catch {
|
||||
/* best-effort cache — ignore */
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// One persisted tree per (workspace, user) — same scoping rationale as the
|
||||
// open-map atom (accounts sharing a browser origin must not leak trees).
|
||||
// `getOnInit: true` reads localStorage synchronously at atom init, so the very
|
||||
// first render already has the cached tree — no blank-then-jump sidebar.
|
||||
const treeDataFamily = atomFamily((scopeKey: string) =>
|
||||
atomWithStorage<SpaceTreeNode[]>(
|
||||
`${TREE_DATA_KEY_PREFIX}${scopeKey}`,
|
||||
[],
|
||||
treeDataStorage,
|
||||
{ getOnInit: true },
|
||||
),
|
||||
);
|
||||
|
||||
// Public facade — same read value (SpaceTreeNode[]) and same setter shape
|
||||
// (value OR functional updater) as the previous in-memory atom, transparently
|
||||
// routed to the persisted tree of the current workspace/user.
|
||||
export const treeDataAtom = atom(
|
||||
(get) => get(treeDataFamily(get(scopeKeyAtom))),
|
||||
(
|
||||
get,
|
||||
set,
|
||||
update: SpaceTreeNode[] | ((prev: SpaceTreeNode[]) => SpaceTreeNode[]),
|
||||
) => {
|
||||
const target = treeDataFamily(get(scopeKeyAtom));
|
||||
const next =
|
||||
typeof update === "function"
|
||||
? (update as (prev: SpaceTreeNode[]) => SpaceTreeNode[])(get(target))
|
||||
: update;
|
||||
set(target, next);
|
||||
},
|
||||
);
|
||||
export const treeDataAtom = atom<SpaceTreeNode[]>([]);
|
||||
|
||||
// Atom
|
||||
export const appendNodeChildrenAtom = atom(
|
||||
|
||||
@@ -1,222 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { createRef } from "react";
|
||||
import { render, act, waitFor, cleanup } from "@testing-library/react";
|
||||
|
||||
// --- Mocks for the heavy / networked module graph ---------------------------
|
||||
// Same isolation strategy as space-tree.expand-all.test.tsx: everything that
|
||||
// would otherwise need a real server / router / DnD stack is mocked. Here we
|
||||
// additionally CAPTURE the DocTree props (onToggle + data) so the test can
|
||||
// drive a lazy-load expand exactly as a row click would, and we control
|
||||
// fetchAllAncestorChildren to assert the fresh fetch happens.
|
||||
|
||||
const fetchAllAncestorChildrenMock = vi.fn();
|
||||
|
||||
// Holder mutated by the DocTree stub each render so the test can read the
|
||||
// latest tree it was handed and invoke its onToggle callback.
|
||||
const docTree: {
|
||||
onToggle?: (id: string, isOpen: boolean) => void | Promise<void>;
|
||||
data: unknown[];
|
||||
} = { data: [] };
|
||||
|
||||
vi.mock("@/features/page/services/page-service.ts", () => ({
|
||||
getSpaceTree: vi.fn(),
|
||||
getPageBreadcrumbs: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/page/queries/page-query.ts", () => ({
|
||||
// No root pages and no further pages — the server data-load effect stays
|
||||
// inert (isDataLoaded never flips), so refreshOpenBranches never runs and the
|
||||
// test exercises ONLY the boot-prune + handleToggle lazy-load path against
|
||||
// the hydrated cache we seed into the atom below.
|
||||
useGetRootSidebarPagesQuery: () => ({
|
||||
data: undefined,
|
||||
hasNextPage: false,
|
||||
fetchNextPage: vi.fn(),
|
||||
isFetching: false,
|
||||
}),
|
||||
usePageQuery: () => ({ data: undefined }),
|
||||
fetchAllAncestorChildren: (...args: unknown[]) =>
|
||||
fetchAllAncestorChildrenMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/page/tree/hooks/use-tree-mutation.ts", () => ({
|
||||
useTreeMutation: () => ({ handleMove: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@mantine/notifications", () => ({
|
||||
notifications: { show: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock("react-router-dom", () => ({
|
||||
useParams: () => ({ pageSlug: undefined }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
extractPageSlugId: () => undefined,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/config.ts", () => ({
|
||||
isCompactPageTreeEnabled: () => false,
|
||||
}));
|
||||
|
||||
// Capture the props DocTree is rendered with instead of rendering anything.
|
||||
vi.mock("./doc-tree", () => ({
|
||||
DocTree: (props: { onToggle: (id: string, isOpen: boolean) => void; data: unknown[] }) => {
|
||||
docTree.onToggle = props.onToggle;
|
||||
docTree.data = props.data;
|
||||
return null;
|
||||
},
|
||||
ROW_HEIGHT_COMPACT: 28,
|
||||
ROW_HEIGHT_STANDARD: 32,
|
||||
}));
|
||||
vi.mock("./space-tree-row", () => ({
|
||||
SpaceTreeRow: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@mantine/core", () => ({
|
||||
Text: ({ children }: { children?: unknown }) => children ?? null,
|
||||
}));
|
||||
|
||||
// In-memory open-map (the real one is localStorage-backed and crashes under the
|
||||
// jsdom shim). Empty at start of each test -> every branch is COLLAPSED, which
|
||||
// is exactly the state we need to prove the boot-prune. `scopeKeyAtom` is
|
||||
// re-exported because the persisted tree-data atom resolves its scope through it.
|
||||
vi.mock("@/features/page/tree/atoms/open-tree-nodes-atom.ts", async () => {
|
||||
const { atom } = await import("jotai");
|
||||
type OpenMap = Record<string, boolean>;
|
||||
const base = atom<OpenMap>({});
|
||||
const openTreeNodesAtom = atom(
|
||||
(get) => get(base),
|
||||
(get, set, update: OpenMap | ((prev: OpenMap) => OpenMap)) => {
|
||||
const next =
|
||||
typeof update === "function"
|
||||
? (update as (prev: OpenMap) => OpenMap)(get(base))
|
||||
: update;
|
||||
set(base, next);
|
||||
},
|
||||
);
|
||||
const scopeKeyAtom = atom(() => "test-workspace:test-user");
|
||||
return { openTreeNodesAtom, scopeKeyAtom };
|
||||
});
|
||||
|
||||
import SpaceTree, { SpaceTreeApi } from "./space-tree";
|
||||
import {
|
||||
treeDataAtom,
|
||||
flushPendingTreeDataWrites,
|
||||
} from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||
import { createStore, Provider } from "jotai";
|
||||
import type { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
|
||||
// The scopeKeyAtom mock resolves to this fixed scope, so the persisted
|
||||
// tree-data atom hydrates from exactly this localStorage key at mount
|
||||
// (getOnInit + atomWithStorage's onMount both read it).
|
||||
const CACHE_KEY = "treeData:v1:test-workspace:test-user";
|
||||
|
||||
function child(
|
||||
id: string,
|
||||
parentPageId: string,
|
||||
hasChildren = false,
|
||||
): SpaceTreeNode {
|
||||
return {
|
||||
id,
|
||||
slugId: `slug-${id}`,
|
||||
name: id,
|
||||
position: "a0",
|
||||
spaceId: "space-1",
|
||||
parentPageId,
|
||||
hasChildren,
|
||||
children: [],
|
||||
};
|
||||
}
|
||||
|
||||
// A hydrated boot cache: a COLLAPSED branch (not in the open-map) that still
|
||||
// carries a stale cached child — the exact shape a previous session left behind
|
||||
// after the branch was expanded then collapsed then persisted.
|
||||
function cachedTreeWithCollapsedBranch(): SpaceTreeNode[] {
|
||||
return [
|
||||
{
|
||||
id: "branch",
|
||||
slugId: "slug-branch",
|
||||
name: "branch",
|
||||
position: "a0",
|
||||
spaceId: "space-1",
|
||||
parentPageId: null as unknown as string,
|
||||
hasChildren: true,
|
||||
children: [child("stale", "branch")],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fetchAllAncestorChildrenMock.mockReset();
|
||||
docTree.onToggle = undefined;
|
||||
docTree.data = [];
|
||||
// Flush any pending debounced write from a previous test before clearing.
|
||||
flushPendingTreeDataWrites();
|
||||
try {
|
||||
localStorage.clear?.();
|
||||
} catch {
|
||||
/* fresh store per test isolates state */
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("SpaceTree boot-cache prune (#159 #8 stale collapsed children)", () => {
|
||||
it("drops a collapsed cached branch's children on boot and fetches fresh on first expand", async () => {
|
||||
// Server returns FRESH children on the lazy-load: the stale cached child is
|
||||
// gone, a renamed/new one takes its place.
|
||||
fetchAllAncestorChildrenMock.mockResolvedValue([child("fresh", "branch")]);
|
||||
|
||||
// Simulate the localStorage-hydrated boot cache: seed the persisted key
|
||||
// BEFORE mount so the atom hydrates it (store.set would be clobbered by
|
||||
// atomWithStorage's onMount re-reading storage — this is the real path).
|
||||
localStorage.setItem(
|
||||
CACHE_KEY,
|
||||
JSON.stringify(cachedTreeWithCollapsedBranch()),
|
||||
);
|
||||
|
||||
const store = createStore();
|
||||
const ref = createRef<SpaceTreeApi>();
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<SpaceTree ref={ref} spaceId="space-1" readOnly={false} />
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
// Boot-prune ran at mount: the COLLAPSED branch's cached children were
|
||||
// dropped to the unloaded shape ([]), so the stale child is no longer there.
|
||||
const branchAfterBoot = docTree.data.find(
|
||||
(n) => (n as SpaceTreeNode).id === "branch",
|
||||
) as SpaceTreeNode;
|
||||
expect(branchAfterBoot.children).toEqual([]);
|
||||
expect(branchAfterBoot.hasChildren).toBe(true);
|
||||
|
||||
// First expand of the collapsed branch after boot must lazy-load fresh
|
||||
// children (before this fix the cached children were kept and the fetch
|
||||
// was skipped, showing stale data).
|
||||
await act(async () => {
|
||||
await docTree.onToggle!("branch", true);
|
||||
});
|
||||
|
||||
expect(fetchAllAncestorChildrenMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchAllAncestorChildrenMock).toHaveBeenCalledWith({
|
||||
pageId: "branch",
|
||||
spaceId: "space-1",
|
||||
});
|
||||
|
||||
// The fresh children replaced the stale cache in the live tree.
|
||||
await waitFor(() => {
|
||||
const branch = store
|
||||
.get(treeDataAtom)
|
||||
.find((n) => n.id === "branch")!;
|
||||
expect(branch.children.map((c) => c.id)).toEqual(["fresh"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -71,8 +71,7 @@ vi.mock("@mantine/core", () => ({
|
||||
// getOnInit), which crashes under jsdom's localStorage shim here. Swap in a
|
||||
// plain in-memory atom with the same read value (OpenMap) and the same setter
|
||||
// shape (value OR functional updater) so the component's open-state logic runs
|
||||
// unchanged while staying inside the test store. `scopeKeyAtom` is also
|
||||
// re-exported (the real module exports it for the persisted tree-data atom).
|
||||
// unchanged while staying inside the test store.
|
||||
vi.mock("@/features/page/tree/atoms/open-tree-nodes-atom.ts", async () => {
|
||||
const { atom } = await import("jotai");
|
||||
type OpenMap = Record<string, boolean>;
|
||||
@@ -87,17 +86,11 @@ vi.mock("@/features/page/tree/atoms/open-tree-nodes-atom.ts", async () => {
|
||||
set(base, next);
|
||||
},
|
||||
);
|
||||
// Fixed scope key: the tree-data atom family resolves through this, so all
|
||||
// tests read/write the same (empty at start of each test) storage key.
|
||||
const scopeKeyAtom = atom(() => "test-workspace:test-user");
|
||||
return { openTreeNodesAtom, scopeKeyAtom };
|
||||
return { openTreeNodesAtom };
|
||||
});
|
||||
|
||||
import SpaceTree, { SpaceTreeApi } from "./space-tree";
|
||||
import {
|
||||
treeDataAtom,
|
||||
flushPendingTreeDataWrites,
|
||||
} from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||
import { openTreeNodesAtom } from "@/features/page/tree/atoms/open-tree-nodes-atom.ts";
|
||||
import { createStore, Provider } from "jotai";
|
||||
import type { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
@@ -141,10 +134,6 @@ function renderTree(store: ReturnType<typeof createStore>) {
|
||||
beforeEach(() => {
|
||||
getSpaceTreeMock.mockReset();
|
||||
notificationsShowMock.mockReset();
|
||||
// The tree-data atom persists via a ~500 ms trailing debounce; flush it NOW
|
||||
// (cancelling the timer) so a previous test's pending write can't land in
|
||||
// storage mid-test after the clear below.
|
||||
flushPendingTreeDataWrites();
|
||||
// jsdom's localStorage shim here lacks `clear`; guard it. Each test uses a
|
||||
// fresh jotai store anyway, so cross-test open-state never leaks.
|
||||
try {
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
openBranches,
|
||||
closeIds,
|
||||
loadedOpenBranchIds,
|
||||
pruneCollapsedChildren,
|
||||
} from "@/features/page/tree/utils/utils.ts";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
import { treeModel } from "@/features/page/tree/model/tree-model";
|
||||
@@ -200,81 +199,45 @@ const SpaceTree = forwardRef<SpaceTreeApi, SpaceTreeProps>(function SpaceTree(
|
||||
const openIdsRef = useRef(openIds);
|
||||
openIdsRef.current = openIds;
|
||||
|
||||
// Boot-cache hygiene (#159 #8): the localStorage-hydrated tree carries the
|
||||
// children of every branch ever expanded, including ones now COLLAPSED. Their
|
||||
// first expand would skip the lazy-load and render stale children (a
|
||||
// rename/move/delete missed while offline). Drop the cached children of every
|
||||
// COLLAPSED branch ONCE at mount so its first expand fetches fresh via
|
||||
// handleToggle — exactly as it did before the tree was cached. OPEN branches
|
||||
// keep their children and are refreshed by refreshOpenBranches instead, so
|
||||
// this runs before any expand and never double-fetches an open branch.
|
||||
const prunedBootCacheRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (prunedBootCacheRef.current) return;
|
||||
prunedBootCacheRef.current = true;
|
||||
setData((prev) => pruneCollapsedChildren(prev, openIdsRef.current));
|
||||
}, [setData]);
|
||||
|
||||
// Re-fetch and reconcile the children of every currently-open, already-loaded
|
||||
// branch of THIS space. Shared by the socket reconnect handler and the
|
||||
// post-load cache refresh below. The ROOT level is reconciled separately by
|
||||
// the root-query refetch + mergeRootTrees; an UNLOADED branch is skipped
|
||||
// (lazy-load fetches it fresh on expand). Reads refs so it always sees the
|
||||
// latest tree/open-state/space without re-creating the callback.
|
||||
const refreshOpenBranches = useCallback(async () => {
|
||||
const effectSpaceId = spaceIdRef.current;
|
||||
const branchIds = loadedOpenBranchIds(
|
||||
dataRef.current.filter((n) => n?.spaceId === effectSpaceId),
|
||||
openIdsRef.current,
|
||||
);
|
||||
if (branchIds.length === 0) return;
|
||||
for (const id of branchIds) {
|
||||
try {
|
||||
// `fresh: true` bypasses the 30-min sidebar-pages cache so the
|
||||
// reconcile sees the server's CURRENT children (handler-order
|
||||
// independent — no reliance on the global reconnect invalidation).
|
||||
const fresh = await fetchAllAncestorChildren(
|
||||
{ pageId: id, spaceId: effectSpaceId },
|
||||
{ fresh: true },
|
||||
);
|
||||
if (spaceIdRef.current !== effectSpaceId) return; // space switched
|
||||
setData((prev) => treeModel.reconcileChildren(prev, id, fresh));
|
||||
} catch (err) {
|
||||
console.error("[tree] open branch refresh failed", err);
|
||||
}
|
||||
}
|
||||
}, [setData]);
|
||||
|
||||
// Reconnect refresh (#159 #8): on a socket reconnect, refresh open branches
|
||||
// Reconnect refresh (#159 #8): on a socket reconnect, re-fetch and reconcile
|
||||
// the children of every currently-open, already-loaded branch of THIS space,
|
||||
// so a move/rename/delete that happened INSIDE a loaded branch while events
|
||||
// were missed (laptop sleep / wifi gap) is reflected instead of left stale.
|
||||
// No first-connect guard is needed: space-tree usually mounts AFTER the
|
||||
// initial connect, so every `connect` it sees is a reconnect; the rare
|
||||
// The ROOT level is reconciled separately by the root-query refetch +
|
||||
// mergeRootTrees; an UNLOADED branch is skipped (lazy-load fetches it fresh on
|
||||
// expand). No first-connect guard is needed: space-tree usually mounts AFTER
|
||||
// the initial connect, so every `connect` it sees is a reconnect; the rare
|
||||
// initial-connect case has an empty tree, so the refresh is a harmless no-op.
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
const onConnect = () => {
|
||||
refreshOpenBranches();
|
||||
const onConnect = async () => {
|
||||
const effectSpaceId = spaceIdRef.current;
|
||||
const branchIds = loadedOpenBranchIds(
|
||||
dataRef.current.filter((n) => n?.spaceId === effectSpaceId),
|
||||
openIdsRef.current,
|
||||
);
|
||||
if (branchIds.length === 0) return;
|
||||
for (const id of branchIds) {
|
||||
try {
|
||||
// `fresh: true` bypasses the 30-min sidebar-pages cache so the
|
||||
// reconcile sees the server's CURRENT children (handler-order
|
||||
// independent — no reliance on the global reconnect invalidation).
|
||||
const fresh = await fetchAllAncestorChildren(
|
||||
{ pageId: id, spaceId: effectSpaceId },
|
||||
{ fresh: true },
|
||||
);
|
||||
if (spaceIdRef.current !== effectSpaceId) return; // space switched
|
||||
setData((prev) => treeModel.reconcileChildren(prev, id, fresh));
|
||||
} catch (err) {
|
||||
console.error("[tree] reconnect branch refresh failed", err);
|
||||
}
|
||||
}
|
||||
};
|
||||
socket.on("connect", onConnect);
|
||||
return () => {
|
||||
socket.off("connect", onConnect);
|
||||
};
|
||||
}, [socket, refreshOpenBranches]);
|
||||
|
||||
// Post-load cache refresh: the sidebar paints instantly from the
|
||||
// localStorage-cached tree, so children of open branches may be stale. Once
|
||||
// the server root set has been merged for this space (isDataLoaded flips
|
||||
// true), refresh every open, already-loaded branch ONCE per space per mount.
|
||||
// dataRef.current is already up to date here: refs are assigned during
|
||||
// render, and this effect runs after the merge-triggered re-render commit.
|
||||
const refreshedSpacesRef = useRef<Set<string>>(new Set());
|
||||
useEffect(() => {
|
||||
if (!isDataLoaded) return;
|
||||
if (refreshedSpacesRef.current.has(spaceId)) return;
|
||||
refreshedSpacesRef.current.add(spaceId);
|
||||
refreshOpenBranches();
|
||||
}, [isDataLoaded, spaceId, refreshOpenBranches]);
|
||||
}, [socket, setData]);
|
||||
|
||||
const handleToggle = useCallback(
|
||||
async (id: string, isOpen: boolean) => {
|
||||
@@ -370,17 +333,12 @@ const SpaceTree = forwardRef<SpaceTreeApi, SpaceTreeProps>(function SpaceTree(
|
||||
|
||||
return (
|
||||
<div className={classes.treeContainer}>
|
||||
{/* "No pages yet" only after the SERVER confirmed the space is empty —
|
||||
never while just the localStorage cache is empty. */}
|
||||
{isDataLoaded && filteredData.length === 0 && (
|
||||
<Text size="xs" c="dimmed" py="xs" px="sm">
|
||||
{t("No pages yet")}
|
||||
</Text>
|
||||
)}
|
||||
{/* Cache-first paint: render as soon as ANY data exists (synchronous
|
||||
localStorage hydration) instead of waiting for the server round-trip;
|
||||
the background merge/refresh reconciles it afterwards. */}
|
||||
{filteredData.length > 0 && (
|
||||
{isDataLoaded && filteredData.length > 0 && (
|
||||
<DocTree<SpaceTreeNode>
|
||||
data={filteredData}
|
||||
openIds={openIds}
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
closeIds,
|
||||
mergeRootTrees,
|
||||
loadedOpenBranchIds,
|
||||
pruneCollapsedChildren,
|
||||
sortPositionKeys,
|
||||
pageToTreeNode,
|
||||
} from "./utils";
|
||||
@@ -439,62 +438,3 @@ describe("loadedOpenBranchIds (#159 #8 reconnect refresh targets)", () => {
|
||||
expect(ids.sort()).toEqual(["a", "a1"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pruneCollapsedChildren", () => {
|
||||
// Signature: pruneCollapsedChildren(tree: SpaceTreeNode[], openIds:
|
||||
// ReadonlySet<string>): SpaceTreeNode[]. Collapsed nodes (id NOT in openIds)
|
||||
// are reset to `children: []` (hasChildren untouched); open nodes keep their
|
||||
// children but are recursed into so a collapsed branch nested under an open
|
||||
// one is still pruned.
|
||||
//
|
||||
// Fixture:
|
||||
// open "p" (in openIds, hasChildren)
|
||||
// └─ collapsed "c" (NOT in openIds) with STALE child "g"
|
||||
// collapsed "t" (NOT in openIds) with child "t1"
|
||||
// Only "p" is open.
|
||||
function fixture() {
|
||||
const grandchild = treeNode("g"); // stale, cached under the collapsed child
|
||||
const collapsedChild = treeNode("c", [grandchild]);
|
||||
const openParent = treeNode("p", [collapsedChild]);
|
||||
const topCollapsed = treeNode("t", [treeNode("t1")]);
|
||||
return { openParent, collapsedChild, topCollapsed };
|
||||
}
|
||||
|
||||
it("keeps an OPEN parent's children and recurses to prune a nested collapsed branch; prunes a top-level collapsed node", () => {
|
||||
const { openParent, topCollapsed } = fixture();
|
||||
const tree = [openParent, topCollapsed];
|
||||
const result = pruneCollapsedChildren(tree, new Set(["p"]));
|
||||
|
||||
// (a) OPEN parent keeps its children (not cleared) and hasChildren stays true.
|
||||
const p = result[0];
|
||||
expect(p.id).toBe("p");
|
||||
expect(p.hasChildren).toBe(true);
|
||||
expect(p.children).toHaveLength(1);
|
||||
|
||||
// (b) The nested COLLAPSED child under the open parent is pruned to
|
||||
// `children: []` by the recursion, with hasChildren preserved. This is the
|
||||
// open-keep + recurse branch that F1's empty-open-set fixture never hits.
|
||||
const c = p.children[0];
|
||||
expect(c.id).toBe("c");
|
||||
expect(c.children).toEqual([]);
|
||||
expect(c.hasChildren).toBe(true);
|
||||
|
||||
// (c) The top-level collapsed node is pruned to `children: []`, hasChildren kept.
|
||||
const t = result[1];
|
||||
expect(t.id).toBe("t");
|
||||
expect(t.children).toEqual([]);
|
||||
expect(t.hasChildren).toBe(true);
|
||||
});
|
||||
|
||||
it("does not mutate the input tree (returns fresh nodes)", () => {
|
||||
const { openParent, collapsedChild, topCollapsed } = fixture();
|
||||
const tree = [openParent, topCollapsed];
|
||||
pruneCollapsedChildren(tree, new Set(["p"]));
|
||||
|
||||
// Originals are untouched: the collapsed child still carries its stale grandchild.
|
||||
expect(collapsedChild.children).toHaveLength(1);
|
||||
expect(collapsedChild.children[0].id).toBe("g");
|
||||
expect(openParent.children[0]).toBe(collapsedChild);
|
||||
expect(topCollapsed.children).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -293,41 +293,6 @@ export function loadedOpenBranchIds(
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot-cache hygiene (#159 #8): the persisted tree keeps the children of EVERY
|
||||
* branch ever expanded — collapsing a branch never prunes them. So on reload a
|
||||
* COLLAPSED branch hydrates with its old cached children, and `handleToggle`
|
||||
* skips the lazy-load on first expand (children already present) → it shows
|
||||
* STALE children (renamed / moved / deleted while the user was offline) with no
|
||||
* reconcile. `refreshOpenBranches` only refreshes OPEN branches, so collapsed
|
||||
* ones slip through.
|
||||
*
|
||||
* Fix: drop the cached children of every node NOT in the persisted open-set,
|
||||
* resetting it to the canonical UNLOADED shape (`children: []`, `hasChildren`
|
||||
* untouched — see pageToTreeNode). Its first expand then lazy-loads fresh, just
|
||||
* as it did before the tree was cached to localStorage. OPEN branches keep
|
||||
* their children (refreshOpenBranches reconciles those, so they must not be
|
||||
* dropped here) and are recursed into so a collapsed branch nested under an
|
||||
* open one is pruned too.
|
||||
*/
|
||||
export function pruneCollapsedChildren(
|
||||
tree: SpaceTreeNode[],
|
||||
openIds: ReadonlySet<string>,
|
||||
): SpaceTreeNode[] {
|
||||
return tree.map((node) => {
|
||||
const hasLoadedChildren = !!node.children && node.children.length > 0;
|
||||
if (!openIds.has(node.id)) {
|
||||
// Collapsed: drop the whole cached subtree so it reads as unloaded.
|
||||
return hasLoadedChildren ? { ...node, children: [] } : node;
|
||||
}
|
||||
// Open: keep it, but recurse into its children (a nested collapsed branch
|
||||
// must still be pruned).
|
||||
return hasLoadedChildren
|
||||
? { ...node, children: pruneCollapsedChildren(node.children, openIds) }
|
||||
: node;
|
||||
});
|
||||
}
|
||||
|
||||
// Collect every node id in the tree (roots, branches, leaves). Used by
|
||||
// collapseAll to clear the open-state map for all current-space nodes.
|
||||
export function collectAllIds(nodes: SpaceTreeNode[]): string[] {
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeAll,
|
||||
afterEach,
|
||||
} from "vitest";
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
|
||||
// --- Mocks for the heavy / networked module graph ---------------------------
|
||||
// EditSpaceForm wires the "Enable Git sync" Switch to a TanStack-Query mutation
|
||||
// (useUpdateSpaceMutation). We mock ONLY that hook so the test fully controls
|
||||
// mutateAsync (resolve / reject) and isPending, and stub i18n. The real Mantine
|
||||
// Switch is rendered so the checkbox role / disabled state is meaningful.
|
||||
|
||||
// i18n: identity translator — labels stay as their English keys for queries.
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
// Mutation hook: a controllable mutateAsync plus a togglable isPending.
|
||||
const mutateAsync = vi.fn();
|
||||
let isPending = false;
|
||||
vi.mock("@/features/space/queries/space-query.ts", () => ({
|
||||
useUpdateSpaceMutation: () => ({
|
||||
mutateAsync,
|
||||
get isPending() {
|
||||
return isPending;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// jsdom lacks matchMedia, which MantineProvider's color-scheme hook needs.
|
||||
beforeAll(() => {
|
||||
if (!window.matchMedia) {
|
||||
window.matchMedia = (query: string) =>
|
||||
({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
}) as unknown as MediaQueryList;
|
||||
}
|
||||
});
|
||||
|
||||
import { EditSpaceForm } from "./edit-space-form";
|
||||
import type { ISpace } from "@/features/space/types/space.types.ts";
|
||||
|
||||
function makeSpace(overrides: Partial<ISpace> = {}): ISpace {
|
||||
return {
|
||||
id: "space-1",
|
||||
name: "Engineering",
|
||||
description: "",
|
||||
slug: "eng",
|
||||
hostname: "host",
|
||||
creatorId: "u1",
|
||||
createdAt: new Date("2026-01-01"),
|
||||
updatedAt: new Date("2026-01-01"),
|
||||
...overrides,
|
||||
} as ISpace;
|
||||
}
|
||||
|
||||
function renderForm(props: { space: ISpace; readOnly?: boolean }) {
|
||||
return render(
|
||||
<MantineProvider>
|
||||
<EditSpaceForm space={props.space} readOnly={props.readOnly} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
// The form now renders TWO switches (git-sync enable + auto-merge-conflicts) in
|
||||
// that DOM order. Mantine renders each as an <input type="checkbox"
|
||||
// role="switch"> but does NOT expose its label as the accessible name, so we
|
||||
// disambiguate by DOM order (index 0 = enable, 1 = auto-merge) and assert the
|
||||
// human-readable label text is present alongside.
|
||||
function getToggle(): HTMLInputElement {
|
||||
screen.getByText("Enable Git sync");
|
||||
return screen.getAllByRole("switch")[0] as HTMLInputElement;
|
||||
}
|
||||
|
||||
function getAutoMergeToggle(): HTMLInputElement {
|
||||
screen.getByText("Auto-merge conflicts on push");
|
||||
return screen.getAllByRole("switch")[1] as HTMLInputElement;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
mutateAsync.mockReset();
|
||||
isPending = false;
|
||||
});
|
||||
|
||||
describe("EditSpaceForm git-sync toggle", () => {
|
||||
// Test 3: initial checked state derives from settings.gitSync.enabled ?? false.
|
||||
it("derives initial checked state from space.settings.gitSync.enabled (true -> checked)", () => {
|
||||
renderForm({
|
||||
space: makeSpace({ settings: { gitSync: { enabled: true } } }),
|
||||
});
|
||||
expect(getToggle().checked).toBe(true);
|
||||
});
|
||||
|
||||
it("defaults to unchecked when gitSync settings are missing", () => {
|
||||
renderForm({ space: makeSpace() });
|
||||
expect(getToggle().checked).toBe(false);
|
||||
});
|
||||
|
||||
// Test 4: toggling fires the mutation with { spaceId, gitSyncEnabled } and
|
||||
// optimistically flips the switch.
|
||||
it("fires the mutation with the correct payload and optimistically flips on", async () => {
|
||||
mutateAsync.mockResolvedValue(undefined);
|
||||
renderForm({ space: makeSpace() });
|
||||
|
||||
const toggle = getToggle();
|
||||
expect(toggle.checked).toBe(false);
|
||||
|
||||
fireEvent.click(toggle);
|
||||
|
||||
// Optimistic update: the switch reflects the new state immediately.
|
||||
expect(toggle.checked).toBe(true);
|
||||
|
||||
expect(mutateAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mutateAsync).toHaveBeenCalledWith({
|
||||
spaceId: "space-1",
|
||||
gitSyncEnabled: true,
|
||||
});
|
||||
|
||||
// Resolution leaves the toggle on.
|
||||
await waitFor(() => expect(toggle.checked).toBe(true));
|
||||
});
|
||||
|
||||
// Test 5: rollback on mutation error — the most valuable test.
|
||||
it("rolls back the toggle to its prior state when the mutation rejects", async () => {
|
||||
mutateAsync.mockRejectedValue(new Error("network"));
|
||||
renderForm({
|
||||
space: makeSpace({ settings: { gitSync: { enabled: false } } }),
|
||||
});
|
||||
|
||||
const toggle = getToggle();
|
||||
expect(toggle.checked).toBe(false);
|
||||
|
||||
fireEvent.click(toggle);
|
||||
|
||||
// Optimistically flips on before the rejection lands.
|
||||
expect(toggle.checked).toBe(true);
|
||||
expect(mutateAsync).toHaveBeenCalledWith({
|
||||
spaceId: "space-1",
|
||||
gitSyncEnabled: true,
|
||||
});
|
||||
|
||||
// After the rejected promise settles, the component reverts to OFF so the
|
||||
// user is not misled into believing sync is enabled.
|
||||
await waitFor(() => expect(toggle.checked).toBe(false));
|
||||
});
|
||||
|
||||
// Test 6: disabled when readOnly and when the mutation is pending.
|
||||
it("disables the toggle when readOnly", () => {
|
||||
renderForm({ space: makeSpace(), readOnly: true });
|
||||
expect(getToggle().disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("disables the toggle while the mutation is pending", () => {
|
||||
isPending = true;
|
||||
renderForm({ space: makeSpace() });
|
||||
expect(getToggle().disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("EditSpaceForm auto-merge-conflicts toggle", () => {
|
||||
it("derives initial checked state from space.settings.gitSync.autoMergeConflicts (true -> checked)", () => {
|
||||
renderForm({
|
||||
space: makeSpace({
|
||||
settings: { gitSync: { autoMergeConflicts: true } },
|
||||
}),
|
||||
});
|
||||
expect(getAutoMergeToggle().checked).toBe(true);
|
||||
});
|
||||
|
||||
it("defaults to unchecked when autoMergeConflicts is missing (SAFE default)", () => {
|
||||
renderForm({ space: makeSpace() });
|
||||
expect(getAutoMergeToggle().checked).toBe(false);
|
||||
});
|
||||
|
||||
it("fires the mutation with { spaceId, autoMergeConflicts } and optimistically flips on", async () => {
|
||||
mutateAsync.mockResolvedValue(undefined);
|
||||
renderForm({ space: makeSpace() });
|
||||
|
||||
const toggle = getAutoMergeToggle();
|
||||
expect(toggle.checked).toBe(false);
|
||||
|
||||
fireEvent.click(toggle);
|
||||
|
||||
// Optimistic update.
|
||||
expect(toggle.checked).toBe(true);
|
||||
expect(mutateAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mutateAsync).toHaveBeenCalledWith({
|
||||
spaceId: "space-1",
|
||||
autoMergeConflicts: true,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(toggle.checked).toBe(true));
|
||||
});
|
||||
|
||||
it("rolls back to its prior state when the mutation rejects", async () => {
|
||||
mutateAsync.mockRejectedValue(new Error("network"));
|
||||
renderForm({
|
||||
space: makeSpace({
|
||||
settings: { gitSync: { autoMergeConflicts: false } },
|
||||
}),
|
||||
});
|
||||
|
||||
const toggle = getAutoMergeToggle();
|
||||
expect(toggle.checked).toBe(false);
|
||||
|
||||
fireEvent.click(toggle);
|
||||
|
||||
expect(toggle.checked).toBe(true);
|
||||
expect(mutateAsync).toHaveBeenCalledWith({
|
||||
spaceId: "space-1",
|
||||
autoMergeConflicts: true,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(toggle.checked).toBe(false));
|
||||
});
|
||||
|
||||
it("disables the toggle when readOnly", () => {
|
||||
renderForm({ space: makeSpace(), readOnly: true });
|
||||
expect(getAutoMergeToggle().disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,14 @@
|
||||
import { Group, Box, Button, TextInput, Stack, Textarea } from "@mantine/core";
|
||||
import React from "react";
|
||||
import {
|
||||
Group,
|
||||
Box,
|
||||
Button,
|
||||
TextInput,
|
||||
Stack,
|
||||
Textarea,
|
||||
Divider,
|
||||
Switch,
|
||||
} from "@mantine/core";
|
||||
import React, { useState } from "react";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { zod4Resolver } from "mantine-form-zod-resolver";
|
||||
import { z } from "zod/v4";
|
||||
@@ -29,6 +38,37 @@ export function EditSpaceForm({ space, readOnly }: EditSpaceFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const updateSpaceMutation = useUpdateSpaceMutation();
|
||||
|
||||
const [gitSyncEnabled, setGitSyncEnabled] = useState<boolean>(
|
||||
space?.settings?.gitSync?.enabled ?? false,
|
||||
);
|
||||
|
||||
const [autoMergeConflicts, setAutoMergeConflicts] = useState<boolean>(
|
||||
space?.settings?.gitSync?.autoMergeConflicts ?? false,
|
||||
);
|
||||
|
||||
// One parameterized handler for both git-sync space toggles: they differ only by
|
||||
// the local state setter, the mutation payload field, and the error label. The
|
||||
// update is optimistic and reverts the local state on failure (the mutation
|
||||
// surfaces a toast via onError; the raw error is still logged per AGENTS.md).
|
||||
const handleToggle = async (
|
||||
field: "gitSyncEnabled" | "autoMergeConflicts",
|
||||
value: boolean,
|
||||
previous: boolean,
|
||||
setLocal: (next: boolean) => void,
|
||||
errorLabel: string,
|
||||
) => {
|
||||
setLocal(value); // optimistic update
|
||||
try {
|
||||
await updateSpaceMutation.mutateAsync({
|
||||
spaceId: space.id,
|
||||
[field]: value,
|
||||
});
|
||||
} catch (err) {
|
||||
setLocal(previous); // revert on failure
|
||||
console.error(errorLabel, err);
|
||||
}
|
||||
};
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
validate: zod4Resolver(formSchema),
|
||||
initialValues: {
|
||||
@@ -104,6 +144,43 @@ export function EditSpaceForm({ space, readOnly }: EditSpaceFormProps) {
|
||||
</Group>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<Divider my="lg" />
|
||||
|
||||
<Switch
|
||||
label={t("Enable Git sync")}
|
||||
description={t("Sync this space's pages to a Git repository.")}
|
||||
checked={gitSyncEnabled}
|
||||
disabled={readOnly || updateSpaceMutation.isPending}
|
||||
onChange={(event) =>
|
||||
handleToggle(
|
||||
"gitSyncEnabled",
|
||||
event.currentTarget.checked,
|
||||
gitSyncEnabled,
|
||||
setGitSyncEnabled,
|
||||
"Failed to toggle git-sync for space",
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
mt="md"
|
||||
label={t("Auto-merge conflicts on push")}
|
||||
description={t(
|
||||
"When off (recommended), a page whose content still has unresolved Git conflict markers is skipped on push until you resolve the conflict in Git. When on, the markers are stripped and both sides' content is pushed.",
|
||||
)}
|
||||
checked={autoMergeConflicts}
|
||||
disabled={readOnly || updateSpaceMutation.isPending}
|
||||
onChange={(event) =>
|
||||
handleToggle(
|
||||
"autoMergeConflicts",
|
||||
event.currentTarget.checked,
|
||||
autoMergeConflicts,
|
||||
setAutoMergeConflicts,
|
||||
"Failed to toggle git-sync auto-merge-conflicts",
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -13,9 +13,15 @@ export interface ISpaceCommentsSettings {
|
||||
allowViewerComments?: boolean;
|
||||
}
|
||||
|
||||
export interface ISpaceGitSyncSettings {
|
||||
enabled?: boolean;
|
||||
autoMergeConflicts?: boolean;
|
||||
}
|
||||
|
||||
export interface ISpaceSettings {
|
||||
sharing?: ISpaceSharingSettings;
|
||||
comments?: ISpaceCommentsSettings;
|
||||
gitSync?: ISpaceGitSyncSettings;
|
||||
}
|
||||
|
||||
export interface ISpace {
|
||||
@@ -35,6 +41,8 @@ export interface ISpace {
|
||||
// for updates
|
||||
disablePublicSharing?: boolean;
|
||||
allowViewerComments?: boolean;
|
||||
gitSyncEnabled?: boolean;
|
||||
autoMergeConflicts?: boolean;
|
||||
}
|
||||
|
||||
interface IMembership {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import axios, { AxiosInstance } from "axios";
|
||||
import APP_ROUTE from "@/lib/app-route.ts";
|
||||
import { isCloud } from "@/lib/config.ts";
|
||||
import { clearPersistedTreeCaches } from "@/features/page/tree/atoms/tree-data-atom";
|
||||
|
||||
const api: AxiosInstance = axios.create({
|
||||
baseURL: "/api",
|
||||
@@ -72,12 +71,6 @@ function redirectToLogin() {
|
||||
"/invites",
|
||||
];
|
||||
if (!exemptPaths.some((path) => window.location.pathname.startsWith(path))) {
|
||||
// Forced logout (401 / expired session) must purge the persisted sidebar
|
||||
// tree caches too: they contain page titles, and on a shared machine most
|
||||
// sessions end via cookie expiry — not the logout button — so this is the
|
||||
// only cleanup that runs on that path. It also disables further cache
|
||||
// persistence until the full page load below.
|
||||
clearPersistedTreeCaches();
|
||||
const redirectTo = window.location.pathname;
|
||||
if (redirectTo === APP_ROUTE.HOME) {
|
||||
window.location.href = APP_ROUTE.AUTH.LOGIN;
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"migration:reset": "tsx src/database/migrate.ts down-to NO_MIGRATIONS",
|
||||
"migration:codegen": "kysely-codegen --dialect=postgres --camel-case --env-file=../../.env --out-file=./src/database/types/db.d.ts",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"pretest": "pnpm --filter @docmost/editor-ext build",
|
||||
"pretest": "pnpm --filter @docmost/editor-ext build && pnpm --filter @docmost/git-sync build && pnpm --filter @docmost/mcp build",
|
||||
"test": "jest",
|
||||
"test:int": "jest --config test/jest-integration.json",
|
||||
"test:watch": "jest --watch",
|
||||
@@ -41,6 +41,7 @@
|
||||
"@aws-sdk/s3-request-presigner": "3.1050.0",
|
||||
"@azure/storage-blob": "12.31.0",
|
||||
"@clickhouse/client": "^1.18.2",
|
||||
"@docmost/git-sync": "workspace:*",
|
||||
"@docmost/mcp": "workspace:*",
|
||||
"@docmost/pdf-inspector": "1.9.6",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
@@ -189,7 +190,12 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"^.+\\.(t|j)sx?$": "ts-jest"
|
||||
"^.+\\.(t|j)sx?$": [
|
||||
"ts-jest",
|
||||
{
|
||||
"isolatedModules": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"transformIgnorePatterns": [
|
||||
"/node_modules/(?!(\\.pnpm/)?(nanoid|uuid|image-dimensions|marked|happy-dom|lib0)(@|/))"
|
||||
@@ -199,11 +205,17 @@
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node",
|
||||
"setupFiles": [
|
||||
"<rootDir>/../test/jest.setup.ts"
|
||||
],
|
||||
"moduleNameMapper": {
|
||||
"^@docmost/db/(.*)$": "<rootDir>/database/$1",
|
||||
"^@docmost/transactional/(.*)$": "<rootDir>/integrations/transactional/$1",
|
||||
"^@docmost/ee/(.*)$": "<rootDir>/ee/$1",
|
||||
"^src/(.*)$": "<rootDir>/$1"
|
||||
"^src/(.*)$": "<rootDir>/$1",
|
||||
"^@docmost/git-sync$": "<rootDir>/../../../packages/git-sync/src/index.ts",
|
||||
"^@docmost/git-sync/(.*)$": "<rootDir>/../../../packages/git-sync/src/$1",
|
||||
"^(\\.{1,2}/.*)\\.js$": "$1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import { ClsModule } from 'nestjs-cls';
|
||||
import { NoopAuditModule } from './integrations/audit/audit.module';
|
||||
import { ThrottleModule } from './integrations/throttle/throttle.module';
|
||||
import { McpModule } from './integrations/mcp/mcp.module';
|
||||
import { GitSyncModule } from './integrations/git-sync/git-sync.module';
|
||||
import { SandboxModule } from './integrations/sandbox/sandbox.module';
|
||||
import { AiModule } from './integrations/ai/ai.module';
|
||||
import { AiChatModule } from './core/ai-chat/ai-chat.module';
|
||||
@@ -90,6 +91,7 @@ try {
|
||||
TelemetryModule,
|
||||
ThrottleModule,
|
||||
McpModule,
|
||||
GitSyncModule,
|
||||
SandboxModule,
|
||||
AiModule,
|
||||
AiChatModule,
|
||||
|
||||
@@ -155,6 +155,45 @@ export class CollaborationGateway {
|
||||
return this.hocuspocus.openDirectConnection(documentName, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a git-originated body into a page, applying the merge on the instance
|
||||
* that OWNS the live Y.Doc so a connected editor CONVERGES on the change.
|
||||
*
|
||||
* git-sync must NOT use openDirectConnection directly for this: that opens the
|
||||
* document on whichever instance/process runs git-sync (the API/worker). When
|
||||
* an editor is connected to a DIFFERENT collab instance/process, that is a
|
||||
* SEPARATE, detached Y.Doc — the merge lands in the detached doc and the DB,
|
||||
* but the live editor never receives the Yjs update; its next debounced
|
||||
* autosave then overwrites the DB with its stale state and SILENTLY REVERTS
|
||||
* the git change (the data-loss bug). Routing through the custom-event channel
|
||||
* runs the merge on the owning instance's shared Document, whose update is
|
||||
* broadcast to every connection (handleUpdate), so the editor's CRDT converges
|
||||
* on the merged result.
|
||||
*
|
||||
* Without redis there is a single instance, so the write runs locally — which
|
||||
* is already the owning (and only) instance the editor is connected to.
|
||||
*/
|
||||
async writePageBody(
|
||||
documentName: string,
|
||||
payload: {
|
||||
prosemirrorJson: unknown;
|
||||
baseProsemirrorJson?: unknown;
|
||||
userId: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
if (this.redisSync) {
|
||||
await this.handleYjsEvent(
|
||||
'gitSyncWriteBody',
|
||||
documentName,
|
||||
payload as any,
|
||||
);
|
||||
return;
|
||||
}
|
||||
await this.collabEventsService
|
||||
.getHandlers(this.hocuspocus)
|
||||
.gitSyncWriteBody(documentName, payload as any);
|
||||
}
|
||||
|
||||
/*
|
||||
*Can be used before calling openDirectConnection directly
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
// Exercises the REAL `gitSyncWriteBody` collab handler (the owner-routed body
|
||||
// write the data-loss fix introduces). The handler imports the editor graph via
|
||||
// collaboration.util / yjs.util (tiptapExtensions -> editor-ext -> react-dom,
|
||||
// unloadable under jest's node env, same coupling noted in
|
||||
// gitmost-datasource.service.spec.ts), so we stub those + the transformer. The
|
||||
// stubbed toYdoc builds paragraph blocks straight from the ProseMirror JSON so
|
||||
// we can assert convergence on real text.
|
||||
jest.mock('./collaboration.util', () => ({
|
||||
tiptapExtensions: [],
|
||||
getPageId: (name: string) => name.replace(/^page\./, ''),
|
||||
prosemirrorNodeToYElement: jest.fn(),
|
||||
}));
|
||||
jest.mock('./yjs.util', () => ({
|
||||
setYjsMark: jest.fn(),
|
||||
updateYjsMarkAttribute: jest.fn(),
|
||||
}));
|
||||
jest.mock('@hocuspocus/transformer', () => {
|
||||
const Yjs = require('yjs');
|
||||
return {
|
||||
TiptapTransformer: {
|
||||
toYdoc: (json: any) => {
|
||||
if (json?.__throw) throw new Error('boom: malformed doc');
|
||||
const d = new Yjs.Doc();
|
||||
const frag = d.getXmlFragment('default');
|
||||
const blocks = (json?.content ?? []).map((node: any) => {
|
||||
const el = new Yjs.XmlElement(node.type || 'paragraph');
|
||||
const text = (node.content ?? [])
|
||||
.map((t: any) => t.text ?? '')
|
||||
.join('');
|
||||
const t = new Yjs.XmlText();
|
||||
if (text) t.insert(0, text);
|
||||
el.insert(0, [t]);
|
||||
return el;
|
||||
});
|
||||
if (blocks.length) frag.insert(0, blocks);
|
||||
return d;
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import * as Y from 'yjs';
|
||||
import { CollaborationHandler } from './collaboration.handler';
|
||||
|
||||
const pmDoc = (...paras: string[]) => ({
|
||||
type: 'doc',
|
||||
content: paras.map((text) => ({
|
||||
type: 'paragraph',
|
||||
content: text ? [{ type: 'text', text }] : [],
|
||||
})),
|
||||
});
|
||||
|
||||
const texts = (frag: Y.XmlFragment): string[] =>
|
||||
frag.toArray().map((el) =>
|
||||
(el as Y.XmlElement)
|
||||
.toArray()
|
||||
.map((c) => (c as Y.XmlText).toString())
|
||||
.join(''),
|
||||
);
|
||||
|
||||
// Build a fake Hocuspocus whose openDirectConnection yields a DirectConnection
|
||||
// over a REAL shared Document, with a connected "editor" doc that receives the
|
||||
// shared doc's updates (modelling Document.handleUpdate's broadcast on the
|
||||
// OWNING instance). Initial content carries live block ids; the editor starts
|
||||
// fully synced with the shared doc.
|
||||
function fakeHocuspocus(initial: { text: string; id: string }[]) {
|
||||
const shared = new Y.Doc();
|
||||
const frag = shared.getXmlFragment('default');
|
||||
shared.transact(() => {
|
||||
frag.insert(
|
||||
0,
|
||||
initial.map((s) => {
|
||||
const el = new Y.XmlElement('paragraph');
|
||||
el.setAttribute('id', s.id);
|
||||
const t = new Y.XmlText();
|
||||
if (s.text) t.insert(0, s.text);
|
||||
el.insert(0, [t]);
|
||||
return el;
|
||||
}),
|
||||
);
|
||||
});
|
||||
const editor = new Y.Doc();
|
||||
Y.applyUpdate(editor, Y.encodeStateAsUpdate(shared));
|
||||
// Broadcast relay: server-originated updates flow to the connected editor.
|
||||
shared.on('update', (u: Uint8Array, origin: any) => {
|
||||
if (origin !== 'editor') Y.applyUpdate(editor, u, 'server');
|
||||
});
|
||||
|
||||
const openDirectConnection = jest.fn(async () => ({
|
||||
// DirectConnection.transact runs the fn directly against the Document (no
|
||||
// wrapping Y transaction), exactly like @hocuspocus/server.
|
||||
transact: async (fn: (doc: Y.Doc) => void) => fn(shared),
|
||||
disconnect: jest.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
return { hocuspocus: { openDirectConnection } as any, shared, editor };
|
||||
}
|
||||
|
||||
describe('CollaborationHandler.gitSyncWriteBody (owner-routed body write)', () => {
|
||||
it('converges a connected editor on the git change (no silent revert)', async () => {
|
||||
const { hocuspocus, shared, editor } = fakeHocuspocus([
|
||||
{ text: 'alpha', id: 'p1' },
|
||||
{ text: 'beta', id: 'p2' },
|
||||
]);
|
||||
const handler = new CollaborationHandler();
|
||||
const handlers = handler.getHandlers(hocuspocus);
|
||||
|
||||
// git changed block 1 beta -> beta2; base is the pre-change content.
|
||||
await handlers.gitSyncWriteBody('page.x', {
|
||||
prosemirrorJson: pmDoc('alpha', 'beta2'),
|
||||
baseProsemirrorJson: pmDoc('alpha', 'beta'),
|
||||
userId: 'svc-user',
|
||||
});
|
||||
|
||||
// The shared (owning-instance) doc holds the merge...
|
||||
expect(texts(shared.getXmlFragment('default'))).toEqual(['alpha', 'beta2']);
|
||||
// ...and the connected editor CONVERGED via the broadcast (the bug would
|
||||
// leave it on 'beta' and revert the page on its next autosave).
|
||||
expect(texts(editor.getXmlFragment('default'))).toEqual(['alpha', 'beta2']);
|
||||
});
|
||||
|
||||
it('preserves a concurrent edit to a DIFFERENT block (3-way, finding #2)', async () => {
|
||||
const { hocuspocus, shared, editor } = fakeHocuspocus([
|
||||
{ text: 'alpha', id: 'p1' },
|
||||
{ text: 'beta', id: 'p2' },
|
||||
]);
|
||||
// The editor is actively editing block 0 while the push arrives.
|
||||
const eFrag = editor.getXmlFragment('default');
|
||||
editor.transact(
|
||||
() => (eFrag.get(0) as Y.XmlElement).get(0) instanceof Y.XmlText &&
|
||||
((eFrag.get(0) as Y.XmlElement).get(0) as Y.XmlText).insert(5, ' EDIT'),
|
||||
'editor',
|
||||
);
|
||||
Y.applyUpdate(shared, Y.encodeStateAsUpdate(editor), 'editor');
|
||||
|
||||
const handler = new CollaborationHandler();
|
||||
await handler.getHandlers(hocuspocus).gitSyncWriteBody('page.x', {
|
||||
prosemirrorJson: pmDoc('alpha', 'beta2'),
|
||||
baseProsemirrorJson: pmDoc('alpha', 'beta'),
|
||||
userId: 'svc-user',
|
||||
});
|
||||
|
||||
// Human's block-0 edit AND git's block-1 change both survive on the editor.
|
||||
expect(texts(editor.getXmlFragment('default'))).toEqual([
|
||||
'alpha EDIT',
|
||||
'beta2',
|
||||
]);
|
||||
});
|
||||
|
||||
it('FLUSHES the pending debounced store BEFORE merging so an in-flight edit survives (finding #2)', async () => {
|
||||
// QA #119 finding #2: the 3-way merge must run against the latest live-doc
|
||||
// state. A concurrent UI edit that is still in-flight (the store is debounced)
|
||||
// must be drained into the live doc BEFORE git merges, or git clean-applies and
|
||||
// the edit is silently dropped — even on a DIFFERENT block. Model the drain via
|
||||
// the pending-store flush: when it runs, the in-flight block-0 edit lands.
|
||||
const shared = new Y.Doc();
|
||||
const frag = shared.getXmlFragment('default');
|
||||
shared.transact(() => {
|
||||
frag.insert(
|
||||
0,
|
||||
[
|
||||
{ text: 'alpha', id: 'p1' },
|
||||
{ text: 'beta', id: 'p2' },
|
||||
].map((s) => {
|
||||
const el = new Y.XmlElement('paragraph');
|
||||
el.setAttribute('id', s.id);
|
||||
const t = new Y.XmlText();
|
||||
t.insert(0, s.text);
|
||||
el.insert(0, [t]);
|
||||
return el;
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const order: string[] = [];
|
||||
const debouncer = {
|
||||
isDebounced: jest.fn(() => true),
|
||||
executeNow: jest.fn(async () => {
|
||||
order.push('flush');
|
||||
// The in-flight client edit to block 0 only lands once the pending store
|
||||
// is flushed (i.e. the event loop is drained) — BEFORE the merge.
|
||||
shared.transact(() =>
|
||||
((frag.get(0) as Y.XmlElement).get(0) as Y.XmlText).insert(5, ' EDIT'),
|
||||
);
|
||||
}),
|
||||
};
|
||||
const openDirectConnection = jest.fn(async () => ({
|
||||
transact: async (fn: (doc: Y.Doc) => void) => {
|
||||
order.push('merge');
|
||||
fn(shared);
|
||||
},
|
||||
disconnect: jest.fn(async () => undefined),
|
||||
}));
|
||||
const hocuspocus = { openDirectConnection, debouncer } as any;
|
||||
|
||||
const handler = new CollaborationHandler();
|
||||
await handler.getHandlers(hocuspocus).gitSyncWriteBody('page.x', {
|
||||
prosemirrorJson: pmDoc('alpha', 'beta2'), // git changes block 1
|
||||
baseProsemirrorJson: pmDoc('alpha', 'beta'),
|
||||
userId: 'svc-user',
|
||||
});
|
||||
|
||||
// The flush ran, and it ran BEFORE the merge transaction.
|
||||
expect(debouncer.executeNow).toHaveBeenCalledTimes(1);
|
||||
expect(order).toEqual(['flush', 'merge']);
|
||||
// Both the in-flight block-0 edit and git's block-1 change survive — the
|
||||
// pre-flush bug would have produced ['alpha', 'beta2'] (UI edit dropped).
|
||||
expect(texts(shared.getXmlFragment('default'))).toEqual([
|
||||
'alpha EDIT',
|
||||
'beta2',
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not flush when no store is pending (isDebounced false)', async () => {
|
||||
const { hocuspocus, shared } = fakeHocuspocus([{ text: 'a', id: 'p1' }]);
|
||||
const executeNow = jest.fn();
|
||||
(hocuspocus as any).debouncer = {
|
||||
isDebounced: jest.fn(() => false),
|
||||
executeNow,
|
||||
};
|
||||
const handler = new CollaborationHandler();
|
||||
await handler.getHandlers(hocuspocus).gitSyncWriteBody('page.x', {
|
||||
prosemirrorJson: pmDoc('a', 'b'),
|
||||
userId: 'svc-user',
|
||||
});
|
||||
expect(executeNow).not.toHaveBeenCalled();
|
||||
expect(texts(shared.getXmlFragment('default'))).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('crash-safe: a transform failure never opens the connection or mutates the live doc', async () => {
|
||||
const { hocuspocus, shared } = fakeHocuspocus([{ text: 'alpha', id: 'p1' }]);
|
||||
const before = texts(shared.getXmlFragment('default'));
|
||||
const handler = new CollaborationHandler();
|
||||
|
||||
await expect(
|
||||
handler.getHandlers(hocuspocus).gitSyncWriteBody('page.x', {
|
||||
prosemirrorJson: { __throw: true } as any,
|
||||
userId: 'svc-user',
|
||||
}),
|
||||
).rejects.toThrow('boom');
|
||||
|
||||
// The incoming doc is built BEFORE opening the connection, so the throw
|
||||
// happens first: the live doc is untouched and no connection was opened.
|
||||
expect(hocuspocus.openDirectConnection).not.toHaveBeenCalled();
|
||||
expect(texts(shared.getXmlFragment('default'))).toEqual(before);
|
||||
});
|
||||
|
||||
it('falls back to a 2-way merge when no base is supplied', async () => {
|
||||
const { hocuspocus, shared, editor } = fakeHocuspocus([
|
||||
{ text: 'alpha', id: 'p1' },
|
||||
]);
|
||||
const handler = new CollaborationHandler();
|
||||
|
||||
await handler.getHandlers(hocuspocus).gitSyncWriteBody('page.x', {
|
||||
prosemirrorJson: pmDoc('alpha', 'gamma'),
|
||||
userId: 'svc-user',
|
||||
});
|
||||
|
||||
expect(texts(shared.getXmlFragment('default'))).toEqual(['alpha', 'gamma']);
|
||||
expect(texts(editor.getXmlFragment('default'))).toEqual(['alpha', 'gamma']);
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
import { setYjsMark, updateYjsMarkAttribute, YjsSelection } from './yjs.util';
|
||||
import * as Y from 'yjs';
|
||||
import { User } from '@docmost/db/types/entity.types';
|
||||
import {
|
||||
mergeXmlFragments,
|
||||
mergeXmlFragments3WayWithStats,
|
||||
} from './merge/yjs-body-merge';
|
||||
|
||||
export type CollabEventHandlers = ReturnType<
|
||||
CollaborationHandler['getHandlers']
|
||||
@@ -112,9 +116,130 @@ export class CollaborationHandler {
|
||||
},
|
||||
);
|
||||
},
|
||||
/**
|
||||
* Git-sync body write, applied as a block-level MERGE into the LIVE doc on
|
||||
* the instance that OWNS it (routed here via the custom-event channel —
|
||||
* see CollaborationGateway.writePageBody). Running on the owning instance
|
||||
* is what makes a connected editor CONVERGE: the merge mutates the shared
|
||||
* Document, whose update is broadcast to every connection, so the editor's
|
||||
* CRDT applies the git change instead of silently reverting it on its next
|
||||
* autosave (the data-loss bug this fixes).
|
||||
*
|
||||
* With a `baseProsemirrorJson` (the last-synced common ancestor) it does a
|
||||
* THREE-WAY merge — a block only the human changed is kept, a block only
|
||||
* git changed is taken (conflicts -> git). Without a base it falls back to
|
||||
* the 2-way merge.
|
||||
*/
|
||||
gitSyncWriteBody: async (
|
||||
documentName: string,
|
||||
payload: {
|
||||
prosemirrorJson: any;
|
||||
baseProsemirrorJson?: any;
|
||||
userId: string;
|
||||
},
|
||||
) => {
|
||||
const { prosemirrorJson, baseProsemirrorJson, userId } = payload;
|
||||
|
||||
// Build the incoming (and base) Yjs docs BEFORE opening the connection /
|
||||
// touching the live doc. If a transform throws (a malformed/unsupported
|
||||
// doc) we must NOT have mutated the live body — otherwise a conversion
|
||||
// failure could leave the page empty (crash-safe conversion).
|
||||
const targetDoc = TiptapTransformer.toYdoc(
|
||||
prosemirrorJson,
|
||||
'default',
|
||||
tiptapExtensions,
|
||||
);
|
||||
const baseDoc =
|
||||
baseProsemirrorJson != null
|
||||
? TiptapTransformer.toYdoc(
|
||||
baseProsemirrorJson,
|
||||
'default',
|
||||
tiptapExtensions,
|
||||
)
|
||||
: null;
|
||||
|
||||
// CONCURRENT-EDIT FLUSH (QA #119, finding #2). The 3-way merge below runs
|
||||
// against the LIVE Y.Doc, so a concurrent UI edit is only preserved if it
|
||||
// is already part of that doc. A user's edit is debounced before it lands
|
||||
// (the editor batches; the collab store is debounced up to 10s), so the
|
||||
// merge could otherwise run against a PRE-EDIT doc: git would then
|
||||
// clean-apply (no same-block conflict detected) and the in-flight UI edit
|
||||
// — even on a DIFFERENT block — would be silently dropped.
|
||||
//
|
||||
// Flushing the pending debounced store here (a) drains the event loop so a
|
||||
// just-arrived client Yjs update is applied to the live doc BEFORE we
|
||||
// merge, and (b) persists the live doc so the merge baseline is current
|
||||
// even on the doc-reload-from-DB path. After the flush the merge sees the
|
||||
// latest state, so an edit on a different block is MERGED (not overwritten)
|
||||
// and a genuine same-block edit is detected as a conflict -> the
|
||||
// boundary-snapshot in PersistenceExtension pins it to page history
|
||||
// (recoverable) instead of vanishing silently.
|
||||
await this.flushPendingStore(hocuspocus, documentName);
|
||||
|
||||
// actor:'git-sync' + the service user flow into PersistenceExtension
|
||||
// (lastUpdatedSource='git-sync', lastUpdatedById=userId).
|
||||
await this.withYdocConnection(
|
||||
hocuspocus,
|
||||
documentName,
|
||||
{ actor: 'git-sync', user: { id: userId } },
|
||||
(doc) => {
|
||||
const liveFrag = doc.getXmlFragment('default');
|
||||
const targetFrag = targetDoc.getXmlFragment('default');
|
||||
if (baseDoc) {
|
||||
const { conflicts } = mergeXmlFragments3WayWithStats(
|
||||
liveFrag,
|
||||
targetFrag,
|
||||
baseDoc.getXmlFragment('default'),
|
||||
);
|
||||
// SAME-BLOCK conflict contract (SPEC §9): a block both the human
|
||||
// and git changed resolves to GIT (deterministic). Make that
|
||||
// OBSERVABLE rather than silent — log it. The losing human content
|
||||
// is NOT destroyed: the persistence extension's boundary snapshot
|
||||
// pins the pre-merge page state to history on this user->git-sync
|
||||
// transition, so it stays recoverable.
|
||||
if (conflicts > 0) {
|
||||
this.logger.warn(
|
||||
`git-sync merge for ${documentName}: ${conflicts} same-block ` +
|
||||
`conflict(s) resolved to the git version; the prior page ` +
|
||||
`state is preserved in page history (recoverable).`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
mergeXmlFragments(liveFrag, targetFrag);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush any pending DEBOUNCED store for `documentName` so the live Y.Doc and the
|
||||
* DB are current BEFORE a git-sync merge reads them (QA #119, finding #2 —
|
||||
* concurrent UI edit silently lost). Mirrors the PersistenceExtension.onDisconnect
|
||||
* flush: only acts when a store is actually pending (`isDebounced`), runs the
|
||||
* SAME scheduled payload (`executeNow`, preserving the edit's context/actor), and
|
||||
* never throws — a flush failure must not abort the git-sync write. Awaiting it
|
||||
* also drains the event loop, so a client Yjs update sitting in the socket buffer
|
||||
* is applied to the live doc before the merge transaction runs.
|
||||
*/
|
||||
private async flushPendingStore(
|
||||
hocuspocus: Hocuspocus,
|
||||
documentName: string,
|
||||
): Promise<void> {
|
||||
const debounceId = `onStoreDocument-${documentName}`;
|
||||
try {
|
||||
const debouncer = (hocuspocus as any)?.debouncer;
|
||||
if (!debouncer?.isDebounced?.(debounceId)) return;
|
||||
await debouncer.executeNow(debounceId);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`git-sync pre-merge flush failed for ${documentName}: ` +
|
||||
(err instanceof Error ? err.message : String(err)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async withYdocConnection(
|
||||
hocuspocus: Hocuspocus,
|
||||
documentName: string,
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { PersistenceExtension } from './persistence.extension';
|
||||
|
||||
/**
|
||||
* Regression for the QA #119 "loss-on-fast-close" data loss: editing a page then
|
||||
* closing the tab within the collab debounce window (~3-18s) lost the edit
|
||||
* because, with `unloadImmediately: false`, Hocuspocus does NOT flush the
|
||||
* debounced onStoreDocument on a last-client disconnect. PersistenceExtension
|
||||
* now flushes the pending store on the LAST disconnect (and only then).
|
||||
*/
|
||||
describe('PersistenceExtension.onDisconnect flush (loss-on-fast-close)', () => {
|
||||
function makeExt(): PersistenceExtension {
|
||||
// onDisconnect touches none of the injected deps; pass casts.
|
||||
return new PersistenceExtension(
|
||||
null as any,
|
||||
null as any,
|
||||
null as any,
|
||||
null as any,
|
||||
null as any,
|
||||
null as any,
|
||||
null as any,
|
||||
null as any,
|
||||
);
|
||||
}
|
||||
|
||||
function makeData(opts: {
|
||||
clientsCount: number;
|
||||
isDebounced: boolean;
|
||||
isLoading?: boolean;
|
||||
}) {
|
||||
const executeNow = jest.fn(async () => undefined);
|
||||
const isDebounced = jest.fn(() => opts.isDebounced);
|
||||
return {
|
||||
executeNow,
|
||||
isDebounced,
|
||||
payload: {
|
||||
clientsCount: opts.clientsCount,
|
||||
context: {},
|
||||
document: { isLoading: opts.isLoading ?? false } as any,
|
||||
documentName: 'page.abc',
|
||||
instance: { debouncer: { isDebounced, executeNow } } as any,
|
||||
requestHeaders: {},
|
||||
requestParameters: new URLSearchParams(),
|
||||
socketId: 's',
|
||||
} as any,
|
||||
};
|
||||
}
|
||||
|
||||
it('flushes the pending store when the LAST client disconnects', async () => {
|
||||
const ext = makeExt();
|
||||
const { executeNow, payload } = makeData({
|
||||
clientsCount: 0,
|
||||
isDebounced: true,
|
||||
});
|
||||
await ext.onDisconnect(payload);
|
||||
expect(executeNow).toHaveBeenCalledTimes(1);
|
||||
expect(executeNow).toHaveBeenCalledWith('onStoreDocument-page.abc');
|
||||
});
|
||||
|
||||
it('does NOT flush while other editors remain connected', async () => {
|
||||
const ext = makeExt();
|
||||
const { executeNow, payload } = makeData({
|
||||
clientsCount: 2,
|
||||
isDebounced: true,
|
||||
});
|
||||
await ext.onDisconnect(payload);
|
||||
expect(executeNow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does NOT write when nothing is pending (already persisted)', async () => {
|
||||
const ext = makeExt();
|
||||
const { executeNow, payload } = makeData({
|
||||
clientsCount: 0,
|
||||
isDebounced: false,
|
||||
});
|
||||
await ext.onDisconnect(payload);
|
||||
expect(executeNow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does NOT flush a doc that is still loading (load error guard)', async () => {
|
||||
const ext = makeExt();
|
||||
const { executeNow, payload } = makeData({
|
||||
clientsCount: 0,
|
||||
isDebounced: true,
|
||||
isLoading: true,
|
||||
});
|
||||
await ext.onDisconnect(payload);
|
||||
expect(executeNow).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
// Stub collaboration.util so importing the extension does not drag in the
|
||||
// editor-ext -> @tiptap/react -> react-dom graph (unloadable under jest's node
|
||||
// env, same coupling the gitmost-datasource / mcp specs document). The
|
||||
// extension only calls getPageId, jsonToText and isEmptyParagraphDoc from it on
|
||||
// the store path; tiptapExtensions is unused by onStoreDocument.
|
||||
jest.mock('../collaboration.util', () => ({
|
||||
tiptapExtensions: [],
|
||||
getPageId: (name: string) => name.replace(/^page\./, ''),
|
||||
jsonToText: () => 'text',
|
||||
isEmptyParagraphDoc: () => false,
|
||||
// The post-write mention extraction walks the doc via jsonToNode().descendants;
|
||||
// return a node-like stub with no descendants so no mentions are produced
|
||||
// (mention handling is out of scope here — we only assert provenance).
|
||||
jsonToNode: () => ({ descendants: () => undefined }),
|
||||
}));
|
||||
|
||||
// Control the Yjs<->JSON bridge: fromYdoc returns the "incoming" doc the writer
|
||||
// is storing. We keep it distinct from the page's persisted content so the
|
||||
// no-op guard (isDeepStrictEqual) never short-circuits the write.
|
||||
const INCOMING_JSON = { type: 'doc', content: [{ type: 'paragraph' }, { t: 1 }] };
|
||||
jest.mock('@hocuspocus/transformer', () => ({
|
||||
TiptapTransformer: {
|
||||
fromYdoc: jest.fn(() => INCOMING_JSON),
|
||||
toYdoc: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Run the executeTx callback inline with a passthrough trx.
|
||||
jest.mock('@docmost/db/utils', () => ({
|
||||
executeTx: jest.fn(async (_db: any, cb: any) => cb({} as any)),
|
||||
}));
|
||||
|
||||
import * as Y from 'yjs';
|
||||
import { PersistenceExtension } from './persistence.extension';
|
||||
import {
|
||||
onChangePayload,
|
||||
onStoreDocumentPayload,
|
||||
} from '@hocuspocus/server';
|
||||
|
||||
/**
|
||||
* Provenance-precedence coverage for PersistenceExtension.onStoreDocument
|
||||
* (test-strategy Module 4 / item #2): the contract `agent > git-sync > user`,
|
||||
* plus the negative that a git-sync store does NOT pin a boundary history
|
||||
* snapshot. We drive the precedence through the real public method (onChange to
|
||||
* arm the sticky agent marker, then onStoreDocument), mocking the repos / db /
|
||||
* Yjs bridge so no real database or collab server is needed. The store's
|
||||
* persisted `lastUpdatedSource` and the saveHistory call are the observable
|
||||
* outputs.
|
||||
*/
|
||||
describe('PersistenceExtension.onStoreDocument — provenance precedence (#2)', () => {
|
||||
const DOCUMENT_NAME = 'page.page-1';
|
||||
const PAGE_ID = 'page-1';
|
||||
|
||||
// `page.content` differs from INCOMING_JSON so the write is never skipped.
|
||||
const persistedPage = (overrides?: { lastUpdatedSource?: string }) => ({
|
||||
id: PAGE_ID,
|
||||
slugId: 'slug-1',
|
||||
spaceId: 'space-1',
|
||||
workspaceId: 'ws-1',
|
||||
creatorId: 'creator-1',
|
||||
contributorIds: ['creator-1'],
|
||||
content: { type: 'doc', content: [{ type: 'paragraph', content: [] }] },
|
||||
lastUpdatedSource: overrides?.lastUpdatedSource ?? 'user',
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
const build = (pageOverrides?: { lastUpdatedSource?: string }) => {
|
||||
const pageRepo = {
|
||||
findById: jest.fn().mockResolvedValue(persistedPage(pageOverrides)),
|
||||
updatePage: jest.fn().mockResolvedValue({ numUpdatedRows: 1n }),
|
||||
};
|
||||
const pageHistoryRepo = {
|
||||
// No prior snapshot -> humanBaselineMissing is true, so the ONLY thing
|
||||
// gating the boundary snapshot in these tests is the source precedence.
|
||||
findPageLastHistory: jest.fn().mockResolvedValue(null),
|
||||
saveHistory: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const aiQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||
const historyQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||
const notificationQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||
const collabHistory = {
|
||||
addContributors: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const transclusionService = {
|
||||
syncPageTransclusions: jest.fn().mockResolvedValue(undefined),
|
||||
syncPageReferences: jest.fn().mockResolvedValue(undefined),
|
||||
syncPageTemplateReferences: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const ext = new PersistenceExtension(
|
||||
pageRepo as any,
|
||||
pageHistoryRepo as any,
|
||||
{} as any, // db
|
||||
aiQueue as any,
|
||||
historyQueue as any,
|
||||
notificationQueue as any,
|
||||
collabHistory as any,
|
||||
transclusionService as any,
|
||||
);
|
||||
|
||||
return { ext, pageRepo, pageHistoryRepo, historyQueue };
|
||||
};
|
||||
|
||||
// A real Y.Doc is required for Y.encodeStateAsUpdate(document); broadcastStateless
|
||||
// is a no-op spy. The fromYdoc bridge is mocked, so the doc's contents are
|
||||
// irrelevant to the JSON path.
|
||||
const makeStorePayload = (context: any): onStoreDocumentPayload =>
|
||||
({
|
||||
documentName: DOCUMENT_NAME,
|
||||
document: Object.assign(new Y.Doc(), {
|
||||
broadcastStateless: jest.fn(),
|
||||
}),
|
||||
context,
|
||||
}) as any;
|
||||
|
||||
const makeChangePayload = (actor: string): onChangePayload =>
|
||||
({
|
||||
documentName: DOCUMENT_NAME,
|
||||
context: { user: { id: 'user-1' }, actor },
|
||||
}) as any;
|
||||
|
||||
const sourceOf = (pageRepo: { updatePage: jest.Mock }) =>
|
||||
pageRepo.updatePage.mock.calls[0][0].lastUpdatedSource;
|
||||
|
||||
it("tags 'user' for a plain write (no agent touch, no git-sync actor)", async () => {
|
||||
const { ext, pageRepo } = build();
|
||||
|
||||
await ext.onStoreDocument(
|
||||
makeStorePayload({ user: { id: 'user-1' }, actor: 'user' }),
|
||||
);
|
||||
|
||||
expect(sourceOf(pageRepo)).toBe('user');
|
||||
});
|
||||
|
||||
it("tags 'git-sync' when the writer's actor is 'git-sync' and no agent touched the window", async () => {
|
||||
const { ext, pageRepo } = build();
|
||||
|
||||
await ext.onStoreDocument(
|
||||
makeStorePayload({ user: { id: 'svc-user' }, actor: 'git-sync' }),
|
||||
);
|
||||
|
||||
expect(sourceOf(pageRepo)).toBe('git-sync');
|
||||
});
|
||||
|
||||
it("keeps 'git-sync' for an explicit git-sync store even with a sticky agent marker (#14 loop-guard)", async () => {
|
||||
const { ext, pageRepo } = build();
|
||||
|
||||
// An agent edit landed earlier in the coalescing window (sticky marker),
|
||||
// then a git-sync writer performs the store. Red-team finding #14: an
|
||||
// EXPLICIT current-write actor is authoritative for THIS write, so the
|
||||
// store must stay 'git-sync' — otherwise the PageChangeListener loop-guard
|
||||
// (keyed on lastUpdatedSource === 'git-sync') fails to recognize git-sync's
|
||||
// own write and re-exports it. Explicit 'agent' still wins (see below); the
|
||||
// sticky marker only promotes a plain human writer to 'agent'.
|
||||
await ext.onChange(makeChangePayload('agent'));
|
||||
await ext.onStoreDocument(
|
||||
makeStorePayload({ user: { id: 'svc-user' }, actor: 'git-sync' }),
|
||||
);
|
||||
|
||||
expect(sourceOf(pageRepo)).toBe('git-sync');
|
||||
});
|
||||
|
||||
it("tags 'agent' when the storing writer itself is the agent (no prior onChange)", async () => {
|
||||
const { ext, pageRepo } = build();
|
||||
|
||||
await ext.onStoreDocument(
|
||||
makeStorePayload({ user: { id: 'agent-user' }, actor: 'agent' }),
|
||||
);
|
||||
|
||||
expect(sourceOf(pageRepo)).toBe('agent');
|
||||
});
|
||||
|
||||
// --- boundary snapshot for a git-sync store over a HUMAN baseline -----------
|
||||
// SPEC §9 observable-loss guard (bug #2): a git-sync body write is a block-level
|
||||
// 3-way merge whose same-block rule is "git wins". To keep a concurrent human
|
||||
// edit RECOVERABLE rather than silently overwritten, a git-sync store over a
|
||||
// prior NON-git-sync baseline pins that prior state to page history first —
|
||||
// exactly like the agent path. So saveHistory MUST be called here.
|
||||
it('DOES pin a boundary snapshot for a git-sync store over a prior human state', async () => {
|
||||
const { ext, pageHistoryRepo } = build({ lastUpdatedSource: 'user' });
|
||||
|
||||
await ext.onStoreDocument(
|
||||
makeStorePayload({ user: { id: 'svc-user' }, actor: 'git-sync' }),
|
||||
);
|
||||
|
||||
expect(pageHistoryRepo.saveHistory).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// --- negative: a git-sync store over a git-sync baseline does NOT re-pin -----
|
||||
// The boundary is pinned once on the transition INTO git-sync; a subsequent
|
||||
// git-sync store over an already-git-sync baseline must not churn history.
|
||||
it('does NOT re-pin a boundary snapshot for a git-sync store over a git-sync baseline', async () => {
|
||||
const { ext, pageHistoryRepo } = build({ lastUpdatedSource: 'git-sync' });
|
||||
|
||||
await ext.onStoreDocument(
|
||||
makeStorePayload({ user: { id: 'svc-user' }, actor: 'git-sync' }),
|
||||
);
|
||||
|
||||
expect(pageHistoryRepo.saveHistory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('DOES pin a boundary snapshot for an agent store over a prior human state (control)', async () => {
|
||||
// Confirms the negative above is meaningful: under the SAME mocks, an agent
|
||||
// store over a 'user' baseline DOES trigger the boundary snapshot.
|
||||
const { ext, pageHistoryRepo } = build({ lastUpdatedSource: 'user' });
|
||||
|
||||
await ext.onStoreDocument(
|
||||
makeStorePayload({ user: { id: 'agent-user' }, actor: 'agent' }),
|
||||
);
|
||||
|
||||
expect(pageHistoryRepo.saveHistory).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does NOT pin a boundary snapshot for a plain user store', async () => {
|
||||
const { ext, pageHistoryRepo } = build({ lastUpdatedSource: 'user' });
|
||||
|
||||
await ext.onStoreDocument(
|
||||
makeStorePayload({ user: { id: 'user-1' }, actor: 'user' }),
|
||||
);
|
||||
|
||||
expect(pageHistoryRepo.saveHistory).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
afterUnloadDocumentPayload,
|
||||
Extension,
|
||||
onChangePayload,
|
||||
onDisconnectPayload,
|
||||
onLoadDocumentPayload,
|
||||
onStatelessPayload,
|
||||
onStoreDocumentPayload,
|
||||
@@ -82,7 +83,17 @@ export function resolveSource(
|
||||
stickyTouched: boolean,
|
||||
contextActor?: string,
|
||||
): ProvenanceSource {
|
||||
return stickyTouched || contextActor === 'agent' ? 'agent' : 'user';
|
||||
// An EXPLICIT current-write actor is authoritative for THIS write and wins
|
||||
// over the sticky-agent fallback. Order: explicit 'agent' > explicit
|
||||
// 'git-sync' > sticky agent marker > plain human 'user'. The git-sync case
|
||||
// must NOT be masked by the sticky marker, or the PageChangeListener
|
||||
// loop-guard (which keys on lastUpdatedSource === 'git-sync') would re-export
|
||||
// git-sync's own writes (#14). Explicit agent still wins so a window that
|
||||
// mixed an agent edit stays tagged 'agent'.
|
||||
if (contextActor === 'agent') return 'agent';
|
||||
if (contextActor === 'git-sync') return 'git-sync';
|
||||
if (stickyTouched) return 'agent';
|
||||
return 'user';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -191,6 +202,40 @@ export class PersistenceExtension implements Extension {
|
||||
return new Y.Doc();
|
||||
}
|
||||
|
||||
/**
|
||||
* LOSS-ON-FAST-CLOSE FIX (QA #119). When the LAST editor disconnects, FLUSH any
|
||||
* pending (debounced) store to the DB IMMEDIATELY instead of waiting out the
|
||||
* up-to-10s `debounce` window.
|
||||
*
|
||||
* The collab server runs with `unloadImmediately: false` (collaboration.gateway),
|
||||
* so on a last-client disconnect Hocuspocus does NOT flush the debounced
|
||||
* onStoreDocument — it relies on the timer firing later. A quick edit-then-close
|
||||
* (closing the tab within the debounce window, ~3-18s) therefore left the edit
|
||||
* only in the soon-to-be-unloaded in-memory Y.Doc; meanwhile git-sync mirrored
|
||||
* the STALE/empty DB body to the vault (the reported "59-byte frontmatter-only"
|
||||
* data loss). Running the already-scheduled store now closes that window.
|
||||
*
|
||||
* Gated tightly so it never adds a redundant write: only on the LAST disconnect
|
||||
* (`clientsCount === 0`), only for a fully-loaded doc, and only when a store is
|
||||
* actually pending (`isDebounced`). `executeNow` runs the SAME payload Hocuspocus
|
||||
* scheduled (preserving the edit's context/actor) and clears the timer.
|
||||
*/
|
||||
async onDisconnect(data: onDisconnectPayload) {
|
||||
const { instance, document, documentName, clientsCount } = data;
|
||||
if (clientsCount > 0) return;
|
||||
if (!document || document.isLoading) return;
|
||||
const debounceId = `onStoreDocument-${documentName}`;
|
||||
if (!instance?.debouncer?.isDebounced(debounceId)) return;
|
||||
try {
|
||||
await instance.debouncer.executeNow(debounceId);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`onDisconnect flush failed for ${documentName}: ` +
|
||||
(err instanceof Error ? err.message : String(err)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async onStoreDocument(data: onStoreDocumentPayload) {
|
||||
const { documentName, document, context } = data;
|
||||
|
||||
@@ -213,6 +258,11 @@ export class PersistenceExtension implements Extension {
|
||||
// Sticky agent marker: 'agent' if any agent edit landed in this window, OR
|
||||
// if the current writer is the agent (covers a store with no prior onChange
|
||||
// agent event in the same window). §15 H2.
|
||||
// Provenance precedence: agent > git-sync > user (see resolveSource). A
|
||||
// 'git-sync' store is NOT given an immediate history snapshot — it is
|
||||
// debounced like a human edit (a git-sync write is a block-level merge into
|
||||
// the live doc, so it reads like an incremental human edit, not a bulk
|
||||
// import that would warrant its own immediate snapshot).
|
||||
const lastUpdatedSource = resolveSource(
|
||||
this.consumeAgentTouched(documentName),
|
||||
context?.actor,
|
||||
@@ -279,17 +329,25 @@ export class PersistenceExtension implements Extension {
|
||||
// flag via that same hoisted consume (a "cleared then retyped"
|
||||
// sequence can't leave a usable one behind).
|
||||
const incomingEmpty = isEmptyParagraphDoc(tiptapJson as any);
|
||||
// A git-sync write is authoritative and its content IS the vault file:
|
||||
// an empty incoming doc there means the user DELIBERATELY cleared the
|
||||
// page's markdown in git (there is no "transient glitch empty" for a
|
||||
// file-sourced write). Honor it, otherwise the empty-guard rejects the
|
||||
// clear, the vault ref has already advanced past the empty commit, and
|
||||
// vault<->Docmost diverge permanently (review warning). This mirrors the
|
||||
// #251 intentional-clear allowance for a different authoritative source.
|
||||
const gitSyncClear = lastUpdatedSource === 'git-sync';
|
||||
if (
|
||||
incomingEmpty &&
|
||||
page.content &&
|
||||
!isEmptyParagraphDoc(page.content as any)
|
||||
) {
|
||||
if (allowIntentionalClear) {
|
||||
if (allowIntentionalClear || gitSyncClear) {
|
||||
this.logger.debug(
|
||||
`Intentional clear for ${pageId}: persisting empty doc over ` +
|
||||
`non-empty content (user-signalled)`,
|
||||
`non-empty content (${gitSyncClear ? 'git-sync' : 'user-signalled'})`,
|
||||
);
|
||||
// fall through — the empty write is allowed exactly once.
|
||||
// fall through — the empty write is allowed.
|
||||
} else {
|
||||
this.logger.warn(
|
||||
`Skipping store for ${pageId}: empty live doc would overwrite ` +
|
||||
@@ -314,21 +372,30 @@ export class PersistenceExtension implements Extension {
|
||||
//this.logger.debug('Contributors error:' + err?.['message']);
|
||||
}
|
||||
|
||||
// Approach A — boundary snapshot before the agent's first edit.
|
||||
// When this store is the agent's and the page's currently persisted
|
||||
// state was authored by a human, pin that human state as its own
|
||||
// history version BEFORE the agent overwrites it. `page` still holds
|
||||
// the OLD content/provenance here, so saveHistory(page) captures the
|
||||
// pre-agent state tagged 'user'. The agent's new content is
|
||||
// snapshotted later by the debounced PAGE_HISTORY job ('agent'). Skip
|
||||
// if the prior state is already agent-authored (boundary already
|
||||
// pinned on the user->agent transition), if the page is effectively
|
||||
// empty, or if the latest existing snapshot already equals this human
|
||||
// state (avoid duplicates).
|
||||
if (
|
||||
lastUpdatedSource === 'agent' &&
|
||||
page.lastUpdatedSource !== 'agent'
|
||||
) {
|
||||
// Approach A — boundary snapshot before a MACHINE write overwrites a
|
||||
// human (or other-source) baseline. When this store is from a machine
|
||||
// source — the AGENT or GIT-SYNC — and the page's currently persisted
|
||||
// state was authored by a DIFFERENT source, pin that prior state as its
|
||||
// own history version BEFORE the machine write overwrites it. `page`
|
||||
// still holds the OLD content/provenance here, so saveHistory(page)
|
||||
// captures the pre-write state. The machine's new content is snapshotted
|
||||
// later by the debounced PAGE_HISTORY job.
|
||||
//
|
||||
// For GIT-SYNC this is the OBSERVABLE-LOSS guard (SPEC §9 conflict
|
||||
// contract): a git-sync body write is a block-level 3-way merge whose
|
||||
// same-block rule is "git wins". Without this pin, a concurrent human
|
||||
// edit to a block git also changed would be overwritten with NO trace.
|
||||
// Pinning the pre-merge state here means the human's content is always
|
||||
// RECOVERABLE via page history rather than silently lost — git still
|
||||
// wins the live doc deterministically, but nothing is destroyed.
|
||||
//
|
||||
// Skip if the prior state was already authored by THIS machine source
|
||||
// (boundary already pinned on the transition into it), if the page is
|
||||
// effectively empty, or if the latest existing snapshot already equals
|
||||
// the prior state (avoid duplicates).
|
||||
const isMachineWrite =
|
||||
lastUpdatedSource === 'agent' || lastUpdatedSource === 'git-sync';
|
||||
if (isMachineWrite && page.lastUpdatedSource !== lastUpdatedSource) {
|
||||
// pageHistory.pageId is uuid-typed; use page.id (never the doc-name
|
||||
// slugId) so a `page.<slugId>` doc cannot throw 22P02 here (#260).
|
||||
const lastHistory = await this.pageHistoryRepo.findPageLastHistory(
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
// Regression coverage for the custom-event request/reply protocol in the
|
||||
// RedisSyncExtension. git-sync routes its body write through a custom event
|
||||
// (`gitSyncWriteBody`) which, when the target doc is owned by a DIFFERENT collab
|
||||
// instance, runs REMOTELY inside `handleRedisMessage` on the owning instance. The
|
||||
// remote handler can THROW (markdown->ProseMirror transform on a malformed body).
|
||||
//
|
||||
// Before the fix the throw was uncaught: (1) no `customEventComplete` reply was
|
||||
// published, so the origin's awaiting promise only rejected after `customEventTTL`
|
||||
// (~30s) as a generic 'TIMEOUT', and (2) an unhandledRejection escaped the async
|
||||
// `messageBuffer` listener on the owning instance. These tests assert the throw is
|
||||
// turned into an error-carrying reply that rejects the origin PROMPTLY with the
|
||||
// real message, with the no-throw and local paths unchanged.
|
||||
|
||||
import { RedisSyncExtension } from './redis-sync.extension';
|
||||
|
||||
type Listener = (channel: Buffer, message: Buffer) => unknown;
|
||||
|
||||
// Minimal in-memory pub/sub + lock store shared across FakeRedis duplicates,
|
||||
// modelling the two-instance topology (origin + owner) over one Redis.
|
||||
class FakeRedisBus {
|
||||
instances: FakeRedis[] = [];
|
||||
locks = new Map<string, string>();
|
||||
published: { channel: string; message: Buffer }[] = [];
|
||||
|
||||
register(inst: FakeRedis) {
|
||||
this.instances.push(inst);
|
||||
}
|
||||
|
||||
publish(channel: string, message: Buffer) {
|
||||
this.published.push({ channel, message });
|
||||
for (const inst of this.instances) {
|
||||
if (!inst.subscribed.has(channel)) continue;
|
||||
for (const listener of inst.messageListeners) {
|
||||
// ioredis delivers async; `void` mirrors the production listener
|
||||
// registration (`sub.on('messageBuffer', ...)`), whose rejection would
|
||||
// surface as an unhandledRejection if the handler did not catch.
|
||||
void listener(Buffer.from(channel), message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class FakeRedis {
|
||||
subscribed = new Set<string>();
|
||||
messageListeners: Listener[] = [];
|
||||
|
||||
constructor(private bus: FakeRedisBus) {
|
||||
bus.register(this);
|
||||
}
|
||||
|
||||
duplicate() {
|
||||
return new FakeRedis(this.bus);
|
||||
}
|
||||
|
||||
subscribe(...channels: string[]) {
|
||||
for (const c of channels) this.subscribed.add(c);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
on(event: string, cb: any) {
|
||||
if (event === 'messageBuffer') this.messageListeners.push(cb as Listener);
|
||||
return this;
|
||||
}
|
||||
|
||||
publish(channel: string, message: Buffer) {
|
||||
this.bus.publish(channel, message);
|
||||
return Promise.resolve(1);
|
||||
}
|
||||
|
||||
// Models `SET key val PX ttl NX GET`: only writes when absent (NX); returns the
|
||||
// previous value (GET) so the origin observes the owner already holding the lock.
|
||||
set(key: string, val: string, ...args: any[]) {
|
||||
const hasNX = args.includes('NX');
|
||||
const hasGET = args.includes('GET');
|
||||
const old = this.bus.locks.get(key) ?? null;
|
||||
if (!hasNX || old === null) this.bus.locks.set(key, val);
|
||||
return Promise.resolve(hasGET ? old : 'OK');
|
||||
}
|
||||
|
||||
del(key: string) {
|
||||
this.bus.locks.delete(key);
|
||||
return Promise.resolve(1);
|
||||
}
|
||||
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
const pack = (m: any) => Buffer.from(JSON.stringify(m));
|
||||
const unpack = (b: Buffer) => JSON.parse(b.toString());
|
||||
|
||||
function makeExtension(
|
||||
bus: FakeRedisBus,
|
||||
serverId: string,
|
||||
customEvents: Record<string, (doc: string, payload: any) => Promise<any>>,
|
||||
) {
|
||||
const ext = new RedisSyncExtension({
|
||||
redis: new FakeRedis(bus) as any,
|
||||
pack: pack as any,
|
||||
unpack: unpack as any,
|
||||
serverId,
|
||||
customEvents: customEvents as any,
|
||||
customEventTTL: 30_000,
|
||||
});
|
||||
// Doc is NOT loaded on this instance -> handleEvent takes the remote/proxy path.
|
||||
(ext as any).instance = { documents: new Map() };
|
||||
return ext;
|
||||
}
|
||||
|
||||
describe('RedisSyncExtension custom-event error propagation', () => {
|
||||
let unhandled: unknown[];
|
||||
let onUnhandled: (e: unknown) => void;
|
||||
|
||||
beforeEach(() => {
|
||||
// Fake timers so the 30s TTL fallback timer never fires (and never dangles).
|
||||
jest.useFakeTimers();
|
||||
unhandled = [];
|
||||
onUnhandled = (e) => unhandled.push(e);
|
||||
process.on('unhandledRejection', onUnhandled);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.off('unhandledRejection', onUnhandled);
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
const flush = async () => {
|
||||
for (let i = 0; i < 10; i++) await Promise.resolve();
|
||||
};
|
||||
|
||||
it('owner publishes an error-carrying reply (no unhandledRejection) when the remote handler throws', async () => {
|
||||
const bus = new FakeRedisBus();
|
||||
const owner = makeExtension(bus, 'owner', {
|
||||
boom: async () => {
|
||||
throw new Error('kaboom');
|
||||
},
|
||||
});
|
||||
|
||||
// Drive the remote branch directly, as if the origin's customEventStart arrived.
|
||||
await (owner as any).handleRedisMessage(
|
||||
Buffer.from('collabMsg:owner'),
|
||||
pack({
|
||||
type: 'customEventStart',
|
||||
documentName: 'page.x',
|
||||
eventName: 'boom',
|
||||
payload: {},
|
||||
replyTo: 'collabMsg:origin',
|
||||
replyId: 7,
|
||||
}),
|
||||
);
|
||||
await flush();
|
||||
|
||||
const replies = bus.published
|
||||
.filter((p) => p.channel === 'collabMsg:origin')
|
||||
.map((p) => unpack(p.message));
|
||||
expect(replies).toHaveLength(1);
|
||||
expect(replies[0]).toMatchObject({
|
||||
type: 'customEventComplete',
|
||||
replyId: 7,
|
||||
error: 'kaboom',
|
||||
});
|
||||
expect(unhandled).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('origin rejects PROMPTLY with the real error (not a TTL TIMEOUT) when the remote handler throws', async () => {
|
||||
const bus = new FakeRedisBus();
|
||||
// Owner already holds the document lock.
|
||||
bus.locks.set('collabLock:page.x', 'owner');
|
||||
makeExtension(bus, 'owner', {
|
||||
boom: async () => {
|
||||
throw new Error('kaboom');
|
||||
},
|
||||
});
|
||||
const origin = makeExtension(bus, 'origin', {
|
||||
boom: async () => undefined,
|
||||
});
|
||||
|
||||
const promise = (origin as any).handleEvent('boom', 'page.x', { foo: 1 });
|
||||
// Attach a catch immediately so a rejection is never momentarily unhandled.
|
||||
const settled = promise.then(
|
||||
() => ({ ok: true as const }),
|
||||
(e: unknown) => ({ ok: false as const, error: e }),
|
||||
);
|
||||
|
||||
await flush();
|
||||
// Resolves WITHOUT advancing any timer -> the 30s TIMEOUT fallback did not fire.
|
||||
const result = await settled;
|
||||
expect(result.ok).toBe(false);
|
||||
expect((result as any).error).toBeInstanceOf(Error);
|
||||
expect(((result as any).error as Error).message).toBe('kaboom');
|
||||
expect(unhandled).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('origin resolves with the payload when the remote handler succeeds (unchanged behavior)', async () => {
|
||||
const bus = new FakeRedisBus();
|
||||
bus.locks.set('collabLock:page.x', 'owner');
|
||||
makeExtension(bus, 'owner', {
|
||||
ok: async (_doc: string, payload: any) => ({ echoed: payload }),
|
||||
});
|
||||
const origin = makeExtension(bus, 'origin', {
|
||||
ok: async () => undefined,
|
||||
});
|
||||
|
||||
const promise = (origin as any).handleEvent('ok', 'page.x', { foo: 1 });
|
||||
await flush();
|
||||
await expect(promise).resolves.toEqual({ echoed: { foo: 1 } });
|
||||
expect(unhandled).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -51,9 +51,15 @@ export class RedisSyncExtension<TCE extends CustomEvents> implements Extension {
|
||||
private instance!: Hocuspocus;
|
||||
private readonly customEvents: TCE;
|
||||
private replyIdCounter: number = 0;
|
||||
// @ts-ignore
|
||||
private pendingReplies: Record<number, PromiseWithResolvers<any>['resolve']> =
|
||||
{};
|
||||
private pendingReplies: Record<
|
||||
number,
|
||||
{
|
||||
// @ts-ignore
|
||||
resolve: PromiseWithResolvers<any>['resolve'];
|
||||
// @ts-ignore
|
||||
reject: PromiseWithResolvers<any>['reject'];
|
||||
}
|
||||
> = {};
|
||||
|
||||
constructor(configuration: Configuration<TCE>) {
|
||||
const {
|
||||
@@ -176,25 +182,45 @@ export class RedisSyncExtension<TCE extends CustomEvents> implements Extension {
|
||||
}
|
||||
if (type === 'customEventStart') {
|
||||
const { documentName, eventName, payload, replyTo, replyId } = msg;
|
||||
const res = await this.handleEventLocally(
|
||||
eventName as Extract<keyof TCE, string>,
|
||||
documentName,
|
||||
payload,
|
||||
);
|
||||
const reply: RSAMessageCustomEventComplete = {
|
||||
type: 'customEventComplete',
|
||||
replyId,
|
||||
payload: res,
|
||||
};
|
||||
let reply: RSAMessageCustomEventComplete;
|
||||
try {
|
||||
const res = await this.handleEventLocally(
|
||||
eventName as Extract<keyof TCE, string>,
|
||||
documentName,
|
||||
payload,
|
||||
);
|
||||
reply = {
|
||||
type: 'customEventComplete',
|
||||
replyId,
|
||||
payload: res,
|
||||
};
|
||||
} catch (err) {
|
||||
// The remote handler threw (e.g. the markdown->ProseMirror transform in
|
||||
// gitSyncWriteBody can throw on a malformed body). Reply with the error on
|
||||
// the SAME correlation channel so the origin rejects promptly with the real
|
||||
// message instead of waiting out customEventTTL as a generic 'TIMEOUT'.
|
||||
// Catching here also keeps the throw from escaping this async messageBuffer
|
||||
// listener as an unhandledRejection on the owning instance.
|
||||
reply = {
|
||||
type: 'customEventComplete',
|
||||
replyId,
|
||||
payload: undefined,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
this.pub.publish(`${replyTo}`, this.pack(reply));
|
||||
return;
|
||||
}
|
||||
if (type === 'customEventComplete') {
|
||||
const { replyId, payload } = msg;
|
||||
const resolveFn = this.pendingReplies[replyId];
|
||||
if (!resolveFn) return;
|
||||
const { replyId, payload, error } = msg;
|
||||
const pending = this.pendingReplies[replyId];
|
||||
if (!pending) return;
|
||||
delete this.pendingReplies[replyId];
|
||||
resolveFn(payload);
|
||||
if (error !== undefined) {
|
||||
pending.reject(new Error(error));
|
||||
} else {
|
||||
pending.resolve(payload);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const { socketId } = msg;
|
||||
@@ -273,11 +299,22 @@ export class RedisSyncExtension<TCE extends CustomEvents> implements Extension {
|
||||
};
|
||||
const msg = this.pack(proxyMessage);
|
||||
this.pub.publish(`${this.msgChannel}:${proxyTo}`, msg);
|
||||
// @ts-ignore
|
||||
const { promise, resolve, reject } = Promise.withResolvers();
|
||||
this.pendingReplies[replyId] = resolve;
|
||||
// Manual deferred (no Promise.withResolvers) so this runs on Node < 22 too.
|
||||
let resolve!: (v: unknown) => void;
|
||||
let reject!: (e: unknown) => void;
|
||||
const promise = new Promise((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
this.pendingReplies[replyId] = { resolve, reject };
|
||||
setTimeout(() => {
|
||||
reject('TIMEOUT');
|
||||
// Fallback for a genuinely lost reply. A handler that threw now rejects
|
||||
// promptly via the error-carrying customEventComplete above; this TIMEOUT
|
||||
// only fires when no reply ever comes back.
|
||||
if (this.pendingReplies[replyId]) {
|
||||
delete this.pendingReplies[replyId];
|
||||
reject('TIMEOUT');
|
||||
}
|
||||
}, this.customEventTTL);
|
||||
return promise as Promise<ReturnType<TCE[TName]>>;
|
||||
}
|
||||
|
||||
@@ -72,6 +72,10 @@ export type RSAMessageCustomEventComplete = {
|
||||
type: 'customEventComplete';
|
||||
replyId: number;
|
||||
payload: unknown;
|
||||
// When the remote handler THREW, the owner sends back the error message here
|
||||
// instead of a payload, so the origin can reject its awaiting promise promptly
|
||||
// (with the real error) rather than waiting out the customEventTTL timeout.
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type RSAMessage =
|
||||
|
||||
@@ -0,0 +1,582 @@
|
||||
/**
|
||||
* JEST CONFIG NOTE (#119 ESM refactor): this is the one spec that needs the REAL
|
||||
* `@docmost/git-sync` converter (not a mock). The package is now ESM, which jest
|
||||
* cannot `require()` nor `import()` without --experimental-vm-modules, so the
|
||||
* server jest config `moduleNameMapper`s `@docmost/git-sync` to its TS SOURCE and
|
||||
* strips the ESM `.js` import suffixes. ts-jest then type-checks that source under
|
||||
* the server's (looser) tsconfig and trips a benign narrowing; the global
|
||||
* `isolatedModules: true` on the ts-jest transform (apps/server/package.json)
|
||||
* makes it transpile-only so this spec loads. Full type-checking of the package
|
||||
* is still enforced by its own `tsc`/vitest gates and the server `tsc --noEmit`.
|
||||
*
|
||||
* §13.1 IDEMPOTENCY GATE — the blocking gate for git-sync Phase B.
|
||||
*
|
||||
* Proves the `@docmost/git-sync` pure converter is schema-compatible
|
||||
* with the server's REAL editor-ext document schema: a representative corpus of
|
||||
* editor-ext ProseMirror documents must survive a full round trip through the
|
||||
* actual server write path without losing any node / mark / attribute.
|
||||
*
|
||||
* Pipeline per document (issue #194 §13.1):
|
||||
* 1. md = convertProseMirrorToMarkdown(content) // git-sync export
|
||||
* 2. doc = await markdownToProseMirror(md) // git-sync import
|
||||
* 3. push `doc` through the REAL editor-ext Yjs write path the server uses:
|
||||
* ydoc = TiptapTransformer.toYdoc(doc, 'default', tiptapExtensions)
|
||||
* normalized = TiptapTransformer.fromYdoc(ydoc, 'default')
|
||||
* This is exactly what PersistenceExtension does on store
|
||||
* (apps/server/src/collaboration/extensions/persistence.extension.ts:96/115)
|
||||
* with the same `tiptapExtensions` (collaboration.util.ts) and the same
|
||||
* `@hocuspocus/transformer`, so the gate exercises the real schema
|
||||
* validation that runs on a git-sync write (issue #194 §3.3).
|
||||
* 4. assert docsCanonicallyEqual(canon(original), canon(normalized)) === true
|
||||
*
|
||||
* Any node / mark / attr that editor-ext drops (because the git-sync
|
||||
* docmost-schema named it differently, or declares a different default) makes
|
||||
* the gate FAIL for that document — exactly the schema-divergence issue #194 §3.3 /
|
||||
* §13.1 warn about. Genuine, irreducible divergences are isolated into the
|
||||
* clearly-named `KNOWN DIVERGENCE` block at the bottom (never silently hidden).
|
||||
*
|
||||
* Requires the workspace packages built first:
|
||||
* pnpm --filter @docmost/editor-ext build
|
||||
* pnpm --filter @docmost/git-sync build
|
||||
*/
|
||||
import { TiptapTransformer } from '@hocuspocus/transformer';
|
||||
// Import the server's real schema FIRST so `@docmost/editor-ext` resolves to its
|
||||
// built CJS `dist` (its `main`). The ESM-only `@docmost/git-sync` package is
|
||||
// mapped to its TS SOURCE by the jest `moduleNameMapper` (the built ESM cannot
|
||||
// be `require()`d nor dynamically `import()`ed under jest's node VM), so ts-jest
|
||||
// transpiles the real converter to CJS here — exercising the actual converter
|
||||
// the server ships, not a stub.
|
||||
import { tiptapExtensions } from './collaboration.util';
|
||||
import {
|
||||
convertProseMirrorToMarkdown,
|
||||
markdownToProseMirror,
|
||||
canonicalizeContent,
|
||||
docsCanonicallyEqual,
|
||||
} from '@docmost/git-sync';
|
||||
|
||||
/**
|
||||
* Run a single editor-ext document through the full gate pipeline and return
|
||||
* the canonical original vs the canonical doc as it lands after the real Yjs
|
||||
* write path, plus the intermediate markdown for diagnostics.
|
||||
*/
|
||||
async function runGate(original: any): Promise<{
|
||||
md: string;
|
||||
imported: any;
|
||||
normalized: any;
|
||||
canonOriginal: any;
|
||||
canonNormalized: any;
|
||||
}> {
|
||||
// 1) editor-ext JSON -> markdown (git-sync export).
|
||||
const md = convertProseMirrorToMarkdown(original);
|
||||
|
||||
// 2) markdown -> ProseMirror JSON (git-sync import, docmost-schema).
|
||||
const imported = await markdownToProseMirror(md);
|
||||
|
||||
// 3) push through the REAL editor-ext schema via the server's Yjs write path.
|
||||
// toYdoc validates `imported` against tiptapExtensions (throws on an
|
||||
// unknown node, drops unknown attrs); fromYdoc reads it back as the
|
||||
// normalized editor-ext JSON the server would persist.
|
||||
const ydoc = TiptapTransformer.toYdoc(imported, 'default', tiptapExtensions);
|
||||
const normalized = TiptapTransformer.fromYdoc(ydoc, 'default');
|
||||
|
||||
return {
|
||||
md,
|
||||
imported,
|
||||
normalized,
|
||||
canonOriginal: canonicalizeContent(original),
|
||||
canonNormalized: canonicalizeContent(normalized),
|
||||
};
|
||||
}
|
||||
|
||||
const doc = (...content: any[]) => ({ type: 'doc', content });
|
||||
const text = (t: string, marks?: any[]) =>
|
||||
marks ? { type: 'text', text: t, marks } : { type: 'text', text: t };
|
||||
const para = (...content: any[]) => ({ type: 'paragraph', content });
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Corpus: editor-ext ProseMirror documents covering the common node/mark types.
|
||||
// Node / mark / attr names and DEFAULTS are taken from the real schema —
|
||||
// editor-ext (packages/editor-ext/src) + the server's tiptapExtensions
|
||||
// (collaboration.util.ts) — NOT guessed. Where editor-ext materializes a
|
||||
// non-null default on import (e.g. image.align="center", callout.type, list
|
||||
// start) the fixture pre-authors that materialized value so the round trip is
|
||||
// already at its fixpoint (matches how the engine normalizes-on-write, SPEC §11).
|
||||
// ---------------------------------------------------------------------------
|
||||
const CORPUS: Record<string, any> = {
|
||||
'paragraphs + headings (h1-h3)': doc(
|
||||
{ type: 'heading', attrs: { level: 1 }, content: [text('Heading one')] },
|
||||
{ type: 'heading', attrs: { level: 2 }, content: [text('Heading two')] },
|
||||
{ type: 'heading', attrs: { level: 3 }, content: [text('Heading three')] },
|
||||
para(text('A plain paragraph of text.')),
|
||||
para(text('Second paragraph.')),
|
||||
),
|
||||
|
||||
// A non-default paragraph alignment now round-trips (item #7 fix): it exports
|
||||
// as `<p style="text-align:center">` and the schema's paragraph parseHTML
|
||||
// reads `style="text-align"` back onto `textAlign` on import, so the alignment
|
||||
// survives the full editor-ext write path. Promoted from the old KNOWN
|
||||
// DIVERGENCE block (which only heading alignment still occupies).
|
||||
'aligned paragraph (textAlign center)': doc({
|
||||
type: 'paragraph',
|
||||
attrs: { textAlign: 'center' },
|
||||
content: [text('centered')],
|
||||
}),
|
||||
|
||||
'inline marks (bold/italic/strike/code)': doc(
|
||||
para(
|
||||
text('normal '),
|
||||
text('bold', [{ type: 'bold' }]),
|
||||
text(' '),
|
||||
text('italic', [{ type: 'italic' }]),
|
||||
text(' '),
|
||||
text('struck', [{ type: 'strike' }]),
|
||||
text(' '),
|
||||
text('code', [{ type: 'code' }]),
|
||||
),
|
||||
),
|
||||
|
||||
'links': doc(
|
||||
para(
|
||||
text('see '),
|
||||
text('the site', [
|
||||
{ type: 'link', attrs: { href: 'https://example.com' } },
|
||||
]),
|
||||
text(' for more'),
|
||||
),
|
||||
),
|
||||
|
||||
'bullet list': doc({
|
||||
type: 'bulletList',
|
||||
content: [
|
||||
{ type: 'listItem', content: [para(text('first'))] },
|
||||
{ type: 'listItem', content: [para(text('second'))] },
|
||||
{ type: 'listItem', content: [para(text('third'))] },
|
||||
],
|
||||
}),
|
||||
|
||||
'ordered list': doc({
|
||||
type: 'orderedList',
|
||||
attrs: { start: 1 },
|
||||
content: [
|
||||
{ type: 'listItem', content: [para(text('one'))] },
|
||||
{ type: 'listItem', content: [para(text('two'))] },
|
||||
],
|
||||
}),
|
||||
|
||||
'task list (checkbox)': doc({
|
||||
type: 'taskList',
|
||||
content: [
|
||||
{
|
||||
type: 'taskItem',
|
||||
attrs: { checked: true },
|
||||
content: [para(text('done item'))],
|
||||
},
|
||||
{
|
||||
type: 'taskItem',
|
||||
attrs: { checked: false },
|
||||
content: [para(text('todo item'))],
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
||||
'blockquote': doc({
|
||||
type: 'blockquote',
|
||||
content: [para(text('a quoted line')), para(text('second quoted line'))],
|
||||
}),
|
||||
|
||||
'callout (info)': doc({
|
||||
type: 'callout',
|
||||
attrs: { type: 'info' },
|
||||
content: [para(text('an informational callout'))],
|
||||
}),
|
||||
|
||||
'callout (warning)': doc({
|
||||
type: 'callout',
|
||||
attrs: { type: 'warning' },
|
||||
content: [para(text('a warning callout'))],
|
||||
}),
|
||||
|
||||
'code block (with language)': doc({
|
||||
type: 'codeBlock',
|
||||
attrs: { language: 'typescript' },
|
||||
// A fenced code block's body is stored with a trailing newline (the form a
|
||||
// markdown ``` fence round-trips to: marked normalizes the code text to end
|
||||
// in "\n"). Authoring the fixture at that fixpoint mirrors how the engine
|
||||
// normalizes-on-write (SPEC §11): codeBlock + `language` round-trip exactly.
|
||||
content: [text('const a: number = 1;\nconsole.log(a);\n')],
|
||||
}),
|
||||
|
||||
'horizontal rule': doc(
|
||||
para(text('before')),
|
||||
{ type: 'horizontalRule' },
|
||||
para(text('after')),
|
||||
),
|
||||
|
||||
'table (header row + cells)': doc({
|
||||
type: 'table',
|
||||
content: [
|
||||
{
|
||||
type: 'tableRow',
|
||||
content: [
|
||||
{
|
||||
type: 'tableHeader',
|
||||
attrs: { colspan: 1, rowspan: 1, colwidth: null },
|
||||
content: [para(text('Name'))],
|
||||
},
|
||||
{
|
||||
type: 'tableHeader',
|
||||
attrs: { colspan: 1, rowspan: 1, colwidth: null },
|
||||
content: [para(text('Value'))],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'tableRow',
|
||||
content: [
|
||||
{
|
||||
type: 'tableCell',
|
||||
attrs: { colspan: 1, rowspan: 1, colwidth: null },
|
||||
content: [para(text('alpha'))],
|
||||
},
|
||||
{
|
||||
type: 'tableCell',
|
||||
attrs: { colspan: 1, rowspan: 1, colwidth: null },
|
||||
content: [para(text('1'))],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
||||
// #8 — a table with a MULTI-BLOCK cell (two paragraphs). A GFM pipe table
|
||||
// cannot hold two blocks without flattening them; the converter emits a
|
||||
// lossless HTML <table> instead, and the two blocks must survive the round trip.
|
||||
'table (multi-block cell, #8)': doc({
|
||||
type: 'table',
|
||||
content: [
|
||||
{
|
||||
type: 'tableRow',
|
||||
content: [
|
||||
{
|
||||
type: 'tableHeader',
|
||||
attrs: { colspan: 1, rowspan: 1, colwidth: null },
|
||||
content: [para(text('H'))],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'tableRow',
|
||||
content: [
|
||||
{
|
||||
type: 'tableCell',
|
||||
attrs: { colspan: 1, rowspan: 1, colwidth: null },
|
||||
content: [para(text('first')), para(text('second'))],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
||||
// #7 — a table nested inside a column. Columns render as HTML containers, and a
|
||||
// table inside one must stay an HTML <table> (a GFM pipe table cannot live
|
||||
// inside an HTML block), round-tripping without being unwrapped or lost.
|
||||
// `widthMode` is pre-authored at its materialized `normal` default (SPEC §11).
|
||||
'table inside a column (#7)': doc({
|
||||
type: 'columns',
|
||||
attrs: { layout: 'two', widthMode: 'normal' },
|
||||
content: [
|
||||
{
|
||||
type: 'column',
|
||||
content: [
|
||||
{
|
||||
type: 'table',
|
||||
content: [
|
||||
{
|
||||
type: 'tableRow',
|
||||
content: [
|
||||
{
|
||||
type: 'tableHeader',
|
||||
attrs: { colspan: 1, rowspan: 1, colwidth: null },
|
||||
content: [para(text('C7'))],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: 'column', content: [para(text('right'))] },
|
||||
],
|
||||
}),
|
||||
|
||||
// --- editor-ext nodes/marks beyond the original corpus (item #7) ----------
|
||||
// Each of these was verified to round-trip CLEANLY through the real gate
|
||||
// (export -> markdown -> import -> editor-ext Yjs write path). Fixtures are
|
||||
// pre-authored at the engine's normalize-on-write fixpoint (SPEC §11), e.g.
|
||||
// details carries the materialized `open:false`, and color marks use the
|
||||
// `rgb(...)` form the HTML re-parser normalizes to.
|
||||
|
||||
'mention (user)': doc(
|
||||
para(
|
||||
text('hi '),
|
||||
{
|
||||
type: 'mention',
|
||||
attrs: {
|
||||
id: 'user-123',
|
||||
label: 'Alice',
|
||||
entityType: 'user',
|
||||
entityId: 'user-123',
|
||||
creatorId: 'creator-1',
|
||||
},
|
||||
},
|
||||
text(' there'),
|
||||
),
|
||||
),
|
||||
|
||||
'inline math': doc(
|
||||
para(
|
||||
text('inline '),
|
||||
{ type: 'mathInline', attrs: { text: 'x^2' } },
|
||||
text(' math'),
|
||||
),
|
||||
),
|
||||
|
||||
'block math': doc({ type: 'mathBlock', attrs: { text: 'x^2 + y^2 = z^2' } }),
|
||||
|
||||
'details (collapsible)': doc({
|
||||
type: 'details',
|
||||
// `open:false` is the value editor-ext materializes on import; pre-authoring
|
||||
// it puts the fixture at its round-trip fixpoint.
|
||||
attrs: { open: false },
|
||||
content: [
|
||||
{ type: 'detailsSummary', content: [text('Summary line')] },
|
||||
{ type: 'detailsContent', content: [para(text('hidden body'))] },
|
||||
],
|
||||
}),
|
||||
|
||||
'highlight (mark, no color)': doc(
|
||||
para(
|
||||
text('a '),
|
||||
text('highlighted', [{ type: 'highlight' }]),
|
||||
text(' word'),
|
||||
),
|
||||
),
|
||||
|
||||
'highlight (mark, with color)': doc(
|
||||
para(
|
||||
text('a '),
|
||||
text('red', [{ type: 'highlight', attrs: { color: 'rgb(255, 0, 0)' } }]),
|
||||
text(' word'),
|
||||
),
|
||||
),
|
||||
|
||||
'subscript': doc(
|
||||
para(text('H'), text('2', [{ type: 'subscript' }]), text('O')),
|
||||
),
|
||||
|
||||
'superscript': doc(
|
||||
para(text('E=mc'), text('2', [{ type: 'superscript' }])),
|
||||
),
|
||||
|
||||
'text color (textStyle)': doc(
|
||||
// The HTML re-parser normalizes CSS colors to the `rgb(...)` form, so the
|
||||
// fixture pre-authors that form; a `#hex` color would round-trip to the
|
||||
// equivalent rgb() and is therefore a value-normalization divergence (see
|
||||
// the KNOWN DIVERGENCE block below).
|
||||
para(text('green', [{ type: 'textStyle', attrs: { color: 'rgb(0, 255, 0)' } }])),
|
||||
),
|
||||
|
||||
'nested / mixed document': doc(
|
||||
{ type: 'heading', attrs: { level: 1 }, content: [text('Mixed')] },
|
||||
para(
|
||||
text('intro with '),
|
||||
text('bold', [{ type: 'bold' }]),
|
||||
text(' and a '),
|
||||
text('link', [{ type: 'link', attrs: { href: 'https://example.com' } }]),
|
||||
text('.'),
|
||||
),
|
||||
{
|
||||
type: 'bulletList',
|
||||
content: [
|
||||
{
|
||||
type: 'listItem',
|
||||
content: [
|
||||
para(text('item with '), text('code', [{ type: 'code' }])),
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'listItem',
|
||||
content: [
|
||||
para(text('item with sublist')),
|
||||
{
|
||||
type: 'bulletList',
|
||||
content: [
|
||||
{ type: 'listItem', content: [para(text('nested a'))] },
|
||||
{ type: 'listItem', content: [para(text('nested b'))] },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'callout',
|
||||
attrs: { type: 'success' },
|
||||
content: [
|
||||
para(text('callout body')),
|
||||
{ type: 'codeBlock', attrs: { language: 'bash' }, content: [text('echo hi\n')] },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'blockquote',
|
||||
content: [para(text('quote at the end'))],
|
||||
},
|
||||
),
|
||||
|
||||
// Atom embeds that carry no inline text: they must round-trip via their
|
||||
// schema-matching HTML (data-type div), NOT a literal that re-imports as plain
|
||||
// text. `subpages` used to export as the literal "{{SUBPAGES}}" and came back
|
||||
// as visible text on the page (red-team round-trip data loss) — this locks it.
|
||||
// editor-ext materializes the `recursive: false` default on import, so the
|
||||
// fixture pre-authors it to sit at the round-trip fixpoint (matches the other
|
||||
// default-materializing fixtures above).
|
||||
'subpages embed': doc({ type: 'subpages', attrs: { recursive: false } }),
|
||||
};
|
||||
|
||||
describe('git-sync converter §13.1 idempotency gate (editor-ext schema)', () => {
|
||||
for (const [name, original] of Object.entries(CORPUS)) {
|
||||
it(`round-trips losslessly: ${name}`, async () => {
|
||||
const { md, canonOriginal, canonNormalized } = await runGate(original);
|
||||
|
||||
const equal = docsCanonicallyEqual(original, canonNormalized);
|
||||
if (!equal) {
|
||||
// Surface a readable diff so a real divergence is actionable.
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
`\n[GATE FAIL] ${name}\n--- markdown ---\n${md}\n` +
|
||||
`--- canonical original ---\n${JSON.stringify(canonOriginal, null, 2)}\n` +
|
||||
`--- canonical round-tripped ---\n${JSON.stringify(canonNormalized, null, 2)}\n`,
|
||||
);
|
||||
}
|
||||
expect(equal).toBe(true);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// KNOWN DIVERGENCE — images (isolated so it does NOT silently weaken the gate).
|
||||
//
|
||||
// This is NOT a schema-name divergence: the `image` NODE itself round-trips
|
||||
// through editor-ext fine (it survives toYdoc under the real tiptapExtensions).
|
||||
// The loss is intrinsic to MARKDOWN, the on-disk transport format git-sync uses:
|
||||
//
|
||||
// 1. `convertProseMirrorToMarkdown` emits a standard `` image
|
||||
// (markdown-converter.ts case "image"). Standard markdown image syntax has
|
||||
// no way to express `width` / `height` / `align`, so those attrs are
|
||||
// DROPPED on export and cannot be recovered on import.
|
||||
// 2. A block-level image is hoisted out of its line by the HTML re-parser,
|
||||
// leaving a leading EMPTY paragraph (the same block-image-hoist limitation
|
||||
// documented in packages/git-sync/test/fixtures/known-limitations).
|
||||
//
|
||||
// The gate documents the EXACT lossy shape below. If the converter is ever
|
||||
// taught to preserve image dimensions (e.g. by emitting an HTML <img> with
|
||||
// data-* attrs, as it already does for video/diagrams), these assertions flip
|
||||
// and the image fixture should be promoted into the green CORPUS above.
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('git-sync converter §13.1 image dimensions preserved (was KNOWN DIVERGENCE)', () => {
|
||||
const imageDoc = doc({
|
||||
type: 'image',
|
||||
attrs: {
|
||||
src: 'https://example.com/pic.png',
|
||||
width: 640,
|
||||
height: 480,
|
||||
align: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
it('preserves width/height/align by exporting an HTML <img> (PR #119 round-trip fix)', async () => {
|
||||
const { md, canonNormalized } = await runGate(imageDoc);
|
||||
|
||||
// A top-level image carrying layout attrs is now exported as a schema-
|
||||
// matching HTML <img> (the same path video/diagrams already use), so the
|
||||
// dimensions and alignment survive the round trip instead of collapsing to
|
||||
// bare ``.
|
||||
expect(md.trim()).toBe(
|
||||
'<img src="https://example.com/pic.png" width="640" height="480" align="center">',
|
||||
);
|
||||
|
||||
// The round-tripped image keeps src + the layout attrs. width/height are
|
||||
// re-imported as strings (matching the video/audio/pdf string convention),
|
||||
// so assert the values rather than the JS type.
|
||||
const imgAttrs = (canonNormalized as any).content[0].attrs;
|
||||
expect((canonNormalized as any).content[0].type).toBe('image');
|
||||
expect(imgAttrs.src).toBe('https://example.com/pic.png');
|
||||
expect(imgAttrs.align).toBe('center');
|
||||
expect(String(imgAttrs.width)).toBe('640');
|
||||
expect(String(imgAttrs.height)).toBe('480');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HEADING text alignment — now round-trips (item A1; formerly a KNOWN DIVERGENCE).
|
||||
// Symmetric with the paragraph fix: a heading's non-default `textAlign` is
|
||||
// exported as a styled `<hN style="text-align:…">` (was a bare ATX `## text`
|
||||
// that dropped it) and re-parsed by the heading + textAlign parseHTML on import,
|
||||
// so a non-default heading alignment SURVIVES a full round trip.
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('git-sync converter §13.1 heading text alignment round-trips', () => {
|
||||
it('preserves a heading textAlign across the markdown round trip', async () => {
|
||||
const alignedHeading = doc({
|
||||
type: 'heading',
|
||||
attrs: { level: 2, textAlign: 'center' },
|
||||
content: [text('centered heading')],
|
||||
});
|
||||
|
||||
const { md, canonNormalized } = await runGate(alignedHeading);
|
||||
|
||||
// Export is a styled <h2> (was a lossy bare `## centered heading`).
|
||||
expect(md.trim()).toBe(
|
||||
'<h2 style="text-align:center">centered heading</h2>',
|
||||
);
|
||||
expect(docsCanonicallyEqual(alignedHeading, canonNormalized)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// KNOWN DIVERGENCE — textStyle color is VALUE-NORMALIZED, not lost (item #7).
|
||||
//
|
||||
// The textStyle/color mark itself round-trips (the green CORPUS has the rgb()
|
||||
// form). But a `#hex` color is normalized to the equivalent `rgb(...)` string
|
||||
// by the HTML re-parser on import, and canonicalize.ts does NOT normalize color
|
||||
// formats — so a `#hex` original is not STRING-identical to its round trip even
|
||||
// though the color is semantically preserved. Locked here so the boundary is
|
||||
// explicit: author color fixtures in rgb() form to stay in the green corpus.
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('git-sync converter §13.1 KNOWN DIVERGENCE (textStyle color #hex -> rgb)', () => {
|
||||
it('normalizes a #hex text color to rgb() (semantically preserved, string-divergent)', async () => {
|
||||
const hexDoc = doc(
|
||||
para(text('green', [{ type: 'textStyle', attrs: { color: '#00ff00' } }])),
|
||||
);
|
||||
|
||||
const { canonNormalized } = await runGate(hexDoc);
|
||||
|
||||
// Color survives, but as the normalized rgb() string.
|
||||
expect(canonNormalized).toEqual({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'green',
|
||||
marks: [{ type: 'textStyle', attrs: { color: 'rgb(0, 255, 0)' } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
// Not string-identical to the #hex original.
|
||||
expect(docsCanonicallyEqual(hexDoc, canonNormalized)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Backward-filled LCS length table for sequences `a` and `b`: `dp[i][j]` is the
|
||||
* length of the longest common subsequence of the suffixes `a[i:]` and `b[j:]`.
|
||||
* O(n*m) time/space — fine for page block counts.
|
||||
*
|
||||
* Shared by the two-way block diff (`yjs-body-merge.diffBlocks`) and the
|
||||
* three-way merge planner (`three-way-merge.lcsPairs`) so the (identical) table
|
||||
* construction lives in ONE place; each caller does its own traceback over the
|
||||
* returned table.
|
||||
*/
|
||||
export function buildLcsTable(a: string[], b: string[]): number[][] {
|
||||
const n = a.length;
|
||||
const m = b.length;
|
||||
const dp: number[][] = Array.from({ length: n + 1 }, () =>
|
||||
new Array(m + 1).fill(0),
|
||||
);
|
||||
for (let i = n - 1; i >= 0; i--) {
|
||||
for (let j = m - 1; j >= 0; j--) {
|
||||
dp[i][j] =
|
||||
a[i] === b[j]
|
||||
? dp[i + 1][j + 1] + 1
|
||||
: Math.max(dp[i + 1][j], dp[i][j + 1]);
|
||||
}
|
||||
}
|
||||
return dp;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { diff3Plan, type Pick } from './three-way-merge';
|
||||
|
||||
// Materialize a plan into the merged key sequence for assertion.
|
||||
function apply(plan: Pick[], live: string[], target: string[]): string[] {
|
||||
return plan.map((p) => (p.src === 'live' ? live[p.index] : target[p.index]));
|
||||
}
|
||||
|
||||
const merge = (o: string[], a: string[], b: string[]): string[] =>
|
||||
apply(diff3Plan(o, a, b), a, b);
|
||||
|
||||
describe('diff3Plan red-team #9 (human edit + adjacent git insert)', () => {
|
||||
it('keeps human block-2 edit AND applies git insert of 2.5', () => {
|
||||
// base: 1 2 3
|
||||
// live: 1 H 3 (human rewrote block 2)
|
||||
// target: 1 2 2.5 3 (git inserted 2.5 after block 2)
|
||||
expect(
|
||||
merge(['1', '2', '3'], ['1', 'H', '3'], ['1', '2', '2.5', '3']),
|
||||
).toEqual(['1', 'H', '2.5', '3']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import {
|
||||
diff3Plan,
|
||||
diff3PlanWithConflicts,
|
||||
type Pick,
|
||||
} from './three-way-merge';
|
||||
|
||||
// Materialize a plan into the merged key sequence for assertion.
|
||||
function apply(plan: Pick[], live: string[], target: string[]): string[] {
|
||||
return plan.map((p) => (p.src === 'live' ? live[p.index] : target[p.index]));
|
||||
}
|
||||
|
||||
const merge = (o: string[], a: string[], b: string[]): string[] =>
|
||||
apply(diff3Plan(o, a, b), a, b);
|
||||
|
||||
describe('diff3Plan (block-level three-way merge)', () => {
|
||||
it('identical on all three sides -> unchanged (all from live)', () => {
|
||||
const plan = diff3Plan(['1', '2', '3'], ['1', '2', '3'], ['1', '2', '3']);
|
||||
expect(plan.every((p) => p.src === 'live')).toBe(true);
|
||||
expect(apply(plan, ['1', '2', '3'], ['1', '2', '3'])).toEqual(['1', '2', '3']);
|
||||
});
|
||||
|
||||
it('git changed a block the human did not -> takes git', () => {
|
||||
expect(merge(['1', '2', '3'], ['1', '2', '3'], ['1', '9', '3'])).toEqual([
|
||||
'1',
|
||||
'9',
|
||||
'3',
|
||||
]);
|
||||
});
|
||||
|
||||
it('human changed a block git did not -> KEEPS the human edit (the core 3-way win)', () => {
|
||||
expect(merge(['1', '2', '3'], ['1', 'H', '3'], ['1', '2', '3'])).toEqual([
|
||||
'1',
|
||||
'H',
|
||||
'3',
|
||||
]);
|
||||
});
|
||||
|
||||
// Bug #2 observability: diff3PlanWithConflicts reports SAME-BLOCK conflicts so
|
||||
// the caller can surface the "git wins" loss (log + history pin) instead of
|
||||
// dropping the human side silently.
|
||||
describe('diff3PlanWithConflicts (same-block conflict reporting)', () => {
|
||||
it('reports 0 conflicts when sides changed DIFFERENT blocks (clean merge)', () => {
|
||||
const r = diff3PlanWithConflicts(
|
||||
['1', '2', '3'],
|
||||
['H', '2', '3'],
|
||||
['1', '2', 'G'],
|
||||
);
|
||||
expect(r.conflicts).toBe(0);
|
||||
expect(apply(r.picks, ['H', '2', '3'], ['1', '2', 'G'])).toEqual([
|
||||
'H',
|
||||
'2',
|
||||
'G',
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports 1 conflict and git wins when BOTH rewrote the SAME block', () => {
|
||||
const r = diff3PlanWithConflicts(
|
||||
['1', '2', '3'],
|
||||
['1', 'H', '3'], // human rewrote block 2
|
||||
['1', 'G', '3'], // git rewrote block 2
|
||||
);
|
||||
expect(r.conflicts).toBe(1);
|
||||
// Git wins the contested block; the human 'H' is NOT in the picks.
|
||||
expect(apply(r.picks, ['1', 'H', '3'], ['1', 'G', '3'])).toEqual([
|
||||
'1',
|
||||
'G',
|
||||
'3',
|
||||
]);
|
||||
});
|
||||
|
||||
it('does NOT count a git-only region (no human content to lose) as a conflict', () => {
|
||||
const r = diff3PlanWithConflicts(
|
||||
['1', '2', '3'],
|
||||
['1', '2', '3'], // human unchanged
|
||||
['1', '9', '3'], // git rewrote block 2
|
||||
);
|
||||
expect(r.conflicts).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('human and git changed DIFFERENT blocks -> both preserved', () => {
|
||||
// human rewrote block 1, git rewrote block 3.
|
||||
expect(merge(['1', '2', '3'], ['H', '2', '3'], ['1', '2', 'G'])).toEqual([
|
||||
'H',
|
||||
'2',
|
||||
'G',
|
||||
]);
|
||||
});
|
||||
|
||||
it('human inserted a block AND git changed a different block -> both preserved', () => {
|
||||
expect(
|
||||
merge(['1', '2', '3'], ['1', '1.5', '2', '3'], ['1', '2', 'G']),
|
||||
).toEqual(['1', '1.5', '2', 'G']);
|
||||
});
|
||||
|
||||
it('both changed the SAME block -> conflict resolves to git', () => {
|
||||
expect(merge(['1', '2', '3'], ['1', 'H', '3'], ['1', 'G', '3'])).toEqual([
|
||||
'1',
|
||||
'G',
|
||||
'3',
|
||||
]);
|
||||
});
|
||||
|
||||
it('both made the SAME edit -> that edit (no duplication)', () => {
|
||||
expect(merge(['1', '2', '3'], ['1', 'X', '3'], ['1', 'X', '3'])).toEqual([
|
||||
'1',
|
||||
'X',
|
||||
'3',
|
||||
]);
|
||||
});
|
||||
|
||||
it('human deleted a block git left alone -> deletion preserved', () => {
|
||||
expect(merge(['1', '2', '3'], ['1', '3'], ['1', '2', '3'])).toEqual([
|
||||
'1',
|
||||
'3',
|
||||
]);
|
||||
});
|
||||
|
||||
it('git deleted a block the human left alone -> deletion applied', () => {
|
||||
expect(merge(['1', '2', '3'], ['1', '2', '3'], ['1', '3'])).toEqual([
|
||||
'1',
|
||||
'3',
|
||||
]);
|
||||
});
|
||||
|
||||
it('both deleted the same block -> gone (no conflict)', () => {
|
||||
expect(merge(['1', '2', '3'], ['1', '3'], ['1', '3'])).toEqual(['1', '3']);
|
||||
});
|
||||
|
||||
it('git appended a trailing block -> appended', () => {
|
||||
expect(merge(['1', '2'], ['1', '2'], ['1', '2', '3'])).toEqual([
|
||||
'1',
|
||||
'2',
|
||||
'3',
|
||||
]);
|
||||
});
|
||||
|
||||
it('human appended a trailing block git did not -> kept', () => {
|
||||
expect(merge(['1', '2'], ['1', '2', '3'], ['1', '2'])).toEqual([
|
||||
'1',
|
||||
'2',
|
||||
'3',
|
||||
]);
|
||||
});
|
||||
|
||||
it('empty base, git provides content (brand-new page body) -> git content', () => {
|
||||
expect(merge([], [], ['1', '2'])).toEqual(['1', '2']);
|
||||
});
|
||||
|
||||
it('git changed block 1, human edited block 3, far apart -> both kept', () => {
|
||||
expect(
|
||||
merge(
|
||||
['a', 'b', 'c', 'd', 'e'],
|
||||
['a', 'b', 'c', 'd', 'E'],
|
||||
['A', 'b', 'c', 'd', 'e'],
|
||||
),
|
||||
).toEqual(['A', 'b', 'c', 'd', 'E']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* Pure block-level THREE-WAY merge planner (diff3) over arrays of opaque block
|
||||
* keys. Used by the git-sync body write to merge an incoming git body into the
|
||||
* live page using the last-synced version as the common ancestor (review #5):
|
||||
*
|
||||
* - a block only the human changed (live != base, git == base) -> keep LIVE
|
||||
* - a block only git changed (git != base, live == base) -> take GIT
|
||||
* - a block both sides changed (a real conflict) -> GIT wins
|
||||
* - inserts/deletes from either side are preserved when unambiguous
|
||||
*
|
||||
* Content-agnostic: it works on string keys and returns the merged block order as
|
||||
* picks ({ src: 'live'|'target', index }) — the caller (the Yjs applier)
|
||||
* materializes them — so the whole algorithm is unit-testable on plain arrays.
|
||||
*
|
||||
* Algorithm: anchor on base blocks present (unchanged) in BOTH live and target
|
||||
* (their LCS-with-base intersection). Between consecutive anchors lies one region
|
||||
* the human and/or git rewrote; resolve each region three-way. Stable anchor
|
||||
* blocks are emitted from LIVE so the applier keeps the existing Yjs block
|
||||
* instances (and the human's in-flight edits) in place.
|
||||
*
|
||||
* LOCATION (deferred): this and its `lcs.ts` sibling are pure, framework-free and
|
||||
* could conceptually live in `packages/git-sync` (the engine). They are kept in
|
||||
* the server integration on purpose: `packages/git-sync` is a VENDORED engine
|
||||
* (pinned upstream, manually re-synced), so adding first-party files there
|
||||
* complicates the re-sync story, and the only consumer today is the server. Move
|
||||
* them into the engine only once the vendoring re-sync story is settled.
|
||||
*/
|
||||
|
||||
import { buildLcsTable } from './lcs';
|
||||
|
||||
/** Matched index pairs of the longest common subsequence of `a` and `b`. */
|
||||
function lcsPairs(a: string[], b: string[]): Array<[number, number]> {
|
||||
const n = a.length;
|
||||
const m = b.length;
|
||||
const dp = buildLcsTable(a, b);
|
||||
const pairs: Array<[number, number]> = [];
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < n && j < m) {
|
||||
if (a[i] === b[j]) {
|
||||
pairs.push([i, j]);
|
||||
i++;
|
||||
j++;
|
||||
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
|
||||
i++;
|
||||
} else {
|
||||
j++;
|
||||
}
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
|
||||
/** o-index -> matched index in the other side (only for LCS-matched blocks). */
|
||||
function matchMap(pairs: Array<[number, number]>): Map<number, number> {
|
||||
const m = new Map<number, number>();
|
||||
for (const [o, x] of pairs) m.set(o, x);
|
||||
return m;
|
||||
}
|
||||
|
||||
/**
|
||||
* One change `side` made to `base` within a region: base blocks `[oStart,oEnd)`
|
||||
* were replaced by the side's blocks listed in `content` (region-local indices).
|
||||
* A pure insert has `oStart === oEnd`; a pure delete has empty `content`.
|
||||
*/
|
||||
interface Hunk {
|
||||
oStart: number;
|
||||
oEnd: number;
|
||||
content: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Diff `o` against one side as a list of non-overlapping hunks (the base spans
|
||||
* the side rewrote/inserted/deleted), derived from their LCS alignment.
|
||||
*/
|
||||
function buildHunks(o: string[], side: string[]): Hunk[] {
|
||||
const pairs = lcsPairs(o, side); // [oIdx, sideIdx] kept (unchanged) blocks
|
||||
const hunks: Hunk[] = [];
|
||||
let prevO = -1;
|
||||
let prevS = -1;
|
||||
const flush = (curO: number, curS: number): void => {
|
||||
const oStart = prevO + 1;
|
||||
const oEnd = curO;
|
||||
const content: number[] = [];
|
||||
for (let s = prevS + 1; s < curS; s++) content.push(s);
|
||||
if (oEnd > oStart || content.length > 0) hunks.push({ oStart, oEnd, content });
|
||||
};
|
||||
for (const [oIdx, sIdx] of pairs) {
|
||||
flush(oIdx, sIdx);
|
||||
prevO = oIdx;
|
||||
prevS = sIdx;
|
||||
}
|
||||
flush(o.length, side.length);
|
||||
return hunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Do two hunks (one per side) touch the same base region? Pure inserts only
|
||||
* collide when nested strictly inside the other hunk's base span (or, for two
|
||||
* inserts, at the same gap); changes sitting at a shared boundary do not.
|
||||
*/
|
||||
function hunksOverlap(a: Hunk, b: Hunk): boolean {
|
||||
const aIns = a.oStart === a.oEnd;
|
||||
const bIns = b.oStart === b.oEnd;
|
||||
if (aIns && bIns) return a.oStart === b.oStart;
|
||||
if (aIns) return b.oStart < a.oStart && a.oStart < b.oEnd;
|
||||
if (bIns) return a.oStart < b.oStart && b.oStart < a.oEnd;
|
||||
return Math.max(a.oStart, b.oStart) < Math.min(a.oEnd, b.oEnd);
|
||||
}
|
||||
|
||||
interface LocalPick {
|
||||
src: 'live' | 'target';
|
||||
local: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fine-grained three-way merge of ONE inter-anchor region. Combines the human's
|
||||
* and git's NON-overlapping hunks (e.g. a human edit to one block plus a git
|
||||
* insert/delete of OTHER blocks in the same region) so neither change is lost.
|
||||
* Returns the merged region as region-local picks, or `null` when the two sides
|
||||
* changed the SAME base block — a genuine conflict the caller resolves by the
|
||||
* original all-or-nothing rule (git wins the whole region).
|
||||
*/
|
||||
function tryMergeRegion(
|
||||
o: string[],
|
||||
a: string[],
|
||||
b: string[],
|
||||
): LocalPick[] | null {
|
||||
// Agreement short-circuit (review #11). When live (a) and target (b) are
|
||||
// identical, both sides converged on the SAME result — diff3 "agreement", NOT
|
||||
// a conflict. This is the dominant echo case (live == target != base) that
|
||||
// otherwise trips the overlap check below and is logged as a false "N same-block
|
||||
// conflict(s) resolved to the git version", masking REAL data-loss signals.
|
||||
// Emit the region straight from live (which equals target); no conflict.
|
||||
if (a.length === b.length && a.every((v, i) => v === b[i])) {
|
||||
return a.map((_v, i) => ({ src: 'live', local: i }) as LocalPick);
|
||||
}
|
||||
|
||||
const aHunks = buildHunks(o, a);
|
||||
const bHunks = buildHunks(o, b);
|
||||
|
||||
// Any overlap between a human hunk and a git hunk is a real conflict; bail so
|
||||
// the caller falls back to git-wins (preserving the original behavior).
|
||||
for (const ah of aHunks) {
|
||||
for (const bh of bHunks) {
|
||||
if (hunksOverlap(ah, bh)) return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Disjoint: live index of each base block that BOTH sides kept (stable).
|
||||
const aKept = matchMap(lcsPairs(o, a)); // base index -> live index
|
||||
|
||||
const out: LocalPick[] = [];
|
||||
let pa = 0;
|
||||
let pb = 0;
|
||||
let oi = 0;
|
||||
while (oi < o.length || pa < aHunks.length || pb < bHunks.length) {
|
||||
const ah = pa < aHunks.length ? aHunks[pa] : null;
|
||||
const bh = pb < bHunks.length ? bHunks[pb] : null;
|
||||
const nextStart = Math.min(
|
||||
ah ? ah.oStart : o.length,
|
||||
bh ? bh.oStart : o.length,
|
||||
);
|
||||
|
||||
// Emit stable base blocks (kept by both) until the next hunk, from LIVE.
|
||||
while (oi < nextStart) {
|
||||
out.push({ src: 'live', local: aKept.get(oi) as number });
|
||||
oi++;
|
||||
}
|
||||
if (!ah && !bh) break;
|
||||
|
||||
// Apply the hunk at oi. When both sides act here they are disjoint, so the
|
||||
// pure-insert (oEnd === oi) is emitted before the side that consumes base oi.
|
||||
const aHere = ah !== null && ah.oStart === oi;
|
||||
const bHere = bh !== null && bh.oStart === oi;
|
||||
let useA: boolean;
|
||||
if (aHere && bHere) {
|
||||
useA = ah!.oEnd === oi; // insert side first; otherwise either order is fine
|
||||
} else {
|
||||
useA = aHere;
|
||||
}
|
||||
const h = (useA ? ah : bh) as Hunk;
|
||||
const src: 'live' | 'target' = useA ? 'live' : 'target';
|
||||
for (const idx of h.content) out.push({ src, local: idx });
|
||||
oi = h.oEnd;
|
||||
if (useA) pa++;
|
||||
else pb++;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface Pick {
|
||||
src: 'live' | 'target';
|
||||
index: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The merged block order PLUS how many regions resolved as a genuine SAME-BLOCK
|
||||
* conflict (both sides rewrote the same base block — `tryMergeRegion` returned
|
||||
* null and git won the whole region, so the live/human version of those blocks
|
||||
* is NOT in `picks`). `conflicts > 0` is the OBSERVABLE signal the caller uses to
|
||||
* surface "git won a concurrent same-block edit" (log it + pin the human
|
||||
* baseline to page history) instead of dropping the human side silently.
|
||||
*/
|
||||
export interface Diff3Result {
|
||||
picks: Pick[];
|
||||
conflicts: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Three-way merge of base `o`, live `a`, target `b` (arrays of block keys).
|
||||
* Returns the merged block order as picks from live/target. Thin wrapper over
|
||||
* `diff3PlanWithConflicts` (kept for the existing pure-array callers/tests).
|
||||
*/
|
||||
export function diff3Plan(o: string[], a: string[], b: string[]): Pick[] {
|
||||
return diff3PlanWithConflicts(o, a, b).picks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Like `diff3Plan` but also reports the SAME-BLOCK conflict count (see
|
||||
* `Diff3Result`). A region where both the human and git rewrote the same base
|
||||
* block cannot be merged automatically; the rule is deterministic — GIT WINS the
|
||||
* whole region — but the human's version of those blocks is then absent from the
|
||||
* picks, so we count it so the caller can make the loss observable/recoverable
|
||||
* rather than silent (the documented conflict contract).
|
||||
*/
|
||||
export function diff3PlanWithConflicts(
|
||||
o: string[],
|
||||
a: string[],
|
||||
b: string[],
|
||||
): Diff3Result {
|
||||
const oToA = matchMap(lcsPairs(o, a));
|
||||
const oToB = matchMap(lcsPairs(o, b));
|
||||
|
||||
const res: Pick[] = [];
|
||||
let conflicts = 0;
|
||||
let oi = 0;
|
||||
let ai = 0;
|
||||
let bi = 0;
|
||||
|
||||
for (;;) {
|
||||
// Next anchor: a base block present (unchanged) in BOTH live and target.
|
||||
let anchor = oi;
|
||||
while (anchor < o.length && !(oToA.has(anchor) && oToB.has(anchor))) {
|
||||
anchor++;
|
||||
}
|
||||
const aEnd = anchor < o.length ? (oToA.get(anchor) as number) : a.length;
|
||||
const bEnd = anchor < o.length ? (oToB.get(anchor) as number) : b.length;
|
||||
|
||||
// Resolve the region [oi,anchor) that one or both sides rewrote/inserted.
|
||||
// Try a fine-grained three-way merge first so a human block-edit survives a
|
||||
// git insert/delete of OTHER blocks in the same region; only a genuine
|
||||
// same-block conflict (null) falls back to the original git-wins rule.
|
||||
const merged = tryMergeRegion(
|
||||
o.slice(oi, anchor),
|
||||
a.slice(ai, aEnd),
|
||||
b.slice(bi, bEnd),
|
||||
);
|
||||
if (merged) {
|
||||
for (const p of merged) {
|
||||
res.push(
|
||||
p.src === 'live'
|
||||
? { src: 'live', index: ai + p.local }
|
||||
: { src: 'target', index: bi + p.local },
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// SAME-BLOCK CONFLICT: count it ONLY when the human side actually had
|
||||
// content in this region that git's win discards (live region non-empty).
|
||||
// A region only git rewrote (live region empty) is not a human loss.
|
||||
if (aEnd > ai) conflicts++;
|
||||
for (let k = bi; k < bEnd; k++) res.push({ src: 'target', index: k });
|
||||
}
|
||||
|
||||
if (anchor >= o.length) break;
|
||||
|
||||
// Emit the stable anchor block from LIVE, then advance past it on all sides.
|
||||
res.push({ src: 'live', index: aEnd });
|
||||
ai = aEnd + 1;
|
||||
bi = bEnd + 1;
|
||||
oi = anchor + 1;
|
||||
}
|
||||
|
||||
return { picks: res, conflicts };
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { TiptapTransformer } from '@hocuspocus/transformer';
|
||||
import * as Y from 'yjs';
|
||||
import {
|
||||
markdownToProseMirror,
|
||||
convertProseMirrorToMarkdown,
|
||||
} from '@docmost/git-sync';
|
||||
|
||||
import { tiptapExtensions } from '../collaboration.util';
|
||||
import { mergeXmlFragments, mergeXmlFragments3Way } from './yjs-body-merge';
|
||||
|
||||
/**
|
||||
* Regression for the QA #119 callout findings (body-duplication re-verify +
|
||||
* "callout strips the whole body"). These reproduce the ACTUAL live merge path:
|
||||
*
|
||||
* live = TiptapTransformer.toYdoc(editor JSON, tiptapExtensions) (the
|
||||
* collaboration server's materialization — schema defaults stamped)
|
||||
* git = toYdoc(markdownToProseMirror(convertProseMirrorToMarkdown(editor)))
|
||||
* (the engine round-trip the push side feeds into writePageBody)
|
||||
*
|
||||
* A page containing a callout (with a neighbouring heading + paragraphs) must:
|
||||
* - merge with ZERO ops on an unchanged resync (no duplication — bug #1), and
|
||||
* - NEVER lose blocks / collapse to empty (no strip — bug #2),
|
||||
* across repeated cycles, for every editor-canonical callout type.
|
||||
*/
|
||||
|
||||
const toYdoc = (content: unknown[]) =>
|
||||
TiptapTransformer.toYdoc(
|
||||
{ type: 'doc', content },
|
||||
'default',
|
||||
tiptapExtensions as any,
|
||||
);
|
||||
|
||||
const blockTypes = (f: Y.XmlFragment) =>
|
||||
f.toArray().map((n: any) => n.nodeName);
|
||||
|
||||
function editorPage(calloutType: string) {
|
||||
return [
|
||||
{
|
||||
type: 'heading',
|
||||
attrs: { id: 'h1', level: 1 },
|
||||
content: [{ type: 'text', text: 'Title here' }],
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
attrs: { id: 'p1' },
|
||||
content: [{ type: 'text', text: 'Para before callout' }],
|
||||
},
|
||||
{
|
||||
type: 'callout',
|
||||
attrs: { type: calloutType },
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
attrs: { id: 'pc' },
|
||||
content: [{ type: 'text', text: 'Inside the callout' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
attrs: { id: 'p2' },
|
||||
content: [{ type: 'text', text: 'Para after callout' }],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async function gitRoundTrip(content: unknown[]): Promise<any[]> {
|
||||
const md = await convertProseMirrorToMarkdown({ type: 'doc', content });
|
||||
const json = await markdownToProseMirror(md);
|
||||
return json.content;
|
||||
}
|
||||
|
||||
describe('git-sync callout merge is idempotent + non-destructive (QA #119)', () => {
|
||||
for (const type of ['info', 'note', 'warning', 'danger', 'success', 'default']) {
|
||||
it(`callout(${type}) resyncs with 0 ops and never strips the body`, async () => {
|
||||
const editor = editorPage(type);
|
||||
const gitContent = await gitRoundTrip(editor);
|
||||
|
||||
const liveDoc = toYdoc(editor);
|
||||
const live = liveDoc.getXmlFragment('default');
|
||||
const before = live.toArray().length;
|
||||
expect(before).toBe(4);
|
||||
|
||||
// 2-way: live vs the git round-trip -> no-op (no dup, no strip).
|
||||
let applied = -1;
|
||||
liveDoc.transact(() => {
|
||||
applied = mergeXmlFragments(live, toYdoc(gitContent).getXmlFragment('default'));
|
||||
});
|
||||
expect(applied).toBe(0);
|
||||
expect(live.toArray().length).toBe(before);
|
||||
|
||||
// 3-way across 4 cycles with base == git (the steady-state) -> stable.
|
||||
for (let cycle = 0; cycle < 4; cycle++) {
|
||||
let a = -1;
|
||||
liveDoc.transact(() => {
|
||||
a = mergeXmlFragments3Way(
|
||||
live,
|
||||
toYdoc(gitContent).getXmlFragment('default'),
|
||||
toYdoc(gitContent).getXmlFragment('default'),
|
||||
);
|
||||
});
|
||||
expect(a).toBe(0);
|
||||
expect(live.toArray().length).toBe(before);
|
||||
expect(blockTypes(live)).toEqual([
|
||||
'heading',
|
||||
'paragraph',
|
||||
'callout',
|
||||
'paragraph',
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it('3-way with a stale base (callout JUST added) keeps the callout + neighbours', async () => {
|
||||
// base = the previously-synced version WITHOUT the callout (git round-trip);
|
||||
// the human just inserted the callout -> the merge must KEEP everything.
|
||||
const prev = [
|
||||
{ type: 'heading', attrs: { id: 'h1', level: 1 }, content: [{ type: 'text', text: 'Title here' }] },
|
||||
{ type: 'paragraph', attrs: { id: 'p1' }, content: [{ type: 'text', text: 'Para before callout' }] },
|
||||
{ type: 'paragraph', attrs: { id: 'p2' }, content: [{ type: 'text', text: 'Para after callout' }] },
|
||||
];
|
||||
const editor = editorPage('info');
|
||||
const baseContent = await gitRoundTrip(prev);
|
||||
const gitContent = await gitRoundTrip(editor);
|
||||
|
||||
const liveDoc = toYdoc(editor);
|
||||
const live = liveDoc.getXmlFragment('default');
|
||||
liveDoc.transact(() => {
|
||||
mergeXmlFragments3Way(
|
||||
live,
|
||||
toYdoc(gitContent).getXmlFragment('default'),
|
||||
toYdoc(baseContent).getXmlFragment('default'),
|
||||
);
|
||||
});
|
||||
// Body survives in full — NOT stripped to empty / a lone paragraph.
|
||||
expect(blockTypes(live)).toEqual([
|
||||
'heading',
|
||||
'paragraph',
|
||||
'callout',
|
||||
'paragraph',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('git-sync callout type fidelity (QA "callout type -> [!info]")', () => {
|
||||
for (const type of ['info', 'note', 'warning', 'danger', 'success', 'default']) {
|
||||
it(`preserves callout type "${type}" across the engine round-trip`, async () => {
|
||||
const content = editorPage(type);
|
||||
const gitContent = await gitRoundTrip(content);
|
||||
const co = gitContent.find((b: any) => b.type === 'callout');
|
||||
expect(co?.attrs?.type).toBe(type);
|
||||
});
|
||||
}
|
||||
|
||||
it('maps a known GitHub/Obsidian alias to the editor banner (tip -> success)', async () => {
|
||||
// `tip` is not a schema callout type — it is an input alias the editor itself
|
||||
// maps onto the supported set (GITHUB_ALERT_TYPE_MAP: tip -> success). git-sync
|
||||
// mirrors that so the ingest lands on the closest banner instead of flatly info.
|
||||
const content = editorPage('tip');
|
||||
const gitContent = await gitRoundTrip(content);
|
||||
const co = gitContent.find((b: any) => b.type === 'callout');
|
||||
expect(co?.attrs?.type).toBe('success');
|
||||
});
|
||||
|
||||
it('flattens a genuinely unknown callout type to info', async () => {
|
||||
const content = editorPage('banana'); // not a type and not a known alias
|
||||
const gitContent = await gitRoundTrip(content);
|
||||
const co = gitContent.find((b: any) => b.type === 'callout');
|
||||
expect(co?.attrs?.type).toBe('info');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import { mergeXmlFragments, mergeXmlFragments3Way } from './yjs-body-merge';
|
||||
|
||||
/**
|
||||
* Regression for the HIGH-severity runaway whole-body duplication: a page body
|
||||
* was RE-APPENDED in full on every git-sync reconcile cycle, unbounded, with NO
|
||||
* client connected.
|
||||
*
|
||||
* ROOT CAUSE (confirmed in-process against the real failing page): the LIVE Yjs
|
||||
* document materializes the editor-schema default `indent: 0` on every
|
||||
* paragraph/heading (and on the paragraph inside every list item, callout, and
|
||||
* table cell), but a body re-imported from git — parsed from clean markdown —
|
||||
* carries NO indent attribute. So every live block's comparison key differed from
|
||||
* the same block coming back from git; the three-way merge could anchor on
|
||||
* NOTHING, and the trailing unit that git's export already contained (but the
|
||||
* merge could not match against the byte-identical live tail) was re-appended
|
||||
* each cycle. Each grown export then diverged from the last-pushed base by one
|
||||
* more unit — a self-sustaining loop.
|
||||
*
|
||||
* The fix normalizes the materialized default (`indent: 0`) out of the block key
|
||||
* (the schema-derived `serializeXmlNode` normalization in yjs-body-merge.ts drops
|
||||
* every attr equal to its ProseMirror-schema default; `indent: 0` is one such),
|
||||
* so a live block compares equal to its git-round-tripped twin and the resync is
|
||||
* a true no-op. The sibling `yjs-body-merge.schema-defaults.spec.ts` covers the
|
||||
* rest of the bug class (image.align, link mark internal, …).
|
||||
*
|
||||
* These tests model that EXACTLY at the Yjs level: a LIVE fragment whose blocks
|
||||
* carry `indent: 0` + block ids, versus a git-derived fragment of the SAME
|
||||
* content with neither — for a body built from BYTE-IDENTICAL units that each
|
||||
* contain a heading, a paragraph, a callout, and a table with empty cells (the
|
||||
* trigger). RED before the fix (the merge applies > 0 ops and the body grows),
|
||||
* GREEN after (0 ops, no growth).
|
||||
*/
|
||||
|
||||
type Attrs = Record<string, string | number>;
|
||||
|
||||
function el(
|
||||
name: string,
|
||||
attrs: Attrs,
|
||||
children: (Y.XmlElement | Y.XmlText)[],
|
||||
) {
|
||||
const e = new Y.XmlElement(name);
|
||||
for (const [k, v] of Object.entries(attrs)) e.setAttribute(k, v as string);
|
||||
if (children.length) e.insert(0, children);
|
||||
return e;
|
||||
}
|
||||
|
||||
function text(s: string): Y.XmlText {
|
||||
const t = new Y.XmlText();
|
||||
if (s) t.insert(0, s);
|
||||
return t;
|
||||
}
|
||||
|
||||
/**
|
||||
* One byte-identical content unit (heading / paragraph / callout / table-with-
|
||||
* empty-cells). `live` toggles the two things that exist ONLY in the live Yjs
|
||||
* doc and NOT in a git round-trip: the materialized `indent: 0` default and the
|
||||
* per-block `id`. `n` makes each unit's ids unique (as the editor would stamp)
|
||||
* while keeping the visible CONTENT byte-identical across units.
|
||||
*/
|
||||
function unit(
|
||||
live: boolean,
|
||||
n: number,
|
||||
headingText = 'Big Heading',
|
||||
): Y.XmlElement[] {
|
||||
const ind: Attrs = live ? { indent: 0 } : {};
|
||||
const id = (base: string): Attrs => (live ? { id: `${base}${n}` } : {});
|
||||
const para = (attrs: Attrs, s: string) =>
|
||||
el('paragraph', { ...attrs, ...ind }, [text(s)]);
|
||||
|
||||
const cell = (name: string) =>
|
||||
el(name, { colspan: 1, rowspan: 1 }, [para({}, '')]);
|
||||
|
||||
return [
|
||||
el('heading', { ...id('h'), level: 1, ...ind }, [text(headingText)]),
|
||||
para(id('p'), 'Para with the same words'),
|
||||
el('callout', { type: 'info' }, [para(id('c'), 'CalloutText here')]),
|
||||
el('table', {}, [
|
||||
el('tableRow', {}, [cell('tableHeader'), cell('tableHeader')]),
|
||||
el('tableRow', {}, [cell('tableCell'), cell('tableCell')]),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
function fragmentOf(units: Y.XmlElement[][]): {
|
||||
doc: Y.Doc;
|
||||
frag: Y.XmlFragment;
|
||||
} {
|
||||
const doc = new Y.Doc();
|
||||
const frag = doc.getXmlFragment('default');
|
||||
const blocks = units.flat();
|
||||
if (blocks.length) frag.insert(0, blocks);
|
||||
return { doc, frag };
|
||||
}
|
||||
|
||||
const blockCount = (frag: Y.XmlFragment): number => frag.toArray().length;
|
||||
|
||||
describe('git-sync reconcile import is idempotent (no whole-body duplication)', () => {
|
||||
const UNITS = 3;
|
||||
|
||||
it('3-way: identical content, live carries indent:0, base stale-by-one -> 0 ops, no growth', () => {
|
||||
// LIVE: the editor-stamped Yjs doc (indent:0 + ids on every block).
|
||||
const { doc: liveDoc, frag: live } = fragmentOf(
|
||||
Array.from({ length: UNITS }, (_, i) => unit(true, i)),
|
||||
);
|
||||
// INCOMING (git export -> re-import): same content, NO indent / ids.
|
||||
const { frag: incoming } = fragmentOf(
|
||||
Array.from({ length: UNITS }, (_, i) => unit(false, i)),
|
||||
);
|
||||
// BASE = last-pushed file, lagging by ONE unit (the realistic divergence
|
||||
// that drives the trailing insert-vs-insert).
|
||||
const { frag: base } = fragmentOf(
|
||||
Array.from({ length: UNITS - 1 }, (_, i) => unit(false, i)),
|
||||
);
|
||||
|
||||
const before = blockCount(live);
|
||||
let applied = -1;
|
||||
liveDoc.transact(() => {
|
||||
applied = mergeXmlFragments3Way(live, incoming, base);
|
||||
});
|
||||
|
||||
expect(applied).toBe(0);
|
||||
expect(blockCount(live)).toBe(before);
|
||||
});
|
||||
|
||||
it('3-way is a fixpoint across repeated cycles (does not grow)', () => {
|
||||
const { doc: liveDoc, frag: live } = fragmentOf(
|
||||
Array.from({ length: UNITS }, (_, i) => unit(true, i)),
|
||||
);
|
||||
const incomingUnits = () =>
|
||||
fragmentOf(Array.from({ length: UNITS }, (_, i) => unit(false, i))).frag;
|
||||
const baseUnits = () =>
|
||||
fragmentOf(Array.from({ length: UNITS - 1 }, (_, i) => unit(false, i)))
|
||||
.frag;
|
||||
|
||||
const before = blockCount(live);
|
||||
for (let cycle = 0; cycle < 5; cycle++) {
|
||||
let applied = -1;
|
||||
liveDoc.transact(() => {
|
||||
applied = mergeXmlFragments3Way(live, incomingUnits(), baseUnits());
|
||||
});
|
||||
expect(applied).toBe(0);
|
||||
expect(blockCount(live)).toBe(before);
|
||||
}
|
||||
});
|
||||
|
||||
it('2-way: identical content, live carries indent:0 -> 0 ops, no growth', () => {
|
||||
const { doc: liveDoc, frag: live } = fragmentOf(
|
||||
Array.from({ length: UNITS }, (_, i) => unit(true, i)),
|
||||
);
|
||||
const { frag: incoming } = fragmentOf(
|
||||
Array.from({ length: UNITS }, (_, i) => unit(false, i)),
|
||||
);
|
||||
|
||||
const before = blockCount(live);
|
||||
let applied = -1;
|
||||
liveDoc.transact(() => {
|
||||
applied = mergeXmlFragments(live, incoming);
|
||||
});
|
||||
|
||||
expect(applied).toBe(0);
|
||||
expect(blockCount(live)).toBe(before);
|
||||
});
|
||||
|
||||
it('does NOT regress real edits: a git change to one block still lands', () => {
|
||||
const { doc: liveDoc, frag: live } = fragmentOf(
|
||||
Array.from({ length: UNITS }, (_, i) => unit(true, i)),
|
||||
);
|
||||
const base = fragmentOf(
|
||||
Array.from({ length: UNITS }, (_, i) => unit(false, i)),
|
||||
).frag;
|
||||
// git edits the heading text of the LAST unit.
|
||||
const incoming = fragmentOf(
|
||||
Array.from({ length: UNITS }, (_, i) =>
|
||||
unit(false, i, i === UNITS - 1 ? 'EDITED Heading' : 'Big Heading'),
|
||||
),
|
||||
).frag;
|
||||
|
||||
const before = blockCount(live);
|
||||
liveDoc.transact(() => {
|
||||
mergeXmlFragments3Way(live, incoming, base);
|
||||
});
|
||||
|
||||
// The edit landed, and the body did NOT grow (one block changed in place).
|
||||
const headings = live
|
||||
.toArray()
|
||||
.filter((b) => (b as Y.XmlElement).nodeName === 'heading')
|
||||
.map((b) =>
|
||||
(b as Y.XmlElement)
|
||||
.toArray()
|
||||
.map((c) => (c as Y.XmlText).toString())
|
||||
.join(''),
|
||||
);
|
||||
expect(headings).toContain('EDITED Heading');
|
||||
expect(blockCount(live)).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,316 @@
|
||||
import { TiptapTransformer } from '@hocuspocus/transformer';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import { tiptapExtensions } from '../collaboration.util';
|
||||
import { mergeXmlFragments, mergeXmlFragments3Way } from './yjs-body-merge';
|
||||
|
||||
/**
|
||||
* Regression for the BUG CLASS behind the runaway whole-body duplication: the
|
||||
* point-fix (7a7b840e) only normalized `indent: 0`, but the SAME divergence
|
||||
* recurs for every attribute whose editor-ext (server) schema default the live
|
||||
* Yjs doc MATERIALIZES while the git round-trip — which comes through the engine
|
||||
* schema (different, usually null, defaults) plus `y-prosemirror`'s null-attr
|
||||
* dropping — does NOT carry. Confirmed triggers beyond `indent`:
|
||||
*
|
||||
* - `image.align` : editor-ext default "center" (materialized) vs engine
|
||||
* default null (dropped) -> element-attr divergence.
|
||||
* - link mark `internal`: editor-ext default false (materialized) vs engine
|
||||
* default null -> MARK-attr divergence (the prior denylist
|
||||
* could not reach marks at all — they are serialized raw in
|
||||
* the XmlText delta).
|
||||
*
|
||||
* `highlight.colorName` is normalized too (defense-in-depth); it is NOT a strong
|
||||
* real-world trigger because BOTH schemas default it to null, but the schema-
|
||||
* derived normalization handles it for free and stays idempotent.
|
||||
*
|
||||
* The fix derives the defaults from the ACTUAL ProseMirror schema (getSchema of
|
||||
* the server tiptapExtensions) and drops any element- OR mark-attribute equal to
|
||||
* its schema default (or null/undefined) from the block comparison key — so a
|
||||
* live block compares equal to its git-round-tripped twin and an unchanged
|
||||
* resync applies 0 ops. RED before the fix (keys diverge -> ops > 0 / growth),
|
||||
* GREEN after.
|
||||
*/
|
||||
|
||||
type Attrs = Record<string, unknown>;
|
||||
|
||||
function el(
|
||||
name: string,
|
||||
attrs: Attrs,
|
||||
children: (Y.XmlElement | Y.XmlText)[],
|
||||
): Y.XmlElement {
|
||||
const e = new Y.XmlElement(name);
|
||||
for (const [k, v] of Object.entries(attrs)) e.setAttribute(k, v as string);
|
||||
if (children.length) e.insert(0, children);
|
||||
return e;
|
||||
}
|
||||
|
||||
/** Text carrying marks, as the live Yjs doc stores them (XmlText format ops). */
|
||||
function markedText(s: string, marks: Record<string, unknown>): Y.XmlText {
|
||||
const t = new Y.XmlText();
|
||||
t.insert(0, s, marks);
|
||||
return t;
|
||||
}
|
||||
|
||||
/**
|
||||
* One byte-identical RICH unit: a paragraph with a LINK, a top-level IMAGE, and
|
||||
* a paragraph with a HIGHLIGHT. `live` toggles exactly what the editor
|
||||
* materializes but a git round-trip does not: block `id`, `indent: 0`,
|
||||
* `image.align: "center"`, the link mark's `internal: false`, and the
|
||||
* highlight's `colorName: null`.
|
||||
*/
|
||||
function richUnit(live: boolean, n: number): Y.XmlElement[] {
|
||||
const ind: Attrs = live ? { indent: 0 } : {};
|
||||
const id = (base: string): Attrs => (live ? { id: `${base}${n}` } : {});
|
||||
|
||||
const linkMarks = live
|
||||
? {
|
||||
link: {
|
||||
href: 'https://example.com',
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer nofollow',
|
||||
class: null,
|
||||
title: null,
|
||||
internal: false, // editor-ext default, materialized
|
||||
},
|
||||
}
|
||||
: {
|
||||
link: {
|
||||
href: 'https://example.com',
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer nofollow',
|
||||
internal: null, // engine default
|
||||
},
|
||||
};
|
||||
|
||||
const hlMarks = live
|
||||
? { highlight: { color: '#ffd43b', colorName: null } }
|
||||
: { highlight: { color: '#ffd43b' } };
|
||||
|
||||
const imageAttrs: Attrs = live
|
||||
? { src: 'https://img.example.com/a.png', align: 'center' } // materialized
|
||||
: { src: 'https://img.example.com/a.png' }; // align:null dropped on git side
|
||||
|
||||
return [
|
||||
el('paragraph', { ...id('lp'), ...ind }, [
|
||||
markedText('click here', linkMarks),
|
||||
]),
|
||||
el('image', imageAttrs, []),
|
||||
el('paragraph', { ...id('hp'), ...ind }, [markedText('hot', hlMarks)]),
|
||||
];
|
||||
}
|
||||
|
||||
function fragmentOf(units: Y.XmlElement[][]): {
|
||||
doc: Y.Doc;
|
||||
frag: Y.XmlFragment;
|
||||
} {
|
||||
const doc = new Y.Doc();
|
||||
const frag = doc.getXmlFragment('default');
|
||||
const blocks = units.flat();
|
||||
if (blocks.length) frag.insert(0, blocks);
|
||||
return { doc, frag };
|
||||
}
|
||||
|
||||
const blockCount = (frag: Y.XmlFragment): number => frag.toArray().length;
|
||||
|
||||
describe('git-sync reconcile is idempotent for schema-default attrs (image/link/highlight)', () => {
|
||||
const UNITS = 3;
|
||||
|
||||
it('3-way: live carries image.align/link.internal/indent defaults, base stale-by-one -> 0 ops', () => {
|
||||
const { doc: liveDoc, frag: live } = fragmentOf(
|
||||
Array.from({ length: UNITS }, (_, i) => richUnit(true, i)),
|
||||
);
|
||||
const { frag: incoming } = fragmentOf(
|
||||
Array.from({ length: UNITS }, (_, i) => richUnit(false, i)),
|
||||
);
|
||||
const { frag: base } = fragmentOf(
|
||||
Array.from({ length: UNITS - 1 }, (_, i) => richUnit(false, i)),
|
||||
);
|
||||
|
||||
const before = blockCount(live);
|
||||
let applied = -1;
|
||||
liveDoc.transact(() => {
|
||||
applied = mergeXmlFragments3Way(live, incoming, base);
|
||||
});
|
||||
|
||||
expect(applied).toBe(0);
|
||||
expect(blockCount(live)).toBe(before);
|
||||
});
|
||||
|
||||
it('2-way: live carries the materialized defaults -> 0 ops, no growth', () => {
|
||||
const { doc: liveDoc, frag: live } = fragmentOf(
|
||||
Array.from({ length: UNITS }, (_, i) => richUnit(true, i)),
|
||||
);
|
||||
const { frag: incoming } = fragmentOf(
|
||||
Array.from({ length: UNITS }, (_, i) => richUnit(false, i)),
|
||||
);
|
||||
|
||||
const before = blockCount(live);
|
||||
let applied = -1;
|
||||
liveDoc.transact(() => {
|
||||
applied = mergeXmlFragments(live, incoming);
|
||||
});
|
||||
|
||||
expect(applied).toBe(0);
|
||||
expect(blockCount(live)).toBe(before);
|
||||
});
|
||||
|
||||
it('is a fixpoint across repeated cycles (does not grow)', () => {
|
||||
const { doc: liveDoc, frag: live } = fragmentOf(
|
||||
Array.from({ length: UNITS }, (_, i) => richUnit(true, i)),
|
||||
);
|
||||
const incoming = () =>
|
||||
fragmentOf(Array.from({ length: UNITS }, (_, i) => richUnit(false, i)))
|
||||
.frag;
|
||||
const base = () =>
|
||||
fragmentOf(
|
||||
Array.from({ length: UNITS - 1 }, (_, i) => richUnit(false, i)),
|
||||
).frag;
|
||||
|
||||
const before = blockCount(live);
|
||||
for (let cycle = 0; cycle < 5; cycle++) {
|
||||
let applied = -1;
|
||||
liveDoc.transact(() => {
|
||||
applied = mergeXmlFragments3Way(live, incoming(), base());
|
||||
});
|
||||
expect(applied).toBe(0);
|
||||
expect(blockCount(live)).toBe(before);
|
||||
}
|
||||
});
|
||||
|
||||
it('does NOT regress a genuine non-default value (a real link.href / image.align:left still diffs)', () => {
|
||||
const { doc: liveDoc, frag: live } = fragmentOf([richUnit(true, 0)]);
|
||||
const base = fragmentOf([richUnit(false, 0)]).frag;
|
||||
// git genuinely changes the image alignment to a NON-default value.
|
||||
const incomingUnit = richUnit(false, 0);
|
||||
(incomingUnit[1] as Y.XmlElement).setAttribute('align', 'left');
|
||||
const incoming = fragmentOf([incomingUnit]).frag;
|
||||
|
||||
liveDoc.transact(() => {
|
||||
mergeXmlFragments3Way(live, incoming, base);
|
||||
});
|
||||
|
||||
const img = live
|
||||
.toArray()
|
||||
.find((b) => (b as Y.XmlElement).nodeName === 'image') as Y.XmlElement;
|
||||
expect(img.getAttribute('align')).toBe('left');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* FAITHFUL end-to-end proof through the REAL server transformer: build the live
|
||||
* doc the way the collaboration server does (defaults omitted in the JSON ->
|
||||
* TiptapTransformer.toYdoc MATERIALIZES image.align:"center", link.internal:false,
|
||||
* indent:0) versus the git-derived doc (engine-style: defaults emitted as
|
||||
* explicit null, no block ids). An unchanged resync must apply 0 ops.
|
||||
*/
|
||||
describe('git-sync reconcile is idempotent through the real toYdoc materialization', () => {
|
||||
const liveContent = [
|
||||
{
|
||||
type: 'paragraph',
|
||||
attrs: { id: 'p1' },
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'click here',
|
||||
marks: [{ type: 'link', attrs: { href: 'https://example.com' } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: 'image', attrs: { src: 'https://img.example.com/a.png' } },
|
||||
{
|
||||
type: 'paragraph',
|
||||
attrs: { id: 'p2' },
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'hot',
|
||||
marks: [{ type: 'highlight', attrs: { color: '#ffd43b' } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// git/engine-style: explicit nulls for the engine-default attrs, no ids.
|
||||
const gitContent = [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'click here',
|
||||
marks: [
|
||||
{
|
||||
type: 'link',
|
||||
attrs: {
|
||||
href: 'https://example.com',
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer nofollow',
|
||||
class: null,
|
||||
title: null,
|
||||
internal: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'image',
|
||||
attrs: { src: 'https://img.example.com/a.png', align: null },
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'hot',
|
||||
marks: [
|
||||
{ type: 'highlight', attrs: { color: '#ffd43b', colorName: null } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const toYdoc = (content: unknown[]) =>
|
||||
TiptapTransformer.toYdoc(
|
||||
{ type: 'doc', content },
|
||||
'default',
|
||||
tiptapExtensions as any,
|
||||
);
|
||||
|
||||
it('3-way: materialized-default live vs engine-style git, base stale-by-one -> 0 ops', () => {
|
||||
const liveDoc = toYdoc(liveContent);
|
||||
const targetDoc = toYdoc(gitContent);
|
||||
const baseDoc = toYdoc(gitContent.slice(0, gitContent.length - 1));
|
||||
|
||||
const live = liveDoc.getXmlFragment('default');
|
||||
const before = live.toArray().length;
|
||||
let applied = -1;
|
||||
liveDoc.transact(() => {
|
||||
applied = mergeXmlFragments3Way(
|
||||
live,
|
||||
targetDoc.getXmlFragment('default'),
|
||||
baseDoc.getXmlFragment('default'),
|
||||
);
|
||||
});
|
||||
|
||||
expect(applied).toBe(0);
|
||||
expect(live.toArray().length).toBe(before);
|
||||
});
|
||||
|
||||
it('2-way: materialized-default live vs engine-style git -> 0 ops', () => {
|
||||
const liveDoc = toYdoc(liveContent);
|
||||
const targetDoc = toYdoc(gitContent);
|
||||
|
||||
const live = liveDoc.getXmlFragment('default');
|
||||
const before = live.toArray().length;
|
||||
let applied = -1;
|
||||
liveDoc.transact(() => {
|
||||
applied = mergeXmlFragments(live, targetDoc.getXmlFragment('default'));
|
||||
});
|
||||
|
||||
expect(applied).toBe(0);
|
||||
expect(live.toArray().length).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,373 @@
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import {
|
||||
mergeXmlFragments,
|
||||
mergeXmlFragments3Way,
|
||||
mergeXmlFragments3WayWithStats,
|
||||
cloneXmlNode,
|
||||
diffBlocks,
|
||||
} from './yjs-body-merge';
|
||||
|
||||
// Build a Y.XmlFragment('default') in `doc` from a list of paragraph specs.
|
||||
// Each spec is the paragraph's plain text (a single XmlText child).
|
||||
function buildFragment(doc: Y.Doc, paragraphs: string[]): Y.XmlFragment {
|
||||
const frag = doc.getXmlFragment('default');
|
||||
const blocks = paragraphs.map((text) => {
|
||||
const el = new Y.XmlElement('paragraph');
|
||||
const t = new Y.XmlText();
|
||||
if (text) t.insert(0, text);
|
||||
el.insert(0, [t]);
|
||||
return el;
|
||||
});
|
||||
if (blocks.length) frag.insert(0, blocks);
|
||||
return frag;
|
||||
}
|
||||
|
||||
function texts(frag: Y.XmlFragment): string[] {
|
||||
return frag.toArray().map((el) => (el as Y.XmlElement).toArray()
|
||||
.map((c) => (c as Y.XmlText).toString())
|
||||
.join(''));
|
||||
}
|
||||
|
||||
describe('yjs-body-merge', () => {
|
||||
describe('diffBlocks (LCS edit script)', () => {
|
||||
it('identical sequences produce only keeps (no edits)', () => {
|
||||
const ops = diffBlocks(['a', 'b', 'c'], ['a', 'b', 'c']);
|
||||
expect(ops.every((o) => o.op === 'keep')).toBe(true);
|
||||
});
|
||||
|
||||
it('a single changed middle element is one del + one ins', () => {
|
||||
const ops = diffBlocks(['a', 'b', 'c'], ['a', 'B', 'c']);
|
||||
expect(ops.filter((o) => o.op === 'del')).toHaveLength(1);
|
||||
expect(ops.filter((o) => o.op === 'ins')).toHaveLength(1);
|
||||
expect(ops.filter((o) => o.op === 'keep')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeXmlFragments', () => {
|
||||
it('identical content is a complete no-op (0 ops) — never clobbers an unchanged resync', () => {
|
||||
const live = new Y.Doc();
|
||||
const target = new Y.Doc();
|
||||
const liveFrag = buildFragment(live, ['one', 'two', 'three']);
|
||||
const targetFrag = buildFragment(target, ['one', 'two', 'three']);
|
||||
|
||||
// Capture block identities to prove they are left untouched.
|
||||
const before = liveFrag.toArray();
|
||||
let applied = -1;
|
||||
live.transact(() => {
|
||||
applied = mergeXmlFragments(liveFrag, targetFrag);
|
||||
});
|
||||
|
||||
expect(applied).toBe(0);
|
||||
// Same Y.XmlElement instances — nothing was deleted/recreated.
|
||||
expect(liveFrag.toArray()).toEqual(before);
|
||||
expect(texts(liveFrag)).toEqual(['one', 'two', 'three']);
|
||||
});
|
||||
|
||||
it('a human edit to one block survives a git change to a DIFFERENT block', () => {
|
||||
// Live: the human has the doc open; block 0 holds their edit. Git changed
|
||||
// only block 2. The merge must touch ONLY block 2 and leave block 0 (and
|
||||
// its in-flight edit) exactly as-is.
|
||||
const live = new Y.Doc();
|
||||
const target = new Y.Doc();
|
||||
const liveFrag = buildFragment(live, ['HUMAN EDIT', 'shared', 'old tail']);
|
||||
const targetFrag = buildFragment(target, [
|
||||
'HUMAN EDIT',
|
||||
'shared',
|
||||
'new tail from git',
|
||||
]);
|
||||
|
||||
const block0Before = liveFrag.get(0); // the human's block instance
|
||||
const block1Before = liveFrag.get(1);
|
||||
|
||||
let applied = -1;
|
||||
live.transact(() => {
|
||||
applied = mergeXmlFragments(liveFrag, targetFrag);
|
||||
});
|
||||
|
||||
// Only block 2 was replaced: one del + one ins.
|
||||
expect(applied).toBe(2);
|
||||
// The human's block and the shared block are the SAME instances (untouched).
|
||||
expect(liveFrag.get(0)).toBe(block0Before);
|
||||
expect(liveFrag.get(1)).toBe(block1Before);
|
||||
// Block 2 now carries git's content.
|
||||
expect(texts(liveFrag)).toEqual([
|
||||
'HUMAN EDIT',
|
||||
'shared',
|
||||
'new tail from git',
|
||||
]);
|
||||
});
|
||||
|
||||
it('appends a new trailing block without disturbing existing ones', () => {
|
||||
const live = new Y.Doc();
|
||||
const target = new Y.Doc();
|
||||
const liveFrag = buildFragment(live, ['a', 'b']);
|
||||
const targetFrag = buildFragment(target, ['a', 'b', 'c']);
|
||||
const a = liveFrag.get(0);
|
||||
const b = liveFrag.get(1);
|
||||
|
||||
let applied = -1;
|
||||
live.transact(() => {
|
||||
applied = mergeXmlFragments(liveFrag, targetFrag);
|
||||
});
|
||||
|
||||
expect(applied).toBe(1); // single insert
|
||||
expect(liveFrag.get(0)).toBe(a);
|
||||
expect(liveFrag.get(1)).toBe(b);
|
||||
expect(texts(liveFrag)).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('deletes a removed block, keeping its neighbours', () => {
|
||||
const live = new Y.Doc();
|
||||
const target = new Y.Doc();
|
||||
const liveFrag = buildFragment(live, ['a', 'b', 'c']);
|
||||
const targetFrag = buildFragment(target, ['a', 'c']);
|
||||
const a = liveFrag.get(0);
|
||||
|
||||
let applied = -1;
|
||||
live.transact(() => {
|
||||
applied = mergeXmlFragments(liveFrag, targetFrag);
|
||||
});
|
||||
|
||||
expect(applied).toBe(1); // single delete
|
||||
expect(liveFrag.get(0)).toBe(a);
|
||||
expect(texts(liveFrag)).toEqual(['a', 'c']);
|
||||
});
|
||||
|
||||
it('a fully different body is replaced (and stays valid)', () => {
|
||||
const live = new Y.Doc();
|
||||
const target = new Y.Doc();
|
||||
const liveFrag = buildFragment(live, ['x', 'y']);
|
||||
const targetFrag = buildFragment(target, ['p', 'q', 'r']);
|
||||
live.transact(() => mergeXmlFragments(liveFrag, targetFrag));
|
||||
expect(texts(liveFrag)).toEqual(['p', 'q', 'r']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeXmlFragments3Way', () => {
|
||||
it('keeps a human edit to one block while applying a git change to another (3-way)', () => {
|
||||
// base (last synced): [a, b, c]. Human edited block 0 in the live doc; git
|
||||
// changed block 2 in the incoming file. 3-way must keep BOTH — the 2-way
|
||||
// merge would instead revert the human's block 0 to git's stale version.
|
||||
const base = new Y.Doc();
|
||||
const live = new Y.Doc();
|
||||
const target = new Y.Doc();
|
||||
const baseFrag = buildFragment(base, ['a', 'b', 'c']);
|
||||
const liveFrag = buildFragment(live, ['HUMAN', 'b', 'c']);
|
||||
const targetFrag = buildFragment(target, ['a', 'b', 'GIT']);
|
||||
|
||||
const humanBlock = liveFrag.get(0); // the human's live instance
|
||||
live.transact(() =>
|
||||
mergeXmlFragments3Way(liveFrag, targetFrag, baseFrag),
|
||||
);
|
||||
|
||||
// Human's block preserved as the SAME instance; git's change applied.
|
||||
expect(liveFrag.get(0)).toBe(humanBlock);
|
||||
expect(texts(liveFrag)).toEqual(['HUMAN', 'b', 'GIT']);
|
||||
});
|
||||
|
||||
it('a block both sides changed resolves to git (conflict policy)', () => {
|
||||
const base = new Y.Doc();
|
||||
const live = new Y.Doc();
|
||||
const target = new Y.Doc();
|
||||
const baseFrag = buildFragment(base, ['a', 'b', 'c']);
|
||||
const liveFrag = buildFragment(live, ['a', 'HUMAN', 'c']);
|
||||
const targetFrag = buildFragment(target, ['a', 'GIT', 'c']);
|
||||
|
||||
live.transact(() =>
|
||||
mergeXmlFragments3Way(liveFrag, targetFrag, baseFrag),
|
||||
);
|
||||
expect(texts(liveFrag)).toEqual(['a', 'GIT', 'c']);
|
||||
});
|
||||
|
||||
// Bug #2 observability: the stats variant reports the same-block conflict so
|
||||
// the handler can log it + the persistence layer can pin the human baseline.
|
||||
it('reports the same-block conflict count via mergeXmlFragments3WayWithStats', () => {
|
||||
const base = new Y.Doc();
|
||||
const live = new Y.Doc();
|
||||
const target = new Y.Doc();
|
||||
const baseFrag = buildFragment(base, ['a', 'b', 'c']);
|
||||
const liveFrag = buildFragment(live, ['a', 'HUMAN', 'c']);
|
||||
const targetFrag = buildFragment(target, ['a', 'GIT', 'c']);
|
||||
|
||||
let result!: { applied: number; conflicts: number };
|
||||
live.transact(() => {
|
||||
result = mergeXmlFragments3WayWithStats(liveFrag, targetFrag, baseFrag);
|
||||
});
|
||||
expect(result.conflicts).toBe(1);
|
||||
expect(texts(liveFrag)).toEqual(['a', 'GIT', 'c']);
|
||||
});
|
||||
|
||||
it('reports 0 conflicts for a clean different-block 3-way merge', () => {
|
||||
const base = new Y.Doc();
|
||||
const live = new Y.Doc();
|
||||
const target = new Y.Doc();
|
||||
const baseFrag = buildFragment(base, ['a', 'b', 'c']);
|
||||
const liveFrag = buildFragment(live, ['HUMAN', 'b', 'c']);
|
||||
const targetFrag = buildFragment(target, ['a', 'b', 'GIT']);
|
||||
|
||||
let result!: { applied: number; conflicts: number };
|
||||
live.transact(() => {
|
||||
result = mergeXmlFragments3WayWithStats(liveFrag, targetFrag, baseFrag);
|
||||
});
|
||||
expect(result.conflicts).toBe(0);
|
||||
expect(texts(liveFrag)).toEqual(['HUMAN', 'b', 'GIT']);
|
||||
});
|
||||
|
||||
it('git change with no concurrent human edit (live == base) applies cleanly', () => {
|
||||
const base = new Y.Doc();
|
||||
const live = new Y.Doc();
|
||||
const target = new Y.Doc();
|
||||
const baseFrag = buildFragment(base, ['a', 'b']);
|
||||
const liveFrag = buildFragment(live, ['a', 'b']);
|
||||
const targetFrag = buildFragment(target, ['a', 'B2']);
|
||||
|
||||
live.transact(() =>
|
||||
mergeXmlFragments3Way(liveFrag, targetFrag, baseFrag),
|
||||
);
|
||||
expect(texts(liveFrag)).toEqual(['a', 'B2']);
|
||||
});
|
||||
});
|
||||
|
||||
// Regression: start-of-document content duplicating on every two-way sync.
|
||||
//
|
||||
// The LIVE Docmost doc stamps a per-block UniqueID on every heading/paragraph;
|
||||
// a body arriving FROM git is parsed from clean markdown and carries NO block
|
||||
// ids. If the merge comparison key includes that `id`, an unchanged live block
|
||||
// never matches the SAME block coming from git, so the three-way merge cannot
|
||||
// anchor on it — and an incoming block with no anchor (content inserted at the
|
||||
// TOP of the page) is RE-ADDED on every cycle, an unbounded duplication loop.
|
||||
// These tests model that exact id-asymmetry and assert the reconciliation is
|
||||
// IDEMPOTENT (no block growth). They are RED before excluding `id` from the
|
||||
// key in `serializeXmlNode`.
|
||||
describe('idempotent reconciliation with live block ids (start-of-doc dup)', () => {
|
||||
// Build a fragment from block specs. `id` is set only when provided, mirroring
|
||||
// the live doc (ids present) vs a git-parsed body (ids absent).
|
||||
type Spec = { tag: 'heading' | 'paragraph'; text: string; id?: string };
|
||||
function buildDoc(doc: Y.Doc, specs: Spec[]): Y.XmlFragment {
|
||||
const frag = doc.getXmlFragment('default');
|
||||
const blocks = specs.map((s) => {
|
||||
const el = new Y.XmlElement(s.tag);
|
||||
if (s.id) el.setAttribute('id', s.id);
|
||||
if (s.tag === 'heading') el.setAttribute('level', '2');
|
||||
const t = new Y.XmlText();
|
||||
if (s.text) t.insert(0, s.text);
|
||||
el.insert(0, [t]);
|
||||
return el;
|
||||
});
|
||||
if (blocks.length) frag.insert(0, blocks);
|
||||
return frag;
|
||||
}
|
||||
const textsOf = (frag: Y.XmlFragment): string[] =>
|
||||
frag.toArray().map((el) =>
|
||||
(el as Y.XmlElement)
|
||||
.toArray()
|
||||
.map((c) => (c as Y.XmlText).toString())
|
||||
.join(''),
|
||||
);
|
||||
|
||||
it('re-merging the SAME git body does NOT re-add the top block (idempotent)', () => {
|
||||
// last-synced base (from git markdown): NO block ids.
|
||||
const base = new Y.Doc();
|
||||
const baseFrag = buildDoc(base, [
|
||||
{ tag: 'heading', text: 'Title' },
|
||||
{ tag: 'paragraph', text: 'Some paragraph.' },
|
||||
{ tag: 'paragraph', text: 'End block.' },
|
||||
]);
|
||||
// live Docmost doc: SAME content, but every block carries a UniqueID.
|
||||
const live = new Y.Doc();
|
||||
const liveFrag = buildDoc(live, [
|
||||
{ tag: 'heading', text: 'Title', id: 'ida' },
|
||||
{ tag: 'paragraph', text: 'Some paragraph.', id: 'idb' },
|
||||
{ tag: 'paragraph', text: 'End block.', id: 'idc' },
|
||||
]);
|
||||
// incoming git body: the user inserted a heading at the very TOP.
|
||||
const buildTarget = (): Y.XmlFragment =>
|
||||
buildDoc(new Y.Doc(), [
|
||||
{ tag: 'heading', text: 'TOPDUP' },
|
||||
{ tag: 'heading', text: 'Title' },
|
||||
{ tag: 'paragraph', text: 'Some paragraph.' },
|
||||
{ tag: 'paragraph', text: 'End block.' },
|
||||
]);
|
||||
|
||||
// First sync: the top block is added once.
|
||||
live.transact(() =>
|
||||
mergeXmlFragments3Way(liveFrag, buildTarget(), baseFrag),
|
||||
);
|
||||
expect(textsOf(liveFrag)).toEqual([
|
||||
'TOPDUP',
|
||||
'Title',
|
||||
'Some paragraph.',
|
||||
'End block.',
|
||||
]);
|
||||
|
||||
// Subsequent sync of the SAME git body against the SAME base must be a
|
||||
// NO-OP — not a second copy of the top block. Before the fix this re-adds
|
||||
// 'TOPDUP', growing the doc on every cycle.
|
||||
live.transact(() =>
|
||||
mergeXmlFragments3Way(liveFrag, buildTarget(), baseFrag),
|
||||
);
|
||||
expect(textsOf(liveFrag)).toEqual([
|
||||
'TOPDUP',
|
||||
'Title',
|
||||
'Some paragraph.',
|
||||
'End block.',
|
||||
]);
|
||||
expect(textsOf(liveFrag).filter((t) => t === 'TOPDUP')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('an unchanged git body (live ids, none in git) is a complete no-op', () => {
|
||||
// base == git body (no pending git change); live is the same content with
|
||||
// ids. With `id` in the key the whole body looks rewritten; the merge must
|
||||
// still leave live byte-identical (block instances untouched).
|
||||
const base = new Y.Doc();
|
||||
const baseFrag = buildDoc(base, [
|
||||
{ tag: 'heading', text: 'Title' },
|
||||
{ tag: 'paragraph', text: 'Body.' },
|
||||
]);
|
||||
const live = new Y.Doc();
|
||||
const liveFrag = buildDoc(live, [
|
||||
{ tag: 'heading', text: 'Title', id: 'ida' },
|
||||
{ tag: 'paragraph', text: 'Body.', id: 'idb' },
|
||||
]);
|
||||
const before = liveFrag.toArray();
|
||||
let applied = -1;
|
||||
live.transact(() => {
|
||||
applied = mergeXmlFragments3Way(
|
||||
liveFrag,
|
||||
buildDoc(new Y.Doc(), [
|
||||
{ tag: 'heading', text: 'Title' },
|
||||
{ tag: 'paragraph', text: 'Body.' },
|
||||
]),
|
||||
baseFrag,
|
||||
);
|
||||
});
|
||||
expect(applied).toBe(0);
|
||||
// Same live block instances (ids preserved) — nothing recreated.
|
||||
expect(liveFrag.toArray()).toEqual(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cloneXmlNode', () => {
|
||||
it('preserves text marks (XmlText delta) across docs', () => {
|
||||
const src = new Y.Doc();
|
||||
const srcFrag = src.getXmlFragment('default');
|
||||
const el = new Y.XmlElement('paragraph');
|
||||
const t = new Y.XmlText();
|
||||
t.insert(0, 'plain ');
|
||||
t.insert(6, 'bold', { bold: true });
|
||||
el.insert(0, [t]);
|
||||
srcFrag.insert(0, [el]);
|
||||
|
||||
const dst = new Y.Doc();
|
||||
const dstFrag = dst.getXmlFragment('default');
|
||||
dstFrag.insert(0, [cloneXmlNode(srcFrag.get(0) as Y.XmlElement)]);
|
||||
|
||||
const clonedText = (dstFrag.get(0) as Y.XmlElement).get(0) as Y.XmlText;
|
||||
expect(clonedText.toDelta()).toEqual([
|
||||
{ insert: 'plain ' },
|
||||
{ insert: 'bold', attributes: { bold: true } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,371 @@
|
||||
import * as Y from 'yjs';
|
||||
import { getSchema } from '@tiptap/core';
|
||||
import type { Schema } from '@tiptap/pm/model';
|
||||
|
||||
import { tiptapExtensions } from '../collaboration.util';
|
||||
import { diff3PlanWithConflicts } from './three-way-merge';
|
||||
import { buildLcsTable } from './lcs';
|
||||
|
||||
/**
|
||||
* Block-level merge of an incoming (git) page body into a LIVE Yjs document,
|
||||
* replacing the previous full-body "delete everything + re-insert" write that
|
||||
* clobbered concurrent human edits on every sync (review #5 — "do the write as a
|
||||
* merge").
|
||||
*
|
||||
* Strategy: diff the two documents at TOP-LEVEL BLOCK granularity (an LCS over a
|
||||
* canonical structural serialization of each block) and apply only the minimal
|
||||
* insert/delete operations. Blocks that are byte-identical on both sides are
|
||||
* left UNTOUCHED in the live doc — so a human editing one paragraph is unaffected
|
||||
* when git changes a different paragraph, and an unchanged re-sync is a complete
|
||||
* no-op (zero Yjs operations). Yjs then CRDT-merges the minimal ops with any
|
||||
* concurrent edits.
|
||||
*
|
||||
* Merge mode: a THREE-WAY merge (live vs incoming vs base) runs whenever the
|
||||
* engine plumbs the last-synced base (`baseMarkdown` from refs/docmost/last-pushed)
|
||||
* — which it now does end-to-end — so a block both sides changed is a genuine
|
||||
* conflict resolved deterministically (git wins that block; the prior state is
|
||||
* preserved in page history). Only when NO base is available (a brand-new file)
|
||||
* does it fall back to a 2-way merge (live vs incoming). Common cases — unchanged
|
||||
* resync and edits to DIFFERENT blocks — are lossless in both modes.
|
||||
*/
|
||||
|
||||
type XmlNode = Y.XmlElement | Y.XmlText | Y.XmlHook;
|
||||
|
||||
/**
|
||||
* Node attributes that are VOLATILE identity (not content) and so must be
|
||||
* excluded from the block comparison key.
|
||||
*
|
||||
* `id` is the per-block UniqueID the editor stamps on every heading/paragraph
|
||||
* (and transclusionSource). It exists ONLY in the live Yjs document — a body
|
||||
* arriving from git is parsed from clean markdown, which carries no block ids
|
||||
* (`markdownToProseMirror` materializes `id: null`, which the Yjs transform then
|
||||
* drops). If `id` were part of the key, an UNCHANGED live block (id "abc123")
|
||||
* would never match the SAME block coming from git (no id), so the three-way
|
||||
* merge's LCS could not anchor on it. The merge would then treat every live
|
||||
* block as deleted-and-reinserted and, when an incoming block has no matching
|
||||
* anchor (e.g. content inserted at the very TOP of the page), RE-ADD a copy of
|
||||
* it on every sync cycle — a non-convergent, unbounded duplication loop
|
||||
* (start-of-document content duplicating each push/pull cycle).
|
||||
*
|
||||
* Excluding `id` makes blocks compare by CONTENT, so an unchanged block matches
|
||||
* across the git round-trip and the reconciliation is idempotent. Block identity
|
||||
* is still preserved in the merged output: `diff3Plan` keeps the LIVE block
|
||||
* INSTANCE (with its id) for an anchor — picks are by index, not by key — so the
|
||||
* stable Yjs block (and any in-flight human edit on it) stays put. This mirrors
|
||||
* `canonicalize.ts`, which already strips the regenerated block `id` from the
|
||||
* round-trip idempotency comparison for exactly the same reason.
|
||||
*
|
||||
* Known limitation (accepted trade-off of content-based matching): two GENUINELY
|
||||
* DISTINCT blocks whose content is byte-identical now collapse to the same content
|
||||
* key, so when git deletes one of the duplicates the LCS may drop the OTHER live
|
||||
* instance instead. The visible result is identical (one copy removed, one kept),
|
||||
* but a concurrent in-flight human edit on the dropped instance could be lost.
|
||||
*/
|
||||
const VOLATILE_KEY_ATTRS = new Set(['id']);
|
||||
|
||||
/**
|
||||
* The editor (ProseMirror) schema, built ONCE from the same `tiptapExtensions`
|
||||
* the collaboration server uses to materialize Yjs docs. Memoized: building the
|
||||
* schema is non-trivial and the block key is computed per block per cycle.
|
||||
*
|
||||
* Why the schema (not a hardcoded denylist): the LIVE Yjs document is produced by
|
||||
* `TiptapTransformer.toYdoc(pm, 'default', tiptapExtensions)`, which STAMPS every
|
||||
* schema-default attribute onto every node and mark — `indent: 0` on every
|
||||
* paragraph/heading, `image.align: "center"`, the link mark's `internal: false`,
|
||||
* `highlight.colorName: null`, and so on for youtube/pdf/any future node. A body
|
||||
* re-imported from git comes through the engine's `markdownToProseMirror`, whose
|
||||
* schema declares those attrs with DIFFERENT (usually null) defaults; the
|
||||
* resulting null/absent element attrs are then DROPPED by `y-prosemirror`'s
|
||||
* toYdoc. So the SAME block carries materialized defaults on the live side and
|
||||
* nothing on the git side, its key diverges, the three-way merge anchors on
|
||||
* NOTHING, and the whole body is RE-APPENDED every reconcile cycle — an unbounded
|
||||
* duplication loop with no client connected.
|
||||
*
|
||||
* Deriving the defaults from the actual schema normalizes ALL such attributes
|
||||
* generally (it is not another per-attribute denylist): any attribute whose value
|
||||
* equals the schema default — or is null/undefined — is dropped from the key, on
|
||||
* BOTH element attributes and the mark attributes inside each XmlText delta, so a
|
||||
* live block compares equal to its git-round-tripped twin and an unchanged resync
|
||||
* applies zero ops. Genuinely non-default values (a real `indent: 2`, an
|
||||
* `align: "left"`, a real `link.href`, a real highlight color) are content and
|
||||
* stay in the key, so real edits still diff and land.
|
||||
*/
|
||||
let memoSchema: Schema | null = null;
|
||||
let memoSchemaTried = false;
|
||||
function getMergeSchema(): Schema | null {
|
||||
if (!memoSchemaTried) {
|
||||
memoSchemaTried = true;
|
||||
try {
|
||||
memoSchema = getSchema(tiptapExtensions as any);
|
||||
} catch {
|
||||
// Defensive: if the schema can't be built (e.g. a degenerate extension
|
||||
// set in a unit test that stubs `tiptapExtensions`), fall back to dropping
|
||||
// only null/undefined attrs. The real server always builds it fine.
|
||||
memoSchema = null;
|
||||
}
|
||||
}
|
||||
return memoSchema;
|
||||
}
|
||||
|
||||
/** True if `value` is the schema default for `attrName` of `attrSpecs`, or is
|
||||
* null/undefined (which a git round-trip drops). Such attributes are excluded
|
||||
* from the comparison key. `attrSpecs` is a ProseMirror node/mark spec attr map
|
||||
* (`{ [name]: { default } }`); a missing map (unknown node/mark) only drops
|
||||
* null/undefined. (A non-null value matching an attr declared without a default
|
||||
* cannot occur — `spec.default === value` is then `undefined === value`, false.) */
|
||||
function isDefaultAttr(
|
||||
attrSpecs: Record<string, any> | undefined | null,
|
||||
attrName: string,
|
||||
value: unknown,
|
||||
): boolean {
|
||||
if (value === null || value === undefined) return true;
|
||||
const spec = attrSpecs?.[attrName];
|
||||
return !!spec && spec.default === value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize one XmlText delta op's mark attributes: drop every mark-attr whose
|
||||
* value equals the mark's schema default (or is null/undefined), so the link
|
||||
* mark's materialized `internal: false`/`target: "_blank"` and a highlight's
|
||||
* `colorName: null` no longer diverge from a git round-trip that carries neither.
|
||||
* The text (op.insert) and genuinely-set mark attrs (a real `href`, a real
|
||||
* highlight color) are preserved verbatim. `attributes` maps markName -> mark
|
||||
* attrs object (or `true`/boolean for attr-less marks); each is handled safely.
|
||||
*/
|
||||
function normalizeDelta(delta: any[]): any[] {
|
||||
const schema = getMergeSchema();
|
||||
return delta.map((op) => {
|
||||
if (!op || op.attributes == null || typeof op.attributes !== 'object') {
|
||||
return op;
|
||||
}
|
||||
const marks: Record<string, unknown> = {};
|
||||
for (const markName of Object.keys(op.attributes).sort()) {
|
||||
const markVal = op.attributes[markName];
|
||||
if (markVal === null || markVal === undefined) continue;
|
||||
if (typeof markVal !== 'object') {
|
||||
// attr-less mark stored as a primitive (e.g. `true`) — keep as-is.
|
||||
marks[markName] = markVal;
|
||||
continue;
|
||||
}
|
||||
const markSpec = schema?.marks[markName]?.spec.attrs as
|
||||
| Record<string, any>
|
||||
| undefined;
|
||||
const cleaned: Record<string, unknown> = {};
|
||||
for (const ak of Object.keys(markVal as object).sort()) {
|
||||
const av = (markVal as Record<string, unknown>)[ak];
|
||||
if (isDefaultAttr(markSpec, ak, av)) continue;
|
||||
cleaned[ak] = av;
|
||||
}
|
||||
marks[markName] = cleaned;
|
||||
}
|
||||
return { ...op, attributes: marks };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical, comparable serialization of a Yjs XML node (structure + text +
|
||||
* marks + attributes), with attribute keys sorted so equal blocks always produce
|
||||
* an identical string regardless of attribute insertion order. The volatile
|
||||
* block `id` (see `VOLATILE_KEY_ATTRS`) and every schema-default attribute (see
|
||||
* `getMergeSchema`) are excluded at every level — on element attributes AND on
|
||||
* the mark attributes inside each XmlText delta — so a block compares equal by
|
||||
* CONTENT across the git round-trip (which materializes neither), keeping the
|
||||
* merge anchor-able and idempotent.
|
||||
*/
|
||||
export function serializeXmlNode(node: unknown): unknown {
|
||||
if (node instanceof Y.XmlText) {
|
||||
return { t: normalizeDelta(node.toDelta()) };
|
||||
}
|
||||
if (node instanceof Y.XmlElement) {
|
||||
const attrs = node.getAttributes() as Record<string, unknown>;
|
||||
const attrSpecs = getMergeSchema()?.nodes[node.nodeName]?.spec.attrs as
|
||||
| Record<string, any>
|
||||
| undefined;
|
||||
const sorted: Record<string, unknown> = {};
|
||||
for (const k of Object.keys(attrs).sort()) {
|
||||
if (VOLATILE_KEY_ATTRS.has(k)) continue;
|
||||
if (isDefaultAttr(attrSpecs, k, attrs[k])) continue;
|
||||
sorted[k] = attrs[k];
|
||||
}
|
||||
return {
|
||||
n: node.nodeName,
|
||||
a: sorted,
|
||||
c: node.toArray().map(serializeXmlNode),
|
||||
};
|
||||
}
|
||||
// XmlHook / unknown: fall back to a stable string so it compares by identity
|
||||
// of its serialized form (these do not occur in the Docmost block schema).
|
||||
return { u: String(node) };
|
||||
}
|
||||
|
||||
const key = (node: unknown): string => JSON.stringify(serializeXmlNode(node));
|
||||
|
||||
/**
|
||||
* Deep-clone a detached/owned Yjs XML node into a fresh node that can be inserted
|
||||
* into ANOTHER document (Yjs types are bound to their doc, so cross-doc moves are
|
||||
* impossible — we rebuild). Preserves nodeName, attributes, text+marks (via the
|
||||
* XmlText delta) and the full child subtree.
|
||||
*/
|
||||
export function cloneXmlNode(node: XmlNode): Y.XmlElement | Y.XmlText {
|
||||
if (node instanceof Y.XmlText) {
|
||||
const t = new Y.XmlText();
|
||||
const delta = node.toDelta();
|
||||
if (delta.length) t.applyDelta(delta);
|
||||
return t;
|
||||
}
|
||||
if (node instanceof Y.XmlElement) {
|
||||
const el = new Y.XmlElement(node.nodeName);
|
||||
const attrs = node.getAttributes() as Record<string, unknown>;
|
||||
for (const k of Object.keys(attrs)) el.setAttribute(k, attrs[k] as string);
|
||||
const kids = node.toArray().map((c) => cloneXmlNode(c as XmlNode));
|
||||
if (kids.length) el.insert(0, kids);
|
||||
return el;
|
||||
}
|
||||
// Best-effort for any other node type (XmlHook — does not occur in the
|
||||
// Docmost block schema): an empty paragraph so the merge never crashes.
|
||||
return new Y.XmlElement('paragraph');
|
||||
}
|
||||
|
||||
type Op = { op: 'keep' } | { op: 'del' } | { op: 'ins'; bi: number };
|
||||
|
||||
/**
|
||||
* LCS-based edit script turning sequence `a` (live block keys) into `b` (incoming
|
||||
* block keys): a run of keep/del/ins ops. O(n*m) table — fine for page block
|
||||
* counts.
|
||||
*/
|
||||
export function diffBlocks(a: string[], b: string[]): Op[] {
|
||||
const n = a.length;
|
||||
const m = b.length;
|
||||
const dp = buildLcsTable(a, b);
|
||||
const ops: Op[] = [];
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < n && j < m) {
|
||||
if (a[i] === b[j]) {
|
||||
ops.push({ op: 'keep' });
|
||||
i++;
|
||||
j++;
|
||||
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
|
||||
ops.push({ op: 'del' });
|
||||
i++;
|
||||
} else {
|
||||
ops.push({ op: 'ins', bi: j });
|
||||
j++;
|
||||
}
|
||||
}
|
||||
while (i < n) {
|
||||
ops.push({ op: 'del' });
|
||||
i++;
|
||||
}
|
||||
while (j < m) {
|
||||
ops.push({ op: 'ins', bi: j });
|
||||
j++;
|
||||
}
|
||||
return ops;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge `target` block children into `live`, mutating `live` in place with the
|
||||
* minimal set of inserts/deletes. MUST be called inside a Yjs transaction.
|
||||
* Returns the number of block operations applied (0 == content already identical).
|
||||
*/
|
||||
export function mergeXmlFragments(
|
||||
live: Y.XmlFragment,
|
||||
target: Y.XmlFragment,
|
||||
): number {
|
||||
const liveKids = live.toArray();
|
||||
const targetKids = target.toArray();
|
||||
const liveKeys = liveKids.map(key);
|
||||
const targetKeys = targetKids.map(key);
|
||||
|
||||
const ops = diffBlocks(liveKeys, targetKeys);
|
||||
|
||||
let cursor = 0; // index into the LIVE fragment as we mutate it
|
||||
let applied = 0;
|
||||
for (const op of ops) {
|
||||
if (op.op === 'keep') {
|
||||
cursor++;
|
||||
} else if (op.op === 'del') {
|
||||
live.delete(cursor, 1); // remove the live block at the cursor; do not advance
|
||||
applied++;
|
||||
} else {
|
||||
live.insert(cursor, [cloneXmlNode(targetKids[op.bi] as XmlNode)]);
|
||||
cursor++;
|
||||
applied++;
|
||||
}
|
||||
}
|
||||
return applied;
|
||||
}
|
||||
|
||||
/** Outcome of a 3-way block merge: ops applied + same-block conflict count. */
|
||||
export interface Merge3WayResult {
|
||||
/** Number of block insert/delete operations spliced into `live`. */
|
||||
applied: number;
|
||||
/**
|
||||
* Regions where the human AND git rewrote the SAME base block. The rule is
|
||||
* deterministic (GIT WINS the region), so the human's version of those blocks
|
||||
* is dropped from the live doc. `conflicts > 0` is the OBSERVABLE signal the
|
||||
* caller uses to LOG the loss and pin the human baseline to page history (so it
|
||||
* is recoverable), instead of the edit vanishing silently.
|
||||
*/
|
||||
conflicts: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* THREE-WAY block merge: reconcile `live` toward `target` using `base` (the
|
||||
* last-synced common ancestor) so a block only the human changed is KEPT and a
|
||||
* block only git changed is taken — instead of git's version always winning
|
||||
* (review #5). Conflicts (both changed the same block) resolve to git.
|
||||
*
|
||||
* Implementation: diff3Plan computes the merged block ORDER (picks from live or
|
||||
* target); we materialize that as a virtual target fragment and reuse the 2-way
|
||||
* `mergeXmlFragments` to splice it into `live` minimally (so untouched live block
|
||||
* instances — and their in-flight edits — stay put). MUST be called inside a Yjs
|
||||
* transaction. Returns the number of block operations applied. (Use
|
||||
* `mergeXmlFragments3WayWithStats` when the SAME-BLOCK conflict count is needed.)
|
||||
*/
|
||||
export function mergeXmlFragments3Way(
|
||||
live: Y.XmlFragment,
|
||||
target: Y.XmlFragment,
|
||||
base: Y.XmlFragment,
|
||||
): number {
|
||||
return mergeXmlFragments3WayWithStats(live, target, base).applied;
|
||||
}
|
||||
|
||||
/**
|
||||
* As `mergeXmlFragments3Way`, but also returns the SAME-BLOCK conflict count so
|
||||
* the caller can make a "git won a concurrent same-block edit" event OBSERVABLE
|
||||
* (the documented conflict contract: git wins deterministically, but the losing
|
||||
* human content is never destroyed silently — it is logged and recoverable via
|
||||
* page history).
|
||||
*/
|
||||
export function mergeXmlFragments3WayWithStats(
|
||||
live: Y.XmlFragment,
|
||||
target: Y.XmlFragment,
|
||||
base: Y.XmlFragment,
|
||||
): Merge3WayResult {
|
||||
const liveKids = live.toArray();
|
||||
const targetKids = target.toArray();
|
||||
const liveKeys = liveKids.map(key);
|
||||
const targetKeys = targetKids.map(key);
|
||||
const baseKeys = base.toArray().map(key);
|
||||
|
||||
const { picks: plan, conflicts } = diff3PlanWithConflicts(
|
||||
baseKeys,
|
||||
liveKeys,
|
||||
targetKeys,
|
||||
);
|
||||
|
||||
// Build the merged block sequence in a throwaway doc, cloning from whichever
|
||||
// side each pick came from, then 2-way merge it back into the live fragment.
|
||||
const merged = new Y.Doc();
|
||||
const mergedFrag = merged.getXmlFragment('default');
|
||||
const nodes = plan.map((p) =>
|
||||
cloneXmlNode(
|
||||
(p.src === 'live' ? liveKids[p.index] : targetKids[p.index]) as XmlNode,
|
||||
),
|
||||
);
|
||||
if (nodes.length) mergedFrag.insert(0, nodes);
|
||||
|
||||
return { applied: mergeXmlFragments(live, mergedFrag), conflicts };
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { getSchema } from '@tiptap/core';
|
||||
import { Schema } from '@tiptap/pm/model';
|
||||
import { tiptapExtensions } from './collaboration.util';
|
||||
// The vendored git-sync mirror's extension set. Imported via the subpath the
|
||||
// server jest config maps to the package SOURCE (moduleNameMapper
|
||||
// `^@docmost/git-sync/(.*)$`), so this reads the real mirror, not a build.
|
||||
import { docmostExtensions as gitSyncExtensions } from '@docmost/git-sync/lib/docmost-schema';
|
||||
|
||||
/**
|
||||
* ATTRIBUTE-LEVEL SCHEMA CONTRACT (review #293, variant A).
|
||||
*
|
||||
* The document schema exists as three hand-synced copies (editor-ext =
|
||||
* source-of-truth, plus the git-sync and mcp converter mirrors). The existing
|
||||
* `schema-editor-ext-contract.test.ts` compares only node/mark TYPE NAMES, so a
|
||||
* NEW ATTRIBUTE added to an existing node upstream slips through and its value is
|
||||
* silently dropped on every git-sync round trip. That is a repeatedly-hit
|
||||
* data-loss class (image caption #221, paragraph alignment #10, details `open`).
|
||||
*
|
||||
* This test closes the attribute gap MECHANICALLY: it builds the real canonical
|
||||
* schema from the server's `tiptapExtensions` (the same set the collab write path
|
||||
* uses) and the git-sync mirror schema, then asserts that for every node/mark the
|
||||
* two schemas share, their ATTRIBUTE-KEY sets are equal — minus a committed
|
||||
* allowlist of intentional, understood divergences. A forgotten attribute now
|
||||
* fails CI loudly instead of losing data in production.
|
||||
*
|
||||
* WHY THIS ISN'T THE "fragile attribute compare" the sibling name-level contract
|
||||
* (`packages/git-sync/test/schema-editor-ext-contract.test.ts`) deferred: that
|
||||
* concern was about comparing raw extension CONFIGS, where editor-ext spreads
|
||||
* global attributes (textAlign, id, …) across separate extensions and StarterKit
|
||||
* contributes types the mirror gets elsewhere. We instead compare the RESOLVED
|
||||
* ProseMirror `Schema` objects — `getSchema()` has already merged every
|
||||
* addGlobalAttributes spread into concrete per-node attrs on both sides — so the
|
||||
* compare is apples-to-apples (57 shared nodes/marks, only a handful of
|
||||
* documented divergences) rather than config-shape noise.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Intentional, understood attribute divergences between the canonical schema and
|
||||
* the git-sync mirror. Each entry MUST carry a reason. The test asserts the
|
||||
* allowlist is not stale (every listed attr is actually still divergent), so this
|
||||
* cannot rot into a silent escape hatch.
|
||||
*
|
||||
* Shape: { [nodeOrMarkName]: { canonicalOnly?: string[]; mirrorOnly?: string[] } }
|
||||
*/
|
||||
const ALLOWED_DIVERGENCES: Record<
|
||||
string,
|
||||
{ canonicalOnly?: string[]; mirrorOnly?: string[] }
|
||||
> = {
|
||||
// mirrorOnly: the converter mirror carries `align` on table cells/headers so a
|
||||
// GFM column-alignment marker (:--, :-:, --:) can be reconstructed on export;
|
||||
// editor-ext expresses cell alignment differently. Intentional, round-trip-used.
|
||||
tableCell: { mirrorOnly: ['align'] },
|
||||
tableHeader: { mirrorOnly: ['align'] },
|
||||
// youtube: the mirror adds `align` (media alignment it renders as data-align)
|
||||
// and does NOT carry editor-ext's `start` (video start-time). `start` is a
|
||||
// PRE-EXISTING gap (a youtube embed's start offset is not preserved across a
|
||||
// markdown round trip) — documented here so the contract is green for the known
|
||||
// state and RED for any NEW drift. Follow-up: carry `start` through the mirror.
|
||||
youtube: { mirrorOnly: ['align'], canonicalOnly: ['start'] },
|
||||
// image.title: the mirror carries a `title` attr (used to round-trip the
|
||||
// markdown image title ``) that editor-ext does not declare
|
||||
// on its image node. Mirror-only and round-trip-used, not data loss. Intentional.
|
||||
image: { mirrorOnly: ['title'] },
|
||||
// highlight.colorName (a named-color alias alongside the color value) is a
|
||||
// PRE-EXISTING mirror gap; the color value itself round-trips. Documented.
|
||||
highlight: { canonicalOnly: ['colorName'] },
|
||||
};
|
||||
|
||||
function attrKeys(schema: Schema): Map<string, Set<string>> {
|
||||
const out = new Map<string, Set<string>>();
|
||||
for (const [name, type] of Object.entries(schema.nodes)) {
|
||||
out.set(name, new Set(Object.keys((type.spec as any).attrs ?? {})));
|
||||
}
|
||||
for (const [name, type] of Object.entries(schema.marks)) {
|
||||
out.set(name, new Set(Object.keys((type.spec as any).attrs ?? {})));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function diff(a: Set<string>, b: Set<string>): string[] {
|
||||
return [...a].filter((x) => !b.has(x)).sort();
|
||||
}
|
||||
|
||||
describe('schema attribute contract: git-sync mirror vs canonical editor-ext', () => {
|
||||
const canonical = attrKeys(getSchema(tiptapExtensions as never));
|
||||
const mirror = attrKeys(getSchema(gitSyncExtensions as never));
|
||||
|
||||
it('builds meaningful schemas (guard against a vacuous pass)', () => {
|
||||
expect(canonical.size).toBeGreaterThan(10);
|
||||
expect(mirror.size).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
it('every shared node/mark has matching attribute keys (modulo the allowlist)', () => {
|
||||
const drift: string[] = [];
|
||||
for (const [name, canonAttrs] of canonical) {
|
||||
const mirrorAttrs = mirror.get(name);
|
||||
if (!mirrorAttrs) continue; // name-level gaps are the other test's job
|
||||
const allow = ALLOWED_DIVERGENCES[name] ?? {};
|
||||
const canonicalOnly = diff(canonAttrs, mirrorAttrs).filter(
|
||||
(k) => !(allow.canonicalOnly ?? []).includes(k),
|
||||
);
|
||||
const mirrorOnly = diff(mirrorAttrs, canonAttrs).filter(
|
||||
(k) => !(allow.mirrorOnly ?? []).includes(k),
|
||||
);
|
||||
if (canonicalOnly.length) {
|
||||
drift.push(
|
||||
`${name}: attrs in editor-ext but MISSING from git-sync mirror ` +
|
||||
`(silently dropped on round trip): ${canonicalOnly.join(', ')}`,
|
||||
);
|
||||
}
|
||||
if (mirrorOnly.length) {
|
||||
drift.push(
|
||||
`${name}: attrs in git-sync mirror but NOT in editor-ext ` +
|
||||
`(mirror invented an attribute): ${mirrorOnly.join(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
expect(drift).toEqual([]);
|
||||
});
|
||||
|
||||
it('the allowlist is not stale (every listed divergence is still real)', () => {
|
||||
const stale: string[] = [];
|
||||
for (const [name, allow] of Object.entries(ALLOWED_DIVERGENCES)) {
|
||||
const canonAttrs = canonical.get(name);
|
||||
const mirrorAttrs = mirror.get(name);
|
||||
if (!canonAttrs || !mirrorAttrs) {
|
||||
stale.push(`${name}: no longer a shared node/mark`);
|
||||
continue;
|
||||
}
|
||||
for (const k of allow.canonicalOnly ?? []) {
|
||||
if (!(canonAttrs.has(k) && !mirrorAttrs.has(k))) {
|
||||
stale.push(`${name}.canonicalOnly '${k}' is no longer divergent`);
|
||||
}
|
||||
}
|
||||
for (const k of allow.mirrorOnly ?? []) {
|
||||
if (!(mirrorAttrs.has(k) && !canonAttrs.has(k))) {
|
||||
stale.push(`${name}.mirrorOnly '${k}' is no longer divergent`);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(stale).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -73,6 +73,32 @@ describe('agentSourceFields', () => {
|
||||
).toEqual({ lastUpdatedSource: 'agent', lastUpdatedAiChatId: null });
|
||||
});
|
||||
|
||||
it("stamps ONLY the source column 'git-sync' (no chat key) for a git-sync write", () => {
|
||||
// The git-sync data plane (issue #194 §8.1) has no internal ai_chats row, so
|
||||
// it stamps the *Source column 'git-sync' and OMITS the chat key entirely
|
||||
// (unlike the agent branch, which also writes aiChatId). Pinned directly here
|
||||
// because the page.service.spec only exercises it indirectly.
|
||||
expect(
|
||||
agentSourceFields(
|
||||
{ actor: 'git-sync', aiChatId: null },
|
||||
'lastUpdatedSource',
|
||||
'lastUpdatedAiChatId',
|
||||
),
|
||||
).toEqual({ lastUpdatedSource: 'git-sync' });
|
||||
});
|
||||
|
||||
it("ignores any aiChatId on a git-sync write (chat key never written)", () => {
|
||||
// Even if a non-null aiChatId is present, the git-sync branch must not emit
|
||||
// the chat key.
|
||||
expect(
|
||||
agentSourceFields(
|
||||
{ actor: 'git-sync', aiChatId: 'should-be-ignored' },
|
||||
'createdSource',
|
||||
'aiChatId',
|
||||
),
|
||||
).toEqual({ createdSource: 'git-sync' });
|
||||
});
|
||||
|
||||
it('returns {} for a user write so the column keeps its default', () => {
|
||||
expect(
|
||||
agentSourceFields(
|
||||
|
||||
@@ -9,6 +9,8 @@ import { ProvenanceSource } from '../../core/auth/dto/jwt-payload';
|
||||
* cannot fake an 'agent' marker.
|
||||
*/
|
||||
export interface AuthProvenanceData {
|
||||
// ProvenanceSource includes 'git-sync' — set by the in-process git-sync data
|
||||
// plane (issue #194 §8.1) when it drives PageService writes; never from a request token.
|
||||
actor: ProvenanceSource;
|
||||
aiChatId: string | null;
|
||||
}
|
||||
@@ -60,6 +62,14 @@ export function agentSourceFields<S extends string, C extends string>(
|
||||
sourceKey: S,
|
||||
chatKey: C,
|
||||
): Partial<Record<S, ProvenanceSource> & Record<C, string | null>> {
|
||||
// git-sync data-plane write (issue #194 §8.1): stamp the source 'git-sync' with NO
|
||||
// aiChatId (it has no internal ai_chats row). Mirrors the agent branch; each
|
||||
// write has a single actor, so precedence is irrelevant here.
|
||||
if (provenance?.actor === 'git-sync') {
|
||||
return { [sourceKey]: 'git-sync' } as Partial<
|
||||
Record<S, ProvenanceSource> & Record<C, string | null>
|
||||
>;
|
||||
}
|
||||
if (provenance?.actor !== 'agent') return {};
|
||||
return {
|
||||
[sourceKey]: 'agent',
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Dynamic ESM import bridge for a CommonJS build.
|
||||
*
|
||||
* The server compiles with `module: commonjs`, and TypeScript downlevels a
|
||||
* literal `import()` expression to `require()` — which cannot load an ESM-only
|
||||
* package (`@docmost/mcp`, `@docmost/git-sync`). Indirecting through `new
|
||||
* Function` hides the `import()` from the TS downleveler so the REAL dynamic
|
||||
* `import()` survives to runtime and can load ESM from CommonJS.
|
||||
*
|
||||
* This is the single shared copy of that bridge. The per-package typed loaders
|
||||
* (git-sync.loader.ts, docmost-client.loader.ts, mcp.service.ts) import this and
|
||||
* keep their own typed `loadX()` wrappers (require.resolve + pathToFileURL +
|
||||
* memoization) on top.
|
||||
*/
|
||||
export const esmImport = new Function(
|
||||
'specifier',
|
||||
'return import(specifier)',
|
||||
) as (specifier: string) => Promise<unknown>;
|
||||
@@ -0,0 +1,71 @@
|
||||
import { resolveRequestWorkspace } from './resolve-request-workspace';
|
||||
|
||||
// Unit tests for the shared self-hosted/cloud workspace resolver deduplicated out
|
||||
// of DomainMiddleware + GitHttpService (architecture #11). They must behave
|
||||
// identically, so this pins the single source of truth.
|
||||
|
||||
type AnyMock = jest.Mock;
|
||||
|
||||
function build(opts: {
|
||||
selfHosted: boolean;
|
||||
first?: { id: string } | null;
|
||||
byHostname?: { id: string } | null;
|
||||
}) {
|
||||
const env = {
|
||||
isSelfHosted: jest.fn(() => opts.selfHosted),
|
||||
isCloud: jest.fn(() => !opts.selfHosted),
|
||||
};
|
||||
const repo = {
|
||||
findFirst: jest.fn(async () => opts.first ?? null) as AnyMock,
|
||||
findByHostname: jest.fn(async () => opts.byHostname ?? null) as AnyMock,
|
||||
};
|
||||
return { env, repo };
|
||||
}
|
||||
|
||||
describe('resolveRequestWorkspace', () => {
|
||||
it('self-hosted: returns the first/default workspace, ignoring the host', async () => {
|
||||
const { env, repo } = build({ selfHosted: true, first: { id: 'ws-1' } });
|
||||
const ws = await resolveRequestWorkspace(
|
||||
env as any,
|
||||
repo as any,
|
||||
'anything.example.com',
|
||||
);
|
||||
expect(ws).toEqual({ id: 'ws-1' });
|
||||
expect(repo.findFirst).toHaveBeenCalledTimes(1);
|
||||
expect(repo.findByHostname).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('self-hosted: returns null when no workspace is configured', async () => {
|
||||
const { env, repo } = build({ selfHosted: true, first: null });
|
||||
expect(await resolveRequestWorkspace(env as any, repo as any, 'h')).toBeNull();
|
||||
});
|
||||
|
||||
it('cloud: resolves by the host-header subdomain', async () => {
|
||||
const { env, repo } = build({
|
||||
selfHosted: false,
|
||||
byHostname: { id: 'ws-acme' },
|
||||
});
|
||||
const ws = await resolveRequestWorkspace(
|
||||
env as any,
|
||||
repo as any,
|
||||
'acme.example.com',
|
||||
);
|
||||
expect(ws).toEqual({ id: 'ws-acme' });
|
||||
expect(repo.findByHostname).toHaveBeenCalledWith('acme');
|
||||
expect(repo.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('cloud: returns null for a blank/missing host (no throw)', async () => {
|
||||
const { env, repo } = build({ selfHosted: false, byHostname: { id: 'x' } });
|
||||
expect(await resolveRequestWorkspace(env as any, repo as any, undefined)).toBeNull();
|
||||
expect(await resolveRequestWorkspace(env as any, repo as any, '')).toBeNull();
|
||||
expect(repo.findByHostname).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('cloud: returns null when the subdomain matches no workspace', async () => {
|
||||
const { env, repo } = build({ selfHosted: false, byHostname: null });
|
||||
expect(
|
||||
await resolveRequestWorkspace(env as any, repo as any, 'ghost.example.com'),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
|
||||
import { Workspace } from '@docmost/db/types/entity.types';
|
||||
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
||||
|
||||
/**
|
||||
* The ONE canonical way to resolve the workspace for an incoming request:
|
||||
* - self-hosted (single workspace) -> the first/default workspace;
|
||||
* - cloud (multi-tenant) -> resolved by the host-header subdomain.
|
||||
* Returns null when none resolves (no workspace configured, or a blank/unknown
|
||||
* subdomain on cloud). `isSelfHosted()` is `!isCloud()`, so exactly one branch is
|
||||
* always taken.
|
||||
*
|
||||
* Extracted so the self-hosted/cloud branch is not hand-duplicated. Shared by
|
||||
* `DomainMiddleware` (the normal /api request path) and `GitHttpService` (the raw
|
||||
* root-mounted /git smart-HTTP host, which Nest middleware does NOT run for) so
|
||||
* the two cannot drift.
|
||||
*
|
||||
* This helper does NOT catch DB errors — callers decide: DomainMiddleware lets a
|
||||
* throw bubble (as before); GitHttpService wraps it to log + treat as
|
||||
* unresolvable (-> 404). A blank/missing host on cloud resolves to null rather
|
||||
* than throwing.
|
||||
*/
|
||||
export async function resolveRequestWorkspace(
|
||||
environmentService: EnvironmentService,
|
||||
workspaceRepo: WorkspaceRepo,
|
||||
hostHeader: string | undefined,
|
||||
): Promise<Workspace | null> {
|
||||
if (environmentService.isSelfHosted()) {
|
||||
return (await workspaceRepo.findFirst()) ?? null;
|
||||
}
|
||||
// Cloud (isSelfHosted === !isCloud, so this is the only remaining branch).
|
||||
const subdomain = hostHeader ? hostHeader.split('.')[0] : '';
|
||||
if (!subdomain) return null;
|
||||
return (await workspaceRepo.findByHostname(subdomain)) ?? null;
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Injectable, NestMiddleware, NotFoundException } from '@nestjs/common';
|
||||
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
||||
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
|
||||
import { resolveRequestWorkspace } from '../helpers/resolve-request-workspace';
|
||||
|
||||
@Injectable()
|
||||
export class DomainMiddleware implements NestMiddleware {
|
||||
@@ -14,30 +15,19 @@ export class DomainMiddleware implements NestMiddleware {
|
||||
res: FastifyReply['raw'],
|
||||
next: () => void,
|
||||
) {
|
||||
if (this.environmentService.isSelfHosted()) {
|
||||
const workspace = await this.workspaceRepo.findFirst();
|
||||
if (!workspace) {
|
||||
//throw new NotFoundException('Workspace not found');
|
||||
(req as any).workspaceId = null;
|
||||
return next();
|
||||
}
|
||||
|
||||
// TODO: unify
|
||||
(req as any).workspaceId = workspace.id;
|
||||
(req as any).workspace = workspace;
|
||||
} else if (this.environmentService.isCloud()) {
|
||||
const header = req.headers.host;
|
||||
const subdomain = header.split('.')[0];
|
||||
|
||||
const workspace = await this.workspaceRepo.findByHostname(subdomain);
|
||||
|
||||
if (!workspace) {
|
||||
(req as any).workspaceId = null;
|
||||
return next();
|
||||
}
|
||||
// Shared self-hosted/cloud resolution (the SAME branch the /git host uses),
|
||||
// so the logic cannot drift between the two.
|
||||
const workspace = await resolveRequestWorkspace(
|
||||
this.environmentService,
|
||||
this.workspaceRepo,
|
||||
req.headers.host,
|
||||
);
|
||||
|
||||
if (workspace) {
|
||||
(req as any).workspaceId = workspace.id;
|
||||
(req as any).workspace = workspace;
|
||||
} else {
|
||||
(req as any).workspaceId = null;
|
||||
}
|
||||
|
||||
next();
|
||||
|
||||
@@ -303,11 +303,6 @@ describe('buildSystemPrompt page-changed note (#274)', () => {
|
||||
expect(prompt).toContain(NOTE_MARKER);
|
||||
expect(prompt).toContain('-old line');
|
||||
expect(prompt).toContain('+new line');
|
||||
// Strengthened note (#274): instructs a fresh re-read via getPage and steers
|
||||
// the agent toward small, targeted edits instead of a full-page overwrite.
|
||||
expect(prompt).toContain('getPage');
|
||||
expect(prompt.toLowerCase()).toContain('targeted');
|
||||
expect(prompt).toContain('editPageText');
|
||||
// Inside the safety sandwich: the trailing SAFETY block follows the note.
|
||||
expect(prompt.lastIndexOf(SAFETY_MARKER)).toBeGreaterThan(
|
||||
prompt.indexOf(NOTE_MARKER),
|
||||
|
||||
@@ -85,17 +85,11 @@ const INTERRUPT_NOTE =
|
||||
const PAGE_CHANGED_NOTE =
|
||||
'NOTE: The user edited the open page AFTER your last response in this ' +
|
||||
'conversation, so any copy of that page you produced or remember from earlier ' +
|
||||
'is now STALE and must not be reused. Before you edit the page, you MUST first ' +
|
||||
're-read its current content with the getPage tool and base your work on that ' +
|
||||
'live version — never on your earlier copy or on the transcript. The unified ' +
|
||||
'diff below shows exactly what the user changed since you last spoke (lines ' +
|
||||
'starting with "-" were removed, "+" were added) and is the source of truth. ' +
|
||||
'Preserve every one of the user\'s edits: make the smallest change that ' +
|
||||
'satisfies the request using the targeted edit tools (editPageText, patchNode, ' +
|
||||
'insertNode, deleteNode) rather than replacing the whole page, and do not ' +
|
||||
'revert, drop, or overwrite anything the user changed. If a full rewrite is ' +
|
||||
'truly unavoidable, start from the current getPage content and carry over all ' +
|
||||
'of the user\'s edits.';
|
||||
'is now STALE. The unified diff below shows exactly what changed since you last ' +
|
||||
'spoke (lines starting with "-" were removed, "+" were added) and is the source ' +
|
||||
'of truth. Preserve the user\'s edits: build on the current page, do not revert ' +
|
||||
'or overwrite their changes. If you need the full up-to-date page, re-read it ' +
|
||||
'with the getPage tool before editing.';
|
||||
|
||||
/**
|
||||
* Sanitize a value interpolated into a prompt XML-ish attribute (e.g.
|
||||
|
||||
@@ -356,32 +356,6 @@ describe('flushAssistant', () => {
|
||||
expect(flushed.toolCalls).not.toBeNull();
|
||||
expect(flushed.metadata.error).toBe('boom');
|
||||
});
|
||||
|
||||
// #274 observability: the page-change diff the agent saw this turn is persisted
|
||||
// to metadata.pageChanged when a non-empty diff was injected, and omitted when
|
||||
// the diff is empty/whitespace or the arg is not supplied.
|
||||
it('persists metadata.pageChanged when a non-empty diff was injected', () => {
|
||||
const f = flushAssistant([], '', 'completed', {
|
||||
pageChanged: { title: 'Doc', diff: '@@ -1 +1 @@\n-old\n+new' },
|
||||
});
|
||||
expect(f.metadata.pageChanged).toEqual({
|
||||
title: 'Doc',
|
||||
diff: '@@ -1 +1 @@\n-old\n+new',
|
||||
});
|
||||
});
|
||||
|
||||
it('omits metadata.pageChanged for an empty/whitespace diff or a missing arg', () => {
|
||||
const whitespace = flushAssistant([], '', 'completed', {
|
||||
pageChanged: { title: 'Doc', diff: ' \n ' },
|
||||
});
|
||||
expect('pageChanged' in whitespace.metadata).toBe(false);
|
||||
|
||||
const nullArg = flushAssistant([], '', 'completed', { pageChanged: null });
|
||||
expect('pageChanged' in nullArg.metadata).toBe(false);
|
||||
|
||||
const omitted = flushAssistant([], '', 'streaming');
|
||||
expect('pageChanged' in omitted.metadata).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -685,7 +685,7 @@ export class AiChatService implements OnModuleInit {
|
||||
// no-op (guarded below) so the turn still streams to the user.
|
||||
let assistantId: string | undefined;
|
||||
try {
|
||||
const seed = flushAssistant([], '', 'streaming', { pageChanged });
|
||||
const seed = flushAssistant([], '', 'streaming');
|
||||
const seeded = await this.aiChatMessageRepo.insert({
|
||||
chatId,
|
||||
workspaceId: workspace.id,
|
||||
@@ -720,7 +720,7 @@ export class AiChatService implements OnModuleInit {
|
||||
await this.aiChatMessageRepo.update(
|
||||
assistantId,
|
||||
workspace.id,
|
||||
flushAssistant(capturedSteps, '', 'streaming', { pageChanged }),
|
||||
flushAssistant(capturedSteps, '', 'streaming'),
|
||||
{ onlyIfStreaming: true },
|
||||
);
|
||||
} catch (err) {
|
||||
@@ -860,7 +860,6 @@ export class AiChatService implements OnModuleInit {
|
||||
// resolved from the admin-configured provider settings (in
|
||||
// closure scope here). Omitted/0 = no limit.
|
||||
maxContextTokens: resolved?.chatContextWindow,
|
||||
pageChanged,
|
||||
}),
|
||||
);
|
||||
// Lifecycle: release the external MCP clients leased for this turn.
|
||||
@@ -912,7 +911,6 @@ export class AiChatService implements OnModuleInit {
|
||||
await finalizeAssistant(
|
||||
flushAssistant(capturedSteps, inProgressText, 'error', {
|
||||
error: errorText,
|
||||
pageChanged,
|
||||
}),
|
||||
);
|
||||
await closeExternalClients();
|
||||
@@ -942,9 +940,7 @@ export class AiChatService implements OnModuleInit {
|
||||
`steps=${steps.length}`,
|
||||
);
|
||||
await finalizeAssistant(
|
||||
flushAssistant(capturedSteps, inProgressText, 'aborted', {
|
||||
pageChanged,
|
||||
}),
|
||||
flushAssistant(capturedSteps, inProgressText, 'aborted'),
|
||||
);
|
||||
await closeExternalClients();
|
||||
// Advance the page snapshot even on abort (#274): an agent edit that
|
||||
@@ -1510,7 +1506,6 @@ export function flushAssistant(
|
||||
contextTokens?: number;
|
||||
maxContextTokens?: number;
|
||||
error?: string;
|
||||
pageChanged?: { title: string; diff: string } | null;
|
||||
},
|
||||
): AssistantFlush {
|
||||
const finished = capturedSteps ?? [];
|
||||
@@ -1543,15 +1538,6 @@ export function flushAssistant(
|
||||
if (extra?.maxContextTokens)
|
||||
metadata.maxContextTokens = extra.maxContextTokens;
|
||||
if (extra?.error) metadata.error = extra.error;
|
||||
// Persist the page-change diff the agent saw this turn (#274 observability),
|
||||
// so history / the Markdown export can show what the user changed. Only when
|
||||
// a non-empty diff was actually injected into the prompt this turn.
|
||||
if (extra?.pageChanged && extra.pageChanged.diff?.trim().length) {
|
||||
metadata.pageChanged = {
|
||||
title: extra.pageChanged.title,
|
||||
diff: extra.pageChanged.diff,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: stepsText + trailing,
|
||||
|
||||
@@ -269,168 +269,6 @@ describe('buildChatMarkdown (server) — structure', () => {
|
||||
expect(md).toContain('**⚠️ Error:** 401: Unauthorized');
|
||||
});
|
||||
|
||||
// #274 observability: an assistant row whose turn started with a user edit to
|
||||
// the open page carries metadata.pageChanged = { title, diff }; the export
|
||||
// renders the diff the agent saw, before the message body.
|
||||
it('renders the persisted page-change diff block for an assistant row', () => {
|
||||
const md = buildChatMarkdown({
|
||||
title: 'T',
|
||||
chatId: 'c',
|
||||
rows: [
|
||||
row({
|
||||
role: 'assistant',
|
||||
content: 'answer',
|
||||
metadata: {
|
||||
pageChanged: { title: 'Doc', diff: '@@ -1 +1 @@\n-old\n+new' },
|
||||
} as never,
|
||||
}),
|
||||
],
|
||||
});
|
||||
expect(md).toContain(
|
||||
'The user edited this page before this turn; the diff the agent saw:',
|
||||
);
|
||||
expect(md).toContain('("Doc")');
|
||||
expect(md).toContain('-old');
|
||||
expect(md).toContain('+new');
|
||||
// The diff sits before the message body (chronological: change, then reply).
|
||||
expect(md.indexOf('-old')).toBeLessThan(md.indexOf('answer'));
|
||||
});
|
||||
|
||||
it('does not render the page-change block when metadata.pageChanged is absent', () => {
|
||||
const md = buildChatMarkdown({
|
||||
title: 'T',
|
||||
chatId: 'c',
|
||||
rows: [row({ role: 'assistant', content: 'answer' })],
|
||||
});
|
||||
expect(md).not.toContain(
|
||||
'The user edited this page before this turn; the diff the agent saw:',
|
||||
);
|
||||
});
|
||||
|
||||
// #288 F1/F2: an empty page title must render the BARE heading with no
|
||||
// `("…")` suffix (the `pc.title ? … : …` false branch).
|
||||
it('renders the page-change heading with no title suffix when title is empty', () => {
|
||||
const md = buildChatMarkdown({
|
||||
title: 'T',
|
||||
chatId: 'c',
|
||||
rows: [
|
||||
row({
|
||||
role: 'assistant',
|
||||
content: 'answer',
|
||||
metadata: {
|
||||
pageChanged: { title: '', diff: '@@ -1 +1 @@\n-old\n+new' },
|
||||
} as never,
|
||||
}),
|
||||
],
|
||||
});
|
||||
// Bare heading, single line, no parenthesized title.
|
||||
expect(md).toContain(
|
||||
'> **📝 The user edited this page before this turn; the diff the agent saw:**',
|
||||
);
|
||||
expect(md).not.toContain('("');
|
||||
expect(md).toContain('-old');
|
||||
});
|
||||
|
||||
// #288 F1: the page title is UNTRUSTED cross-user data, so a title carrying a
|
||||
// newline / backtick / `"` / `<`/`>` must be neutralized by escapeAttr before
|
||||
// it is interpolated into the `> **…**` blockquote heading — otherwise it
|
||||
// could break the blockquote onto multiple lines or inject markup/HTML into
|
||||
// the downloaded .md. escapeAttr strips `<>"` and collapses whitespace runs to
|
||||
// a single space, so `Ev"il\n> `x` <b>` becomes ``Evil `x` b``.
|
||||
it('escapes an untrusted page title in the page-change heading', () => {
|
||||
const md = buildChatMarkdown({
|
||||
title: 'T',
|
||||
chatId: 'c',
|
||||
rows: [
|
||||
row({
|
||||
role: 'assistant',
|
||||
content: 'answer',
|
||||
metadata: {
|
||||
pageChanged: {
|
||||
title: 'Ev"il\n> `x` <b>',
|
||||
diff: '@@ -1 +1 @@\n-old\n+new',
|
||||
},
|
||||
} as never,
|
||||
}),
|
||||
],
|
||||
});
|
||||
// The heading stays a single blockquote line with the escaped title.
|
||||
expect(md).toContain(
|
||||
'> **📝 The user edited this page before this turn; the diff the agent saw: ("Evil `x` b")**',
|
||||
);
|
||||
// No raw attribute/markup breakers survived from the title.
|
||||
expect(md).not.toContain('Ev"il');
|
||||
expect(md).not.toContain('<b>');
|
||||
});
|
||||
|
||||
// #288 review F1: escapeAttr ALONE is insufficient for this MARKDOWN sink —
|
||||
// link/image syntax survives it. A cross-user title with `` /
|
||||
// `[phish](url)` must NOT become a working remote image or clickable link in
|
||||
// the downloaded .md; markdownHeadingSafe backslash-escapes `[`/`]` so both are
|
||||
// inert. (Non-vacuous: fails against the escapeAttr-only version, which left
|
||||
// `](https://` intact.)
|
||||
it('neutralizes markdown link/image syntax in an untrusted page title', () => {
|
||||
const md = buildChatMarkdown({
|
||||
title: 'T',
|
||||
chatId: 'c',
|
||||
rows: [
|
||||
row({
|
||||
role: 'assistant',
|
||||
content: 'answer',
|
||||
metadata: {
|
||||
pageChanged: {
|
||||
title:
|
||||
' and [click](https://phish.example)',
|
||||
diff: '@@ -1 +1 @@\n-old\n+new',
|
||||
},
|
||||
} as never,
|
||||
}),
|
||||
],
|
||||
});
|
||||
// No WORKING image/link syntax survives — the `[…]` sits escaped as `\[…\]`,
|
||||
// so the unescaped ``: after escaping the
|
||||
// literal `\](https://` still contains `](https://` as a raw substring — that
|
||||
// check would false-fail even though the link is inert.)
|
||||
expect(md).not.toContain(';
|
||||
expect(md).not.toContain('[click](');
|
||||
// The brackets are backslash-escaped, so `[text](url)`/`` are inert.
|
||||
expect(md).toContain('\\[');
|
||||
expect(md).toContain('\\]');
|
||||
// The heading stays a SINGLE blockquote line (no newline injected).
|
||||
const headingLine = md
|
||||
.split('\n')
|
||||
.find((l) => l.includes('the diff the agent saw:'));
|
||||
expect(headingLine).toBeDefined();
|
||||
expect(headingLine).toContain('\\[x\\]');
|
||||
expect(headingLine).toContain('\\[click\\]');
|
||||
});
|
||||
|
||||
// #288 internal review Finding 2: a NON-empty title made up entirely of
|
||||
// escapeAttr breakers (`<>"`) escapes to '' — the ternary must then fall to the
|
||||
// BARE heading with NO `("…")` suffix. Locks the ternary-on-escaped-value
|
||||
// behavior (distinct from the empty-string input test above).
|
||||
it('renders the bare heading for a title that escapes to empty', () => {
|
||||
const md = buildChatMarkdown({
|
||||
title: 'T',
|
||||
chatId: 'c',
|
||||
rows: [
|
||||
row({
|
||||
role: 'assistant',
|
||||
content: 'answer',
|
||||
metadata: {
|
||||
pageChanged: { title: '<>"', diff: '@@ -1 +1 @@\n-old\n+new' },
|
||||
} as never,
|
||||
}),
|
||||
],
|
||||
});
|
||||
expect(md).toContain(
|
||||
'> **📝 The user edited this page before this turn; the diff the agent saw:**',
|
||||
);
|
||||
expect(md).not.toContain('("');
|
||||
expect(md).toContain('-old');
|
||||
});
|
||||
|
||||
it('escapes embedded triple-backtick fences with a longer delimiter', () => {
|
||||
const md = buildChatMarkdown({
|
||||
title: 'T',
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
*/
|
||||
|
||||
import type { AiChatMessage } from '@docmost/db/types/entity.types';
|
||||
import { escapeAttr } from './ai-chat.prompt';
|
||||
|
||||
/** Supported export label languages. Defaults to English. */
|
||||
export type ExportLang = 'en' | 'ru';
|
||||
@@ -64,7 +63,6 @@ const LABELS: Record<
|
||||
tools: Record<string, string>;
|
||||
ranTool: (name: string) => string;
|
||||
stillGenerating: string;
|
||||
pageEditedByUser: string;
|
||||
}
|
||||
> = {
|
||||
en: {
|
||||
@@ -85,8 +83,6 @@ const LABELS: Record<
|
||||
ranTool: (name) => `Ran tool ${name}`,
|
||||
stillGenerating:
|
||||
'This message is still being generated — the export captured a partial, in-progress response.',
|
||||
pageEditedByUser:
|
||||
'The user edited this page before this turn; the diff the agent saw:',
|
||||
},
|
||||
ru: {
|
||||
untitled: 'Без названия',
|
||||
@@ -106,29 +102,9 @@ const LABELS: Record<
|
||||
ranTool: (name) => `Выполнил инструмент ${name}`,
|
||||
stillGenerating:
|
||||
'Это сообщение всё ещё генерируется — экспорт захватил частичный, незавершённый ответ.',
|
||||
pageEditedByUser:
|
||||
'Пользователь изменил страницу перед этим ходом; дифф, который видел агент:',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Make an untrusted title safe to interpolate into a Markdown blockquote
|
||||
* HEADING. escapeAttr() neutralizes the XML/HTML breakers (`<` `>` `"`) and
|
||||
* collapses whitespace for the PROMPT sink (`page="…"`), but this export sink is
|
||||
* MARKDOWN — link/image syntax survives escapeAttr. So additionally backslash-
|
||||
* escape `[` and `]`: that disables both `[text](url)` links and ``
|
||||
* images, so a cross-user title like `` or `[phish](http://evil)`
|
||||
* cannot inject a remote (auto-loading) image or a clickable link into the
|
||||
* downloaded .md disguised as a trusted system annotation. A bare `(url)` with no
|
||||
* preceding `[]` is inert Markdown, so brackets are the only security-critical
|
||||
* characters here. (We leave backticks to escapeAttr's whitespace pass — a title
|
||||
* shown as inline code cannot escape the blockquote line or load a resource, so
|
||||
* it is not a security concern for this sink.)
|
||||
*/
|
||||
function markdownHeadingSafe(title: string): string {
|
||||
return escapeAttr(title).replace(/[[\]]/g, (m) => `\\${m}`);
|
||||
}
|
||||
|
||||
/** True for AI SDK tool parts (static `tool-*` or `dynamic-tool`). */
|
||||
function isToolPart(type: string): boolean {
|
||||
return type.startsWith('tool-') || type === 'dynamic-tool';
|
||||
@@ -232,23 +208,6 @@ function rowParts(row: AiChatMessage): ExportPart[] {
|
||||
: [{ type: 'text', text: row.content ?? '' }];
|
||||
}
|
||||
|
||||
/** The persisted page-change diff the agent saw this turn (#274), when any. */
|
||||
function pageChangedOf(
|
||||
row: AiChatMessage,
|
||||
): { title: string; diff: string } | undefined {
|
||||
const meta = (row.metadata ?? {}) as {
|
||||
pageChanged?: { title?: string; diff?: string };
|
||||
};
|
||||
const pc = meta.pageChanged;
|
||||
if (pc && typeof pc.diff === 'string' && pc.diff.trim().length > 0) {
|
||||
return {
|
||||
title: typeof pc.title === 'string' ? pc.title : '',
|
||||
diff: pc.diff,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a chat to a Markdown string from its persisted rows. Source = DB
|
||||
* ONLY (no live client state). A row whose `status` is still 'streaming' is an
|
||||
@@ -307,26 +266,6 @@ export function buildChatMarkdown(args: {
|
||||
blocks.push(`<!-- ${iso} -->`);
|
||||
}
|
||||
|
||||
// Page-change observability (#274): show the diff the agent saw at the start
|
||||
// of this turn, before its response, so the export reflects the stale-page
|
||||
// warning the model received.
|
||||
const pc = pageChangedOf(row);
|
||||
if (pc) {
|
||||
// The page title is UNTRUSTED cross-user data (a collaborative page's title
|
||||
// controllable by another user). escapeAttr() alone (the prompt sink) is
|
||||
// INSUFFICIENT here: this is a MARKDOWN sink, so we neutralize link/image
|
||||
// syntax too (backslash-escaping `[`/`]`) before interpolating it into this
|
||||
// `> **…**` blockquote heading — otherwise `` / `[phish](url)` would
|
||||
// inject a remote image or clickable link into the downloaded .md. An
|
||||
// all-`<>"` title escapes to empty and correctly falls to the bare heading.
|
||||
// The diff body is already safe via fence(). (#288 review F1.)
|
||||
const safeTitle = markdownHeadingSafe(pc.title);
|
||||
const heading = safeTitle
|
||||
? `${L.pageEditedByUser} ("${safeTitle}")`
|
||||
: L.pageEditedByUser;
|
||||
blocks.push(`> **📝 ${heading}**\n\n${fence(pc.diff, 'diff')}`);
|
||||
}
|
||||
|
||||
blocks.push(...renderMessageParts(rowParts(row), lang));
|
||||
|
||||
// A still-'streaming' row is an interrupted/in-progress turn captured by the
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { esmImport } from '../../../common/helpers/esm-import';
|
||||
|
||||
/**
|
||||
* Minimal structural type for the `DocmostClient` class we consume from the
|
||||
@@ -240,14 +241,8 @@ interface DocmostMcpModule {
|
||||
SHARED_TOOL_SPECS: Record<string, SharedToolSpec>;
|
||||
}
|
||||
|
||||
// TS with module:commonjs downlevels a literal `import()` to `require()`, which
|
||||
// cannot load the ESM-only `@docmost/mcp` package. Indirect through Function so
|
||||
// the real dynamic `import()` survives compilation and can load ESM from
|
||||
// CommonJS at runtime (same trick as integrations/mcp/mcp.service.ts).
|
||||
const esmImport = new Function(
|
||||
'specifier',
|
||||
'return import(specifier)',
|
||||
) as (specifier: string) => Promise<unknown>;
|
||||
// The CJS->ESM dynamic-import bridge lives in one shared helper
|
||||
// (common/helpers/esm-import.ts). The typed `loadDocmostMcp()` wrapper stays here.
|
||||
|
||||
// Memoize the in-flight/loaded module so the dynamic import runs at most once.
|
||||
let modulePromise: Promise<DocmostMcpModule> | null = null;
|
||||
|
||||
@@ -3,8 +3,12 @@
|
||||
* from the SIGNED token claim (never a request body), so 'agent' is unspoofable.
|
||||
* Single source of truth so a typo like 'agnet' can't slip through as a bare
|
||||
* string (#143 review). Distinct from `ActorType` (auth principal kind).
|
||||
*
|
||||
* 'git-sync' marks writes made by the git-sync data plane (issue #194 §8.1). It NEVER
|
||||
* travels in a user-facing token; it is set in-process on the collab connection
|
||||
* context by the native datasource, so it cannot be spoofed from a request.
|
||||
*/
|
||||
export type ProvenanceSource = 'user' | 'agent';
|
||||
export type ProvenanceSource = 'user' | 'agent' | 'git-sync';
|
||||
|
||||
export enum JwtType {
|
||||
ACCESS = 'access',
|
||||
@@ -26,7 +30,8 @@ export type JwtPayload = {
|
||||
// normal user token (treated as 'user'); set only when the internal agent
|
||||
// mints a provenance access token so REST writes (create/rename/move page,
|
||||
// comment create/resolve) record a non-spoofable 'agent' marker (§6.5 / §15
|
||||
// C3 / §14 N2).
|
||||
// C3 / §14 N2). (git-sync writes use the in-process actor, not a token — see
|
||||
// the ProvenanceSource note.)
|
||||
actor?: ProvenanceSource;
|
||||
// Nullable: an external MCP agent has no internal ai_chats row, so it carries
|
||||
// an 'agent' actor with a null aiChatId.
|
||||
@@ -39,7 +44,8 @@ export type JwtCollabPayload = {
|
||||
type: 'collab';
|
||||
// Optional agent-edit provenance, signed into the collab token. Absent for
|
||||
// the human collab path (treated as 'user'); set only when the internal agent
|
||||
// mints a provenance collab token (§6.6 / §15 C2).
|
||||
// mints a provenance collab token (§6.6 / §15 C2). 'git-sync' (in ProvenanceSource)
|
||||
// is accepted for type-compatibility with the in-process git-sync write path.
|
||||
actor?: ProvenanceSource;
|
||||
// Nullable: an external MCP agent has no internal ai_chats row, so it carries
|
||||
// an 'agent' actor with a null aiChatId.
|
||||
|
||||
@@ -1,237 +0,0 @@
|
||||
import { CommentService } from './comment.service';
|
||||
|
||||
/**
|
||||
* Caller-contract coverage for the three live comment broadcasts (#300/#304):
|
||||
* - commentCreated (create @153)
|
||||
* - commentUpdated (update @214) ← the fragile path this suite spotlights
|
||||
* - commentResolved (resolveComment @283)
|
||||
*
|
||||
* All three must emit a payload carrying the {agent,launcher} avatar stack for an
|
||||
* AGENT comment, and NEITHER field for a non-agent comment. The enrichment lives
|
||||
* in CommentRepo.findById(..., {includeCreator:true}); the service contract these
|
||||
* tests pin is that every broadcast reads its payload from that enriched
|
||||
* single-row load rather than from an un-enriched object.
|
||||
*
|
||||
* NON-VACUITY for the update path: the service is handed an UN-enriched input
|
||||
* comment (no agent/launcher), while findById returns the ENRICHED shape. The
|
||||
* pre-#304 update() re-emitted the caller's object in place, so it would emit the
|
||||
* un-enriched input and the `agent`/`launcher` assertions would FAIL. The fix
|
||||
* re-fetches via findById, so the broadcast carries the stack regardless of how
|
||||
* the caller pre-loaded the comment.
|
||||
*/
|
||||
describe('CommentService — broadcast carries the agent avatar stack', () => {
|
||||
// An enriched agent comment as CommentRepo.findById(..., includeCreator:true)
|
||||
// returns it: the {agent,launcher} pair is attached and agentRole is stripped.
|
||||
const enrichedAgentComment = (over?: Record<string, unknown>) => ({
|
||||
id: 'comment-new',
|
||||
pageId: 'page-1',
|
||||
spaceId: 'space-1',
|
||||
workspaceId: 'ws-1',
|
||||
content: { type: 'doc', content: [] },
|
||||
createdSource: 'agent',
|
||||
agent: { name: 'Researcher', emoji: '🔬', avatarUrl: null },
|
||||
launcher: { name: 'Alice', avatarUrl: 'a.png' },
|
||||
...over,
|
||||
});
|
||||
|
||||
// A plain human comment: findById attaches neither agent nor launcher.
|
||||
const plainHumanComment = (over?: Record<string, unknown>) => ({
|
||||
id: 'comment-new',
|
||||
pageId: 'page-1',
|
||||
spaceId: 'space-1',
|
||||
workspaceId: 'ws-1',
|
||||
content: { type: 'doc', content: [] },
|
||||
createdSource: 'user',
|
||||
...over,
|
||||
});
|
||||
|
||||
function makeService(findByIdReturn: unknown) {
|
||||
const commentRepo: any = {
|
||||
// In these flows findById is only the post-write enriched re-read
|
||||
// (no parentCommentId is set, so no parent lookup path is taken).
|
||||
findById: jest.fn(async () => findByIdReturn),
|
||||
insertComment: jest.fn(async () => ({ id: 'comment-new' })),
|
||||
updateComment: jest.fn(async () => undefined),
|
||||
};
|
||||
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 service = new CommentService(
|
||||
commentRepo,
|
||||
pageRepo,
|
||||
wsService,
|
||||
collaborationGateway,
|
||||
generalQueue,
|
||||
notificationQueue,
|
||||
);
|
||||
|
||||
return { service, commentRepo, wsService };
|
||||
}
|
||||
|
||||
// Pull the emitted event object (3rd arg of emitCommentEvent) for an operation.
|
||||
const emittedEvent = (wsService: any, operation: string) =>
|
||||
wsService.emitCommentEvent.mock.calls
|
||||
.map((c: any[]) => c[2])
|
||||
.find((e: any) => e.operation === operation);
|
||||
|
||||
const page = { id: 'page-1', spaceId: 'space-1' } as any;
|
||||
const user = (id = 'user-1') => ({ id }) as any;
|
||||
const emptyDoc = JSON.stringify({ type: 'doc', content: [] });
|
||||
|
||||
describe('commentCreated', () => {
|
||||
it('emits agent + launcher for an agent comment', async () => {
|
||||
const { service, wsService } = makeService(enrichedAgentComment());
|
||||
|
||||
await service.create(
|
||||
{ page, workspaceId: 'ws-1', user: user() },
|
||||
{ content: emptyDoc } as any,
|
||||
{ actor: 'agent', aiChatId: 'chat-1' },
|
||||
);
|
||||
|
||||
const event = emittedEvent(wsService, 'commentCreated');
|
||||
expect(event).toBeDefined();
|
||||
expect(event.comment.agent).toEqual({
|
||||
name: 'Researcher',
|
||||
emoji: '🔬',
|
||||
avatarUrl: null,
|
||||
});
|
||||
expect(event.comment.launcher).toEqual({ name: 'Alice', avatarUrl: 'a.png' });
|
||||
});
|
||||
|
||||
it('emits neither field for a non-agent comment', async () => {
|
||||
const { service, wsService } = makeService(plainHumanComment());
|
||||
|
||||
await service.create(
|
||||
{ page, workspaceId: 'ws-1', user: user() },
|
||||
{ content: emptyDoc } as any,
|
||||
);
|
||||
|
||||
const event = emittedEvent(wsService, 'commentCreated');
|
||||
expect(event).toBeDefined();
|
||||
expect(event.comment).not.toHaveProperty('agent');
|
||||
expect(event.comment).not.toHaveProperty('launcher');
|
||||
});
|
||||
});
|
||||
|
||||
describe('commentUpdated — the fragile path (spotlight)', () => {
|
||||
it('emits agent + launcher even when the caller pre-loaded an UN-enriched comment', async () => {
|
||||
// findById (the re-fetch) returns the enriched shape...
|
||||
const { service, wsService, commentRepo } = makeService(
|
||||
enrichedAgentComment(),
|
||||
);
|
||||
|
||||
// ...but the caller hands in an object with NO agent/launcher. The pre-#304
|
||||
// update() re-emitted THIS object in place, so this test fails against it;
|
||||
// the re-fetch fix makes the broadcast independent of the pre-load.
|
||||
const inputComment: any = {
|
||||
id: 'comment-new',
|
||||
creatorId: 'user-1',
|
||||
pageId: 'page-1',
|
||||
spaceId: 'space-1',
|
||||
workspaceId: 'ws-1',
|
||||
content: { type: 'doc', content: [] },
|
||||
// deliberately no `agent` / `launcher`
|
||||
};
|
||||
|
||||
await service.update(
|
||||
inputComment,
|
||||
{ content: emptyDoc } as any,
|
||||
user('user-1'),
|
||||
);
|
||||
|
||||
// The broadcast must re-read the enriched row (persisted update, then load).
|
||||
expect(commentRepo.updateComment).toHaveBeenCalled();
|
||||
expect(commentRepo.findById).toHaveBeenCalledWith('comment-new', {
|
||||
includeCreator: true,
|
||||
includeResolvedBy: true,
|
||||
});
|
||||
|
||||
const event = emittedEvent(wsService, 'commentUpdated');
|
||||
expect(event).toBeDefined();
|
||||
expect(event.comment.agent).toEqual({
|
||||
name: 'Researcher',
|
||||
emoji: '🔬',
|
||||
avatarUrl: null,
|
||||
});
|
||||
expect(event.comment.launcher).toEqual({ name: 'Alice', avatarUrl: 'a.png' });
|
||||
});
|
||||
|
||||
it('emits neither field for a non-agent comment', async () => {
|
||||
const { service, wsService } = makeService(plainHumanComment());
|
||||
|
||||
const inputComment: any = {
|
||||
id: 'comment-new',
|
||||
creatorId: 'user-1',
|
||||
pageId: 'page-1',
|
||||
spaceId: 'space-1',
|
||||
workspaceId: 'ws-1',
|
||||
content: { type: 'doc', content: [] },
|
||||
};
|
||||
|
||||
await service.update(
|
||||
inputComment,
|
||||
{ content: emptyDoc } as any,
|
||||
user('user-1'),
|
||||
);
|
||||
|
||||
const event = emittedEvent(wsService, 'commentUpdated');
|
||||
expect(event).toBeDefined();
|
||||
expect(event.comment).not.toHaveProperty('agent');
|
||||
expect(event.comment).not.toHaveProperty('launcher');
|
||||
});
|
||||
});
|
||||
|
||||
describe('commentResolved', () => {
|
||||
it('emits agent + launcher for an agent comment', async () => {
|
||||
const { service, wsService } = makeService(enrichedAgentComment());
|
||||
|
||||
await service.resolveComment(
|
||||
{
|
||||
id: 'comment-new',
|
||||
creatorId: 'user-1',
|
||||
pageId: 'page-1',
|
||||
spaceId: 'space-1',
|
||||
workspaceId: 'ws-1',
|
||||
} as any,
|
||||
true,
|
||||
user('user-1'),
|
||||
{ actor: 'agent', aiChatId: 'chat-1' },
|
||||
);
|
||||
|
||||
const event = emittedEvent(wsService, 'commentResolved');
|
||||
expect(event).toBeDefined();
|
||||
expect(event.comment.agent).toEqual({
|
||||
name: 'Researcher',
|
||||
emoji: '🔬',
|
||||
avatarUrl: null,
|
||||
});
|
||||
expect(event.comment.launcher).toEqual({ name: 'Alice', avatarUrl: 'a.png' });
|
||||
});
|
||||
|
||||
it('emits neither field for a non-agent comment', async () => {
|
||||
const { service, wsService } = makeService(plainHumanComment());
|
||||
|
||||
await service.resolveComment(
|
||||
{
|
||||
id: 'comment-new',
|
||||
creatorId: 'user-1',
|
||||
pageId: 'page-1',
|
||||
spaceId: 'space-1',
|
||||
workspaceId: 'ws-1',
|
||||
} as any,
|
||||
true,
|
||||
user('user-1'),
|
||||
);
|
||||
|
||||
const event = emittedEvent(wsService, 'commentResolved');
|
||||
expect(event).toBeDefined();
|
||||
expect(event.comment).not.toHaveProperty('agent');
|
||||
expect(event.comment).not.toHaveProperty('launcher');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -207,27 +207,17 @@ export class CommentService {
|
||||
false,
|
||||
);
|
||||
|
||||
// Re-fetch the enriched comment before broadcasting, symmetric with
|
||||
// create()/resolveComment(). updateComment() above has already persisted the
|
||||
// new content/timestamps, so this single-row read reflects the edit AND
|
||||
// carries the same {agent,launcher} avatar stack (via includeCreator) as the
|
||||
// other two broadcasts. This deliberately does NOT reuse the caller's
|
||||
// pre-loaded `comment`: relying on the controller happening to load it with
|
||||
// includeCreator:true is exactly the fragile coupling that let the agent
|
||||
// stack silently vanish on edit once already (#300/#304) — a future caller
|
||||
// dropping that flag must not regress the broadcast.
|
||||
const updatedComment = await this.commentRepo.findById(comment.id, {
|
||||
includeCreator: true,
|
||||
includeResolvedBy: true,
|
||||
});
|
||||
comment.content = commentContent;
|
||||
comment.editedAt = editedAt;
|
||||
comment.updatedAt = editedAt;
|
||||
|
||||
this.wsService.emitCommentEvent(comment.spaceId, comment.pageId, {
|
||||
operation: 'commentUpdated',
|
||||
pageId: comment.pageId,
|
||||
comment: updatedComment,
|
||||
comment,
|
||||
});
|
||||
|
||||
return updatedComment;
|
||||
return comment;
|
||||
}
|
||||
|
||||
async resolveComment(
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { PageService } from './page.service';
|
||||
import { MovePageDto } from '../dto/move-page.dto';
|
||||
import { Page } from '@docmost/db/types/entity.types';
|
||||
import { CreatePageDto } from '../dto/create-page.dto';
|
||||
import { UpdatePageDto } from '../dto/update-page.dto';
|
||||
import { Page, User } from '@docmost/db/types/entity.types';
|
||||
import { DEFAULT_TEMPORARY_NOTE_HOURS } from '../constants/temporary-note.constants';
|
||||
import { AuthProvenanceData } from '../../../common/decorators/auth-provenance.decorator';
|
||||
|
||||
// Direct instantiation with stub deps. The Test.createTestingModule form failed
|
||||
// to resolve the @InjectKysely()/@InjectQueue() tokens at compile(), and this
|
||||
@@ -496,4 +499,295 @@ describe('PageService', () => {
|
||||
expect(db.selectFrom).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('git-sync provenance stamping (#1)', () => {
|
||||
const GIT_SYNC: AuthProvenanceData = { actor: 'git-sync', aiChatId: null };
|
||||
const USER_PROVENANCE: AuthProvenanceData = { actor: 'user', aiChatId: null };
|
||||
|
||||
describe('create()', () => {
|
||||
// Build a service whose insertPage/generalQueue are observable and whose
|
||||
// nextPagePosition (a DB query) is stubbed, so create() reaches insertPage
|
||||
// without a real database.
|
||||
const makeService = () => {
|
||||
const insertedPage = { id: 'page-1', slugId: 'slug-1' };
|
||||
const pageRepo = {
|
||||
insertPage: jest.fn().mockResolvedValue(insertedPage),
|
||||
};
|
||||
// add() is fire-and-forget (the service .catch()es it); resolve so no
|
||||
// unhandled rejection leaks.
|
||||
const generalQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||
|
||||
const svc = new PageService(
|
||||
pageRepo as any, // pageRepo
|
||||
{} as any, // pagePermissionRepo
|
||||
{} as any, // attachmentRepo
|
||||
{} as any, // db
|
||||
{} as any, // storageService
|
||||
{} as any, // attachmentQueue
|
||||
{} as any, // aiQueue
|
||||
generalQueue as any, // generalQueue
|
||||
{} as any, // eventEmitter
|
||||
{} as any, // collaborationGateway
|
||||
{} as any, // watcherService
|
||||
{} as any, // transclusionService
|
||||
);
|
||||
|
||||
// nextPagePosition runs a kysely query; stub it so create() never hits
|
||||
// the db. No DTO content is provided, so parseProsemirrorContent is
|
||||
// skipped entirely (content/textContent/ydoc stay undefined).
|
||||
jest.spyOn(svc, 'nextPagePosition').mockResolvedValue('a0');
|
||||
|
||||
return { svc, pageRepo };
|
||||
};
|
||||
|
||||
const createDto: CreatePageDto = {
|
||||
title: 'New page',
|
||||
spaceId: 'space-1',
|
||||
} as any;
|
||||
|
||||
it("stamps lastUpdatedSource:'git-sync' on the insertPage payload", async () => {
|
||||
const { svc, pageRepo } = makeService();
|
||||
|
||||
await svc.create('user-1', 'ws-1', createDto, GIT_SYNC);
|
||||
|
||||
expect(pageRepo.insertPage).toHaveBeenCalledTimes(1);
|
||||
expect(pageRepo.insertPage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ lastUpdatedSource: 'git-sync' }),
|
||||
);
|
||||
// git-sync carries no aiChatId (unlike the agent branch).
|
||||
const payload = pageRepo.insertPage.mock.calls[0][0];
|
||||
expect(payload.lastUpdatedAiChatId).toBeUndefined();
|
||||
// The human stays the responsible author.
|
||||
expect(payload.creatorId).toBe('user-1');
|
||||
expect(payload.lastUpdatedById).toBe('user-1');
|
||||
});
|
||||
|
||||
it('leaves the source column unset for a plain user create', async () => {
|
||||
const { svc, pageRepo } = makeService();
|
||||
|
||||
await svc.create('user-1', 'ws-1', createDto, USER_PROVENANCE);
|
||||
|
||||
const payload = pageRepo.insertPage.mock.calls[0][0];
|
||||
expect(payload.lastUpdatedSource).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('update() (rename)', () => {
|
||||
const makeService = () => {
|
||||
const pageRepo = {
|
||||
updatePage: jest.fn().mockResolvedValue({ numUpdatedRows: 1n }),
|
||||
// update() re-reads the row at the end to return the refreshed page.
|
||||
findById: jest.fn().mockResolvedValue({ id: 'page-1' }),
|
||||
};
|
||||
const generalQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||
const aiQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||
|
||||
const svc = new PageService(
|
||||
pageRepo as any, // pageRepo
|
||||
{} as any, // pagePermissionRepo
|
||||
{} as any, // attachmentRepo
|
||||
{} as any, // db
|
||||
{} as any, // storageService
|
||||
{} as any, // attachmentQueue
|
||||
aiQueue as any, // aiQueue
|
||||
generalQueue as any, // generalQueue
|
||||
{} as any, // eventEmitter
|
||||
{} as any, // collaborationGateway
|
||||
{} as any, // watcherService
|
||||
{} as any, // transclusionService
|
||||
);
|
||||
|
||||
return { svc, pageRepo };
|
||||
};
|
||||
|
||||
const page: Page = {
|
||||
id: 'page-1',
|
||||
slugId: 'slug-1',
|
||||
spaceId: 'space-1',
|
||||
workspaceId: 'ws-1',
|
||||
title: 'Old title',
|
||||
icon: null,
|
||||
parentPageId: null,
|
||||
contributorIds: [],
|
||||
} as any;
|
||||
|
||||
const user: User = { id: 'user-1' } as any;
|
||||
|
||||
it("stamps lastUpdatedSource:'git-sync' on the updatePage payload", async () => {
|
||||
const { svc, pageRepo } = makeService();
|
||||
const dto: UpdatePageDto = { title: 'New title' } as any;
|
||||
|
||||
await svc.update(page, dto, user, GIT_SYNC);
|
||||
|
||||
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
|
||||
const payload = pageRepo.updatePage.mock.calls[0][0];
|
||||
expect(payload.lastUpdatedSource).toBe('git-sync');
|
||||
expect(payload.lastUpdatedAiChatId).toBeUndefined();
|
||||
// The acting user stays the responsible author.
|
||||
expect(payload.lastUpdatedById).toBe('user-1');
|
||||
});
|
||||
|
||||
it('leaves the source column unset for a plain user rename', async () => {
|
||||
const { svc, pageRepo } = makeService();
|
||||
const dto: UpdatePageDto = { title: 'New title' } as any;
|
||||
|
||||
await svc.update(page, dto, user, USER_PROVENANCE);
|
||||
|
||||
const payload = pageRepo.updatePage.mock.calls[0][0];
|
||||
expect(payload.lastUpdatedSource).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('movePage()', () => {
|
||||
const SPACE_ID = 'space-1';
|
||||
const VALID_POSITION = 'a0';
|
||||
|
||||
const makeService = () => {
|
||||
const pageRepo = {
|
||||
findById: jest.fn().mockResolvedValue({
|
||||
id: 'dest-parent',
|
||||
deletedAt: null,
|
||||
spaceId: SPACE_ID,
|
||||
}),
|
||||
updatePage: jest.fn().mockResolvedValue({ numUpdatedRows: 1n }),
|
||||
};
|
||||
const eventEmitter = { emit: jest.fn() };
|
||||
|
||||
// movePage now runs the cycle-check + UPDATE inside executeTx(this.db),
|
||||
// i.e. this.db.transaction().execute(fn => fn(trx)). A permissive
|
||||
// chainable Proxy stands in for the Kysely trx so the per-space
|
||||
// advisory-lock `sql``.execute(trx)` resolves and updatePage runs.
|
||||
const trxStub: any = new Proxy(function () {}, {
|
||||
get: (_t, p) =>
|
||||
p === 'then'
|
||||
? undefined
|
||||
: p === 'execute' || p === 'executeTakeFirst'
|
||||
? () => Promise.resolve([])
|
||||
: () => trxStub,
|
||||
});
|
||||
const db = {
|
||||
transaction: () => ({ execute: (fn: any) => fn(trxStub) }),
|
||||
};
|
||||
|
||||
const svc = new PageService(
|
||||
pageRepo as any, // pageRepo
|
||||
{} as any, // pagePermissionRepo
|
||||
{} as any, // attachmentRepo
|
||||
db as any, // db
|
||||
{} as any, // storageService
|
||||
{} as any, // attachmentQueue
|
||||
{} as any, // aiQueue
|
||||
{} as any, // generalQueue
|
||||
eventEmitter as any, // eventEmitter
|
||||
{} as any, // collaborationGateway
|
||||
{} as any, // watcherService
|
||||
{} as any, // transclusionService
|
||||
);
|
||||
|
||||
// No cycle: the destination's ancestor chain does not contain the moved
|
||||
// page, so movePage reaches updatePage.
|
||||
jest
|
||||
.spyOn(svc, 'getPageBreadCrumbs')
|
||||
.mockResolvedValue([{ id: 'dest-parent' }, { id: 'root' }] as any);
|
||||
|
||||
return { svc, pageRepo };
|
||||
};
|
||||
|
||||
const movedPage: Page = {
|
||||
id: 'page-1',
|
||||
parentPageId: 'old-parent',
|
||||
spaceId: SPACE_ID,
|
||||
workspaceId: 'ws-1',
|
||||
slugId: 'slug-1',
|
||||
title: 'Page 1',
|
||||
icon: null,
|
||||
} as any;
|
||||
|
||||
const dto: MovePageDto = {
|
||||
pageId: 'page-1',
|
||||
position: VALID_POSITION,
|
||||
parentPageId: 'dest-parent',
|
||||
};
|
||||
|
||||
it("stamps lastUpdatedSource:'git-sync' on the updatePage payload", async () => {
|
||||
const { svc, pageRepo } = makeService();
|
||||
|
||||
await svc.movePage(dto, movedPage, GIT_SYNC);
|
||||
|
||||
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
|
||||
const payload = pageRepo.updatePage.mock.calls[0][0];
|
||||
expect(payload.lastUpdatedSource).toBe('git-sync');
|
||||
expect(payload.lastUpdatedAiChatId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('leaves the source column unset for a plain user move', async () => {
|
||||
const { svc, pageRepo } = makeService();
|
||||
|
||||
await svc.movePage(dto, movedPage, USER_PROVENANCE);
|
||||
|
||||
const payload = pageRepo.updatePage.mock.calls[0][0];
|
||||
expect(payload.lastUpdatedSource).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('removePage()', () => {
|
||||
// removePage forwards a `source` 4th arg to pageRepo.removePage: 'git-sync'
|
||||
// for a git-sync-driven soft-delete (so the change-listener loop-guard skips
|
||||
// its own write), undefined otherwise.
|
||||
const makeService = () => {
|
||||
const pageRepo = {
|
||||
removePage: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const svc = new PageService(
|
||||
pageRepo as any, // pageRepo
|
||||
{} as any, // pagePermissionRepo
|
||||
{} as any, // attachmentRepo
|
||||
{} as any, // db
|
||||
{} as any, // storageService
|
||||
{} as any, // attachmentQueue
|
||||
{} as any, // aiQueue
|
||||
{} as any, // generalQueue
|
||||
{} as any, // eventEmitter
|
||||
{} as any, // collaborationGateway
|
||||
{} as any, // watcherService
|
||||
{} as any, // transclusionService
|
||||
);
|
||||
|
||||
return { svc, pageRepo };
|
||||
};
|
||||
|
||||
it("forwards 'git-sync' as the source for a git-sync soft-delete", async () => {
|
||||
const { svc, pageRepo } = makeService();
|
||||
|
||||
await svc.removePage('page-1', 'user-1', 'ws-1', GIT_SYNC);
|
||||
|
||||
expect(pageRepo.removePage).toHaveBeenCalledTimes(1);
|
||||
const [pageId, userId, workspaceId, source] =
|
||||
pageRepo.removePage.mock.calls[0];
|
||||
expect(pageId).toBe('page-1');
|
||||
expect(userId).toBe('user-1');
|
||||
expect(workspaceId).toBe('ws-1');
|
||||
expect(source).toBe('git-sync');
|
||||
});
|
||||
|
||||
it('forwards undefined as the source for a plain user delete', async () => {
|
||||
const { svc, pageRepo } = makeService();
|
||||
|
||||
await svc.removePage('page-1', 'user-1', 'ws-1', USER_PROVENANCE);
|
||||
|
||||
const [, , , source] = pageRepo.removePage.mock.calls[0];
|
||||
expect(source).toBeUndefined();
|
||||
});
|
||||
|
||||
it('forwards undefined as the source when no provenance is given', async () => {
|
||||
const { svc, pageRepo } = makeService();
|
||||
|
||||
await svc.removePage('page-1', 'user-1', 'ws-1');
|
||||
|
||||
const [, , , source] = pageRepo.removePage.mock.calls[0];
|
||||
expect(source).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -948,6 +948,12 @@ export class PageService {
|
||||
// Optional agent-edit provenance (from the signed access claim). Stamps the
|
||||
// source marker when the agent moves a page via REST (§6.6 REST path).
|
||||
provenance?: AuthProvenanceData,
|
||||
// Optional responsible author. When set (git-sync), the move is ATTRIBUTED
|
||||
// to that account via `lastUpdatedById` — parity with create/delete/rename,
|
||||
// which all stamp the service user. A normal user move omits it, leaving
|
||||
// `lastUpdatedById` untouched (a reparent is not a content edit, so the
|
||||
// existing author is preserved — unchanged behavior).
|
||||
actorUserId?: string,
|
||||
) {
|
||||
// validate position value by attempting to generate a key
|
||||
try {
|
||||
@@ -1017,6 +1023,9 @@ export class PageService {
|
||||
{
|
||||
position: dto.position,
|
||||
parentPageId: parentPageId,
|
||||
// Attribute a git-initiated move to the service account (parity with
|
||||
// create/delete/rename). Omitted for normal user moves -> unchanged.
|
||||
...(actorUserId ? { lastUpdatedById: actorUserId } : {}),
|
||||
// Agent-edit provenance: annotate the source on an agent move. A
|
||||
// normal user request leaves the existing source value unchanged.
|
||||
...agentSourceFields(
|
||||
@@ -1289,8 +1298,18 @@ export class PageService {
|
||||
pageId: string,
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
// Optional provenance. A git-sync-driven soft-delete stamps
|
||||
// `lastUpdatedSource = 'git-sync'` so the change-listener loop-guard skips
|
||||
// its own write (mirrors the create/update/move provenance branches above).
|
||||
provenance?: AuthProvenanceData,
|
||||
): Promise<void> {
|
||||
await this.pageRepo.removePage(pageId, userId, workspaceId);
|
||||
const isGitSync = provenance?.actor === 'git-sync';
|
||||
await this.pageRepo.removePage(
|
||||
pageId,
|
||||
userId,
|
||||
workspaceId,
|
||||
isGitSync ? 'git-sync' : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
private async parseProsemirrorContent(
|
||||
|
||||
@@ -15,4 +15,12 @@ export class UpdateSpaceDto extends PartialType(CreateSpaceDto) {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
allowViewerComments: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
gitSyncEnabled?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
autoMergeConflicts?: boolean;
|
||||
}
|
||||
|
||||
@@ -22,4 +22,199 @@ describe('SpaceService', () => {
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('updateSpace gitSyncEnabled', () => {
|
||||
const workspaceId = 'ws-1';
|
||||
const spaceId = 'space-1';
|
||||
|
||||
// executeTx runs the callback immediately with a passthrough trx so the
|
||||
// repo calls happen inline; mirrors how the sibling sharing/comments flags
|
||||
// are persisted.
|
||||
const buildService = (settingsBefore: Record<string, any>) => {
|
||||
const spaceRepo = {
|
||||
findById: jest.fn().mockResolvedValue({
|
||||
id: spaceId,
|
||||
name: 'Space',
|
||||
slug: 'space',
|
||||
description: '',
|
||||
settings: settingsBefore,
|
||||
}),
|
||||
updateGitSyncSettings: jest.fn().mockResolvedValue({}),
|
||||
updateSharingSettings: jest.fn().mockResolvedValue({}),
|
||||
updateCommentSettings: jest.fn().mockResolvedValue({}),
|
||||
updateSpace: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ id: spaceId, name: 'Space', slug: 'space' }),
|
||||
slugExists: jest.fn().mockResolvedValue(false),
|
||||
};
|
||||
const auditService = { log: jest.fn() };
|
||||
|
||||
const svc = new SpaceService(
|
||||
spaceRepo as any,
|
||||
{} as any, // spaceMemberService
|
||||
{} as any, // shareRepo
|
||||
{} as any, // workspaceRepo
|
||||
{} as any, // licenseCheckService
|
||||
{} as any, // db
|
||||
{} as any, // attachmentQueue
|
||||
auditService as any,
|
||||
);
|
||||
|
||||
// executeTx is invoked via the imported helper; patch it on the module.
|
||||
jest
|
||||
.spyOn(require('@docmost/db/utils'), 'executeTx')
|
||||
.mockImplementation(async (_db: any, cb: any) => cb({} as any));
|
||||
|
||||
return { svc, spaceRepo, auditService };
|
||||
};
|
||||
|
||||
it('persists gitSyncEnabled via updateGitSyncSettings(enabled)', async () => {
|
||||
const { svc, spaceRepo } = buildService({});
|
||||
|
||||
await svc.updateSpace(
|
||||
{ spaceId, gitSyncEnabled: true } as any,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
expect(spaceRepo.updateGitSyncSettings).toHaveBeenCalledWith(
|
||||
spaceId,
|
||||
workspaceId,
|
||||
'enabled',
|
||||
true,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not call updateGitSyncSettings when flag is undefined', async () => {
|
||||
const { svc, spaceRepo } = buildService({});
|
||||
|
||||
await svc.updateSpace({ spaceId } as any, workspaceId);
|
||||
|
||||
expect(spaceRepo.updateGitSyncSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// --- audit delta on the git-sync toggle (test-strategy Module 4 / item #5)
|
||||
// updateSpace builds a before/after delta only when a flag's value actually
|
||||
// changes, and only logs an audit event when that delta is non-empty. These
|
||||
// assert that contract specifically for gitSyncEnabled.
|
||||
it('writes a SPACE_UPDATED audit delta on a REAL gitSyncEnabled change (false -> true)', async () => {
|
||||
// Prior persisted state: gitSync.enabled = false; the request flips it on.
|
||||
const { svc, auditService } = buildService({ gitSync: { enabled: false } });
|
||||
|
||||
await svc.updateSpace(
|
||||
{ spaceId, gitSyncEnabled: true } as any,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
expect(auditService.log).toHaveBeenCalledTimes(1);
|
||||
expect(auditService.log).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
resourceId: spaceId,
|
||||
spaceId,
|
||||
changes: {
|
||||
before: expect.objectContaining({ gitSyncEnabled: false }),
|
||||
after: expect.objectContaining({ gitSyncEnabled: true }),
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('also records the delta when no prior gitSync settings exist (undefined -> true defaults prev to false)', async () => {
|
||||
// No gitSync key at all: prev resolves to the `?? false` default, so
|
||||
// enabling it is still a real change and is audited.
|
||||
const { svc, auditService } = buildService({});
|
||||
|
||||
await svc.updateSpace(
|
||||
{ spaceId, gitSyncEnabled: true } as any,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
expect(auditService.log).toHaveBeenCalledTimes(1);
|
||||
const call = auditService.log.mock.calls[0][0];
|
||||
expect(call.changes.before.gitSyncEnabled).toBe(false);
|
||||
expect(call.changes.after.gitSyncEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('does NOT write an audit delta on a no-op gitSyncEnabled (same value true -> true)', async () => {
|
||||
// Prior persisted state already true; the request sets the same value.
|
||||
// updateGitSyncSettings still runs (idempotent persist), but nothing is
|
||||
// added to the before/after delta, so no audit event is emitted.
|
||||
const { svc, spaceRepo, auditService } = buildService({
|
||||
gitSync: { enabled: true },
|
||||
});
|
||||
|
||||
await svc.updateSpace(
|
||||
{ spaceId, gitSyncEnabled: true } as any,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
expect(spaceRepo.updateGitSyncSettings).toHaveBeenCalledTimes(1);
|
||||
expect(auditService.log).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// --- autoMergeConflicts: a SECOND key in the SAME `gitSync` jsonb object,
|
||||
// persisted the same way as `enabled` (the repo's jsonb-merge keeps siblings).
|
||||
it('persists autoMergeConflicts via updateGitSyncSettings(autoMergeConflicts)', async () => {
|
||||
const { svc, spaceRepo } = buildService({});
|
||||
|
||||
await svc.updateSpace(
|
||||
{ spaceId, autoMergeConflicts: true } as any,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
expect(spaceRepo.updateGitSyncSettings).toHaveBeenCalledWith(
|
||||
spaceId,
|
||||
workspaceId,
|
||||
'autoMergeConflicts',
|
||||
true,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not call updateGitSyncSettings when autoMergeConflicts is undefined', async () => {
|
||||
const { svc, spaceRepo } = buildService({});
|
||||
|
||||
await svc.updateSpace({ spaceId } as any, workspaceId);
|
||||
|
||||
expect(spaceRepo.updateGitSyncSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('writes a SPACE_UPDATED audit delta on a REAL autoMergeConflicts change (false -> true)', async () => {
|
||||
// Prior persisted state: gitSync.autoMergeConflicts = false; flip it on.
|
||||
const { svc, auditService } = buildService({
|
||||
gitSync: { autoMergeConflicts: false },
|
||||
});
|
||||
|
||||
await svc.updateSpace(
|
||||
{ spaceId, autoMergeConflicts: true } as any,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
expect(auditService.log).toHaveBeenCalledTimes(1);
|
||||
expect(auditService.log).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
resourceId: spaceId,
|
||||
spaceId,
|
||||
changes: {
|
||||
before: expect.objectContaining({ autoMergeConflicts: false }),
|
||||
after: expect.objectContaining({ autoMergeConflicts: true }),
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('does NOT write an audit delta on a no-op autoMergeConflicts (same value true -> true)', async () => {
|
||||
const { svc, spaceRepo, auditService } = buildService({
|
||||
gitSync: { autoMergeConflicts: true },
|
||||
});
|
||||
|
||||
await svc.updateSpace(
|
||||
{ spaceId, autoMergeConflicts: true } as any,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
expect(spaceRepo.updateGitSyncSettings).toHaveBeenCalledTimes(1);
|
||||
expect(auditService.log).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -213,6 +213,41 @@ export class SpaceService {
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof updateSpaceDto.gitSyncEnabled !== 'undefined') {
|
||||
const prev = settingsBefore?.gitSync?.enabled ?? false;
|
||||
if (prev !== updateSpaceDto.gitSyncEnabled) {
|
||||
before.gitSyncEnabled = prev;
|
||||
after.gitSyncEnabled = updateSpaceDto.gitSyncEnabled;
|
||||
}
|
||||
|
||||
await this.spaceRepo.updateGitSyncSettings(
|
||||
updateSpaceDto.spaceId,
|
||||
workspaceId,
|
||||
'enabled',
|
||||
updateSpaceDto.gitSyncEnabled,
|
||||
trx,
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof updateSpaceDto.autoMergeConflicts !== 'undefined') {
|
||||
const prev = settingsBefore?.gitSync?.autoMergeConflicts ?? false;
|
||||
if (prev !== updateSpaceDto.autoMergeConflicts) {
|
||||
before.autoMergeConflicts = prev;
|
||||
after.autoMergeConflicts = updateSpaceDto.autoMergeConflicts;
|
||||
}
|
||||
|
||||
// Merges into the SAME `gitSync` jsonb object as `enabled` (the repo's
|
||||
// jsonb-merge preserves sibling keys), so toggling one never clobbers the
|
||||
// other.
|
||||
await this.spaceRepo.updateGitSyncSettings(
|
||||
updateSpaceDto.spaceId,
|
||||
workspaceId,
|
||||
'autoMergeConflicts',
|
||||
updateSpaceDto.autoMergeConflicts,
|
||||
trx,
|
||||
);
|
||||
}
|
||||
|
||||
updatedSpace = await this.spaceRepo.updateSpace(
|
||||
{
|
||||
name: updateSpaceDto.name,
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
import { resolveAgentProvenance } from './agent-provenance';
|
||||
import { commentAgentRoleQuery } from './comment/comment.repo';
|
||||
import { pageHistoryAgentRoleQuery } from './page/page-history.repo';
|
||||
|
||||
/**
|
||||
* The server-authoritative "agent avatar stack" resolver (#300) normalizes the
|
||||
* two provenance shapes into { agent (front), launcher (behind) } so the client
|
||||
* never branches. These tests pin the exact resolved shape for the three agent
|
||||
* cases plus the non-agent pass-through.
|
||||
*/
|
||||
describe('resolveAgentProvenance', () => {
|
||||
const human = { name: 'Alice', avatarUrl: 'a.png' };
|
||||
|
||||
it('internal chat WITH role: agent = role (emoji, no avatar), launcher = human', () => {
|
||||
const result = resolveAgentProvenance({
|
||||
isAgent: true,
|
||||
aiChatId: 'chat-1',
|
||||
creator: human,
|
||||
agentRole: { name: 'Researcher', emoji: '🔬' },
|
||||
});
|
||||
expect(result).toEqual({
|
||||
agent: { name: 'Researcher', emoji: '🔬', avatarUrl: null },
|
||||
launcher: { name: 'Alice', avatarUrl: 'a.png' },
|
||||
});
|
||||
});
|
||||
|
||||
it('internal chat WITHOUT role: agent = "AI agent" fallback, launcher = human', () => {
|
||||
const result = resolveAgentProvenance({
|
||||
isAgent: true,
|
||||
aiChatId: 'chat-1',
|
||||
creator: human,
|
||||
agentRole: null,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
agent: { name: 'AI agent', avatarUrl: null },
|
||||
launcher: { name: 'Alice', avatarUrl: 'a.png' },
|
||||
});
|
||||
// The fallback agent carries no emoji (only sparkles glyph on the client).
|
||||
expect(result?.agent).not.toHaveProperty('emoji');
|
||||
});
|
||||
|
||||
it('external MCP (aiChatId null): agent = the account itself, launcher = null', () => {
|
||||
const result = resolveAgentProvenance({
|
||||
isAgent: true,
|
||||
aiChatId: null,
|
||||
creator: { name: 'MCP Bot', avatarUrl: 'bot.png' },
|
||||
agentRole: null,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
agent: { name: 'MCP Bot', avatarUrl: 'bot.png' },
|
||||
launcher: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('non-agent content: returns null so the caller omits both fields', () => {
|
||||
expect(
|
||||
resolveAgentProvenance({
|
||||
isAgent: false,
|
||||
aiChatId: null,
|
||||
creator: human,
|
||||
agentRole: null,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The role-resolution subquery must NOT filter on enabled/deletedAt: historical
|
||||
* agent content keeps its signature even after the role is disabled or
|
||||
* soft-deleted (same rule as AiAgentRoleRepo.findById, NOT findLiveEnabled). We
|
||||
* record the query-builder calls and assert the join binds only id<->roleId and
|
||||
* that `where` is never called with an enabled/deletedAt filter.
|
||||
*/
|
||||
describe('agent role subquery — no live/enabled filter', () => {
|
||||
function makeRecorder() {
|
||||
const calls: { method: string; args: unknown[] }[] = [];
|
||||
const builder = new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_t, prop: string) {
|
||||
return (...args: unknown[]) => {
|
||||
calls.push({ method: prop, args });
|
||||
return builder;
|
||||
};
|
||||
},
|
||||
},
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const eb = { selectFrom: (...args: unknown[]) => (calls.push({ method: 'selectFrom', args }), builder) } as any;
|
||||
return { eb, calls };
|
||||
}
|
||||
|
||||
function assertNoLiveFilter(
|
||||
query: (eb: any) => unknown, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
chatIdColumn: string,
|
||||
) {
|
||||
const { eb, calls } = makeRecorder();
|
||||
query(eb);
|
||||
|
||||
const innerJoin = calls.find((c) => c.method === 'innerJoin');
|
||||
expect(innerJoin?.args).toEqual([
|
||||
'aiAgentRoles',
|
||||
'aiAgentRoles.id',
|
||||
'aiChats.roleId',
|
||||
]);
|
||||
|
||||
const whereRef = calls.find((c) => c.method === 'whereRef');
|
||||
expect(whereRef?.args).toEqual(['aiChats.id', '=', chatIdColumn]);
|
||||
|
||||
// The security-narrowing filters used by findLiveEnabled must be ABSENT.
|
||||
const filtered = calls
|
||||
.flatMap((c) => c.args)
|
||||
.filter((a) => a === 'enabled' || a === 'deletedAt');
|
||||
expect(filtered).toEqual([]);
|
||||
// No `where(...)` at all (only the join + whereRef).
|
||||
expect(calls.some((c) => c.method === 'where')).toBe(false);
|
||||
}
|
||||
|
||||
it('comment subquery joins by id only, keyed on comments.aiChatId', () => {
|
||||
assertNoLiveFilter(commentAgentRoleQuery, 'comments.aiChatId');
|
||||
});
|
||||
|
||||
it('page-history subquery joins by id only, keyed on lastUpdatedAiChatId', () => {
|
||||
assertNoLiveFilter(
|
||||
pageHistoryAgentRoleQuery,
|
||||
'pageHistory.lastUpdatedAiChatId',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,93 +0,0 @@
|
||||
/**
|
||||
* Server-authoritative "agent avatar stack" provenance (#300).
|
||||
*
|
||||
* Agent-authored content (comments / page-history snapshots) is displayed as a
|
||||
* two-avatar stack: the AGENT in front, and the HUMAN who launched it behind.
|
||||
* This module normalizes the two provenance shapes the client can encounter into
|
||||
* the SAME pair of sub-objects so the client never has to branch:
|
||||
*
|
||||
* agent — FRONT (the acting agent identity)
|
||||
* launcher — BEHIND (the human on whose behalf it acted; null when there is none)
|
||||
*
|
||||
* The discriminator is purely SERVER-SIDE data (createdSource / lastUpdatedSource
|
||||
* plus aiChatId) that only the server can set — none of it is read from request
|
||||
* input, so an external caller cannot spoof an `agent` badge.
|
||||
*/
|
||||
|
||||
/** Front avatar identity. `avatarUrl`/`emoji` feed the glyph source priority. */
|
||||
export interface AgentInfo {
|
||||
name: string;
|
||||
emoji?: string | null;
|
||||
avatarUrl?: string | null;
|
||||
}
|
||||
|
||||
/** Behind avatar identity — the human who launched the agent (internal chat). */
|
||||
export interface LauncherInfo {
|
||||
name: string;
|
||||
avatarUrl?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inputs to the resolver, drawn entirely from server-side columns:
|
||||
* - `isAgent` — createdSource/lastUpdatedSource === 'agent'.
|
||||
* - `aiChatId` — internal-AI-chat discriminator: non-null => internal chat (the
|
||||
* provenance token was minted for the human, so `creator` is the human and the
|
||||
* agent identity comes from the chat's role); null => external MCP (the login
|
||||
* IS a dedicated agent account, so `creator` is the agent, no separate human).
|
||||
* - `creator` — the row's human author (internal) OR agent account (MCP).
|
||||
* - `agentRole`— the chat's bound role (name + optional emoji), resolved WITHOUT
|
||||
* any enabled/deleted filter so historical content keeps its signature even
|
||||
* after the role is disabled or soft-deleted; null when the chat has no role.
|
||||
*/
|
||||
export interface AgentProvenanceInput {
|
||||
isAgent: boolean;
|
||||
aiChatId: string | null | undefined;
|
||||
creator: { name: string; avatarUrl?: string | null } | null | undefined;
|
||||
agentRole: { name: string; emoji?: string | null } | null | undefined;
|
||||
}
|
||||
|
||||
export interface AgentProvenance {
|
||||
agent: AgentInfo;
|
||||
launcher: LauncherInfo | null;
|
||||
}
|
||||
|
||||
/** Fallback display name for an internal agent edit whose chat has no role. */
|
||||
export const AGENT_FALLBACK_NAME = 'AI agent';
|
||||
|
||||
/**
|
||||
* Resolve the front/behind identities from server-side provenance. Returns
|
||||
* `null` for non-agent content so the caller can OMIT both fields (the client
|
||||
* then keeps its plain single-human avatar).
|
||||
*/
|
||||
export function resolveAgentProvenance(
|
||||
input: AgentProvenanceInput,
|
||||
): AgentProvenance | null {
|
||||
if (!input.isAgent) return null;
|
||||
|
||||
// External MCP: no internal chat row; the login itself is the agent account.
|
||||
if (input.aiChatId == null) {
|
||||
return {
|
||||
agent: {
|
||||
name: input.creator?.name ?? AGENT_FALLBACK_NAME,
|
||||
avatarUrl: input.creator?.avatarUrl ?? null,
|
||||
},
|
||||
launcher: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Internal AI chat: the agent identity is the chat's role (or the fallback
|
||||
// when the chat has no role), and the launcher is the human chat owner.
|
||||
const agent: AgentInfo = input.agentRole
|
||||
? {
|
||||
name: input.agentRole.name,
|
||||
emoji: input.agentRole.emoji ?? null,
|
||||
avatarUrl: null,
|
||||
}
|
||||
: { name: AGENT_FALLBACK_NAME, avatarUrl: null };
|
||||
|
||||
const launcher: LauncherInfo | null = input.creator
|
||||
? { name: input.creator.name, avatarUrl: input.creator.avatarUrl ?? null }
|
||||
: null;
|
||||
|
||||
return { agent, launcher };
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
import { CommentRepo } from './comment.repo';
|
||||
|
||||
/**
|
||||
* Enrichment coverage for CommentRepo.findById (#300).
|
||||
*
|
||||
* The {agent,launcher} avatar stack must be attached on the SINGLE-ROW read
|
||||
* path, not only on findPageComments — the live websocket broadcasts
|
||||
* (commentCreated/commentUpdated/commentResolved) return a comment loaded via
|
||||
* findById. These tests would FAIL against the previous un-enriched findById
|
||||
* (which returned the raw row without calling attachCommentAgent and without
|
||||
* selecting the agent-role subquery).
|
||||
*
|
||||
* The Kysely db is replaced by a chainable recorder so the query never touches a
|
||||
* real database: it records the `.select(...)` args (to prove the agent-role
|
||||
* subquery is selected on the includeCreator path) and returns a preset row from
|
||||
* executeTakeFirst (to prove attachCommentAgent maps it into {agent,launcher}).
|
||||
*/
|
||||
describe('CommentRepo.findById — agent avatar stack enrichment', () => {
|
||||
function makeRepo(row: unknown) {
|
||||
const selectArgs: unknown[] = [];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const builder: any = {
|
||||
selectFrom: () => builder,
|
||||
selectAll: () => builder,
|
||||
select: (arg: unknown) => {
|
||||
selectArgs.push(arg);
|
||||
return builder;
|
||||
},
|
||||
// Kysely's $if(condition, cb) invokes cb(qb) only when the condition is
|
||||
// truthy; mirror that so gating (includeCreator) is exercised faithfully.
|
||||
$if: (cond: unknown, cb: (qb: unknown) => unknown) => {
|
||||
if (cond) cb(builder);
|
||||
return builder;
|
||||
},
|
||||
where: () => builder,
|
||||
executeTakeFirst: async () => row,
|
||||
};
|
||||
const db = { selectFrom: () => builder };
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const repo = new CommentRepo(db as any);
|
||||
return { repo, selectArgs };
|
||||
}
|
||||
|
||||
const enrichOpts = { includeCreator: true, includeResolvedBy: true };
|
||||
|
||||
it('internal agent chat WITH role: returns agent = role, launcher = creator, and strips agentRole', async () => {
|
||||
const { repo, selectArgs } = makeRepo({
|
||||
id: 'c-1',
|
||||
createdSource: 'agent',
|
||||
aiChatId: 'chat-1',
|
||||
creator: { name: 'Alice', avatarUrl: 'a.png' },
|
||||
agentRole: { name: 'Researcher', emoji: '🔬' },
|
||||
});
|
||||
|
||||
const result: any = await repo.findById('c-1', enrichOpts);
|
||||
|
||||
expect(result.agent).toEqual({
|
||||
name: 'Researcher',
|
||||
emoji: '🔬',
|
||||
avatarUrl: null,
|
||||
});
|
||||
expect(result.launcher).toEqual({ name: 'Alice', avatarUrl: 'a.png' });
|
||||
// The internal join column must never leak to the client.
|
||||
expect(result).not.toHaveProperty('agentRole');
|
||||
// The enrichment SELECTs the agent-role subquery on the includeCreator path
|
||||
// (mirrors the list-query proof; absent in the pre-fix findById).
|
||||
expect(selectArgs).toContain(repo.withAgentRole);
|
||||
});
|
||||
|
||||
it('external MCP agent (aiChatId null): agent = the account, launcher = null', async () => {
|
||||
const { repo } = makeRepo({
|
||||
id: 'c-2',
|
||||
createdSource: 'agent',
|
||||
aiChatId: null,
|
||||
creator: { name: 'MCP Bot', avatarUrl: 'bot.png' },
|
||||
agentRole: null,
|
||||
});
|
||||
|
||||
const result: any = await repo.findById('c-2', enrichOpts);
|
||||
|
||||
expect(result.agent).toEqual({ name: 'MCP Bot', avatarUrl: 'bot.png' });
|
||||
expect(result.launcher).toBeNull();
|
||||
expect(result).not.toHaveProperty('agentRole');
|
||||
});
|
||||
|
||||
it('non-agent comment: neither agent nor launcher is attached', async () => {
|
||||
const { repo } = makeRepo({
|
||||
id: 'c-3',
|
||||
createdSource: 'user',
|
||||
aiChatId: null,
|
||||
creator: { name: 'Bob', avatarUrl: null },
|
||||
agentRole: null,
|
||||
});
|
||||
|
||||
const result: any = await repo.findById('c-3', enrichOpts);
|
||||
|
||||
expect(result).not.toHaveProperty('agent');
|
||||
expect(result).not.toHaveProperty('launcher');
|
||||
// A plain human comment still strips the internal join column.
|
||||
expect(result).not.toHaveProperty('agentRole');
|
||||
});
|
||||
|
||||
it('missing row: returns undefined without crashing the enrichment', async () => {
|
||||
const { repo } = makeRepo(undefined);
|
||||
await expect(repo.findById('nope', enrichOpts)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('non-includeCreator callers keep the plain shape (no enrichment, no agent-role select)', async () => {
|
||||
const { repo, selectArgs } = makeRepo({
|
||||
id: 'c-4',
|
||||
createdSource: 'agent',
|
||||
aiChatId: 'chat-1',
|
||||
});
|
||||
|
||||
// No opts => the enrichment (and its subquery select) must be skipped, so
|
||||
// callers doing a bare lookup (parent-comment check, controller findOne)
|
||||
// are unaffected by the additive fields.
|
||||
const result: any = await repo.findById('c-4');
|
||||
|
||||
expect(result).not.toHaveProperty('agent');
|
||||
expect(result).not.toHaveProperty('launcher');
|
||||
expect(selectArgs).not.toContain(repo.withAgentRole);
|
||||
});
|
||||
});
|
||||
@@ -12,24 +12,6 @@ import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagin
|
||||
import { ExpressionBuilder } from 'kysely';
|
||||
import { DB } from '@docmost/db/types/db';
|
||||
import { jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||
import { resolveAgentProvenance } from '../agent-provenance';
|
||||
|
||||
/**
|
||||
* Role-resolution subquery for a comment's bound AI chat (#300). Joins
|
||||
* comments.aiChatId -> ai_chats.role_id -> ai_agent_roles and selects the role's
|
||||
* name + emoji. NO enabled/deletedAt filter: historical agent content must keep
|
||||
* its signature even after the role is later disabled or soft-deleted — the same
|
||||
* "resolve by id, ignore live/enabled" rule as AiAgentRoleRepo.findById (NOT
|
||||
* findLiveEnabled). Exported so a unit test can assert the join binds only
|
||||
* id<->roleId and never filters on enabled/deletedAt.
|
||||
*/
|
||||
export function commentAgentRoleQuery(eb: ExpressionBuilder<DB, 'comments'>) {
|
||||
return eb
|
||||
.selectFrom('aiChats')
|
||||
.innerJoin('aiAgentRoles', 'aiAgentRoles.id', 'aiChats.roleId')
|
||||
.select(['aiAgentRoles.name', 'aiAgentRoles.emoji'])
|
||||
.whereRef('aiChats.id', '=', 'comments.aiChatId');
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CommentRepo {
|
||||
@@ -40,30 +22,13 @@ export class CommentRepo {
|
||||
commentId: string,
|
||||
opts?: { includeCreator: boolean; includeResolvedBy: boolean },
|
||||
): Promise<Comment> {
|
||||
const comment = await this.db
|
||||
return await this.db
|
||||
.selectFrom('comments')
|
||||
.selectAll('comments')
|
||||
.$if(opts?.includeCreator, (qb) => qb.select(this.withCreator))
|
||||
.$if(opts?.includeResolvedBy, (qb) => qb.select(this.withResolvedBy))
|
||||
// #300: enrich the single-row read with the agent-role subquery so the
|
||||
// {agent,launcher} avatar stack is attached here too — the live websocket
|
||||
// broadcasts (commentCreated/Updated/Resolved) return a comment loaded via
|
||||
// findById, and must carry the SAME provenance as the list query
|
||||
// findPageComments. Without this a freshly created / edited / resolved
|
||||
// agent comment arrives un-enriched and the client's
|
||||
// `createdSource === 'agent' && agent` gate drops the stack until a full
|
||||
// refetch. Gated on includeCreator (mirroring findPageComments, which
|
||||
// always selects the creator): the internal-chat launcher IS the creator,
|
||||
// so the resolver needs it, and every broadcast caller passes
|
||||
// includeCreator: true. Non-includeCreator callers keep the plain shape.
|
||||
.$if(opts?.includeCreator, (qb) => qb.select(this.withAgentRole))
|
||||
.where('id', '=', commentId)
|
||||
.executeTakeFirst();
|
||||
|
||||
// Guard a missing row (don't destructure undefined in attachCommentAgent)
|
||||
// and leave non-enriched callers' shape untouched.
|
||||
if (!comment || !opts?.includeCreator) return comment;
|
||||
return attachCommentAgent(comment) as Comment;
|
||||
}
|
||||
|
||||
async findPageComments(pageId: string, pagination: PaginationOptions) {
|
||||
@@ -72,18 +37,15 @@ export class CommentRepo {
|
||||
.selectAll('comments')
|
||||
.select((eb) => this.withCreator(eb))
|
||||
.select((eb) => this.withResolvedBy(eb))
|
||||
.select((eb) => this.withAgentRole(eb))
|
||||
.where('pageId', '=', pageId);
|
||||
|
||||
const result = await executeWithCursorPagination(query, {
|
||||
return executeWithCursorPagination(query, {
|
||||
perPage: pagination.limit,
|
||||
cursor: pagination.cursor,
|
||||
beforeCursor: pagination.beforeCursor,
|
||||
fields: [{ expression: 'id', direction: 'asc' }],
|
||||
parseCursor: (cursor) => ({ id: cursor.id }),
|
||||
});
|
||||
|
||||
return { ...result, items: result.items.map(attachCommentAgent) };
|
||||
}
|
||||
|
||||
async updateComment(
|
||||
@@ -120,12 +82,6 @@ export class CommentRepo {
|
||||
).as('creator');
|
||||
}
|
||||
|
||||
/** Select the comment's resolved chat role (name + emoji) as `agentRole`, or
|
||||
* null when the comment has no internal chat / the chat has no role (#300). */
|
||||
withAgentRole(eb: ExpressionBuilder<DB, 'comments'>) {
|
||||
return jsonObjectFrom(commentAgentRoleQuery(eb)).as('agentRole');
|
||||
}
|
||||
|
||||
withResolvedBy(eb: ExpressionBuilder<DB, 'comments'>) {
|
||||
return jsonObjectFrom(
|
||||
eb
|
||||
@@ -160,30 +116,3 @@ export class CommentRepo {
|
||||
return Number(result?.count) > 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach the normalized agent/launcher provenance (#300) to a comment row and
|
||||
* strip the internal `agentRole` join column. Non-agent rows pass through
|
||||
* unchanged (neither field added — the client keeps the plain human avatar). The
|
||||
* human author (`creator`) is the launcher for an internal chat, or the agent
|
||||
* itself for external MCP; the resolver encodes both cases.
|
||||
*/
|
||||
function attachCommentAgent<
|
||||
R extends {
|
||||
createdSource?: string | null;
|
||||
aiChatId?: string | null;
|
||||
creator?: { name: string; avatarUrl?: string | null } | null;
|
||||
agentRole?: { name: string; emoji?: string | null } | null;
|
||||
},
|
||||
>(row: R) {
|
||||
const { agentRole, ...rest } = row;
|
||||
const provenance = resolveAgentProvenance({
|
||||
isAgent: row.createdSource === 'agent',
|
||||
aiChatId: row.aiChatId,
|
||||
creator: row.creator,
|
||||
agentRole,
|
||||
});
|
||||
return provenance
|
||||
? { ...rest, agent: provenance.agent, launcher: provenance.launcher }
|
||||
: rest;
|
||||
}
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
import { PageHistoryRepo } from './page-history.repo';
|
||||
|
||||
/**
|
||||
* Enrichment coverage for the page-history agent avatar stack (#300/#304).
|
||||
*
|
||||
* attachPageHistoryAgent maps a DIFFERENT column set than comments —
|
||||
* `lastUpdatedSource` / `lastUpdatedAiChatId` / `lastUpdatedBy` instead of
|
||||
* `createdSource` / `aiChatId` / `creator` — so it needs its own direct proof
|
||||
* that the {agent,launcher} pair resolves for each provenance shape and that the
|
||||
* internal `agentRole` join column is stripped.
|
||||
*
|
||||
* The mapping is exercised through findPageHistoryByPageId (the only page-history
|
||||
* path that enriches). The Kysely db is a chainable recorder: query-builder
|
||||
* methods return the builder and `.execute()` (called by
|
||||
* executeWithCursorPagination) yields preset rows, so no real database is
|
||||
* touched. The `.select((eb) => ...)` callbacks are recorded but never invoked,
|
||||
* so the preset row stands in for what the DB would have returned.
|
||||
*
|
||||
* NON-VACUITY: against an identity mapping (raw row pass-through) the agent-case
|
||||
* assertions fail — `agent`/`launcher` would be undefined and the internal
|
||||
* `agentRole` column would leak.
|
||||
*/
|
||||
describe('PageHistoryRepo.findPageHistoryByPageId — agent avatar stack enrichment', () => {
|
||||
function makeRepo(rows: unknown[]) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const builder: any = {
|
||||
selectFrom: () => builder,
|
||||
select: () => builder,
|
||||
where: () => builder,
|
||||
orderBy: () => builder,
|
||||
limit: () => builder,
|
||||
execute: async () => rows,
|
||||
};
|
||||
const db = { selectFrom: () => builder };
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return new PageHistoryRepo(db as any);
|
||||
}
|
||||
|
||||
// perPage high enough that a single preset row never triggers the extra-row
|
||||
// "has next page" branch (which would call generateCursor).
|
||||
const pagination = { limit: 50 } as any;
|
||||
|
||||
const firstItem = async (row: Record<string, unknown>) => {
|
||||
const repo = makeRepo([row]);
|
||||
const result = await repo.findPageHistoryByPageId('page-1', pagination);
|
||||
return result.items[0] as any;
|
||||
};
|
||||
|
||||
it('internal chat WITH role: agent = role (emoji, no avatar), launcher = human, agentRole stripped', async () => {
|
||||
const item = await firstItem({
|
||||
id: 'ph-1',
|
||||
lastUpdatedSource: 'agent',
|
||||
lastUpdatedAiChatId: 'chat-1',
|
||||
lastUpdatedBy: { name: 'Alice', avatarUrl: 'a.png' },
|
||||
agentRole: { name: 'Editor', emoji: '✏️' },
|
||||
});
|
||||
|
||||
expect(item.agent).toEqual({ name: 'Editor', emoji: '✏️', avatarUrl: null });
|
||||
expect(item.launcher).toEqual({ name: 'Alice', avatarUrl: 'a.png' });
|
||||
// The internal join column must never leak to the client.
|
||||
expect(item).not.toHaveProperty('agentRole');
|
||||
});
|
||||
|
||||
it('internal chat WITHOUT role: agent = "AI agent" fallback, launcher = human', async () => {
|
||||
const item = await firstItem({
|
||||
id: 'ph-2',
|
||||
lastUpdatedSource: 'agent',
|
||||
lastUpdatedAiChatId: 'chat-1',
|
||||
lastUpdatedBy: { name: 'Alice', avatarUrl: 'a.png' },
|
||||
agentRole: null,
|
||||
});
|
||||
|
||||
expect(item.agent).toEqual({ name: 'AI agent', avatarUrl: null });
|
||||
expect(item.agent).not.toHaveProperty('emoji');
|
||||
expect(item.launcher).toEqual({ name: 'Alice', avatarUrl: 'a.png' });
|
||||
expect(item).not.toHaveProperty('agentRole');
|
||||
});
|
||||
|
||||
it('external MCP (lastUpdatedAiChatId null): agent = the account itself, launcher = null', async () => {
|
||||
const item = await firstItem({
|
||||
id: 'ph-3',
|
||||
lastUpdatedSource: 'agent',
|
||||
lastUpdatedAiChatId: null,
|
||||
lastUpdatedBy: { name: 'MCP Bot', avatarUrl: 'bot.png' },
|
||||
agentRole: null,
|
||||
});
|
||||
|
||||
expect(item.agent).toEqual({ name: 'MCP Bot', avatarUrl: 'bot.png' });
|
||||
expect(item.launcher).toBeNull();
|
||||
expect(item).not.toHaveProperty('agentRole');
|
||||
});
|
||||
|
||||
it('non-agent (lastUpdatedSource !== "agent"): neither agent nor launcher, agentRole stripped', async () => {
|
||||
const item = await firstItem({
|
||||
id: 'ph-4',
|
||||
lastUpdatedSource: 'user',
|
||||
lastUpdatedAiChatId: null,
|
||||
lastUpdatedBy: { name: 'Bob', avatarUrl: null },
|
||||
agentRole: null,
|
||||
});
|
||||
|
||||
expect(item).not.toHaveProperty('agent');
|
||||
expect(item).not.toHaveProperty('launcher');
|
||||
// A plain human row still strips the internal join column.
|
||||
expect(item).not.toHaveProperty('agentRole');
|
||||
});
|
||||
});
|
||||
@@ -12,25 +12,6 @@ import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagin
|
||||
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||
import { ExpressionBuilder, sql } from 'kysely';
|
||||
import { DB } from '@docmost/db/types/db';
|
||||
import { resolveAgentProvenance } from '../agent-provenance';
|
||||
|
||||
/**
|
||||
* Role-resolution subquery for a page-history row's bound AI chat (#300). Joins
|
||||
* pageHistory.lastUpdatedAiChatId -> ai_chats.role_id -> ai_agent_roles and
|
||||
* selects the role's name + emoji. NO enabled/deletedAt filter: historical agent
|
||||
* content must keep its signature even after the role is disabled or soft-deleted
|
||||
* (same rule as AiAgentRoleRepo.findById, NOT findLiveEnabled). Exported so a
|
||||
* unit test can assert the join never filters on enabled/deletedAt.
|
||||
*/
|
||||
export function pageHistoryAgentRoleQuery(
|
||||
eb: ExpressionBuilder<DB, 'pageHistory'>,
|
||||
) {
|
||||
return eb
|
||||
.selectFrom('aiChats')
|
||||
.innerJoin('aiAgentRoles', 'aiAgentRoles.id', 'aiChats.roleId')
|
||||
.select(['aiAgentRoles.name', 'aiAgentRoles.emoji'])
|
||||
.whereRef('aiChats.id', '=', 'pageHistory.lastUpdatedAiChatId');
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PageHistoryRepo {
|
||||
@@ -113,18 +94,15 @@ export class PageHistoryRepo {
|
||||
.select(this.baseFields)
|
||||
.select((eb) => this.withLastUpdatedBy(eb))
|
||||
.select((eb) => this.withContributors(eb))
|
||||
.select((eb) => this.withAgentRole(eb))
|
||||
.where('pageId', '=', pageId);
|
||||
|
||||
const result = await executeWithCursorPagination(query, {
|
||||
return executeWithCursorPagination(query, {
|
||||
perPage: pagination.limit,
|
||||
cursor: pagination.cursor,
|
||||
beforeCursor: pagination.beforeCursor,
|
||||
fields: [{ expression: 'id', direction: 'desc' }],
|
||||
parseCursor: (cursor) => ({ id: cursor.id }),
|
||||
});
|
||||
|
||||
return { ...result, items: result.items.map(attachPageHistoryAgent) };
|
||||
}
|
||||
|
||||
async findPageLastHistory(
|
||||
@@ -160,12 +138,6 @@ export class PageHistoryRepo {
|
||||
).as('lastUpdatedBy');
|
||||
}
|
||||
|
||||
/** Select the row's resolved chat role (name + emoji) as `agentRole`, or null
|
||||
* when there is no internal chat / the chat has no role (#300). */
|
||||
withAgentRole(eb: ExpressionBuilder<DB, 'pageHistory'>) {
|
||||
return jsonObjectFrom(pageHistoryAgentRoleQuery(eb)).as('agentRole');
|
||||
}
|
||||
|
||||
withContributors(eb: ExpressionBuilder<DB, 'pageHistory'>) {
|
||||
return jsonArrayFrom(
|
||||
eb
|
||||
@@ -179,30 +151,3 @@ export class PageHistoryRepo {
|
||||
).as('contributors');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach the normalized agent/launcher provenance (#300) to a page-history row
|
||||
* and strip the internal `agentRole` join column. The trigger is
|
||||
* `lastUpdatedSource === 'agent'`, the internal-chat discriminator is
|
||||
* `lastUpdatedAiChatId`, and the human is `lastUpdatedBy`. Non-agent rows pass
|
||||
* through unchanged (neither field added).
|
||||
*/
|
||||
function attachPageHistoryAgent<
|
||||
R extends {
|
||||
lastUpdatedSource?: string | null;
|
||||
lastUpdatedAiChatId?: string | null;
|
||||
lastUpdatedBy?: { name: string; avatarUrl?: string | null } | null;
|
||||
agentRole?: { name: string; emoji?: string | null } | null;
|
||||
},
|
||||
>(row: R) {
|
||||
const { agentRole, ...rest } = row;
|
||||
const provenance = resolveAgentProvenance({
|
||||
isAgent: row.lastUpdatedSource === 'agent',
|
||||
aiChatId: row.lastUpdatedAiChatId,
|
||||
creator: row.lastUpdatedBy,
|
||||
agentRole,
|
||||
});
|
||||
return provenance
|
||||
? { ...rest, agent: provenance.agent, launcher: provenance.launcher }
|
||||
: rest;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user