Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b64c11c69a | |||
| 3a34829e78 | |||
| 424b2628b0 | |||
| 5c734e2d65 | |||
| deca17d9f6 | |||
| 719bcf87db | |||
| 9069700371 | |||
| 8daa6a0588 | |||
| 2b7c8eeda2 | |||
| b401ee0ff8 | |||
| 06c53ba565 | |||
| ea8c46d37e | |||
| b17e1d4a02 | |||
| 0c4aee7467 | |||
| 88b34755f6 | |||
| fa2bce1075 | |||
| f9d74a8701 | |||
| 2967133ebd | |||
| 3085ec1b50 | |||
| 05ec9feaf9 | |||
| 8e12579925 | |||
| 20703d06c2 | |||
| dab2660999 | |||
| 751d55e9db | |||
| 02308012a6 | |||
| 0665fcb630 | |||
| 97bd554cb5 | |||
| f77a6b42de | |||
| 134b627806 | |||
| 3267512ed9 | |||
| 48bd27b83c | |||
| 265b81c93d | |||
| ed808876be | |||
| a72ddbbe86 | |||
| d8fc724d90 | |||
| e4bfbcabaa | |||
| b8cce4f814 | |||
| c5bff2d84a | |||
| a325ddbabd | |||
| 80fc30633b | |||
| e17d5bc060 | |||
| 2c2d60a5dc | |||
| 1417209915 | |||
| f555fc87da | |||
| d6d1195abd | |||
| 36b940fdb8 | |||
| 0050ad7ebb | |||
| 43b11d92ab | |||
| ce70fab1df | |||
| 7b4617db70 | |||
| b51dae16a6 | |||
| 39735afd73 | |||
| 9b4b38a611 | |||
| eebbe6717c | |||
| e348433a39 | |||
| f759084f41 | |||
| 459d636ffb |
@@ -92,6 +92,19 @@ IFRAME_EMBED_ALLOWED=false
|
|||||||
# Example: https://intranet.example.com,https://portal.example.com
|
# Example: https://intranet.example.com,https://portal.example.com
|
||||||
IFRAME_ALLOWED_ORIGINS=
|
IFRAME_ALLOWED_ORIGINS=
|
||||||
|
|
||||||
|
# Comma-separated list of additional origins allowed to call the API via CORS.
|
||||||
|
# The APP_URL origin and native mobile (Capacitor) origins are always allowed.
|
||||||
|
# Leave empty for a same-origin (web-only) deployment.
|
||||||
|
CORS_ALLOWED_ORIGINS=
|
||||||
|
|
||||||
|
# Expose OpenAPI/Swagger docs at /api/docs (development/debugging aid only).
|
||||||
|
SWAGGER_ENABLED=false
|
||||||
|
|
||||||
|
# Capacitor (mobile shell): hosted client URL loaded by the iOS shell so the
|
||||||
|
# AGPL web client is NOT bundled into the .ipa (see docs/mobile-app-plan.md §9).
|
||||||
|
# Leave empty for Android bundled mode / local development.
|
||||||
|
CAP_SERVER_URL=
|
||||||
|
|
||||||
# Enable debug logging in production (default: false)
|
# Enable debug logging in production (default: false)
|
||||||
DEBUG_MODE=false
|
DEBUG_MODE=false
|
||||||
|
|
||||||
|
|||||||
@@ -151,6 +151,12 @@ jobs:
|
|||||||
- name: Build editor-ext
|
- name: Build editor-ext
|
||||||
run: pnpm --filter @docmost/editor-ext build
|
run: pnpm --filter @docmost/editor-ext build
|
||||||
|
|
||||||
|
# @docmost/prosemirror-markdown is an ESM workspace package the server
|
||||||
|
# imports at runtime; its build/ is gitignored and test:e2e has no pretest
|
||||||
|
# hook, so build it before the e2e run (mirrors the test.yml job).
|
||||||
|
- name: Build prosemirror-markdown
|
||||||
|
run: pnpm --filter @docmost/prosemirror-markdown build
|
||||||
|
|
||||||
- name: Run migrations
|
- name: Run migrations
|
||||||
run: pnpm --filter ./apps/server migration:latest
|
run: pnpm --filter ./apps/server migration:latest
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,49 @@ permissions:
|
|||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
# Guard against a long-lived branch adding a migration whose timestamped
|
||||||
|
# filename sorts BEFORE migrations already applied on the target branch (and
|
||||||
|
# thus in prod). The Kysely startup migrator rejects that as "corrupted
|
||||||
|
# migrations" and crash-loops the app on boot (incident #361). This gate fails
|
||||||
|
# the PR so the migration is renamed to a current timestamp before merge. Only
|
||||||
|
# runs for pull_request events (needs a base branch to diff against).
|
||||||
|
migration-order:
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
steps:
|
||||||
|
- name: Checkout (full history for the base-branch diff)
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- name: Added migrations must sort after the newest on the base branch
|
||||||
|
env:
|
||||||
|
TARGET_BRANCH: ${{ github.base_ref }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
MIG_DIR="apps/server/src/database/migrations"
|
||||||
|
# checkout above already did fetch-depth:0 (full history). Fetch the base
|
||||||
|
# WITHOUT --depth (a shallow graft would truncate the base history and
|
||||||
|
# break the merge-base when the base has moved ahead of the PR merge —
|
||||||
|
# exactly the long-branch-vs-moving-base case this gate guards, #361).
|
||||||
|
git fetch --no-tags origin "$TARGET_BRANCH"
|
||||||
|
newest_on_target=$(git ls-tree -r --name-only "origin/${TARGET_BRANCH}" "$MIG_DIR" | sort | tail -1)
|
||||||
|
# NO `|| true`: a diff failure (e.g. an unresolved merge-base) must fail
|
||||||
|
# the job CLOSED — a gate whose job is to BLOCK must never pass on error.
|
||||||
|
# `set -e` above already aborts on a non-zero diff exit.
|
||||||
|
added=$(git diff --diff-filter=A --name-only "origin/${TARGET_BRANCH}...HEAD" -- "$MIG_DIR")
|
||||||
|
bad=0
|
||||||
|
for f in $added; do
|
||||||
|
if [[ "$f" < "$newest_on_target" || "$f" == "$newest_on_target" ]]; then
|
||||||
|
echo "::error::Migration $f sorts at or before the newest on ${TARGET_BRANCH} ($newest_on_target) — rename it with a CURRENT timestamp before merge (do not change its contents). See incident #361."
|
||||||
|
bad=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ "$bad" -eq 0 ]; then
|
||||||
|
echo "Migration order OK (added migrations all sort after $newest_on_target)."
|
||||||
|
fi
|
||||||
|
exit $bad
|
||||||
|
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
|
|||||||
@@ -64,3 +64,8 @@ lerna-debug.log*
|
|||||||
|
|
||||||
# Self-hosted VAD / onnxruntime-web assets (copied from node_modules at dev/build time)
|
# Self-hosted VAD / onnxruntime-web assets (copied from node_modules at dev/build time)
|
||||||
apps/client/public/vad/
|
apps/client/public/vad/
|
||||||
|
|
||||||
|
# Capacitor native platform projects (generated locally via 'npx cap add ios|android')
|
||||||
|
/ios
|
||||||
|
/android
|
||||||
|
.capacitor
|
||||||
|
|||||||
@@ -201,7 +201,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 |
|
| `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/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`. Consumes the shared converter/schema from `@docmost/prosemirror-markdown` (#293) — it no longer carries its own vendored converter/schema copy |
|
| `packages/mcp` | `@docmost/mcp` | MCP SDK, Tiptap, Yjs | Standalone MCP server, also bundled into the server at `/mcp`. Consumes the shared converter/schema from `@docmost/prosemirror-markdown` (#293) — it no longer carries its own vendored converter/schema copy |
|
||||||
| `packages/prosemirror-markdown` | `@docmost/prosemirror-markdown` | Tiptap, marked, jsdom | The single, canonical ProseMirror↔Markdown converter + Docmost schema mirror (#293). Consumed by `mcp` and `git-sync`; there is exactly ONE copy of the converter now |
|
| `packages/prosemirror-markdown` | `@docmost/prosemirror-markdown` | Tiptap, marked, jsdom | The single, canonical ProseMirror↔Markdown converter + Docmost schema mirror (#293). Consumed by `mcp`, `git-sync`, AND `apps/server` (server-side markdown import/export, #345); there is exactly ONE copy of the converter now |
|
||||||
|
|
||||||
`build` targets are Nx-cached and dependency-ordered (`dependsOn: ["^build"]`), so `editor-ext` builds before the apps. `nx.json` sets `affected.defaultBase: main`.
|
`build` targets are Nx-cached and dependency-ordered (`dependsOn: ["^build"]`), so `editor-ext` builds before the apps. `nx.json` sets `affected.defaultBase: main`.
|
||||||
|
|
||||||
@@ -214,6 +214,12 @@ Run from the repo root unless noted. The dev workflow needs **Postgres (with the
|
|||||||
> server, `APP_SECRET` mismatch between processes, a stale `editor-ext` white-
|
> server, `APP_SECRET` mismatch between processes, a stale `editor-ext` white-
|
||||||
> screening the client, LAN exposure. See **[docs/dev-stand.md](docs/dev-stand.md)**
|
> screening the client, LAN exposure. See **[docs/dev-stand.md](docs/dev-stand.md)**
|
||||||
> for the step-by-step and the traps.
|
> for the step-by-step and the traps.
|
||||||
|
>
|
||||||
|
> **Testing the app against a stand** (browser E2E + out-of-band verification) has
|
||||||
|
> its own non-obvious traps — the page has two ProseMirror editors (only the body is
|
||||||
|
> collab-bound), a ~10s store debounce, and API-seeding the thing under test is a
|
||||||
|
> silent no-test. See **[docs/how-to-test.md](docs/how-to-test.md)** before writing
|
||||||
|
> UI tests.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm install # install all workspaces (uses pnpm patches; see package.json `pnpm.patchedDependencies`)
|
pnpm install # install all workspaces (uses pnpm patches; see package.json `pnpm.patchedDependencies`)
|
||||||
@@ -250,7 +256,10 @@ pnpm --filter server migration:codegen # regenerate src/databa
|
|||||||
```
|
```
|
||||||
Migration files live in `apps/server/src/database/migrations/` and are named `YYYYMMDDThhmmss-description.ts`. Fork-specific migrations only **add** tables (`page_embeddings`, `ai_chats`, `ai_chat_messages`, `ai_provider_credentials`, `ai_mcp_servers`, `page_template_references`) and columns (e.g. `pages.is_template`, a `NOT NULL DEFAULT false` boolean) — never drop/rewrite Docmost data.
|
Migration files live in `apps/server/src/database/migrations/` and are named `YYYYMMDDThhmmss-description.ts`. Fork-specific migrations only **add** tables (`page_embeddings`, `ai_chats`, `ai_chat_messages`, `ai_provider_credentials`, `ai_mcp_servers`, `page_template_references`) and columns (e.g. `pages.is_template`, a `NOT NULL DEFAULT false` boolean) — never drop/rewrite Docmost data.
|
||||||
|
|
||||||
**Migration ordering — always check when merging branches/features.** Kysely runs migrations in **alphabetical (= timestamp) order** and refuses to start if a *new* migration sorts **before** one already applied to the DB (`corrupted migrations: ... must always have a name that comes alphabetically after the last executed migration`). When you merge a branch or land a feature, verify your migration's timestamp still sorts **after every migration that may already be applied on the target** (`/bin/ls -1 apps/server/src/database/migrations | sort | tail`). Branches developed in parallel routinely break this: a feature branch adds `…T130000-…`, `main` meanwhile ships and deploys `…T150000-…`, and after the merge the older-timestamped file is rejected at boot. **Fix = rename your migration to a timestamp after the latest one already in the target** (content unchanged — the filename is the ordering key), then rebuild so the compiled `dist/database/migrations/` picks up the new name.
|
**Migration ordering — always check when merging branches/features.** Kysely runs migrations in **alphabetical (= timestamp) order**. A *new* migration that sorts **before** one already applied to the DB is a "back-dated" migration, which branches developed in parallel routinely produce: a feature branch adds `…T130000-…`, `develop` meanwhile ships and deploys `…T150000-…`, and after the merge the older-timestamped file has been skipped. Two layers guard this (both added for incident #361, where a back-dated migration crash-looped prod for ~11 min):
|
||||||
|
|
||||||
|
- **CI gate (primary):** the `migration-order` job in `.github/workflows/test.yml` fails a PR whose added migration sorts at/before the newest on the base branch. **So the fix is to rename your migration to a timestamp after the latest one already in the target** (`/bin/ls -1 apps/server/src/database/migrations | sort | tail`; content unchanged — the filename is the ordering key), then rebuild so the compiled `dist/database/migrations/` picks up the new name.
|
||||||
|
- **Runtime safety net:** both Migrators (`migration.service.ts` startup auto-migrate + `migrate.ts` CLI) set `allowUnorderedMigrations: true`, so the app does **not** refuse to start on an out-of-order migration — it applies the skipped older one instead of crash-looping. Kysely's `#ensureNoMissingMigrations` guard is still on (a *removed* applied migration is still an error). Because apply order can then differ from lexicographic across instances, migrations must stay **independent** (each creates its own objects) — the CI gate remains the primary line; this net only covers a gate bypass (manual push / hotfix branch).
|
||||||
|
|
||||||
## Architecture — the big picture
|
## Architecture — the big picture
|
||||||
|
|
||||||
@@ -284,7 +293,7 @@ The API server is a Fastify app with a global `/api` prefix (`main.ts` excludes
|
|||||||
### Client structure
|
### 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:
|
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.
|
- **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. The ProseMirror↔Markdown converter and its Docmost schema mirror now live in a SINGLE package, `@docmost/prosemirror-markdown` (#293), consumed by both `mcp` and `git-sync` — do NOT reintroduce a per-package copy. `editor-ext` is the upstream source of the Tiptap schema; the package's `docmost-schema.ts` mirrors it and a serializer-contract test (`packages/prosemirror-markdown/test/serializer-contract.test.ts`) guards the boundary (every schema node must have a converter case), so a drift surfaces as a failing test rather than silent divergence.
|
- The editor is Tiptap; shared node/mark extensions live in `packages/editor-ext` and are imported by **both the client and the server** (collaboration, schema, `canonicalizeFootnotes`) — editor schema changes often need to be made in `editor-ext`, not just the client. Server-side markdown import/export no longer lives in `editor-ext`: it goes through the canonical converter (#345, see below). The ProseMirror↔Markdown converter and its Docmost schema mirror now live in a SINGLE package, `@docmost/prosemirror-markdown` (#293), consumed by `mcp`, `git-sync`, and `apps/server` (#345) — do NOT reintroduce a per-package copy. `editor-ext` is the upstream source of the Tiptap schema; the package's `docmost-schema.ts` mirrors it and a serializer-contract test (`packages/prosemirror-markdown/test/serializer-contract.test.ts`) guards the boundary (every schema node must have a converter case), so a drift surfaces as a failing test rather than silent divergence.
|
||||||
- API access goes through `apps/client/src/lib/api-client.ts` (axios). The `@` alias maps to `apps/client/src`.
|
- 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`.
|
- 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`.
|
||||||
|
|
||||||
@@ -294,7 +303,7 @@ Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirro
|
|||||||
- **Errors must never be swallowed or shown as generic messages.** Every caught error MUST (1) be logged in full to the console/logger — error name, message, stack, `cause`, and (for HTTP/provider failures) the status code and response body — and (2) be surfaced to the user with a *specific, human-readable explanation of what actually went wrong*, never a bare generic string like "Something went wrong" / "Could not start recording" / "Transcription failed". Include the real reason (the underlying error/provider message) in the user-facing text. On the server, wrap third-party/provider failures with `describeProviderError` (or equivalent) and rethrow as a meaningful HTTP status + message — never let them collapse into an opaque 500. On the client, `console.error(<context>, err)` the raw error AND show the extracted reason (e.g. `err.response?.data?.message`, or the error `name: message`) in the notification.
|
- **Errors must never be swallowed or shown as generic messages.** Every caught error MUST (1) be logged in full to the console/logger — error name, message, stack, `cause`, and (for HTTP/provider failures) the status code and response body — and (2) be surfaced to the user with a *specific, human-readable explanation of what actually went wrong*, never a bare generic string like "Something went wrong" / "Could not start recording" / "Transcription failed". Include the real reason (the underlying error/provider message) in the user-facing text. On the server, wrap third-party/provider failures with `describeProviderError` (or equivalent) and rethrow as a meaningful HTTP status + message — never let them collapse into an opaque 500. On the client, `console.error(<context>, err)` the raw error AND show the extracted reason (e.g. `err.response?.data?.message`, or the error `name: message`) in the notification.
|
||||||
- The version string shown in the UI comes from `APP_VERSION` (CI/Docker) or `git describe --tags --always` (local), resolved in `vite.config.ts` — not from `package.json`.
|
- The version string shown in the UI comes from `APP_VERSION` (CI/Docker) or `git describe --tags --always` (local), resolved in `vite.config.ts` — not from `package.json`.
|
||||||
- Server TS config is permissive (`noImplicitAny: false`, `strictNullChecks: false`, `no-explicit-any` lint disabled). Follow the existing relaxed style rather than tightening types broadly.
|
- Server TS config is permissive (`noImplicitAny: false`, `strictNullChecks: false`, `no-explicit-any` lint disabled). Follow the existing relaxed style rather than tightening types broadly.
|
||||||
- Dependency versions are heavily pinned via `pnpm.overrides` and `pnpm.patchedDependencies` (`scimmy`, `yjs`) in the root `package.json`. Don't bump pinned/patched deps casually; the patches and overrides exist for compatibility/security reasons.
|
- Dependency versions are heavily pinned via `pnpm.overrides` and `pnpm.patchedDependencies` (`scimmy`, `yjs`, `ai`) in the root `package.json`. Don't bump pinned/patched deps casually; the patches and overrides exist for compatibility/security reasons. The `ai@6.0.134` patch disables the SDK's O(n²) cumulative `partialOutput` accumulation when no output strategy is requested (server heap OOM on long agent runs, #184; tripwire test: `apps/server/src/integrations/ai/ai-sdk-partial-output.patch.spec.ts`) — it MUST be re-created via `pnpm patch` when bumping `ai`.
|
||||||
- **Adding/renaming/removing an MCP tool requires updating `SERVER_INSTRUCTIONS`** in `packages/mcp/src/index.ts` — the intent-routing guide MCP clients receive on initialize. This applies both to inline `server.registerTool(...)` calls in `index.ts` and to specs in `packages/mcp/src/tool-specs.ts`. Enforced by `packages/mcp/test/unit/server-instructions.test.mjs`, which fails when a registered tool is not mentioned in the guide (deliberate opt-outs go into its `EXCEPTIONS` list). `packages/mcp/build/` is gitignored and rebuilt in CI/Docker via `pnpm build` (same convention as `git-sync`/`prosemirror-markdown`) — never commit it; rebuild locally after editing to run the tests.
|
- **Adding/renaming/removing an MCP tool requires updating `SERVER_INSTRUCTIONS`** in `packages/mcp/src/index.ts` — the intent-routing guide MCP clients receive on initialize. This applies both to inline `server.registerTool(...)` calls in `index.ts` and to specs in `packages/mcp/src/tool-specs.ts`. Enforced by `packages/mcp/test/unit/server-instructions.test.mjs`, which fails when a registered tool is not mentioned in the guide (deliberate opt-outs go into its `EXCEPTIONS` list). `packages/mcp/build/` is gitignored and rebuilt in CI/Docker via `pnpm build` (same convention as `git-sync`/`prosemirror-markdown`) — never commit it; rebuild locally after editing to run the tests.
|
||||||
|
|
||||||
## CI / release
|
## CI / release
|
||||||
|
|||||||
@@ -166,9 +166,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
JSON-compatible schema (no custom tags / no code execution) behind the same
|
JSON-compatible schema (no custom tags / no code execution) behind the same
|
||||||
size-cap, redirect and path-traversal guards. The `AI_AGENT_ROLES_CATALOG_URL`
|
size-cap, redirect and path-traversal guards. The `AI_AGENT_ROLES_CATALOG_URL`
|
||||||
base-URL contract is unchanged. (#229)
|
base-URL contract is unchanged. (#229)
|
||||||
|
- **CORS is now an explicit allowlist** (replaces the previous unconfigured
|
||||||
|
`app.enableCors()`). The same-origin web client is unaffected, but any
|
||||||
|
separately-hosted cross-domain client must now be listed in
|
||||||
|
`CORS_ALLOWED_ORIGINS` (native Capacitor/Ionic/localhost WebView origins are
|
||||||
|
allowed automatically). Requests with no `Origin` header (server-to-server)
|
||||||
|
are still allowed. **Upgrade note:** the old bare `app.enableCors()` reflected
|
||||||
|
*any* origin (with `credentials:false`), so any previously-working cross-domain
|
||||||
|
REST/browser client is now rejected until its origin is added to
|
||||||
|
`CORS_ALLOWED_ORIGINS` (see `.env.example`).
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Offline reading support**: opened pages, their sidebar tree, breadcrumb
|
||||||
|
children, and comments are cached in IndexedDB (TanStack Query persister plus
|
||||||
|
`y-indexeddb` for the page's Yjs document), and a PWA service worker
|
||||||
|
(vite-plugin-pwa) serves an app shell so previously opened pages stay readable
|
||||||
|
offline. The two offline stores (the persisted query cache and the Yjs page
|
||||||
|
documents) are cleared on logout AND on sign-in so a previous user's private
|
||||||
|
data does not remain in the browser; the same purge also defensively drops any
|
||||||
|
legacy service-worker `api-get-cache` left by older clients (current builds
|
||||||
|
serve `/api` as NetworkOnly, so there is no active service-worker API cache).
|
||||||
|
- **Mobile bootstrap**: a `returnToken` opt-in on login so native/mobile clients
|
||||||
|
can request the access JWT in the response body (`data.authToken`) in addition
|
||||||
|
to the httpOnly cookie (the web client stays cookie-only); an optional
|
||||||
|
OpenAPI/Swagger UI at `/api/docs` gated by `SWAGGER_ENABLED` (off by default);
|
||||||
|
and new env vars `CORS_ALLOWED_ORIGINS`, `SWAGGER_ENABLED`, `CAP_SERVER_URL`.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- **The server no longer runs out of heap during long autonomous agent runs.** A
|
||||||
|
new pnpm patch on `ai@6.0.134` stops the SDK from building a cumulative
|
||||||
|
snapshot of the ENTIRE turn text on every streamed text-delta when no output
|
||||||
|
strategy was requested (our server never requests one). Unpatched, those
|
||||||
|
O(n²) `partialOutput` snapshots piled up in a never-consumed internal
|
||||||
|
`tee()` branch of the stream result — a ~20-step, ~28k-chunk agent run
|
||||||
|
retained ~1.7 GB and OOM'd the 2 GB JS heap. Streaming granularity is
|
||||||
|
unchanged; the patch must be re-created if `ai` is ever bumped. (#184)
|
||||||
- **Internal links in exported Markdown no longer lose their visible text.** A
|
- **Internal links in exported Markdown no longer lose their visible text.** A
|
||||||
link whose target page name had no file extension (e.g. a bare title) was
|
link whose target page name had no file extension (e.g. a bare title) was
|
||||||
collapsed to empty text during export, producing an unclickable, label-less
|
collapsed to empty text during export, producing an unclickable, label-less
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
<meta name="theme-color" content="#0E1117" media="(prefers-color-scheme: dark)" />
|
<meta name="theme-color" content="#0E1117" media="(prefers-color-scheme: dark)" />
|
||||||
<meta name="theme-color" content="#f6f7f9" media="(prefers-color-scheme: light)" />
|
<meta name="theme-color" content="#f6f7f9" media="(prefers-color-scheme: light)" />
|
||||||
<link rel="manifest" href="/manifest.json" />
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
<link rel="apple-touch-icon" href="/icons/app-icon-192x192.png" />
|
||||||
<meta name="mobile-web-app-capable" content="yes" />
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-touch-fullscreen" content="yes" />
|
<meta name="apple-touch-fullscreen" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Gitmost" />
|
<meta name="apple-mobile-web-app-title" content="Gitmost" />
|
||||||
|
|||||||
@@ -33,7 +33,9 @@
|
|||||||
"@slidoapp/emoji-mart-data": "1.2.4",
|
"@slidoapp/emoji-mart-data": "1.2.4",
|
||||||
"@slidoapp/emoji-mart-react": "1.1.5",
|
"@slidoapp/emoji-mart-react": "1.1.5",
|
||||||
"@tabler/icons-react": "3.40.0",
|
"@tabler/icons-react": "3.40.0",
|
||||||
|
"@tanstack/query-async-storage-persister": "5.90.17",
|
||||||
"@tanstack/react-query": "5.90.17",
|
"@tanstack/react-query": "5.90.17",
|
||||||
|
"@tanstack/react-query-persist-client": "5.90.17",
|
||||||
"@tanstack/react-virtual": "3.13.24",
|
"@tanstack/react-virtual": "3.13.24",
|
||||||
"ai": "6.0.207",
|
"ai": "6.0.207",
|
||||||
"alfaaz": "1.1.0",
|
"alfaaz": "1.1.0",
|
||||||
@@ -46,6 +48,7 @@
|
|||||||
"highlightjs-sap-abap": "0.3.0",
|
"highlightjs-sap-abap": "0.3.0",
|
||||||
"i18next": "25.10.1",
|
"i18next": "25.10.1",
|
||||||
"i18next-http-backend": "3.0.6",
|
"i18next-http-backend": "3.0.6",
|
||||||
|
"idb-keyval": "6.2.5",
|
||||||
"jotai": "2.18.1",
|
"jotai": "2.18.1",
|
||||||
"jotai-optics": "0.4.0",
|
"jotai-optics": "0.4.0",
|
||||||
"js-cookie": "3.0.7",
|
"js-cookie": "3.0.7",
|
||||||
@@ -98,6 +101,7 @@
|
|||||||
"typescript": "5.9.3",
|
"typescript": "5.9.3",
|
||||||
"typescript-eslint": "8.57.1",
|
"typescript-eslint": "8.57.1",
|
||||||
"vite": "8.0.5",
|
"vite": "8.0.5",
|
||||||
|
"vite-plugin-pwa": "1.3.0",
|
||||||
"vitest": "4.1.6"
|
"vitest": "4.1.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -471,6 +471,18 @@
|
|||||||
"Move page": "Move page",
|
"Move page": "Move page",
|
||||||
"Move page to a different space.": "Move page to a different space.",
|
"Move page to a different space.": "Move page to a different space.",
|
||||||
"Real-time editor connection lost. Retrying...": "Real-time editor connection lost. Retrying...",
|
"Real-time editor connection lost. Retrying...": "Real-time editor connection lost. Retrying...",
|
||||||
|
"Offline — changes are saved locally and will sync when you reconnect": "Offline — changes are saved locally and will sync when you reconnect",
|
||||||
|
"Syncing changes…": "Syncing changes…",
|
||||||
|
"All changes synced": "All changes synced",
|
||||||
|
"Update available": "Update available",
|
||||||
|
"Reload": "Reload",
|
||||||
|
"Make available offline": "Make available offline",
|
||||||
|
"Saving page for offline use...": "Saving page for offline use...",
|
||||||
|
"Page is now available offline": "Page is now available offline",
|
||||||
|
"Failed to make page available offline": "Failed to make page available offline",
|
||||||
|
"You're offline": "You're offline",
|
||||||
|
"This page hasn't been saved for offline use, so it can't be loaded right now. Reconnect to the internet and try again.": "This page hasn't been saved for offline use, so it can't be loaded right now. Reconnect to the internet and try again.",
|
||||||
|
"Retry": "Retry",
|
||||||
"Table of contents": "Table of contents",
|
"Table of contents": "Table of contents",
|
||||||
"Add headings (H1, H2, H3) to generate a table of contents.": "Add headings (H1, H2, H3) to generate a table of contents.",
|
"Add headings (H1, H2, H3) to generate a table of contents.": "Add headings (H1, H2, H3) to generate a table of contents.",
|
||||||
"Share": "Share",
|
"Share": "Share",
|
||||||
@@ -1373,6 +1385,39 @@
|
|||||||
"The role catalog is unavailable": "The role catalog is unavailable",
|
"The role catalog is unavailable": "The role catalog is unavailable",
|
||||||
"Please try again later.": "Please try again later.",
|
"Please try again later.": "Please try again later.",
|
||||||
"No bundles available": "No bundles available",
|
"No bundles available": "No bundles available",
|
||||||
|
"Content": "Content",
|
||||||
|
"Content language of the roles": "Content language of the roles",
|
||||||
|
"{{count}} updates available in {{bundles}} bundles": "{{count}} updates available in {{bundles}} bundles",
|
||||||
|
"Update all ({{count}})": "Update all ({{count}})",
|
||||||
|
"Updating {{current}}/{{total}}…": "Updating {{current}}/{{total}}…",
|
||||||
|
"{{count}} roles are installed in another language. A different language installs separately and appears as new.": "{{count}} roles are installed in another language. A different language installs separately and appears as new.",
|
||||||
|
"{{count}} roles": "{{count}} roles",
|
||||||
|
"{{count}} new — none installed": "{{count}} new — none installed",
|
||||||
|
"All installed · up to date": "All installed · up to date",
|
||||||
|
"{{count}} updates · {{installed}} up to date": "{{count}} updates · {{installed}} up to date",
|
||||||
|
"{{count}} new": "{{count}} new",
|
||||||
|
"{{count}} installed": "{{count}} installed",
|
||||||
|
"{{count}} updates": "{{count}} updates",
|
||||||
|
"Install bundle": "Install bundle",
|
||||||
|
"Install {{count}} selected": "Install {{count}} selected",
|
||||||
|
"Install bundle ({{count}})": "Install bundle ({{count}})",
|
||||||
|
"{{selected}} of {{total}} selected": "{{selected}} of {{total}} selected",
|
||||||
|
"Select all": "Select all",
|
||||||
|
"Deselect all": "Deselect all",
|
||||||
|
"Skipped": "Skipped",
|
||||||
|
"v{{version}}": "v{{version}}",
|
||||||
|
"{{count}} roles installed": "{{count}} roles installed",
|
||||||
|
"{{count}} roles installed · {{renamed}} renamed": "{{count}} roles installed · {{renamed}} renamed",
|
||||||
|
"{{count}} roles updated": "{{count}} roles updated",
|
||||||
|
"Installed {{installed}} · {{skipped}} skipped": "Installed {{installed}} · {{skipped}} skipped",
|
||||||
|
"A role named \"{{name}}\" already exists in this workspace.": "A role named \"{{name}}\" already exists in this workspace.",
|
||||||
|
"\"{{name}}\" is already installed.": "\"{{name}}\" is already installed.",
|
||||||
|
"Rename & install": "Rename & install",
|
||||||
|
"Couldn’t load the catalog": "Couldn’t load the catalog",
|
||||||
|
"Check your connection and try again. Installed roles are not affected.": "Check your connection and try again. Installed roles are not affected.",
|
||||||
|
"Retry": "Retry",
|
||||||
|
"The catalog is empty": "The catalog is empty",
|
||||||
|
"No role bundles are published for this language yet. Try switching the content language.": "No role bundles are published for this language yet. Try switching the content language.",
|
||||||
"Already up to date": "Already up to date",
|
"Already up to date": "Already up to date",
|
||||||
"Updated to the latest version": "Updated to the latest version",
|
"Updated to the latest version": "Updated to the latest version",
|
||||||
"This role is no longer in the catalog": "This role is no longer in the catalog",
|
"This role is no longer in the catalog": "This role is no longer in the catalog",
|
||||||
|
|||||||
@@ -487,6 +487,18 @@
|
|||||||
"Move page": "Переместить страницу",
|
"Move page": "Переместить страницу",
|
||||||
"Move page to a different space.": "Переместите страницу в другое пространство.",
|
"Move page to a different space.": "Переместите страницу в другое пространство.",
|
||||||
"Real-time editor connection lost. Retrying...": "Соединение с редактором в реальном времени потеряно. Повторная попытка...",
|
"Real-time editor connection lost. Retrying...": "Соединение с редактором в реальном времени потеряно. Повторная попытка...",
|
||||||
|
"Offline — changes are saved locally and will sync when you reconnect": "Нет сети — изменения сохраняются локально и синхронизируются при восстановлении соединения",
|
||||||
|
"Syncing changes…": "Синхронизация изменений…",
|
||||||
|
"All changes synced": "Все изменения синхронизированы",
|
||||||
|
"Update available": "Доступно обновление",
|
||||||
|
"Reload": "Перезагрузить",
|
||||||
|
"Make available offline": "Сделать доступным офлайн",
|
||||||
|
"Saving page for offline use...": "Сохраняем страницу для офлайн-доступа…",
|
||||||
|
"Page is now available offline": "Страница доступна офлайн",
|
||||||
|
"Failed to make page available offline": "Не удалось сделать страницу доступной офлайн",
|
||||||
|
"You're offline": "Вы офлайн",
|
||||||
|
"This page hasn't been saved for offline use, so it can't be loaded right now. Reconnect to the internet and try again.": "Эта страница не была сохранена для офлайн-доступа, поэтому её нельзя загрузить сейчас. Подключитесь к интернету и попробуйте снова.",
|
||||||
|
"Retry": "Повторить",
|
||||||
"Table of contents": "Оглавление",
|
"Table of contents": "Оглавление",
|
||||||
"Add headings (H1, H2, H3) to generate a table of contents.": "Добавьте заголовки (H1, H2, H3), чтобы создать оглавление.",
|
"Add headings (H1, H2, H3) to generate a table of contents.": "Добавьте заголовки (H1, H2, H3), чтобы создать оглавление.",
|
||||||
"Share": "Поделиться",
|
"Share": "Поделиться",
|
||||||
@@ -1235,6 +1247,39 @@
|
|||||||
"The role catalog is unavailable": "Каталог ролей недоступен",
|
"The role catalog is unavailable": "Каталог ролей недоступен",
|
||||||
"Please try again later.": "Попробуйте позже.",
|
"Please try again later.": "Попробуйте позже.",
|
||||||
"No bundles available": "Наборы недоступны",
|
"No bundles available": "Наборы недоступны",
|
||||||
|
"Content": "Язык контента",
|
||||||
|
"Content language of the roles": "Язык контента ролей",
|
||||||
|
"{{count}} updates available in {{bundles}} bundles": "Доступно обновлений: {{count}} в наборах: {{bundles}}",
|
||||||
|
"Update all ({{count}})": "Обновить все ({{count}})",
|
||||||
|
"Updating {{current}}/{{total}}…": "Обновление {{current}}/{{total}}…",
|
||||||
|
"{{count}} roles are installed in another language. A different language installs separately and appears as new.": "Ролей установлено на другом языке: {{count}}. Другой язык устанавливается отдельно и отображается как новый.",
|
||||||
|
"{{count}} roles": "ролей: {{count}}",
|
||||||
|
"{{count}} new — none installed": "новых: {{count}} — ничего не установлено",
|
||||||
|
"All installed · up to date": "Все установлены · актуальны",
|
||||||
|
"{{count}} updates · {{installed}} up to date": "обновлений: {{count}} · актуальны: {{installed}}",
|
||||||
|
"{{count}} new": "новых: {{count}}",
|
||||||
|
"{{count}} installed": "установлено: {{count}}",
|
||||||
|
"{{count}} updates": "обновлений: {{count}}",
|
||||||
|
"Install bundle": "Установить набор",
|
||||||
|
"Install {{count}} selected": "Установить выбранные ({{count}})",
|
||||||
|
"Install bundle ({{count}})": "Установить набор ({{count}})",
|
||||||
|
"{{selected}} of {{total}} selected": "выбрано {{selected}} из {{total}}",
|
||||||
|
"Select all": "Выбрать все",
|
||||||
|
"Deselect all": "Снять выбор",
|
||||||
|
"Skipped": "Пропущено",
|
||||||
|
"v{{version}}": "v{{version}}",
|
||||||
|
"{{count}} roles installed": "Установлено ролей: {{count}}",
|
||||||
|
"{{count}} roles installed · {{renamed}} renamed": "Установлено ролей: {{count}} · переименовано: {{renamed}}",
|
||||||
|
"{{count}} roles updated": "Обновлено ролей: {{count}}",
|
||||||
|
"Installed {{installed}} · {{skipped}} skipped": "Установлено: {{installed}} · пропущено: {{skipped}}",
|
||||||
|
"A role named \"{{name}}\" already exists in this workspace.": "Роль с именем «{{name}}» уже существует в этом рабочем пространстве.",
|
||||||
|
"\"{{name}}\" is already installed.": "«{{name}}» уже установлена.",
|
||||||
|
"Rename & install": "Переименовать и установить",
|
||||||
|
"Couldn’t load the catalog": "Не удалось загрузить каталог",
|
||||||
|
"Check your connection and try again. Installed roles are not affected.": "Проверьте подключение и попробуйте снова. Установленные роли не затронуты.",
|
||||||
|
"Retry": "Повторить",
|
||||||
|
"The catalog is empty": "Каталог пуст",
|
||||||
|
"No role bundles are published for this language yet. Try switching the content language.": "Для этого языка ещё не опубликовано ни одного набора ролей. Попробуйте сменить язык контента.",
|
||||||
"No roles configured": "Роли не настроены",
|
"No roles configured": "Роли не настроены",
|
||||||
"Already up to date": "Уже актуальна",
|
"Already up to date": "Уже актуальна",
|
||||||
"Updated to the latest version": "Обновлено до последней версии",
|
"Updated to the latest version": "Обновлено до последней версии",
|
||||||
|
|||||||
@@ -1,30 +1,19 @@
|
|||||||
{
|
{
|
||||||
|
"id": "/",
|
||||||
"name": "Gitmost",
|
"name": "Gitmost",
|
||||||
"short_name": "Gitmost",
|
"short_name": "Gitmost",
|
||||||
|
"description": "Gitmost - open-source collaborative documentation and knowledge base.",
|
||||||
|
"lang": "en",
|
||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
|
"orientation": "any",
|
||||||
"background_color": "#0E1117",
|
"background_color": "#0E1117",
|
||||||
"theme_color": "#0E1117",
|
"theme_color": "#0E1117",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{ "src": "icons/favicon-16x16.png", "type": "image/png", "sizes": "16x16" },
|
||||||
"src": "icons/favicon-16x16.png",
|
{ "src": "icons/favicon-32x32.png", "type": "image/png", "sizes": "32x32" },
|
||||||
"type": "image/png",
|
{ "src": "icons/app-icon-192x192.png", "type": "image/png", "sizes": "192x192", "purpose": "any" },
|
||||||
"sizes": "16x16"
|
{ "src": "icons/app-icon-512x512.png", "type": "image/png", "sizes": "512x512", "purpose": "any" }
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "icons/favicon-32x32.png",
|
|
||||||
"type": "image/png",
|
|
||||||
"sizes": "32x32"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "icons/app-icon-192x192.png",
|
|
||||||
"type": "image/png",
|
|
||||||
"sizes": "180x180 192x192"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "icons/app-icon-512x512.png",
|
|
||||||
"type": "image/png",
|
|
||||||
"sizes": "512x512"
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
useInfiniteQuery,
|
useInfiniteQuery,
|
||||||
useMutation,
|
useMutation,
|
||||||
|
useQueries,
|
||||||
useQuery,
|
useQuery,
|
||||||
useQueryClient,
|
useQueryClient,
|
||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
@@ -307,6 +308,29 @@ export function useAiRoleCatalogBundleQuery(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eagerly open EVERY listed bundle's content in parallel for one language. The
|
||||||
|
* redesigned catalog shows each bundle's status summary in its COLLAPSED header,
|
||||||
|
* which needs every role's install state up front — so contents can no longer be
|
||||||
|
* lazy-loaded on expand. The catalog is small, so a fan-out of `useQueries` (one
|
||||||
|
* cached read per bundle, sharing the same cache keys as
|
||||||
|
* `useAiRoleCatalogBundleQuery`) is cheap. Gated by `enabled` (modal open + a
|
||||||
|
* resolved language) so nothing fetches while the modal is closed.
|
||||||
|
*/
|
||||||
|
export function useAiRoleCatalogBundlesQueries(
|
||||||
|
bundleIds: string[],
|
||||||
|
language: string,
|
||||||
|
enabled: boolean,
|
||||||
|
) {
|
||||||
|
return useQueries({
|
||||||
|
queries: bundleIds.map((bundleId) => ({
|
||||||
|
queryKey: AI_ROLE_CATALOG_BUNDLE_RQ_KEY(bundleId, language),
|
||||||
|
queryFn: () => getAiRoleCatalogBundle(bundleId, language),
|
||||||
|
enabled: enabled && !!language,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useImportAiRolesFromCatalogMutation() {
|
export function useImportAiRolesFromCatalogMutation() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|||||||
@@ -77,7 +77,14 @@ describe("useImportAiRolesFromCatalogMutation — success notifications", () =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("errors:[] -> only the summary notification (counts interpolated)", async () => {
|
it("errors:[] -> only the summary notification (counts interpolated)", async () => {
|
||||||
await runMutation({ created: 3, renamed: 1, skipped: 2, errors: [] });
|
await runMutation({
|
||||||
|
created: 3,
|
||||||
|
renamed: 1,
|
||||||
|
skipped: 2,
|
||||||
|
errors: [],
|
||||||
|
createdRoles: [],
|
||||||
|
skippedRoles: [],
|
||||||
|
});
|
||||||
expect(notificationsShowMock).toHaveBeenCalledTimes(1);
|
expect(notificationsShowMock).toHaveBeenCalledTimes(1);
|
||||||
expect(notificationsShowMock).toHaveBeenCalledWith({
|
expect(notificationsShowMock).toHaveBeenCalledWith({
|
||||||
message: "Imported 3, renamed 1, skipped 2",
|
message: "Imported 3, renamed 1, skipped 2",
|
||||||
@@ -93,6 +100,8 @@ describe("useImportAiRolesFromCatalogMutation — success notifications", () =>
|
|||||||
{ slug: "a", message: "name taken" },
|
{ slug: "a", message: "name taken" },
|
||||||
{ slug: "b", message: "name taken" },
|
{ slug: "b", message: "name taken" },
|
||||||
],
|
],
|
||||||
|
createdRoles: [{ slug: "ok", name: "Ok" }],
|
||||||
|
skippedRoles: [],
|
||||||
});
|
});
|
||||||
expect(notificationsShowMock).toHaveBeenCalledTimes(2);
|
expect(notificationsShowMock).toHaveBeenCalledTimes(2);
|
||||||
expect(notificationsShowMock).toHaveBeenNthCalledWith(1, {
|
expect(notificationsShowMock).toHaveBeenNthCalledWith(1, {
|
||||||
|
|||||||
@@ -108,12 +108,25 @@ export interface IAiRoleImportPayload {
|
|||||||
conflict: "skip" | "rename";
|
conflict: "skip" | "rename";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Import result counts (mirrors `importFromCatalog()`). */
|
/**
|
||||||
|
* Import result (mirrors `importFromCatalog()`). The counters (`created`,
|
||||||
|
* `skipped`, `renamed`) drive the summary notification; the per-role lists
|
||||||
|
* (`createdRoles`, `skippedRoles`) drive the redesigned catalog modal's inline
|
||||||
|
* result plaque — which roles were installed (and any rename) and which were
|
||||||
|
* skipped and why (so the plaque can name the conflicting role and offer
|
||||||
|
* "Rename & install").
|
||||||
|
*/
|
||||||
export interface IAiRoleImportResult {
|
export interface IAiRoleImportResult {
|
||||||
created: number;
|
created: number;
|
||||||
skipped: number;
|
skipped: number;
|
||||||
renamed: number;
|
renamed: number;
|
||||||
errors: { slug: string; message: string }[];
|
errors: { slug: string; message: string }[];
|
||||||
|
createdRoles: { slug: string; name: string; renamedTo?: string }[];
|
||||||
|
skippedRoles: {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
reason: "name-conflict" | "already-installed";
|
||||||
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
bundleCounts,
|
||||||
|
bundlePhase,
|
||||||
|
installedLangForRole,
|
||||||
|
mapBundleRolesToView,
|
||||||
|
mapCatalogRoleToView,
|
||||||
|
nameConflictSlugs,
|
||||||
|
partialOffersRename,
|
||||||
|
type CatalogViewRole,
|
||||||
|
} from "./catalog-bundle-model.ts";
|
||||||
|
import type {
|
||||||
|
IAiRole,
|
||||||
|
IAiRoleCatalogRole,
|
||||||
|
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||||
|
|
||||||
|
function installedRole(
|
||||||
|
source: { slug: string; language: string; version: number },
|
||||||
|
overrides: Partial<IAiRole> = {},
|
||||||
|
): IAiRole {
|
||||||
|
return {
|
||||||
|
id: `role-${source.slug}-${source.language}`,
|
||||||
|
name: source.slug,
|
||||||
|
emoji: null,
|
||||||
|
description: null,
|
||||||
|
enabled: true,
|
||||||
|
autoStart: true,
|
||||||
|
launchMessage: null,
|
||||||
|
source,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function catalogRole(
|
||||||
|
overrides: Partial<IAiRoleCatalogRole> = {},
|
||||||
|
): IAiRoleCatalogRole {
|
||||||
|
return {
|
||||||
|
slug: "writer",
|
||||||
|
emoji: "✍️",
|
||||||
|
name: "Writer",
|
||||||
|
description: "Drafts copy.",
|
||||||
|
instructions: "be a writer",
|
||||||
|
autoStart: true,
|
||||||
|
launchMessage: null,
|
||||||
|
version: 3,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a minimal view role for bundlePhase tests.
|
||||||
|
function viewRole(status: CatalogViewRole["status"]): CatalogViewRole {
|
||||||
|
return { slug: `s-${status}`, name: status, description: "", version: 1, status };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("bundlePhase", () => {
|
||||||
|
it("empty bundle -> empty", () => {
|
||||||
|
expect(bundlePhase([])).toBe("empty");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("all importable, none installed -> allNew", () => {
|
||||||
|
expect(bundlePhase([viewRole("import"), viewRole("import")])).toBe(
|
||||||
|
"allNew",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nothing to import or update -> allInstalled", () => {
|
||||||
|
expect(bundlePhase([viewRole("installed"), viewRole("installed")])).toBe(
|
||||||
|
"allInstalled",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates present, nothing to import -> updates", () => {
|
||||||
|
expect(bundlePhase([viewRole("update"), viewRole("installed")])).toBe(
|
||||||
|
"updates",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("import + installed (no updates) -> mixed", () => {
|
||||||
|
expect(bundlePhase([viewRole("import"), viewRole("installed")])).toBe(
|
||||||
|
"mixed",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("import + update -> mixed", () => {
|
||||||
|
expect(bundlePhase([viewRole("import"), viewRole("update")])).toBe("mixed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a skipped role with nothing installed -> mixed (NOT allInstalled)", () => {
|
||||||
|
// F1: a bundle whose only non-installed role was skipped has 0 installed for
|
||||||
|
// it, so the collapsed 'All installed · up to date' header would contradict
|
||||||
|
// the open 'Installed 0 · 1 skipped' plaque. It must be mixed until resolved.
|
||||||
|
expect(bundlePhase([viewRole("skipped")])).toBe("mixed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("installed + a skipped role -> mixed (partial success is not allInstalled)", () => {
|
||||||
|
expect(bundlePhase([viewRole("installed"), viewRole("skipped")])).toBe(
|
||||||
|
"mixed",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("bundleCounts", () => {
|
||||||
|
it("tallies each status once", () => {
|
||||||
|
expect(
|
||||||
|
bundleCounts([
|
||||||
|
viewRole("import"),
|
||||||
|
viewRole("import"),
|
||||||
|
viewRole("installed"),
|
||||||
|
viewRole("update"),
|
||||||
|
viewRole("skipped"),
|
||||||
|
]),
|
||||||
|
).toEqual({ importable: 2, installed: 1, update: 1, skipped: 1 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("nameConflictSlugs / partialOffersRename (reason -> action)", () => {
|
||||||
|
it("only name-conflict skips become the transient overlay / offer rename", () => {
|
||||||
|
const skipped = [
|
||||||
|
{ slug: "writer", name: "Writer", reason: "name-conflict" as const },
|
||||||
|
{ slug: "editor", name: "Editor", reason: "already-installed" as const },
|
||||||
|
];
|
||||||
|
expect(nameConflictSlugs(skipped)).toEqual(["writer"]);
|
||||||
|
expect(partialOffersRename(skipped)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an already-installed-only skip is informational: no overlay, no rename", () => {
|
||||||
|
const skipped = [
|
||||||
|
{ slug: "editor", name: "Editor", reason: "already-installed" as const },
|
||||||
|
];
|
||||||
|
expect(nameConflictSlugs(skipped)).toEqual([]);
|
||||||
|
expect(partialOffersRename(skipped)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("installedLangForRole", () => {
|
||||||
|
it("returns the other language when the same slug is installed elsewhere", () => {
|
||||||
|
const roles = [installedRole({ slug: "writer", language: "ru", version: 2 })];
|
||||||
|
expect(installedLangForRole("writer", roles, "en")).toBe("ru");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when the same slug is installed in the SAME language", () => {
|
||||||
|
const roles = [installedRole({ slug: "writer", language: "en", version: 2 })];
|
||||||
|
expect(installedLangForRole("writer", roles, "en")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when no install of the slug exists", () => {
|
||||||
|
expect(installedLangForRole("writer", [], "en")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores manually-created roles (no source)", () => {
|
||||||
|
const roles = [
|
||||||
|
installedRole({ slug: "writer", language: "ru", version: 2 }, {
|
||||||
|
source: null,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
expect(installedLangForRole("writer", roles, "en")).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mapCatalogRoleToView", () => {
|
||||||
|
it("no install -> import status, catalog version, emoji preserved", () => {
|
||||||
|
const view = mapCatalogRoleToView(catalogRole(), [], "en");
|
||||||
|
expect(view).toMatchObject({
|
||||||
|
slug: "writer",
|
||||||
|
emoji: "✍️",
|
||||||
|
name: "Writer",
|
||||||
|
description: "Drafts copy.",
|
||||||
|
status: "import",
|
||||||
|
version: 3,
|
||||||
|
});
|
||||||
|
expect(view.installedRoleId).toBeUndefined();
|
||||||
|
expect(view.installedLang).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("import with the slug installed in another language -> installedLang set", () => {
|
||||||
|
const roles = [installedRole({ slug: "writer", language: "ru", version: 9 })];
|
||||||
|
const view = mapCatalogRoleToView(catalogRole(), roles, "en");
|
||||||
|
expect(view.status).toBe("import");
|
||||||
|
expect(view.installedLang).toBe("ru");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("installed (up to date) -> installed status, catalog version, installedRoleId", () => {
|
||||||
|
const installed = installedRole({
|
||||||
|
slug: "writer",
|
||||||
|
language: "en",
|
||||||
|
version: 3,
|
||||||
|
});
|
||||||
|
const view = mapCatalogRoleToView(catalogRole(), [installed], "en");
|
||||||
|
expect(view).toMatchObject({
|
||||||
|
status: "installed",
|
||||||
|
version: 3,
|
||||||
|
installedRoleId: installed.id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("update -> version=from, newVersion=to, installedRoleId", () => {
|
||||||
|
const installed = installedRole({
|
||||||
|
slug: "writer",
|
||||||
|
language: "en",
|
||||||
|
version: 1,
|
||||||
|
});
|
||||||
|
const view = mapCatalogRoleToView(catalogRole(), [installed], "en");
|
||||||
|
expect(view).toMatchObject({
|
||||||
|
status: "update",
|
||||||
|
version: 1,
|
||||||
|
newVersion: 3,
|
||||||
|
installedRoleId: installed.id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("missing emoji -> emoji undefined; null description -> empty string", () => {
|
||||||
|
const view = mapCatalogRoleToView(
|
||||||
|
catalogRole({ emoji: null, description: null }),
|
||||||
|
[],
|
||||||
|
"en",
|
||||||
|
);
|
||||||
|
expect(view.emoji).toBeUndefined();
|
||||||
|
expect(view.description).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mapBundleRolesToView", () => {
|
||||||
|
it("maps a bundle's roles preserving order", () => {
|
||||||
|
const roles = [
|
||||||
|
catalogRole({ slug: "a", name: "A", version: 1 }),
|
||||||
|
catalogRole({ slug: "b", name: "B", version: 1 }),
|
||||||
|
];
|
||||||
|
const installed = [installedRole({ slug: "a", language: "en", version: 1 })];
|
||||||
|
const view = mapBundleRolesToView(roles, installed, "en");
|
||||||
|
expect(view.map((r) => r.slug)).toEqual(["a", "b"]);
|
||||||
|
expect(view[0].status).toBe("installed");
|
||||||
|
expect(view[1].status).toBe("import");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import type {
|
||||||
|
IAiRole,
|
||||||
|
IAiRoleCatalogRole,
|
||||||
|
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||||
|
import { catalogRoleInstallState } from "@/features/ai-chat/utils/catalog-role-install-state.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The redesigned catalog modal renders bundles as cards with a summary status
|
||||||
|
* (readable without expanding) and a single primary action. The per-role and
|
||||||
|
* per-bundle view model that drives that UI is derived here as PURE functions so
|
||||||
|
* the mapping, the "installed in another language" hint, and the bundle-phase
|
||||||
|
* computation are unit-testable without mounting the component (mirrors the
|
||||||
|
* `catalogRoleInstallState` precedent).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A role's status in the catalog view model.
|
||||||
|
* - `import` — not installed in the current content language.
|
||||||
|
* - `installed` — installed and up to date.
|
||||||
|
* - `update` — installed, but the catalog ships a newer version.
|
||||||
|
* - `skipped` — TRANSIENT client-only status set after a conflicted import
|
||||||
|
* (a name collision under `conflict:'skip'`); never from the
|
||||||
|
* backend.
|
||||||
|
*/
|
||||||
|
export type RoleStatus = "import" | "installed" | "update" | "skipped";
|
||||||
|
|
||||||
|
/** A catalog role mapped into the modal's view model. */
|
||||||
|
export interface CatalogViewRole {
|
||||||
|
// Slug is the stable identity within a bundle; used as the row key and as the
|
||||||
|
// `slugs[]` payload for import.
|
||||||
|
slug: string;
|
||||||
|
// Optional in the catalog — the row reserves space and renders nothing when
|
||||||
|
// absent.
|
||||||
|
emoji?: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
// For `installed`/`import`: the catalog version. For `update`: the installed
|
||||||
|
// (from) version, with `newVersion` holding the catalog (to) version.
|
||||||
|
version: number;
|
||||||
|
newVersion?: number;
|
||||||
|
status: RoleStatus;
|
||||||
|
// The language a same-slug role is installed under, when it differs from the
|
||||||
|
// current content language (drives the Р5 hint). Only set for `import` roles.
|
||||||
|
installedLang?: string;
|
||||||
|
// The workspace role id, present for `installed`/`update` — needed to call the
|
||||||
|
// update-from-catalog mutation.
|
||||||
|
installedRoleId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The summary phase of a bundle, derived from its roles' statuses. Determines
|
||||||
|
* the collapsed-header summary and the bundle's single primary action.
|
||||||
|
* - `empty` — the bundle has no roles.
|
||||||
|
* - `allNew` — everything is importable, nothing installed.
|
||||||
|
* - `allInstalled` — everything installed & up to date; nothing else pending.
|
||||||
|
* - `updates` — updates available and nothing left to import.
|
||||||
|
* - `mixed` — any other combination.
|
||||||
|
*/
|
||||||
|
export type BundlePhase =
|
||||||
|
| "empty"
|
||||||
|
| "allNew"
|
||||||
|
| "allInstalled"
|
||||||
|
| "updates"
|
||||||
|
| "mixed";
|
||||||
|
|
||||||
|
/** Per-status tallies for a bundle's roles (the single source of truth). */
|
||||||
|
export interface BundleCounts {
|
||||||
|
importable: number;
|
||||||
|
installed: number;
|
||||||
|
update: number;
|
||||||
|
skipped: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count a bundle's roles by status ONCE. Both `bundlePhase` and the panel derive
|
||||||
|
* from this, so the tally logic lives in exactly one place (no rescans / drift).
|
||||||
|
*/
|
||||||
|
export function bundleCounts(roles: CatalogViewRole[]): BundleCounts {
|
||||||
|
const counts: BundleCounts = {
|
||||||
|
importable: 0,
|
||||||
|
installed: 0,
|
||||||
|
update: 0,
|
||||||
|
skipped: 0,
|
||||||
|
};
|
||||||
|
for (const r of roles) {
|
||||||
|
if (r.status === "import") counts.importable += 1;
|
||||||
|
else if (r.status === "installed") counts.installed += 1;
|
||||||
|
else if (r.status === "update") counts.update += 1;
|
||||||
|
else if (r.status === "skipped") counts.skipped += 1;
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bundlePhase(roles: CatalogViewRole[]): BundlePhase {
|
||||||
|
if (roles.length === 0) return "empty";
|
||||||
|
const { importable, installed, update, skipped } = bundleCounts(roles);
|
||||||
|
// A `skipped` role is a pending post-import conflict (0 installed for it), so a
|
||||||
|
// bundle that has ANY skipped role is NOT "all installed & up to date" — that
|
||||||
|
// would make the collapsed green "up to date" header contradict the open
|
||||||
|
// panel's "Installed 0 · 1 skipped" plaque. It is `mixed` until resolved.
|
||||||
|
if (importable === 0 && update === 0 && skipped === 0) return "allInstalled";
|
||||||
|
if (update > 0 && importable === 0 && skipped === 0) return "updates";
|
||||||
|
if (importable > 0 && installed === 0 && update === 0 && skipped === 0)
|
||||||
|
return "allNew";
|
||||||
|
return "mixed";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The subset of a skip result that should be shown as a TRANSIENT `skipped`
|
||||||
|
* overlay in the bundle (so the row offers a re-import path). Only NAME-CONFLICT
|
||||||
|
* skips qualify: an `already-installed` skip (a concurrent-import race) has
|
||||||
|
* nothing to act on — re-importing the same slug would just skip again — so it
|
||||||
|
* must NOT be overlaid (else the row shows a misleading "Rename & install" that
|
||||||
|
* self-heals into a false "installed"). Pure so both reason branches are tested.
|
||||||
|
*/
|
||||||
|
export function nameConflictSlugs(
|
||||||
|
skipped: { slug: string; reason: "name-conflict" | "already-installed" }[],
|
||||||
|
): string[] {
|
||||||
|
return skipped
|
||||||
|
.filter((s) => s.reason === "name-conflict")
|
||||||
|
.map((s) => s.slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a partial-import result should offer the "Rename & install" action:
|
||||||
|
* only when at least one skip is a name conflict (renameable). An
|
||||||
|
* `already-installed`-only partial is informational.
|
||||||
|
*/
|
||||||
|
export function partialOffersRename(
|
||||||
|
skipped: { reason: "name-conflict" | "already-installed" }[],
|
||||||
|
): boolean {
|
||||||
|
return skipped.some((s) => s.reason === "name-conflict");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For a role NOT installed in the current `language`, find a workspace role with
|
||||||
|
* the same catalog `slug` installed under a DIFFERENT language, and return that
|
||||||
|
* language. Drives the "installed in another language" hint (Р5): a different
|
||||||
|
* language of the same slug is a separate install and appears as `import`.
|
||||||
|
*/
|
||||||
|
export function installedLangForRole(
|
||||||
|
slug: string,
|
||||||
|
workspaceRoles: IAiRole[],
|
||||||
|
language: string,
|
||||||
|
): string | undefined {
|
||||||
|
const other = workspaceRoles.find(
|
||||||
|
(r) =>
|
||||||
|
r.source?.slug === slug &&
|
||||||
|
!!r.source?.language &&
|
||||||
|
r.source.language !== language,
|
||||||
|
);
|
||||||
|
return other?.source?.language;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map one catalog role to the view model, computing its install status against
|
||||||
|
* the workspace roles (via `catalogRoleInstallState`) and, for importable roles,
|
||||||
|
* the other-language hint.
|
||||||
|
*/
|
||||||
|
export function mapCatalogRoleToView(
|
||||||
|
role: IAiRoleCatalogRole,
|
||||||
|
workspaceRoles: IAiRole[],
|
||||||
|
language: string,
|
||||||
|
): CatalogViewRole {
|
||||||
|
const state = catalogRoleInstallState(role, workspaceRoles, language);
|
||||||
|
const base = {
|
||||||
|
slug: role.slug,
|
||||||
|
emoji: role.emoji ?? undefined,
|
||||||
|
name: role.name,
|
||||||
|
description: role.description ?? "",
|
||||||
|
};
|
||||||
|
if (state.state === "update") {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
status: "update",
|
||||||
|
version: state.fromVersion,
|
||||||
|
newVersion: state.toVersion,
|
||||||
|
installedRoleId: state.installed.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (state.state === "installed") {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
status: "installed",
|
||||||
|
version: role.version,
|
||||||
|
installedRoleId: state.installed.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
status: "import",
|
||||||
|
version: role.version,
|
||||||
|
installedLang: installedLangForRole(role.slug, workspaceRoles, language),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a whole bundle's catalog roles to the view model, preserving order.
|
||||||
|
*/
|
||||||
|
export function mapBundleRolesToView(
|
||||||
|
roles: IAiRoleCatalogRole[],
|
||||||
|
workspaceRoles: IAiRole[],
|
||||||
|
language: string,
|
||||||
|
): CatalogViewRole[] {
|
||||||
|
return roles.map((r) => mapCatalogRoleToView(r, workspaceRoles, language));
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { renderHook, act } from "@testing-library/react";
|
||||||
|
|
||||||
|
// react-i18next: identity t() so the hook renders without an i18n provider.
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({ t: (k: string) => k }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// react-router-dom: only useNavigate is used by the hook.
|
||||||
|
const navigateMock = vi.fn();
|
||||||
|
vi.mock("react-router-dom", () => ({
|
||||||
|
useNavigate: () => navigateMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// The auth service is the network boundary; stub login/logout per test.
|
||||||
|
const loginMock = vi.fn();
|
||||||
|
const logoutMock = vi.fn();
|
||||||
|
vi.mock("@/features/auth/services/auth-service", () => ({
|
||||||
|
login: (...args: unknown[]) => loginMock(...args),
|
||||||
|
logout: (...args: unknown[]) => logoutMock(...args),
|
||||||
|
forgotPassword: vi.fn(),
|
||||||
|
passwordReset: vi.fn(),
|
||||||
|
setupWorkspace: vi.fn(),
|
||||||
|
verifyUserToken: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/features/workspace/services/workspace-service.ts", () => ({
|
||||||
|
acceptInvitation: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// The offline cache purge is the unit under test — assert it is invoked.
|
||||||
|
const clearOfflineCacheMock = vi.fn();
|
||||||
|
vi.mock("@/features/offline/clear-offline-cache", () => ({
|
||||||
|
clearOfflineCache: () => clearOfflineCacheMock(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// app-route helpers are pure config; provide deterministic values.
|
||||||
|
vi.mock("@/lib/app-route.ts", () => ({
|
||||||
|
default: { AUTH: { LOGIN: "/login" }, HOME: "/home" },
|
||||||
|
getPostLoginRedirect: () => "/home",
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mantine notifications: avoid touching the DOM-bound notification system.
|
||||||
|
vi.mock("@mantine/notifications", () => ({
|
||||||
|
notifications: { show: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
import useAuth from "./use-auth";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
navigateMock.mockReset();
|
||||||
|
loginMock.mockReset();
|
||||||
|
loginMock.mockResolvedValue(undefined);
|
||||||
|
logoutMock.mockReset();
|
||||||
|
logoutMock.mockResolvedValue(undefined);
|
||||||
|
clearOfflineCacheMock.mockReset();
|
||||||
|
clearOfflineCacheMock.mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useAuth.handleSignIn", () => {
|
||||||
|
it("clears the offline cache BEFORE logging in (cross-user leak guard)", async () => {
|
||||||
|
const order: string[] = [];
|
||||||
|
clearOfflineCacheMock.mockImplementation(async () => {
|
||||||
|
order.push("clear");
|
||||||
|
});
|
||||||
|
loginMock.mockImplementation(async () => {
|
||||||
|
order.push("login");
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAuth());
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.signIn({ email: "b@x", password: "pw" } as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(clearOfflineCacheMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(loginMock).toHaveBeenCalledTimes(1);
|
||||||
|
// The purge must run before the new session's login resolves.
|
||||||
|
expect(order).toEqual(["clear", "login"]);
|
||||||
|
expect(navigateMock).toHaveBeenCalledWith("/home");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not block sign-in when the cache purge throws (best-effort)", async () => {
|
||||||
|
clearOfflineCacheMock.mockRejectedValue(new Error("idb unavailable"));
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAuth());
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.signIn({ email: "b@x", password: "pw" } as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Login still proceeds despite the cleanup failure.
|
||||||
|
expect(loginMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(navigateMock).toHaveBeenCalledWith("/home");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useAuth.handleLogout", () => {
|
||||||
|
const replaceMock = vi.fn();
|
||||||
|
let originalLocation: Location;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
replaceMock.mockReset();
|
||||||
|
// window.location.replace is the post-logout redirect. jsdom's real `replace`
|
||||||
|
// is a non-configurable method that warns "not implemented", so swap the
|
||||||
|
// whole location object for one whose `replace` we can capture.
|
||||||
|
originalLocation = window.location;
|
||||||
|
Object.defineProperty(window, "location", {
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
value: { replace: replaceMock },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
Object.defineProperty(window, "location", {
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
value: originalLocation,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("purges the offline cache exactly once BEFORE redirecting (cross-user leak guard)", async () => {
|
||||||
|
const order: string[] = [];
|
||||||
|
clearOfflineCacheMock.mockImplementation(async () => {
|
||||||
|
order.push("clear");
|
||||||
|
});
|
||||||
|
replaceMock.mockImplementation((url: string) => {
|
||||||
|
order.push(`replace:${url}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAuth());
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.logout();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(clearOfflineCacheMock).toHaveBeenCalledTimes(1);
|
||||||
|
// Purge must complete before the redirect (which would otherwise interrupt
|
||||||
|
// the async cleanup).
|
||||||
|
expect(order).toEqual(["clear", "replace:/login?logout=1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still redirects when the cache purge throws (best-effort, never blocks logout)", async () => {
|
||||||
|
clearOfflineCacheMock.mockRejectedValue(new Error("idb unavailable"));
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAuth());
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.logout();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The thrown purge error is swallowed and the redirect still fires.
|
||||||
|
expect(clearOfflineCacheMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(replaceMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(replaceMock).toHaveBeenCalledWith("/login?logout=1");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -24,6 +24,7 @@ import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route.ts";
|
|||||||
import { RESET } from "jotai/utils";
|
import { RESET } from "jotai/utils";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { clearPersistedTreeCaches } from "@/features/page/tree/atoms/tree-data-atom";
|
import { clearPersistedTreeCaches } from "@/features/page/tree/atoms/tree-data-atom";
|
||||||
|
import { clearOfflineCache } from "@/features/offline/clear-offline-cache";
|
||||||
|
|
||||||
export default function useAuth() {
|
export default function useAuth() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -34,6 +35,20 @@ export default function useAuth() {
|
|||||||
const handleSignIn = async (data: ILogin) => {
|
const handleSignIn = async (data: ILogin) => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
|
// Purge any previous user's offline data BEFORE signing in (mirrors logout).
|
||||||
|
// On a shared/kiosk device the prior session may have ended WITHOUT an
|
||||||
|
// explicit logout (cookie/JWT expiry, tab close, force-quit), leaving user
|
||||||
|
// A's persisted query cache (gitmost-rq-cache) and Yjs page bodies
|
||||||
|
// (page.<id>) in IndexedDB. Without this purge user B would briefly read A's
|
||||||
|
// cached currentUser/pages/comments on first render (UserProvider serves the
|
||||||
|
// cached user) and A's page bodies would stay readable offline. Best-effort:
|
||||||
|
// never block sign-in on cache cleanup.
|
||||||
|
try {
|
||||||
|
await clearOfflineCache();
|
||||||
|
} catch {
|
||||||
|
// best-effort: never block sign-in on cache cleanup
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await login(data);
|
await login(data);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -129,6 +144,13 @@ export default function useAuth() {
|
|||||||
// remain.)
|
// remain.)
|
||||||
clearPersistedTreeCaches();
|
clearPersistedTreeCaches();
|
||||||
await logout();
|
await logout();
|
||||||
|
// Purge the previous user's offline data while the page is still alive —
|
||||||
|
// window.location.replace below would otherwise interrupt async cleanup.
|
||||||
|
try {
|
||||||
|
await clearOfflineCache();
|
||||||
|
} catch {
|
||||||
|
// best-effort: never block logout on cache cleanup
|
||||||
|
}
|
||||||
window.location.replace(`${APP_ROUTE.AUTH.LOGIN}?logout=1`);
|
window.location.replace(`${APP_ROUTE.AUTH.LOGIN}?logout=1`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { AxiosError } from "axios";
|
||||||
|
import { collabTokenRetry } from "./auth-query";
|
||||||
|
|
||||||
|
// Regression for the offline white-screen (#237/#238): offline the collab-token
|
||||||
|
// POST rejects as an axios NETWORK error (isAxiosError === true but
|
||||||
|
// error.response === undefined). The old predicate read `error.response.status`
|
||||||
|
// without a guard and threw an uncaught TypeError inside the React Query retryer
|
||||||
|
// BEFORE React mounted, blanking the whole app. The predicate must stay total.
|
||||||
|
describe("collabTokenRetry", () => {
|
||||||
|
it("does NOT throw and returns a retryable value for a network error with no response (offline)", () => {
|
||||||
|
// An axios error with no `response` is exactly the offline/network-failure shape.
|
||||||
|
const networkError = new AxiosError("Network Error");
|
||||||
|
expect(networkError.response).toBeUndefined();
|
||||||
|
|
||||||
|
let result: boolean | number = false;
|
||||||
|
expect(() => {
|
||||||
|
result = collabTokenRetry(0, networkError);
|
||||||
|
}).not.toThrow();
|
||||||
|
// Network failures stay retryable (truthy), matching the original intent.
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false (no retry) for a real 404 response", () => {
|
||||||
|
const notFound = new AxiosError("Not Found");
|
||||||
|
notFound.response = { status: 404 } as AxiosError["response"];
|
||||||
|
expect(collabTokenRetry(0, notFound)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries for a non-404 response (e.g. 500)", () => {
|
||||||
|
const serverError = new AxiosError("Server Error");
|
||||||
|
serverError.response = { status: 500 } as AxiosError["response"];
|
||||||
|
expect(collabTokenRetry(0, serverError)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not throw and retries for a non-axios error", () => {
|
||||||
|
let result: boolean | number = false;
|
||||||
|
expect(() => {
|
||||||
|
result = collabTokenRetry(0, new Error("boom"));
|
||||||
|
}).not.toThrow();
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,27 @@ import { getCollabToken, verifyUserToken } from "../services/auth-service";
|
|||||||
import { ICollabToken, IVerifyUserToken } from "../types/auth.types";
|
import { ICollabToken, IVerifyUserToken } from "../types/auth.types";
|
||||||
import { isAxiosError } from "axios";
|
import { isAxiosError } from "axios";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retry predicate for the collab-token query.
|
||||||
|
*
|
||||||
|
* Offline (or any network failure) the POST rejects as an axios NETWORK error:
|
||||||
|
* `isAxiosError(error) === true` but `error.response === undefined`. Reading
|
||||||
|
* `error.response.status` without a guard threw an uncaught TypeError inside the
|
||||||
|
* React Query retryer BEFORE React mounted, white-screening the whole app on an
|
||||||
|
* offline cold boot (#237/#238). Optional-chaining `error.response?.status`
|
||||||
|
* keeps the predicate total: a network error (no response) is retryable, a real
|
||||||
|
* 404 is not. Extracted (and exported) so it can be unit-tested in isolation.
|
||||||
|
*/
|
||||||
|
export function collabTokenRetry(
|
||||||
|
_failureCount: number,
|
||||||
|
error: Error,
|
||||||
|
): boolean {
|
||||||
|
if (isAxiosError(error) && error.response?.status === 404) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
export function useVerifyUserTokenQuery(
|
export function useVerifyUserTokenQuery(
|
||||||
verify: IVerifyUserToken,
|
verify: IVerifyUserToken,
|
||||||
): UseQueryResult<any, Error> {
|
): UseQueryResult<any, Error> {
|
||||||
@@ -22,13 +43,7 @@ export function useCollabToken(): UseQueryResult<ICollabToken, Error> {
|
|||||||
//refetchInterval: 12 * 60 * 60 * 1000, // 12hrs
|
//refetchInterval: 12 * 60 * 60 * 1000, // 12hrs
|
||||||
//refetchIntervalInBackground: true,
|
//refetchIntervalInBackground: true,
|
||||||
refetchOnMount: true,
|
refetchOnMount: true,
|
||||||
//@ts-ignore
|
retry: collabTokenRetry,
|
||||||
retry: (failureCount, error) => {
|
|
||||||
if (isAxiosError(error) && error.response.status === 404) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return 10;
|
|
||||||
},
|
|
||||||
retryDelay: (retryAttempt) => {
|
retryDelay: (retryAttempt) => {
|
||||||
// Exponential backoff: 5s, 10s, 20s, etc.
|
// Exponential backoff: 5s, 10s, 20s, etc.
|
||||||
return 5000 * Math.pow(2, retryAttempt - 1);
|
return 5000 * Math.pow(2, retryAttempt - 1);
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { notifications } from "@mantine/notifications";
|
|||||||
import { IPagination } from "@/lib/types.ts";
|
import { IPagination } from "@/lib/types.ts";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
|
import { offlineMutationKeys } from "@/features/offline/offline-mutations";
|
||||||
|
|
||||||
export const RQ_KEY = (pageId: string) => ["comments", pageId];
|
export const RQ_KEY = (pageId: string) => ["comments", pageId];
|
||||||
|
|
||||||
@@ -66,6 +67,9 @@ export function useCreateCommentMutation() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return useMutation<IComment, Error, Partial<IComment>>({
|
return useMutation<IComment, Error, Partial<IComment>>({
|
||||||
|
// Stable key so a paused comment-create restored from IndexedDB after an
|
||||||
|
// offline reload finds its default mutationFn and is replayed on reconnect.
|
||||||
|
mutationKey: offlineMutationKeys.createComment,
|
||||||
mutationFn: (data) => createComment(data),
|
mutationFn: (data) => createComment(data),
|
||||||
onSuccess: (newComment) => {
|
onSuccess: (newComment) => {
|
||||||
const cache = queryClient.getQueryData(
|
const cache = queryClient.getQueryData(
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ export const readOnlyEditorAtom = atom<Editor | null>(null);
|
|||||||
|
|
||||||
export const yjsConnectionStatusAtom = atom<string>("");
|
export const yjsConnectionStatusAtom = atom<string>("");
|
||||||
|
|
||||||
|
// Local (IndexedDB) persistence sync state for the current page's Y.Doc.
|
||||||
|
export const isLocalSyncedAtom = atom<boolean>(false);
|
||||||
|
|
||||||
|
// Remote (Hocuspocus) sync state for the current page's Y.Doc.
|
||||||
|
export const isRemoteSyncedAtom = atom<boolean>(false);
|
||||||
|
|
||||||
export const showLinkMenuAtom = atom(false);
|
export const showLinkMenuAtom = atom(false);
|
||||||
|
|
||||||
// Current page's edit mode — initialized from the user's saved preference on
|
// Current page's edit mode — initialized from the user's saved preference on
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { createContext, useContext } from "react";
|
||||||
|
import type { HocuspocusProvider } from "@hocuspocus/provider";
|
||||||
|
import type * as Y from "yjs";
|
||||||
|
|
||||||
|
// Shared collaboration providers lifted above the title/body editors so that
|
||||||
|
// both siblings bind to the SAME Y.Doc and HocuspocusProvider. The title lives
|
||||||
|
// in a dedicated 'title' fragment of the same doc as the body.
|
||||||
|
export interface EditorProvidersContextValue {
|
||||||
|
ydoc: Y.Doc;
|
||||||
|
remote: HocuspocusProvider;
|
||||||
|
providersReady: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EditorProvidersContext =
|
||||||
|
createContext<EditorProvidersContextValue | null>(null);
|
||||||
|
|
||||||
|
// Returns the shared providers, or null when rendered outside of a provider.
|
||||||
|
// Consumers must be null-safe (the body editor falls back to a non-collab mode).
|
||||||
|
export function useEditorProviders(): EditorProvidersContextValue | null {
|
||||||
|
return useContext(EditorProvidersContext);
|
||||||
|
}
|
||||||
@@ -34,6 +34,8 @@ import {
|
|||||||
} from "@/features/editor/atoms/editor-atoms.ts";
|
} from "@/features/editor/atoms/editor-atoms.ts";
|
||||||
import { DictationGroup } from "@/features/editor/components/fixed-toolbar/groups/dictation-group";
|
import { DictationGroup } from "@/features/editor/components/fixed-toolbar/groups/dictation-group";
|
||||||
import { GenerateTitleGroup } from "@/features/editor/components/fixed-toolbar/groups/generate-title-group";
|
import { GenerateTitleGroup } from "@/features/editor/components/fixed-toolbar/groups/generate-title-group";
|
||||||
|
import { usePageCollabProviders } from "@/features/editor/hooks/use-page-collab-providers";
|
||||||
|
import { EditorProvidersContext } from "@/features/editor/contexts/editor-providers-context";
|
||||||
|
|
||||||
const MemoizedTitleEditor = React.memo(TitleEditor);
|
const MemoizedTitleEditor = React.memo(TitleEditor);
|
||||||
const MemoizedPageEditor = React.memo(PageEditor);
|
const MemoizedPageEditor = React.memo(PageEditor);
|
||||||
@@ -80,16 +82,24 @@ export function FullEditor({
|
|||||||
// AI title generation is gated by the general AI chat flag (the same toggle
|
// AI title generation is gated by the general AI chat flag (the same toggle
|
||||||
// that enables the chat agent); the server enforces it too (#199).
|
// that enables the chat agent); the server enforces it too (#199).
|
||||||
const isTitleGenEnabled = workspace?.settings?.ai?.chat === true;
|
const isTitleGenEnabled = workspace?.settings?.ai?.chat === true;
|
||||||
const fullPageWidth = user.settings?.preferences?.fullPageWidth;
|
// `user` can momentarily be null during logout teardown (the currentUser atom
|
||||||
|
// is reset before this subtree unmounts). Optional-chain every access so the
|
||||||
|
// teardown render does not throw "Cannot read properties of null (reading
|
||||||
|
// 'settings')".
|
||||||
|
const fullPageWidth = user?.settings?.preferences?.fullPageWidth;
|
||||||
const editorToolbarEnabled =
|
const editorToolbarEnabled =
|
||||||
user.settings?.preferences?.editorToolbar ?? false;
|
user?.settings?.preferences?.editorToolbar ?? false;
|
||||||
const [currentPageEditMode, setCurrentPageEditMode] = useAtom(
|
const [currentPageEditMode, setCurrentPageEditMode] = useAtom(
|
||||||
currentPageEditModeAtom,
|
currentPageEditModeAtom,
|
||||||
);
|
);
|
||||||
const userPageEditMode =
|
const userPageEditMode =
|
||||||
user.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
|
user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
|
||||||
const isEditMode = currentPageEditMode === PageEditMode.Edit;
|
const isEditMode = currentPageEditMode === PageEditMode.Edit;
|
||||||
|
|
||||||
|
// Single shared Y.Doc + HocuspocusProvider for both the title and body
|
||||||
|
// editors (title lives in the 'title' fragment of the same doc).
|
||||||
|
const { ydoc, remote, providersReady } = usePageCollabProviders(pageId);
|
||||||
|
|
||||||
// Apply the user's saved preference only once on initial load, not on every
|
// Apply the user's saved preference only once on initial load, not on every
|
||||||
// page navigation — so the mode sticks across navigations within a session.
|
// page navigation — so the mode sticks across navigations within a session.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -110,28 +120,32 @@ export function FullEditor({
|
|||||||
)}
|
)}
|
||||||
<MemoizedDeletedPageBanner slugId={slugId} />
|
<MemoizedDeletedPageBanner slugId={slugId} />
|
||||||
<MemoizedTemporaryNoteBanner slugId={slugId} />
|
<MemoizedTemporaryNoteBanner slugId={slugId} />
|
||||||
<MemoizedTitleEditor
|
<EditorProvidersContext.Provider
|
||||||
pageId={pageId}
|
value={ydoc && remote ? { ydoc, remote, providersReady } : null}
|
||||||
slugId={slugId}
|
>
|
||||||
title={title}
|
<MemoizedTitleEditor
|
||||||
spaceSlug={spaceSlug}
|
pageId={pageId}
|
||||||
editable={editable}
|
slugId={slugId}
|
||||||
/>
|
title={title}
|
||||||
<PageByline
|
spaceSlug={spaceSlug}
|
||||||
pageId={pageId}
|
editable={editable}
|
||||||
creator={creator}
|
/>
|
||||||
contributors={contributors}
|
<PageByline
|
||||||
editable={editable}
|
pageId={pageId}
|
||||||
isEditMode={isEditMode}
|
creator={creator}
|
||||||
isDictationEnabled={isDictationEnabled}
|
contributors={contributors}
|
||||||
isTitleGenEnabled={isTitleGenEnabled}
|
editable={editable}
|
||||||
/>
|
isEditMode={isEditMode}
|
||||||
<MemoizedPageEditor
|
isDictationEnabled={isDictationEnabled}
|
||||||
pageId={pageId}
|
isTitleGenEnabled={isTitleGenEnabled}
|
||||||
editable={editable}
|
/>
|
||||||
content={content}
|
<MemoizedPageEditor
|
||||||
canComment={canComment}
|
pageId={pageId}
|
||||||
/>
|
editable={editable}
|
||||||
|
content={content}
|
||||||
|
canComment={canComment}
|
||||||
|
/>
|
||||||
|
</EditorProvidersContext.Provider>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
GitmostListPagesResult,
|
GitmostListPagesResult,
|
||||||
GitmostListSpacesResult,
|
GitmostListSpacesResult,
|
||||||
gitmostDecodePayloadToFile,
|
gitmostDecodePayloadToFile,
|
||||||
|
gitmostInsertTranscriptIntoEditor,
|
||||||
gitmostUploadFileToEditor,
|
gitmostUploadFileToEditor,
|
||||||
} from "@/features/editor/gitmost/gitmost-recording.ts";
|
} from "@/features/editor/gitmost/gitmost-recording.ts";
|
||||||
|
|
||||||
@@ -281,6 +282,18 @@ export default function GitmostGlobalBridge() {
|
|||||||
pageId: page.id,
|
pageId: page.id,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Best-effort: append the transcript (heading + one paragraph per line)
|
||||||
|
// below the just-inserted audio node. The audio insert already
|
||||||
|
// succeeded, so a transcript failure must NOT turn this into an error —
|
||||||
|
// wrap it and, on any throw, log and still return ok. A missing/empty/
|
||||||
|
// non-string transcript is a no-op inside the helper (audio only).
|
||||||
|
try {
|
||||||
|
gitmostInsertTranscriptIntoEditor(editor, payload?.transcript);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[gitmost] transcript insert failed", err);
|
||||||
|
}
|
||||||
|
|
||||||
return { ok: true, pageId: page.id };
|
return { ok: true, pageId: page.id };
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("[gitmost] createPageWithRecording failed", err);
|
console.error("[gitmost] createPageWithRecording failed", err);
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
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 { Heading } from "@tiptap/extension-heading";
|
||||||
|
import { Bold } from "@tiptap/extension-bold";
|
||||||
|
import { Italic } from "@tiptap/extension-italic";
|
||||||
|
import { Link } from "@tiptap/extension-link";
|
||||||
|
import { gitmostInsertTranscriptIntoEditor } from "./gitmost-recording.ts";
|
||||||
|
|
||||||
|
const ZWSP = ""; // U+200B, the helper's block-trigger neutralizer
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #377 — the web-side bridge must append the native host's transcript below the
|
||||||
|
* recording. These exercise the pure insert helper through a REAL Tiptap editor
|
||||||
|
* (Document/Paragraph/Text/Heading + Bold/Italic/Link marks so an HTML-parsing
|
||||||
|
* regression would be caught), asserting the resulting document rather than
|
||||||
|
* mocking the editor: transcript present -> "Transcript" heading + one paragraph
|
||||||
|
* per non-empty line; content is inserted as LITERAL TEXT (no HTML/markdown
|
||||||
|
* parsing); col-0 markdown block triggers are neutralized so git-sync keeps them
|
||||||
|
* paragraphs; absent/empty/non-string -> no-op.
|
||||||
|
*/
|
||||||
|
describe("gitmostInsertTranscriptIntoEditor", () => {
|
||||||
|
const makeEditor = () =>
|
||||||
|
new Editor({
|
||||||
|
// Bold/Italic/Link are registered specifically so that IF the helper ever
|
||||||
|
// regressed to inserting an HTML/markdown string (instead of a text node),
|
||||||
|
// TipTap would parse `<b>`/`*..*`/`[..](..)` into marks and the literal-
|
||||||
|
// text assertions below would fail.
|
||||||
|
extensions: [Document, Paragraph, Text, Heading, Bold, Italic, Link],
|
||||||
|
// Start from a single empty paragraph (a fresh page's baseline). The
|
||||||
|
// helper appends at the end of the doc, i.e. below existing content.
|
||||||
|
content: { type: "doc", content: [{ type: "paragraph" }] },
|
||||||
|
});
|
||||||
|
|
||||||
|
it("inserts a Transcript heading + one paragraph per non-empty line, verbatim", () => {
|
||||||
|
const editor = makeEditor();
|
||||||
|
|
||||||
|
const inserted = gitmostInsertTranscriptIntoEditor(
|
||||||
|
editor,
|
||||||
|
"You: hello there\nSpeaker 1: hi\n\nYou: bye",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(inserted).toBe(true);
|
||||||
|
|
||||||
|
const nodes = (editor.getJSON().content ?? []) as any[];
|
||||||
|
// A level-2 "Transcript" heading is present.
|
||||||
|
const heading = nodes.find((n) => n.type === "heading");
|
||||||
|
expect(heading?.attrs?.level).toBe(2);
|
||||||
|
expect(heading?.content?.[0]?.text).toBe("Transcript");
|
||||||
|
|
||||||
|
// Every non-empty transcript line becomes a paragraph, in order, verbatim;
|
||||||
|
// the blank line between them is dropped.
|
||||||
|
const texts = nodes
|
||||||
|
.filter((n) => n.type === "paragraph")
|
||||||
|
.map((n) => n.content?.[0]?.text)
|
||||||
|
.filter((t) => typeof t === "string");
|
||||||
|
expect(texts).toEqual(["You: hello there", "Speaker 1: hi", "You: bye"]);
|
||||||
|
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("inserts HTML + markdown metacharacters as LITERAL text (no injection / no mark parsing)", () => {
|
||||||
|
const editor = makeEditor();
|
||||||
|
const line =
|
||||||
|
"You: <b>bold</b> <script>alert(1)</script> and *stars* and [link](x)";
|
||||||
|
|
||||||
|
const inserted = gitmostInsertTranscriptIntoEditor(editor, line);
|
||||||
|
expect(inserted).toBe(true);
|
||||||
|
|
||||||
|
const paras = (editor.getJSON().content ?? []).filter(
|
||||||
|
(n: any) => n.type === "paragraph",
|
||||||
|
) as any[];
|
||||||
|
// The transcript line is exactly ONE paragraph holding a SINGLE text node
|
||||||
|
// whose text is the verbatim string — not split into bold/link/other nodes,
|
||||||
|
// not carrying any marks, not raw HTML. This FAILS if the helper switched to
|
||||||
|
// insertContent(htmlString): TipTap would then parse <b>/[link](x)/*stars*.
|
||||||
|
const content = paras[paras.length - 1].content;
|
||||||
|
expect(content).toHaveLength(1);
|
||||||
|
expect(content[0].type).toBe("text");
|
||||||
|
expect(content[0].marks ?? []).toEqual([]);
|
||||||
|
expect(content[0].text).toBe(line);
|
||||||
|
// And no bold/italic/link mark exists anywhere in the document.
|
||||||
|
const html = editor.getHTML();
|
||||||
|
expect(html).not.toMatch(/<(strong|b|em|i|a)\b/);
|
||||||
|
// The angle brackets survived as escaped entities (literal text), not a live
|
||||||
|
// <script>/<b> element.
|
||||||
|
expect(html).not.toMatch(/<script/i);
|
||||||
|
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("neutralizes col-0 markdown block triggers with a leading ZWSP (git-sync safety)", () => {
|
||||||
|
const editor = makeEditor();
|
||||||
|
// Trigger lines (some with a leaked indent) + a normal prefixed line.
|
||||||
|
const inserted = gitmostInsertTranscriptIntoEditor(
|
||||||
|
editor,
|
||||||
|
[
|
||||||
|
"- dash",
|
||||||
|
" > quote", // leading indent must be trimmed then neutralized
|
||||||
|
"# hash",
|
||||||
|
"1. one",
|
||||||
|
"> [!info] note",
|
||||||
|
"```js",
|
||||||
|
"---", // solid thematic break -> horizontalRule (text-losing) if unneutralized
|
||||||
|
"***",
|
||||||
|
"___",
|
||||||
|
"You: normal line",
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
expect(inserted).toBe(true);
|
||||||
|
|
||||||
|
const texts = (editor.getJSON().content ?? [])
|
||||||
|
.filter((n: any) => n.type === "paragraph")
|
||||||
|
.map((n: any) => n.content?.[0]?.text)
|
||||||
|
.filter((t: any) => typeof t === "string") as string[];
|
||||||
|
|
||||||
|
// Every block-trigger line is prefixed with the invisible ZWSP (indent
|
||||||
|
// trimmed first); the normal `You:` line is left byte-exact.
|
||||||
|
expect(texts).toEqual([
|
||||||
|
ZWSP + "- dash",
|
||||||
|
ZWSP + "> quote",
|
||||||
|
ZWSP + "# hash",
|
||||||
|
ZWSP + "1. one",
|
||||||
|
ZWSP + "> [!info] note",
|
||||||
|
ZWSP + "```js",
|
||||||
|
ZWSP + "---",
|
||||||
|
ZWSP + "***",
|
||||||
|
ZWSP + "___",
|
||||||
|
"You: normal line",
|
||||||
|
]);
|
||||||
|
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a no-op for undefined / empty / whitespace-only / non-string transcripts", () => {
|
||||||
|
for (const value of [undefined, "", " \n \n", 42, {}, null]) {
|
||||||
|
const editor = makeEditor();
|
||||||
|
const before = JSON.stringify(editor.getJSON());
|
||||||
|
|
||||||
|
const inserted = gitmostInsertTranscriptIntoEditor(editor, value as any);
|
||||||
|
|
||||||
|
expect(inserted).toBe(false);
|
||||||
|
// Document is untouched (audio-only behavior preserved).
|
||||||
|
expect(JSON.stringify(editor.getJSON())).toBe(before);
|
||||||
|
editor.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -65,6 +65,11 @@ export interface GitmostCreatePagePayload {
|
|||||||
base64: string;
|
base64: string;
|
||||||
filename: string;
|
filename: string;
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
|
// Optional transcript for the recording: plain text, `\n`-separated, each
|
||||||
|
// line already formatted as `You: ...` / `Speaker N: ...` by the native host
|
||||||
|
// (ready to insert, no parsing needed). Omitted (no speech / no models) ->
|
||||||
|
// audio only.
|
||||||
|
transcript?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GitmostCreatePageResult {
|
export interface GitmostCreatePageResult {
|
||||||
@@ -235,6 +240,83 @@ export async function gitmostUploadFileToEditor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Zero-width space (U+200B). Prepended to a transcript line that begins with a
|
||||||
|
// markdown BLOCK trigger: it is invisible in the rendered doc but shifts the
|
||||||
|
// trigger off column 0, so the git-sync doc->markdown->doc round-trip keeps the
|
||||||
|
// line a plain paragraph (see GITMOST_MD_BLOCK_TRIGGER_RE).
|
||||||
|
const GITMOST_ZWSP = "";
|
||||||
|
|
||||||
|
// A markdown BLOCK-level construct that, sitting at column 0 of a paragraph
|
||||||
|
// line, the git-sync markdown serializer (packages/prosemirror-markdown
|
||||||
|
// markdown-converter.ts, `case "paragraph"`) would re-parse into a NON-paragraph
|
||||||
|
// block on the doc->markdown->doc cycle. That serializer emits paragraph text
|
||||||
|
// verbatim with NO block-escape (the pre-existing root cause), so a leading
|
||||||
|
// `#`/`-`/`*`/`+`/`>`, an ordered-list `N.`/`N)`, a code fence ```/~~~, a table
|
||||||
|
// `|`, or a `> [!info]` callout opener would silently become a heading / list /
|
||||||
|
// quote / code block / table / callout. The final alternative matches a WHOLE-
|
||||||
|
// LINE thematic break — solid `---`/`***`/`___` or spaced `- - -`/`_ _ _` (3+ of
|
||||||
|
// the same `-`/`*`/`_`) — which round-trips into a `horizontalRule`; because
|
||||||
|
// that node carries NO text, an un-neutralized separator line would LOSE its
|
||||||
|
// text entirely (worse than the list/quote case). This matches a TRIMMED line's
|
||||||
|
// start; the transcript's own `You:` / `Speaker N:` prefix begins with a letter
|
||||||
|
// and never matches, so prefixed lines are left byte-exact.
|
||||||
|
const GITMOST_MD_BLOCK_TRIGGER_RE =
|
||||||
|
/^(?:#{1,6}(?:\s|$)|[-*+](?:\s|$)|>|\d+[.)](?:\s|$)|```|~~~|\||([-*_])(?:\s*\1){2,}\s*$)/;
|
||||||
|
|
||||||
|
// Append a transcript block BELOW the recording's audio node in a live editor:
|
||||||
|
// a "Transcript" heading followed by one paragraph per non-empty transcript
|
||||||
|
// line. The transcript is plain text, `\n`-separated, each line already
|
||||||
|
// formatted as `You: ...` / `Speaker N: ...` by the native host — line text is
|
||||||
|
// inserted as a TEXT node (never HTML/markdown), so there is no injection or
|
||||||
|
// mark-parsing surface. Each kept line is trimmed (drops an indent that would
|
||||||
|
// both leak into the display and, at col 0, form a markdown block trigger) and,
|
||||||
|
// if it still begins with a col-0 markdown block trigger, gets an invisible
|
||||||
|
// zero-width space prepended so the git-sync round-trip cannot turn it into a
|
||||||
|
// list/quote/heading/callout/code/table (defensive boundary against the
|
||||||
|
// serializer's missing block-escape). This is best-effort and meant to run
|
||||||
|
// AFTER the audio has already been inserted; the caller must guard against a
|
||||||
|
// throw so a transcript failure never fails the (already successful) recording.
|
||||||
|
// Returns true when a block was inserted, false when there was nothing to
|
||||||
|
// insert (transcript undefined/empty/not-a-string). A non-string value is a
|
||||||
|
// no-op, not an error.
|
||||||
|
export function gitmostInsertTranscriptIntoEditor(
|
||||||
|
editor: Editor,
|
||||||
|
transcript: unknown,
|
||||||
|
): boolean {
|
||||||
|
if (typeof transcript !== "string") return false;
|
||||||
|
const lines = transcript
|
||||||
|
.split("\n")
|
||||||
|
// Trim each line and drop blank (whitespace-only) ones.
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line.length > 0)
|
||||||
|
// Neutralize a col-0 markdown block trigger with an invisible ZWSP so the
|
||||||
|
// git-sync round-trip keeps the line a paragraph. Host lines (`You:` /
|
||||||
|
// `Speaker N:`) never match and stay byte-exact.
|
||||||
|
.map((line) =>
|
||||||
|
GITMOST_MD_BLOCK_TRIGGER_RE.test(line) ? GITMOST_ZWSP + line : line,
|
||||||
|
);
|
||||||
|
if (lines.length === 0) return false;
|
||||||
|
|
||||||
|
const content = [
|
||||||
|
{
|
||||||
|
type: "heading",
|
||||||
|
attrs: { level: 2 },
|
||||||
|
content: [{ type: "text", text: "Transcript" }],
|
||||||
|
},
|
||||||
|
...lines.map((line) => ({
|
||||||
|
type: "paragraph",
|
||||||
|
content: [{ type: "text", text: line }],
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Append at the end of the document. On a freshly-created recording page the
|
||||||
|
// audio node is the last block, so the end position places the transcript
|
||||||
|
// directly below it.
|
||||||
|
const endPos = editor.state.doc.content.size;
|
||||||
|
editor.chain().focus().insertContentAt(endPos, content).run();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// Full insert path used by the open-page bridge (insertRecording): guard the
|
// Full insert path used by the open-page bridge (insertRecording): guard the
|
||||||
// editor, validate/decode the payload, then upload. Never throws — resolves to
|
// editor, validate/decode the payload, then upload. Never throws — resolves to
|
||||||
// a result code.
|
// a result code.
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
// jwt-decode is mocked so we can drive the four token states deterministically
|
||||||
|
// (decode success with a chosen exp, or a thrown decode error).
|
||||||
|
const decodeMock = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("jwt-decode", () => ({
|
||||||
|
jwtDecode: decodeMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { collabTokenNeedsRefresh } from "./collab-token";
|
||||||
|
|
||||||
|
const NOW_MS = 1_000_000_000; // fixed "now" in ms (so NOW_MS/1000 seconds)
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
decodeMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("collabTokenNeedsRefresh", () => {
|
||||||
|
it("returns true when there is no token (fetch a fresh one)", () => {
|
||||||
|
expect(collabTokenNeedsRefresh(undefined, NOW_MS)).toBe(true);
|
||||||
|
// jwtDecode must not even be called for a missing token.
|
||||||
|
expect(decodeMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true when the token is malformed (jwtDecode throws)", () => {
|
||||||
|
decodeMock.mockImplementation(() => {
|
||||||
|
throw new Error("invalid token");
|
||||||
|
});
|
||||||
|
expect(collabTokenNeedsRefresh("garbage", NOW_MS)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for a valid, not-yet-expired token (no reconnect)", () => {
|
||||||
|
// exp is in the future relative to NOW.
|
||||||
|
decodeMock.mockReturnValue({ exp: NOW_MS / 1000 + 60 });
|
||||||
|
expect(collabTokenNeedsRefresh("good", NOW_MS)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for a valid but expired token (refresh + reconnect)", () => {
|
||||||
|
// exp is in the past relative to NOW.
|
||||||
|
decodeMock.mockReturnValue({ exp: NOW_MS / 1000 - 60 });
|
||||||
|
expect(collabTokenNeedsRefresh("expired", NOW_MS)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats exp exactly equal to now as expired (>= boundary)", () => {
|
||||||
|
decodeMock.mockReturnValue({ exp: NOW_MS / 1000 });
|
||||||
|
expect(collabTokenNeedsRefresh("boundary", NOW_MS)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { jwtDecode } from "jwt-decode";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide whether a collab token must be refreshed before reconnecting after an
|
||||||
|
* onAuthenticationFailed event. Pure and side-effect free so the four token
|
||||||
|
* states can be unit-tested directly:
|
||||||
|
* - no token -> true (fetch a fresh one and reconnect)
|
||||||
|
* - undecodable/malformed -> true (jwtDecode throws -> refresh)
|
||||||
|
* - valid, not expired -> false (token is still good; do NOT reconnect)
|
||||||
|
* - valid, expired -> true (refresh + reconnect)
|
||||||
|
*
|
||||||
|
* `nowMs` is injectable for deterministic tests; it defaults to `Date.now()`.
|
||||||
|
*/
|
||||||
|
export function collabTokenNeedsRefresh(
|
||||||
|
token: string | undefined,
|
||||||
|
nowMs: number = Date.now(),
|
||||||
|
): boolean {
|
||||||
|
if (!token) return true;
|
||||||
|
try {
|
||||||
|
const payload = jwtDecode<{ exp: number }>(token);
|
||||||
|
return nowMs / 1000 >= payload.exp;
|
||||||
|
} catch {
|
||||||
|
// malformed/undecodable token -> refresh
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -139,7 +139,7 @@ describe("useGeneratePageTitle", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("happy path: applies the title, refreshes cache, writes the field, broadcasts", async () => {
|
it("happy path: applies the title, refreshes cache, broadcasts, and does NOT write the editor", async () => {
|
||||||
const store = createStore();
|
const store = createStore();
|
||||||
const titleEditor = makeTitleEditor();
|
const titleEditor = makeTitleEditor();
|
||||||
store.set(pageEditorAtom as never, makePageEditor("pageA"));
|
store.set(pageEditorAtom as never, makePageEditor("pageA"));
|
||||||
@@ -157,9 +157,11 @@ describe("useGeneratePageTitle", () => {
|
|||||||
title: "Generated Title",
|
title: "Generated Title",
|
||||||
});
|
});
|
||||||
expect(updatePageDataMock).toHaveBeenCalledWith(PAGE_A);
|
expect(updatePageDataMock).toHaveBeenCalledWith(PAGE_A);
|
||||||
expect(titleEditor.commands.setContent).toHaveBeenCalledWith(
|
// The title editor is bound to the Yjs `title` fragment; the server REST
|
||||||
"Generated Title",
|
// update reseeds that fragment and the reseed reaches the bound editor on
|
||||||
);
|
// its own. Writing here too would double/garble the title, so the hook must
|
||||||
|
// NOT touch the editor (regression guard for the Yjs duplication trap).
|
||||||
|
expect(titleEditor.commands.setContent).not.toHaveBeenCalled();
|
||||||
expect(localEmitMock).toHaveBeenCalled();
|
expect(localEmitMock).toHaveBeenCalled();
|
||||||
expect(emitMock).toHaveBeenCalled();
|
expect(emitMock).toHaveBeenCalled();
|
||||||
expect(notificationsShowMock).toHaveBeenCalledWith(
|
expect(notificationsShowMock).toHaveBeenCalledWith(
|
||||||
@@ -167,7 +169,7 @@ describe("useGeneratePageTitle", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does NOT write the visible title field when the user navigated away during generation", async () => {
|
it("keeps the DB write keyed by the captured pageId and still broadcasts after navigation", async () => {
|
||||||
const store = createStore();
|
const store = createStore();
|
||||||
const titleEditor = makeTitleEditor(); // persistent across navigation
|
const titleEditor = makeTitleEditor(); // persistent across navigation
|
||||||
store.set(pageEditorAtom as never, makePageEditor("pageA"));
|
store.set(pageEditorAtom as never, makePageEditor("pageA"));
|
||||||
@@ -203,55 +205,9 @@ describe("useGeneratePageTitle", () => {
|
|||||||
pageId: "pageA",
|
pageId: "pageA",
|
||||||
title: "Generated Title",
|
title: "Generated Title",
|
||||||
});
|
});
|
||||||
// ...but we must NOT stamp page A's title into page B's visible field.
|
// ...the hook never writes the editor regardless of navigation...
|
||||||
expect(titleEditor.commands.setContent).not.toHaveBeenCalled();
|
expect(titleEditor.commands.setContent).not.toHaveBeenCalled();
|
||||||
// The change is still broadcast to other clients.
|
// ...and the change is still broadcast to other clients.
|
||||||
expect(emitMock).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does NOT write the visible title field when the title editor is focused", async () => {
|
|
||||||
const store = createStore();
|
|
||||||
const titleEditor = makeTitleEditor();
|
|
||||||
store.set(pageEditorAtom as never, makePageEditor("pageA"));
|
|
||||||
store.set(titleEditorAtom as never, titleEditor);
|
|
||||||
|
|
||||||
// Resolve generation under our control so we can mark the live title editor
|
|
||||||
// as focused before the post-generation write runs.
|
|
||||||
let resolveTitle!: (t: string) => void;
|
|
||||||
generatePageTitleMock.mockReturnValue(
|
|
||||||
new Promise<string>((res) => {
|
|
||||||
resolveTitle = res;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
updateTitleMock.mockResolvedValue(PAGE_A);
|
|
||||||
const { result } = setup("pageA", store);
|
|
||||||
|
|
||||||
let pending!: Promise<void>;
|
|
||||||
act(() => {
|
|
||||||
pending = result.current.mutateAsync();
|
|
||||||
});
|
|
||||||
|
|
||||||
// The user clicked into the title field while the model ran — overwriting it
|
|
||||||
// now would clobber what they are actively typing.
|
|
||||||
act(() => {
|
|
||||||
(titleEditor as { isFocused: boolean }).isFocused = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
resolveTitle("Generated Title");
|
|
||||||
await pending;
|
|
||||||
});
|
|
||||||
|
|
||||||
// The DB write still persists the value...
|
|
||||||
expect(updateTitleMock).toHaveBeenCalledWith({
|
|
||||||
pageId: "pageA",
|
|
||||||
title: "Generated Title",
|
|
||||||
});
|
|
||||||
expect(updatePageDataMock).toHaveBeenCalledWith(PAGE_A);
|
|
||||||
// ...but the visible field is left alone while it is focused.
|
|
||||||
expect(titleEditor.commands.setContent).not.toHaveBeenCalled();
|
|
||||||
// The change is still broadcast to other clients.
|
|
||||||
expect(localEmitMock).toHaveBeenCalled();
|
|
||||||
expect(emitMock).toHaveBeenCalled();
|
expect(emitMock).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
import { useRef } from "react";
|
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { htmlToMarkdown } from "@docmost/editor-ext";
|
import { htmlToMarkdown } from "@docmost/editor-ext";
|
||||||
import {
|
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
|
||||||
pageEditorAtom,
|
|
||||||
titleEditorAtom,
|
|
||||||
} from "@/features/editor/atoms/editor-atoms.ts";
|
|
||||||
import {
|
import {
|
||||||
updatePageData,
|
updatePageData,
|
||||||
useUpdateTitlePageMutation,
|
useUpdateTitlePageMutation,
|
||||||
@@ -33,18 +29,9 @@ const MAX_CONTENT_CHARS = 20000;
|
|||||||
export function useGeneratePageTitle(pageId: string) {
|
export function useGeneratePageTitle(pageId: string) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const pageEditor = useAtomValue(pageEditorAtom);
|
const pageEditor = useAtomValue(pageEditorAtom);
|
||||||
const titleEditor = useAtomValue(titleEditorAtom);
|
|
||||||
const { mutateAsync: updateTitle } = useUpdateTitlePageMutation();
|
const { mutateAsync: updateTitle } = useUpdateTitlePageMutation();
|
||||||
const emit = useQueryEmit();
|
const emit = useQueryEmit();
|
||||||
|
|
||||||
// The page/title editors come from GLOBAL atoms that re-point when the user
|
|
||||||
// navigates to another page. The mutation below awaits the model for 1-3s, and
|
|
||||||
// its closure captures the editors from the render that started it. Keep a live
|
|
||||||
// reference so the post-generation write targets whatever page is on screen
|
|
||||||
// *now*, not the page the generation was started from.
|
|
||||||
const editorsRef = useRef({ pageEditor, titleEditor });
|
|
||||||
editorsRef.current = { pageEditor, titleEditor };
|
|
||||||
|
|
||||||
return useMutation<void, Error, void>({
|
return useMutation<void, Error, void>({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
if (!pageEditor || pageEditor.isDestroyed) return;
|
if (!pageEditor || pageEditor.isDestroyed) return;
|
||||||
@@ -70,33 +57,15 @@ export function useGeneratePageTitle(pageId: string) {
|
|||||||
const page = await updateTitle({ pageId, title }); // POST /pages/update
|
const page = await updateTitle({ pageId, title }); // POST /pages/update
|
||||||
updatePageData(page); // refresh the react-query cache
|
updatePageData(page); // refresh the react-query cache
|
||||||
|
|
||||||
// Reflect the new title in the field immediately. The button lives in the
|
// Do NOT write the title into the editor here. The title editor is bound to
|
||||||
// byline, so the title editor is not focused — setContent is safe and stays
|
// the Yjs `title` fragment and Yjs is the source of truth. The server REST
|
||||||
// undoable through its History extension (Ctrl/Cmd+Z reverts the change).
|
// /pages/update reseeds that fragment (writePageTitle → writeTitleFragment,
|
||||||
//
|
// a full clear+replace) and the reseed reaches the bound title editor on
|
||||||
// Guard against navigation during generation: if the user switched pages
|
// its own as a remote provider update. The old REST-era setContent here
|
||||||
// while the model ran, the (persistent) title editor now shows ANOTHER
|
// would race that reseed and double/garble the title (the "Yjs duplication
|
||||||
// page, so writing here would drop page A's title into page B's visible
|
// trap"), so it is intentionally omitted. The DB write above is keyed by
|
||||||
// field. page-editor.tsx stamps the live page editor with its pageId
|
// the captured `pageId`, so it stays correct even if the user navigated
|
||||||
// (`editor.storage.pageId`), mirroring TitleEditor's `activePageId !==
|
// away during generation.
|
||||||
// pageId` guard — bail the visible write unless that live editor still
|
|
||||||
// belongs to the page this title was generated for. The DB write above is
|
|
||||||
// already correct (keyed by the captured `pageId`), and the broadcast below
|
|
||||||
// still propagates page A's change to other clients.
|
|
||||||
const livePageEditor = editorsRef.current.pageEditor;
|
|
||||||
const liveTitleEditor = editorsRef.current.titleEditor;
|
|
||||||
// `storage.pageId` is stamped untyped in page-editor.tsx's onCreate.
|
|
||||||
const livePageId = (livePageEditor?.storage as { pageId?: string })
|
|
||||||
?.pageId;
|
|
||||||
const stillOnPage = livePageId === pageId;
|
|
||||||
if (
|
|
||||||
stillOnPage &&
|
|
||||||
liveTitleEditor &&
|
|
||||||
!liveTitleEditor.isDestroyed &&
|
|
||||||
!liveTitleEditor.isFocused
|
|
||||||
) {
|
|
||||||
liveTitleEditor.commands.setContent(page.title);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Broadcast to other clients, mirroring TitleEditor.saveTitle's event shape.
|
// Broadcast to other clients, mirroring TitleEditor.saveTitle's event shape.
|
||||||
const event: UpdateEvent = {
|
const event: UpdateEvent = {
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { IndexeddbPersistence } from "y-indexeddb";
|
||||||
|
import * as Y from "yjs";
|
||||||
|
import {
|
||||||
|
HocuspocusProvider,
|
||||||
|
onStatusParameters,
|
||||||
|
WebSocketStatus,
|
||||||
|
HocuspocusProviderWebsocket,
|
||||||
|
onSyncedParameters,
|
||||||
|
onStatelessParameters,
|
||||||
|
} from "@hocuspocus/provider";
|
||||||
|
import { useAtom, useSetAtom } from "jotai";
|
||||||
|
import useCollaborationUrl from "@/features/editor/hooks/use-collaboration-url";
|
||||||
|
import {
|
||||||
|
isLocalSyncedAtom,
|
||||||
|
isRemoteSyncedAtom,
|
||||||
|
yjsConnectionStatusAtom,
|
||||||
|
} from "@/features/editor/atoms/editor-atoms";
|
||||||
|
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
||||||
|
import { useDocumentVisibility } from "@mantine/hooks";
|
||||||
|
import { useIdle } from "@/hooks/use-idle.ts";
|
||||||
|
import { queryClient } from "@/main.tsx";
|
||||||
|
import { IPage } from "@/features/page/types/page.types.ts";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import { extractPageSlugId } from "@/lib";
|
||||||
|
import { FIVE_MINUTES } from "@/lib/constants.ts";
|
||||||
|
import { collabTokenNeedsRefresh } from "@/features/editor/hooks/collab-token";
|
||||||
|
import { pageYdocName } from "@/features/editor/page-ydoc-name";
|
||||||
|
import { pageKeys } from "@/features/page/queries/page-query";
|
||||||
|
|
||||||
|
export interface PageCollabProviders {
|
||||||
|
ydoc: Y.Doc | null;
|
||||||
|
remote: HocuspocusProvider | null;
|
||||||
|
socket: HocuspocusProviderWebsocket | null;
|
||||||
|
providersReady: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the full collaboration provider lifecycle for a page so that the title
|
||||||
|
* and body editors can share a single Y.Doc + HocuspocusProvider. The behavior
|
||||||
|
* is relocated verbatim from page-editor.tsx: it creates the providers once per
|
||||||
|
* pageId, connects/disconnects on idle/visibility, attaches each render,
|
||||||
|
* destroys on unmount, refreshes the collab token on auth failure, and applies
|
||||||
|
* the onStateless 'page.updated' cache update.
|
||||||
|
*/
|
||||||
|
export function usePageCollabProviders(pageId: string): PageCollabProviders {
|
||||||
|
const collaborationURL = useCollaborationUrl();
|
||||||
|
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
|
||||||
|
yjsConnectionStatusAtom,
|
||||||
|
);
|
||||||
|
const setIsLocalSyncedAtom = useSetAtom(isLocalSyncedAtom);
|
||||||
|
const setIsRemoteSyncedAtom = useSetAtom(isRemoteSyncedAtom);
|
||||||
|
const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken();
|
||||||
|
// The provider-creating effect runs only once per pageId, so any token read
|
||||||
|
// inside its handlers would be captured STALE (the old token at first render).
|
||||||
|
// Mirror the latest token into a ref the auth-failure handler can read live.
|
||||||
|
const collabTokenRef = useRef<string | undefined>(undefined);
|
||||||
|
useEffect(() => {
|
||||||
|
collabTokenRef.current = collabQuery?.token;
|
||||||
|
}, [collabQuery?.token]);
|
||||||
|
const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false });
|
||||||
|
const documentState = useDocumentVisibility();
|
||||||
|
const { pageSlug } = useParams();
|
||||||
|
const slugId = extractPageSlugId(pageSlug);
|
||||||
|
|
||||||
|
// Providers only created once per pageId
|
||||||
|
const providersRef = useRef<{
|
||||||
|
ydoc: Y.Doc;
|
||||||
|
local: IndexeddbPersistence;
|
||||||
|
remote: HocuspocusProvider;
|
||||||
|
socket: HocuspocusProviderWebsocket;
|
||||||
|
} | null>(null);
|
||||||
|
const [providersReady, setProvidersReady] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!providersRef.current) {
|
||||||
|
const documentName = pageYdocName(pageId);
|
||||||
|
const ydoc = new Y.Doc();
|
||||||
|
const local = new IndexeddbPersistence(documentName, ydoc);
|
||||||
|
const socket = new HocuspocusProviderWebsocket({
|
||||||
|
url: collaborationURL,
|
||||||
|
});
|
||||||
|
const onLocalSyncedHandler = () => {
|
||||||
|
setIsLocalSyncedAtom(true);
|
||||||
|
};
|
||||||
|
const onStatusHandler = (event: onStatusParameters) => {
|
||||||
|
setYjsConnectionStatus(event.status);
|
||||||
|
};
|
||||||
|
const onSyncedHandler = (event: onSyncedParameters) => {
|
||||||
|
setIsRemoteSyncedAtom(event.state);
|
||||||
|
};
|
||||||
|
const onStatelessHandler = ({ payload }: onStatelessParameters) => {
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(payload);
|
||||||
|
if (message?.type !== "page.updated" || !message.updatedAt) return;
|
||||||
|
const pageData = queryClient.getQueryData<IPage>(
|
||||||
|
pageKeys.detail(slugId),
|
||||||
|
);
|
||||||
|
if (pageData) {
|
||||||
|
queryClient.setQueryData(pageKeys.detail(slugId), {
|
||||||
|
...pageData,
|
||||||
|
updatedAt: message.updatedAt,
|
||||||
|
...(message.lastUpdatedBy && {
|
||||||
|
lastUpdatedBy: message.lastUpdatedBy,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore unrelated stateless messages
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onAuthenticationFailedHandler = () => {
|
||||||
|
// Read the token from the ref, not the closed-over `collabQuery`: this
|
||||||
|
// handler is created once and would otherwise decode a stale token after
|
||||||
|
// a refetch. A missing/malformed token must NOT crash the handler —
|
||||||
|
// jwtDecode(undefined) throws — so treat any decode failure as "needs
|
||||||
|
// refresh" and proceed to refetch + reconnect instead of getting stuck.
|
||||||
|
if (!collabTokenNeedsRefresh(collabTokenRef.current)) return;
|
||||||
|
refetchCollabToken().then((result) => {
|
||||||
|
if (result.data?.token) {
|
||||||
|
socket.disconnect();
|
||||||
|
setTimeout(() => {
|
||||||
|
remote.configuration.token = result.data.token;
|
||||||
|
socket.connect();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const remote = new HocuspocusProvider({
|
||||||
|
websocketProvider: socket,
|
||||||
|
name: documentName,
|
||||||
|
document: ydoc,
|
||||||
|
token: collabQuery?.token,
|
||||||
|
onAuthenticationFailed: onAuthenticationFailedHandler,
|
||||||
|
onStatus: onStatusHandler,
|
||||||
|
onSynced: onSyncedHandler,
|
||||||
|
onStateless: onStatelessHandler,
|
||||||
|
});
|
||||||
|
|
||||||
|
local.on("synced", onLocalSyncedHandler);
|
||||||
|
providersRef.current = { ydoc, socket, local, remote };
|
||||||
|
setProvidersReady(true);
|
||||||
|
} else {
|
||||||
|
setProvidersReady(true);
|
||||||
|
}
|
||||||
|
// Only destroy on final unmount
|
||||||
|
return () => {
|
||||||
|
providersRef.current?.socket.destroy();
|
||||||
|
providersRef.current?.remote.destroy();
|
||||||
|
providersRef.current?.local.destroy();
|
||||||
|
providersRef.current = null;
|
||||||
|
// Reset shared sync state on page change/unmount.
|
||||||
|
setIsLocalSyncedAtom(false);
|
||||||
|
setIsRemoteSyncedAtom(false);
|
||||||
|
};
|
||||||
|
}, [pageId]);
|
||||||
|
|
||||||
|
// Only connect/disconnect on tab/idle, not destroy
|
||||||
|
useEffect(() => {
|
||||||
|
if (!providersReady || !providersRef.current) return;
|
||||||
|
const socket = providersRef.current.socket;
|
||||||
|
|
||||||
|
if (
|
||||||
|
isIdle &&
|
||||||
|
documentState === "hidden" &&
|
||||||
|
yjsConnectionStatus === WebSocketStatus.Connected
|
||||||
|
) {
|
||||||
|
socket.disconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
documentState === "visible" &&
|
||||||
|
yjsConnectionStatus === WebSocketStatus.Disconnected
|
||||||
|
) {
|
||||||
|
resetIdle();
|
||||||
|
socket.connect();
|
||||||
|
}
|
||||||
|
}, [isIdle, documentState, providersReady, resetIdle]);
|
||||||
|
|
||||||
|
// Attach here, to make sure the connection gets properly established
|
||||||
|
providersRef.current?.remote.attach();
|
||||||
|
|
||||||
|
return {
|
||||||
|
ydoc: providersRef.current?.ydoc ?? null,
|
||||||
|
remote: providersRef.current?.remote ?? null,
|
||||||
|
socket: providersRef.current?.socket ?? null,
|
||||||
|
providersReady,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -6,16 +6,7 @@ import React, {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { IndexeddbPersistence } from "y-indexeddb";
|
import { WebSocketStatus } from "@hocuspocus/provider";
|
||||||
import * as Y from "yjs";
|
|
||||||
import {
|
|
||||||
HocuspocusProvider,
|
|
||||||
onStatusParameters,
|
|
||||||
WebSocketStatus,
|
|
||||||
HocuspocusProviderWebsocket,
|
|
||||||
onSyncedParameters,
|
|
||||||
onStatelessParameters,
|
|
||||||
} from "@hocuspocus/provider";
|
|
||||||
import {
|
import {
|
||||||
Editor,
|
Editor,
|
||||||
EditorContent,
|
EditorContent,
|
||||||
@@ -28,14 +19,16 @@ import {
|
|||||||
mainExtensions,
|
mainExtensions,
|
||||||
} from "@/features/editor/extensions/extensions";
|
} from "@/features/editor/extensions/extensions";
|
||||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||||
import useCollaborationUrl from "@/features/editor/hooks/use-collaboration-url";
|
|
||||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
||||||
import {
|
import {
|
||||||
currentPageEditModeAtom,
|
currentPageEditModeAtom,
|
||||||
dictationAvailabilityAtom,
|
dictationAvailabilityAtom,
|
||||||
|
isLocalSyncedAtom,
|
||||||
|
isRemoteSyncedAtom,
|
||||||
pageEditorAtom,
|
pageEditorAtom,
|
||||||
yjsConnectionStatusAtom,
|
yjsConnectionStatusAtom,
|
||||||
} from "@/features/editor/atoms/editor-atoms";
|
} from "@/features/editor/atoms/editor-atoms";
|
||||||
|
import { useEditorProviders } from "@/features/editor/contexts/editor-providers-context";
|
||||||
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
|
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
|
||||||
import {
|
import {
|
||||||
activeCommentIdAtom,
|
activeCommentIdAtom,
|
||||||
@@ -60,10 +53,8 @@ import {
|
|||||||
} from "@/features/editor/components/common/editor-paste-handler.tsx";
|
} from "@/features/editor/components/common/editor-paste-handler.tsx";
|
||||||
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu-lazy";
|
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu-lazy";
|
||||||
import DrawioMenu from "./components/drawio/drawio-menu";
|
import DrawioMenu from "./components/drawio/drawio-menu";
|
||||||
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
|
||||||
import SearchAndReplaceDialog from "@/features/editor/components/search-and-replace/search-and-replace-dialog.tsx";
|
import SearchAndReplaceDialog from "@/features/editor/components/search-and-replace/search-and-replace-dialog.tsx";
|
||||||
import { useDebouncedCallback, useDocumentVisibility } from "@mantine/hooks";
|
import { useDebouncedCallback } from "@mantine/hooks";
|
||||||
import { useIdle } from "@/hooks/use-idle.ts";
|
|
||||||
import { queryClient } from "@/main.tsx";
|
import { queryClient } from "@/main.tsx";
|
||||||
import { IPage } from "@/features/page/types/page.types.ts";
|
import { IPage } from "@/features/page/types/page.types.ts";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
@@ -74,9 +65,7 @@ import {
|
|||||||
GitmostInsertRecordingResult,
|
GitmostInsertRecordingResult,
|
||||||
gitmostInsertRecordingIntoEditor,
|
gitmostInsertRecordingIntoEditor,
|
||||||
} from "@/features/editor/gitmost/gitmost-recording.ts";
|
} from "@/features/editor/gitmost/gitmost-recording.ts";
|
||||||
import { FIVE_MINUTES } from "@/lib/constants.ts";
|
|
||||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||||
import { jwtDecode } from "jwt-decode";
|
|
||||||
import { searchSpotlight } from "@/features/search/constants.ts";
|
import { searchSpotlight } from "@/features/search/constants.ts";
|
||||||
import { useEditorScroll } from "./hooks/use-editor-scroll";
|
import { useEditorScroll } from "./hooks/use-editor-scroll";
|
||||||
import { useScrollRestoreOnSwap } from "./hooks/use-scroll-position";
|
import { useScrollRestoreOnSwap } from "./hooks/use-scroll-position";
|
||||||
@@ -113,7 +102,6 @@ export default function PageEditor({
|
|||||||
canComment,
|
canComment,
|
||||||
}: PageEditorProps) {
|
}: PageEditorProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const collaborationURL = useCollaborationUrl();
|
|
||||||
const isComponentMounted = useRef(false);
|
const isComponentMounted = useRef(false);
|
||||||
const editorRef = useRef<Editor | null>(null);
|
const editorRef = useRef<Editor | null>(null);
|
||||||
|
|
||||||
@@ -127,22 +115,10 @@ export default function PageEditor({
|
|||||||
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
|
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
|
||||||
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
||||||
const [showReadOnlyCommentPopup] = useAtom(showReadOnlyCommentPopupAtom);
|
const [showReadOnlyCommentPopup] = useAtom(showReadOnlyCommentPopupAtom);
|
||||||
const [isLocalSynced, setIsLocalSynced] = useState(false);
|
|
||||||
const [isRemoteSynced, setIsRemoteSynced] = useState(false);
|
|
||||||
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
|
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
|
||||||
yjsConnectionStatusAtom,
|
yjsConnectionStatusAtom,
|
||||||
);
|
);
|
||||||
const menuContainerRef = useRef(null);
|
const menuContainerRef = useRef(null);
|
||||||
const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken();
|
|
||||||
// Always holds the latest collab token. The provider effect below runs once
|
|
||||||
// per pageId, so a handler created inside it would otherwise close over a
|
|
||||||
// stale `collabQuery`. Reading the ref gives the current token instead.
|
|
||||||
const collabTokenRef = useRef<string | undefined>(undefined);
|
|
||||||
useEffect(() => {
|
|
||||||
collabTokenRef.current = collabQuery?.token;
|
|
||||||
}, [collabQuery?.token]);
|
|
||||||
const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false });
|
|
||||||
const documentState = useDocumentVisibility();
|
|
||||||
const { pageSlug } = useParams();
|
const { pageSlug } = useParams();
|
||||||
const slugId = extractPageSlugId(pageSlug);
|
const slugId = extractPageSlugId(pageSlug);
|
||||||
const currentPageEditMode = useAtomValue(currentPageEditModeAtom);
|
const currentPageEditMode = useAtomValue(currentPageEditModeAtom);
|
||||||
@@ -152,141 +128,27 @@ export default function PageEditor({
|
|||||||
[isComponentMounted],
|
[isComponentMounted],
|
||||||
);
|
);
|
||||||
const { handleScrollTo } = useEditorScroll({ canScroll });
|
const { handleScrollTo } = useEditorScroll({ canScroll });
|
||||||
// Providers only created once per pageId
|
|
||||||
const providersRef = useRef<{
|
|
||||||
local: IndexeddbPersistence;
|
|
||||||
remote: HocuspocusProvider;
|
|
||||||
socket: HocuspocusProviderWebsocket;
|
|
||||||
} | null>(null);
|
|
||||||
const [providersReady, setProvidersReady] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
// Shared providers + Y.Doc lifted into full-editor via context. The provider
|
||||||
if (!providersRef.current) {
|
// lifecycle (creation, idle/visibility connect, attach, destroy, token
|
||||||
const documentName = `page.${pageId}`;
|
// refresh) lives in usePageCollabProviders. Null-safe when rendered without
|
||||||
const ydoc = new Y.Doc();
|
// the context (defensive) — in practice full-editor always provides it.
|
||||||
const local = new IndexeddbPersistence(documentName, ydoc);
|
const editorProviders = useEditorProviders();
|
||||||
const socket = new HocuspocusProviderWebsocket({
|
const remote = editorProviders?.remote ?? null;
|
||||||
url: collaborationURL,
|
const providersReady = editorProviders?.providersReady ?? false;
|
||||||
});
|
const isLocalSynced = useAtomValue(isLocalSyncedAtom);
|
||||||
const onLocalSyncedHandler = () => {
|
const isRemoteSynced = useAtomValue(isRemoteSyncedAtom);
|
||||||
setIsLocalSynced(true);
|
|
||||||
};
|
|
||||||
const onStatusHandler = (event: onStatusParameters) => {
|
|
||||||
setYjsConnectionStatus(event.status);
|
|
||||||
};
|
|
||||||
const onSyncedHandler = (event: onSyncedParameters) => {
|
|
||||||
setIsRemoteSynced(event.state);
|
|
||||||
};
|
|
||||||
const onStatelessHandler = ({ payload }: onStatelessParameters) => {
|
|
||||||
try {
|
|
||||||
const message = JSON.parse(payload);
|
|
||||||
if (message?.type !== "page.updated" || !message.updatedAt) return;
|
|
||||||
const pageData = queryClient.getQueryData<IPage>(["pages", slugId]);
|
|
||||||
if (pageData) {
|
|
||||||
queryClient.setQueryData(["pages", slugId], {
|
|
||||||
...pageData,
|
|
||||||
updatedAt: message.updatedAt,
|
|
||||||
...(message.lastUpdatedBy && {
|
|
||||||
lastUpdatedBy: message.lastUpdatedBy,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore unrelated stateless messages
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const onAuthenticationFailedHandler = () => {
|
|
||||||
// Read the latest token via the ref (the closure-captured `collabQuery`
|
|
||||||
// may be stale). Guard the decode: a missing or unparseable token must
|
|
||||||
// not throw "Invalid token specified" and should trigger a refresh so
|
|
||||||
// the editor reconnects even when the initial token fetch failed.
|
|
||||||
const token = collabTokenRef.current;
|
|
||||||
let needsRefresh = true; // no/unparseable token -> fetch a fresh one and reconnect
|
|
||||||
if (token) {
|
|
||||||
try {
|
|
||||||
// A token that decodes but lacks a numeric `exp` must be treated as
|
|
||||||
// expired (`Date.now()/1000 >= undefined` is `false`, which would
|
|
||||||
// otherwise skip the reconnect), so refresh on any missing/non-number exp.
|
|
||||||
const exp = jwtDecode<{ exp?: number }>(token).exp;
|
|
||||||
needsRefresh = typeof exp !== "number" || Date.now() / 1000 >= exp;
|
|
||||||
} catch {
|
|
||||||
needsRefresh = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!needsRefresh) return;
|
|
||||||
refetchCollabToken().then((result) => {
|
|
||||||
if (result.data?.token) {
|
|
||||||
socket.disconnect();
|
|
||||||
setTimeout(() => {
|
|
||||||
remote.configuration.token = result.data.token;
|
|
||||||
socket.connect();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const remote = new HocuspocusProvider({
|
|
||||||
websocketProvider: socket,
|
|
||||||
name: documentName,
|
|
||||||
document: ydoc,
|
|
||||||
token: collabQuery?.token,
|
|
||||||
onAuthenticationFailed: onAuthenticationFailedHandler,
|
|
||||||
onStatus: onStatusHandler,
|
|
||||||
onSynced: onSyncedHandler,
|
|
||||||
onStateless: onStatelessHandler,
|
|
||||||
});
|
|
||||||
|
|
||||||
local.on("synced", onLocalSyncedHandler);
|
|
||||||
providersRef.current = { socket, local, remote };
|
|
||||||
setProvidersReady(true);
|
|
||||||
} else {
|
|
||||||
setProvidersReady(true);
|
|
||||||
}
|
|
||||||
// Only destroy on final unmount
|
|
||||||
return () => {
|
|
||||||
providersRef.current?.socket.destroy();
|
|
||||||
providersRef.current?.remote.destroy();
|
|
||||||
providersRef.current?.local.destroy();
|
|
||||||
providersRef.current = null;
|
|
||||||
};
|
|
||||||
}, [pageId]);
|
|
||||||
|
|
||||||
// Only connect/disconnect on tab/idle, not destroy
|
|
||||||
useEffect(() => {
|
|
||||||
if (!providersReady || !providersRef.current) return;
|
|
||||||
const socket = providersRef.current.socket;
|
|
||||||
|
|
||||||
if (
|
|
||||||
isIdle &&
|
|
||||||
documentState === "hidden" &&
|
|
||||||
yjsConnectionStatus === WebSocketStatus.Connected
|
|
||||||
) {
|
|
||||||
socket.disconnect();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
documentState === "visible" &&
|
|
||||||
yjsConnectionStatus === WebSocketStatus.Disconnected
|
|
||||||
) {
|
|
||||||
resetIdle();
|
|
||||||
socket.connect();
|
|
||||||
}
|
|
||||||
}, [isIdle, documentState, providersReady, resetIdle]);
|
|
||||||
|
|
||||||
// Attach here, to make sure the connection gets properly established
|
|
||||||
providersRef.current?.remote.attach();
|
|
||||||
|
|
||||||
const extensions = useMemo(() => {
|
const extensions = useMemo(() => {
|
||||||
if (!providersReady || !providersRef.current || !currentUser?.user) {
|
if (!providersReady || !remote || !currentUser?.user) {
|
||||||
return mainExtensions;
|
return mainExtensions;
|
||||||
}
|
}
|
||||||
|
|
||||||
const remoteProvider = providersRef.current.remote;
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
...mainExtensions,
|
...mainExtensions,
|
||||||
...collabExtensions(remoteProvider, currentUser?.user),
|
...collabExtensions(remote, currentUser?.user),
|
||||||
];
|
];
|
||||||
}, [providersReady, currentUser?.user]);
|
}, [providersReady, remote, currentUser?.user]);
|
||||||
|
|
||||||
const editor = useEditor(
|
const editor = useEditor(
|
||||||
{
|
{
|
||||||
@@ -651,7 +513,7 @@ export default function PageEditor({
|
|||||||
{editor &&
|
{editor &&
|
||||||
!editorIsEditable &&
|
!editorIsEditable &&
|
||||||
(editable || canComment) &&
|
(editable || canComment) &&
|
||||||
providersRef.current && <ReadonlyBubbleMenu editor={editor} />}
|
remote && <ReadonlyBubbleMenu editor={editor} />}
|
||||||
{showCommentPopup && (
|
{showCommentPopup && (
|
||||||
<CommentDialog editor={editor} pageId={pageId} />
|
<CommentDialog editor={editor} pageId={pageId} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Single source of truth for the IndexedDB / Hocuspocus document name of a
|
||||||
|
* page's collaborative Yjs doc.
|
||||||
|
*
|
||||||
|
* The `page.<id>` convention is shared knowledge across three call sites: the
|
||||||
|
* live editor providers (`use-page-collab-providers`), the offline warm path
|
||||||
|
* (`make-offline`), and the offline purge (`clear-offline-cache`, which matches
|
||||||
|
* the databases to delete by this prefix). Centralizing it here stops those
|
||||||
|
* sites from silently drifting apart.
|
||||||
|
*/
|
||||||
|
export const PAGE_YDOC_NAME_PREFIX = "page.";
|
||||||
|
|
||||||
|
export const pageYdocName = (pageId: string): string =>
|
||||||
|
`${PAGE_YDOC_NAME_PREFIX}${pageId}`;
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
// isChangeOrigin is mocked so we can simulate local vs remote/collab-origin
|
||||||
|
// transactions without constructing a real ProseMirror/Yjs transaction.
|
||||||
|
const isChangeOriginMock = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("@tiptap/extension-collaboration", () => ({
|
||||||
|
isChangeOrigin: isChangeOriginMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { shouldPropagateTitleChange } from "./title-collab";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
isChangeOriginMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("shouldPropagateTitleChange", () => {
|
||||||
|
it("propagates a genuine local edit (isChangeOrigin false)", () => {
|
||||||
|
isChangeOriginMock.mockReturnValue(false);
|
||||||
|
expect(shouldPropagateTitleChange({ local: true })).toBe(true);
|
||||||
|
expect(isChangeOriginMock).toHaveBeenCalledWith({ local: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips a remote/collab-origin update (isChangeOrigin true)", () => {
|
||||||
|
isChangeOriginMock.mockReturnValue(true);
|
||||||
|
expect(shouldPropagateTitleChange({ remote: true })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("propagates when there is no transaction (treated as local)", () => {
|
||||||
|
expect(shouldPropagateTitleChange(undefined)).toBe(true);
|
||||||
|
// isChangeOrigin must not be called for a missing transaction.
|
||||||
|
expect(isChangeOriginMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { isChangeOrigin } from "@tiptap/extension-collaboration";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a TitleEditor `onUpdate` should drive URL + tree propagation.
|
||||||
|
*
|
||||||
|
* Only genuine LOCAL edits propagate. Remote/collab-origin Yjs updates
|
||||||
|
* (detected via `isChangeOrigin`) are skipped so a remote title change is not
|
||||||
|
* re-broadcast back, which would create a feedback loop. A missing transaction
|
||||||
|
* is treated as a local edit (propagate).
|
||||||
|
*
|
||||||
|
* Extracted as a pure helper so the skip decision is unit-testable without
|
||||||
|
* mounting the full collaborative editor.
|
||||||
|
*/
|
||||||
|
export function shouldPropagateTitleChange(transaction: unknown): boolean {
|
||||||
|
return !(
|
||||||
|
transaction &&
|
||||||
|
isChangeOrigin(transaction as Parameters<typeof isChangeOrigin>[0])
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
|
||||||
|
// Drive the fallback-vs-collaborative switch (titleReady = providersReady &&
|
||||||
|
// !!ydoc) by controlling what the editor-providers context returns.
|
||||||
|
const editorProvidersValue: { ydoc: unknown; providersReady: boolean } = {
|
||||||
|
ydoc: null,
|
||||||
|
providersReady: false,
|
||||||
|
};
|
||||||
|
vi.mock("@/features/editor/contexts/editor-providers-context", () => ({
|
||||||
|
useEditorProviders: () => editorProvidersValue,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the tiptap React bindings so the test does not mount a real editor:
|
||||||
|
// useEditor returns a minimal stub and EditorContent renders a marker.
|
||||||
|
vi.mock("@tiptap/react", () => ({
|
||||||
|
useEditor: () => ({
|
||||||
|
isInitialized: true,
|
||||||
|
commands: { focus: vi.fn() },
|
||||||
|
setEditable: vi.fn(),
|
||||||
|
getText: () => "",
|
||||||
|
}),
|
||||||
|
EditorContent: () => <div data-testid="collab-editor" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({ t: (k: string) => k }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const navigateMock = vi.fn();
|
||||||
|
vi.mock("react-router-dom", () => ({
|
||||||
|
useNavigate: () => navigateMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/features/websocket/use-query-emit.ts", () => ({
|
||||||
|
useQueryEmit: () => vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// page-query transitively imports @/main.tsx; mock it to a pure stub.
|
||||||
|
vi.mock("@/features/page/queries/page-query", () => ({
|
||||||
|
updatePageData: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/main.tsx", () => ({
|
||||||
|
queryClient: { getQueryData: vi.fn(), setQueryData: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { TitleEditor } from "./title-editor";
|
||||||
|
|
||||||
|
const baseProps = {
|
||||||
|
pageId: "p1",
|
||||||
|
slugId: "slug-1",
|
||||||
|
title: "My Page Title",
|
||||||
|
spaceSlug: "space",
|
||||||
|
editable: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
navigateMock.mockReset();
|
||||||
|
editorProvidersValue.ydoc = null;
|
||||||
|
editorProvidersValue.providersReady = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("TitleEditor fallback vs collaborative switch", () => {
|
||||||
|
it("renders a static <h1> with the title before the shared doc is ready", () => {
|
||||||
|
editorProvidersValue.ydoc = null;
|
||||||
|
editorProvidersValue.providersReady = false;
|
||||||
|
|
||||||
|
render(<TitleEditor {...baseProps} />);
|
||||||
|
|
||||||
|
const heading = screen.getByRole("heading", { level: 1 });
|
||||||
|
expect(heading.textContent).toBe("My Page Title");
|
||||||
|
// The collaborative editor must NOT mount until the doc is ready.
|
||||||
|
expect(screen.queryByTestId("collab-editor")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the collaborative editor once the shared doc is ready", () => {
|
||||||
|
editorProvidersValue.ydoc = {}; // truthy shared doc
|
||||||
|
editorProvidersValue.providersReady = true;
|
||||||
|
|
||||||
|
render(<TitleEditor {...baseProps} />);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("collab-editor")).toBeDefined();
|
||||||
|
// The static fallback <h1> is gone — Yjs is the single source of truth and
|
||||||
|
// the prop is never seeded into the collaborative editor.
|
||||||
|
expect(screen.queryByRole("heading", { level: 1 })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import "@/features/editor/styles/index.css";
|
import "@/features/editor/styles/index.css";
|
||||||
import React, { useCallback, useEffect, useState } from "react";
|
import { useEffect } from "react";
|
||||||
import { EditorContent, useEditor } from "@tiptap/react";
|
import { EditorContent, useEditor } from "@tiptap/react";
|
||||||
import { Document } from "@tiptap/extension-document";
|
import { Document } from "@tiptap/extension-document";
|
||||||
import { Heading } from "@tiptap/extension-heading";
|
import { Heading } from "@tiptap/extension-heading";
|
||||||
@@ -11,14 +11,11 @@ import {
|
|||||||
pageEditorAtom,
|
pageEditorAtom,
|
||||||
titleEditorAtom,
|
titleEditorAtom,
|
||||||
} from "@/features/editor/atoms/editor-atoms";
|
} from "@/features/editor/atoms/editor-atoms";
|
||||||
import {
|
import { pageKeys, updatePageData } from "@/features/page/queries/page-query";
|
||||||
updatePageData,
|
|
||||||
useUpdateTitlePageMutation,
|
|
||||||
} from "@/features/page/queries/page-query";
|
|
||||||
import { useDebouncedCallback, getHotkeyHandler } from "@mantine/hooks";
|
import { useDebouncedCallback, getHotkeyHandler } from "@mantine/hooks";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
|
import { Collaboration } from "@tiptap/extension-collaboration";
|
||||||
import { History } from "@tiptap/extension-history";
|
import { shouldPropagateTitleChange } from "@/features/editor/title-collab";
|
||||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -29,6 +26,9 @@ import { PageEditMode } from "@/features/user/types/user.types.ts";
|
|||||||
import { searchSpotlight } from "@/features/search/constants.ts";
|
import { searchSpotlight } from "@/features/search/constants.ts";
|
||||||
import { platformModifierKey } from "@/lib";
|
import { platformModifierKey } from "@/lib";
|
||||||
import { useTitleAutofocus } from "@/features/editor/hooks/use-title-autofocus";
|
import { useTitleAutofocus } from "@/features/editor/hooks/use-title-autofocus";
|
||||||
|
import { useEditorProviders } from "@/features/editor/contexts/editor-providers-context";
|
||||||
|
import { queryClient } from "@/main.tsx";
|
||||||
|
import { IPage } from "@/features/page/types/page.types.ts";
|
||||||
|
|
||||||
export interface TitleEditorProps {
|
export interface TitleEditorProps {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
@@ -46,65 +46,82 @@ export function TitleEditor({
|
|||||||
editable,
|
editable,
|
||||||
}: TitleEditorProps) {
|
}: TitleEditorProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { mutateAsync: updateTitlePageMutationAsync } =
|
|
||||||
useUpdateTitlePageMutation();
|
|
||||||
const pageEditor = useAtomValue(pageEditorAtom);
|
const pageEditor = useAtomValue(pageEditorAtom);
|
||||||
const [, setTitleEditor] = useAtom(titleEditorAtom);
|
const [, setTitleEditor] = useAtom(titleEditorAtom);
|
||||||
const emit = useQueryEmit();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [activePageId, setActivePageId] = useState(pageId);
|
|
||||||
const currentPageEditMode = useAtomValue(currentPageEditModeAtom);
|
const currentPageEditMode = useAtomValue(currentPageEditModeAtom);
|
||||||
|
|
||||||
const titleEditor = useEditor({
|
// Shared Y.Doc (title lives in its own 'title' fragment of the same doc as
|
||||||
extensions: [
|
// the body). Yjs is the source of truth for the title content.
|
||||||
Document.extend({
|
const editorProviders = useEditorProviders();
|
||||||
content: "heading",
|
const ydoc = editorProviders?.ydoc ?? null;
|
||||||
}),
|
const providersReady = editorProviders?.providersReady ?? false;
|
||||||
Heading.configure({
|
|
||||||
levels: [1],
|
// Until the shared doc is ready, the collaborative editor binds nothing and
|
||||||
}),
|
// would render an empty heading until the Yjs 'title' fragment hydrates. Show
|
||||||
Text,
|
// a non-editable static <h1> with the `title` prop in the meantime. The prop
|
||||||
Placeholder.configure({
|
// is NEVER fed into the collaborative editor (Yjs stays the single source of
|
||||||
placeholder: t("Untitled"),
|
// truth — seeding it would duplicate the title).
|
||||||
showOnlyWhenEditable: false,
|
const titleReady = providersReady && !!ydoc;
|
||||||
}),
|
|
||||||
History.configure({
|
const titleEditor = useEditor(
|
||||||
depth: 20,
|
{
|
||||||
}),
|
extensions: [
|
||||||
EmojiCommand,
|
Document.extend({
|
||||||
],
|
content: "heading",
|
||||||
onCreate({ editor }) {
|
}),
|
||||||
if (editor) {
|
Heading.configure({
|
||||||
// @ts-ignore
|
levels: [1],
|
||||||
setTitleEditor(editor);
|
}),
|
||||||
setActivePageId(pageId);
|
Text,
|
||||||
}
|
Placeholder.configure({
|
||||||
},
|
placeholder: t("Untitled"),
|
||||||
onUpdate({ editor }) {
|
showOnlyWhenEditable: false,
|
||||||
debounceUpdate();
|
}),
|
||||||
},
|
// Bind the title to the dedicated 'title' fragment of the shared doc.
|
||||||
editable: editable,
|
// Collaboration also manages undo/redo, so the History extension is
|
||||||
content: title,
|
// intentionally omitted (it would conflict with Yjs). When the doc is
|
||||||
immediatelyRender: true,
|
// not ready yet the editor renders empty until the doc arrives.
|
||||||
shouldRerenderOnTransaction: false,
|
...(ydoc
|
||||||
editorProps: {
|
? [Collaboration.configure({ document: ydoc, field: "title" })]
|
||||||
attributes: {
|
: []),
|
||||||
"aria-label": t("Page title"),
|
EmojiCommand,
|
||||||
|
],
|
||||||
|
onCreate({ editor }) {
|
||||||
|
if (editor) {
|
||||||
|
// @ts-ignore
|
||||||
|
setTitleEditor(editor);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
handleDOMEvents: {
|
onUpdate({ editor, transaction }) {
|
||||||
keydown: (_view, event) => {
|
// Drive URL + tree propagation only on genuine local edits; skip
|
||||||
if (platformModifierKey(event) && event.code === "KeyS") {
|
// remote/collab-origin Yjs updates to avoid feedback loops.
|
||||||
event.preventDefault();
|
if (!shouldPropagateTitleChange(transaction)) return;
|
||||||
return true;
|
debouncedPropagateTitle(editor.getText());
|
||||||
}
|
},
|
||||||
if (platformModifierKey(event) && event.code === "KeyK") {
|
editable: editable,
|
||||||
searchSpotlight.open();
|
immediatelyRender: true,
|
||||||
return true;
|
shouldRerenderOnTransaction: false,
|
||||||
}
|
editorProps: {
|
||||||
|
attributes: {
|
||||||
|
"aria-label": t("Page title"),
|
||||||
|
},
|
||||||
|
handleDOMEvents: {
|
||||||
|
keydown: (_view, event) => {
|
||||||
|
if (platformModifierKey(event) && event.code === "KeyS") {
|
||||||
|
event.preventDefault();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (platformModifierKey(event) && event.code === "KeyK") {
|
||||||
|
searchSpotlight.open();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
[pageId, ydoc],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const anchorId = window.location.hash
|
const anchorId = window.location.hash
|
||||||
@@ -114,69 +131,48 @@ export function TitleEditor({
|
|||||||
navigate(pageSlug, { replace: true });
|
navigate(pageSlug, { replace: true });
|
||||||
}, [title]);
|
}, [title]);
|
||||||
|
|
||||||
const saveTitle = useCallback(() => {
|
// On a local title change: update the URL slug and propagate the change to
|
||||||
if (!titleEditor || activePageId !== pageId) return;
|
// the live tree/breadcrumbs for online users. No REST round-trip — the title
|
||||||
|
// itself is persisted through Yjs. Offline this simply no-ops the socket
|
||||||
if (
|
// emit and the title syncs on reconnect.
|
||||||
titleEditor.getText() === title ||
|
const debouncedPropagateTitle = useDebouncedCallback((titleText: string) => {
|
||||||
(titleEditor.getText() === "" && title === null)
|
const anchorId = window.location.hash
|
||||||
) {
|
? window.location.hash.substring(1)
|
||||||
return;
|
: undefined;
|
||||||
}
|
navigate(buildPageUrl(spaceSlug, slugId, titleText, anchorId), {
|
||||||
|
replace: true,
|
||||||
updateTitlePageMutationAsync({
|
|
||||||
pageId: pageId,
|
|
||||||
title: titleEditor.getText(),
|
|
||||||
}).then((page) => {
|
|
||||||
const event: UpdateEvent = {
|
|
||||||
operation: "updateOne",
|
|
||||||
spaceId: page.spaceId,
|
|
||||||
entity: ["pages"],
|
|
||||||
id: page.id,
|
|
||||||
payload: {
|
|
||||||
title: page.title,
|
|
||||||
slugId: page.slugId,
|
|
||||||
parentPageId: page.parentPageId,
|
|
||||||
icon: page.icon,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
if (page.title !== titleEditor.getText()) return;
|
|
||||||
|
|
||||||
updatePageData(page);
|
|
||||||
|
|
||||||
localEmitter.emit("message", event);
|
|
||||||
emit(event);
|
|
||||||
});
|
});
|
||||||
}, [pageId, title, titleEditor]);
|
|
||||||
|
|
||||||
const debounceUpdate = useDebouncedCallback(saveTitle, 500);
|
const page =
|
||||||
|
queryClient.getQueryData<IPage>(pageKeys.detail(slugId)) ??
|
||||||
|
queryClient.getQueryData<IPage>(pageKeys.detail(pageId));
|
||||||
|
if (!page) return;
|
||||||
|
|
||||||
useEffect(() => {
|
const updatedPage: IPage = { ...page, title: titleText };
|
||||||
// Do not overwrite the title while the user is actively editing it. The
|
|
||||||
// server rebroadcasts PAGE_UPDATED to the author too, and that echo can
|
const event: UpdateEvent = {
|
||||||
// carry a title that lags behind what the user has just typed; resetting
|
operation: "updateOne",
|
||||||
// content from it here would drop in-progress characters and jump the
|
spaceId: page.spaceId,
|
||||||
// cursor. Apply external title changes only when the field is not focused.
|
entity: ["pages"],
|
||||||
if (
|
id: page.id,
|
||||||
titleEditor &&
|
payload: {
|
||||||
!titleEditor.isDestroyed &&
|
title: titleText,
|
||||||
!titleEditor.isFocused &&
|
slugId: page.slugId,
|
||||||
title !== titleEditor.getText()
|
parentPageId: page.parentPageId,
|
||||||
) {
|
icon: page.icon,
|
||||||
titleEditor.commands.setContent(title);
|
},
|
||||||
}
|
};
|
||||||
}, [pageId, title, titleEditor]);
|
|
||||||
|
updatePageData(updatedPage);
|
||||||
|
// Drive the local (same-tab) tree/breadcrumb update. The cross-user tree
|
||||||
|
// refresh is handled server-side: the collab process extracts the renamed
|
||||||
|
// 'title' Yjs fragment and broadcasts a treeUpdate. The previous socket
|
||||||
|
// `emit(event)` here was a no-op (the gateway ignores it) and was removed.
|
||||||
|
localEmitter.emit("message", event);
|
||||||
|
}, 500);
|
||||||
|
|
||||||
useTitleAutofocus(titleEditor, pageId);
|
useTitleAutofocus(titleEditor, pageId);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
// force-save title on navigation
|
|
||||||
saveTitle();
|
|
||||||
};
|
|
||||||
}, [pageId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!titleEditor) return;
|
if (!titleEditor) return;
|
||||||
titleEditor.setEditable(editable && currentPageEditMode === PageEditMode.Edit);
|
titleEditor.setEditable(editable && currentPageEditMode === PageEditMode.Edit);
|
||||||
@@ -243,16 +239,22 @@ export function TitleEditor({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-title">
|
<div className="page-title">
|
||||||
<EditorContent
|
{titleReady ? (
|
||||||
editor={titleEditor}
|
<EditorContent
|
||||||
onKeyDown={(event) => {
|
editor={titleEditor}
|
||||||
// First handle the search hotkey
|
onKeyDown={(event) => {
|
||||||
getHotkeyHandler([["mod+F", openSearchDialog]])(event);
|
// First handle the search hotkey
|
||||||
|
getHotkeyHandler([["mod+F", openSearchDialog]])(event);
|
||||||
|
|
||||||
// Then handle other key events
|
// Then handle other key events
|
||||||
handleTitleKeyDown(event);
|
handleTitleKeyDown(event);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
// Static, non-editable fallback so the title is visible before Yjs
|
||||||
|
// hydrates the 'title' fragment. Not wired into the collaborative editor.
|
||||||
|
<h1>{title}</h1>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { IconHourglass, IconPlus } from "@tabler/icons-react";
|
|||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { onlineManager } from "@tanstack/react-query";
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
|
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
|
||||||
import { useCreatePageMutation } from "@/features/page/queries/page-query.ts";
|
import { useCreatePageMutation } from "@/features/page/queries/page-query.ts";
|
||||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||||
@@ -36,21 +38,39 @@ function CreateNoteButton({
|
|||||||
const createPageMutation = useCreatePageMutation();
|
const createPageMutation = useCreatePageMutation();
|
||||||
|
|
||||||
const createNote = async (space: ISpace) => {
|
const createNote = async (space: ISpace) => {
|
||||||
|
// `spaceId`/`temporary` are accepted by the create-page endpoint but are
|
||||||
|
// not part of the shared `IPageInput` type; cast to satisfy the mutation
|
||||||
|
// signature.
|
||||||
|
const variables = {
|
||||||
|
spaceId: space.id,
|
||||||
|
...(temporary ? { temporary: true } : {}),
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
if (!onlineManager.isOnline()) {
|
||||||
|
// Offline: the create is PAUSED and queued — its promise will not resolve
|
||||||
|
// until we are back online, so awaiting it here would spin the button
|
||||||
|
// forever. Fire it without awaiting (it persists and replays on reconnect)
|
||||||
|
// and tell the user it was saved offline instead of leaving a dead spinner.
|
||||||
|
createPageMutation.mutate(variables);
|
||||||
|
notifications.show({
|
||||||
|
color: "blue",
|
||||||
|
message: t("You're offline. This note will be created once you reconnect."),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// `spaceId`/`temporary` are accepted by the create-page endpoint but are
|
const createdPage = await createPageMutation.mutateAsync(variables);
|
||||||
// not part of the shared `IPageInput` type; cast to satisfy the mutation
|
|
||||||
// signature.
|
|
||||||
const createdPage = await createPageMutation.mutateAsync({
|
|
||||||
spaceId: space.id,
|
|
||||||
...(temporary ? { temporary: true } : {}),
|
|
||||||
} as any);
|
|
||||||
navigate(buildPageUrl(space.slug, createdPage.slugId, createdPage.title));
|
navigate(buildPageUrl(space.slug, createdPage.slugId, createdPage.title));
|
||||||
} catch {
|
} catch {
|
||||||
// useCreatePageMutation already surfaces a red notification on error.
|
// useCreatePageMutation already surfaces a red notification on error.
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isPending = createPageMutation.isPending;
|
// A paused (offline) mutation stays `isPending`, so gate the spinner on it NOT
|
||||||
|
// being paused — otherwise the button would spin forever after an offline
|
||||||
|
// create. The offline path above gives its own "saved offline" feedback.
|
||||||
|
const isPending = createPageMutation.isPending && !createPageMutation.isPaused;
|
||||||
|
|
||||||
// Exactly one writable space → create directly, no picker needed.
|
// Exactly one writable space → create directly, no picker needed.
|
||||||
if (writableSpaces.length === 1) {
|
if (writableSpaces.length === 1) {
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
|
||||||
|
// vi.mock factories are hoisted above imports, so the spies they reference must
|
||||||
|
// be declared via vi.hoisted (also hoisted). These are inspected by assertions.
|
||||||
|
const h = vi.hoisted(() => ({
|
||||||
|
clear: vi.fn(),
|
||||||
|
del: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// The module under test imports the app entry at load time — it must be mocked.
|
||||||
|
vi.mock("@/main.tsx", () => ({
|
||||||
|
queryClient: { clear: h.clear },
|
||||||
|
}));
|
||||||
|
vi.mock("idb-keyval", () => ({
|
||||||
|
del: h.del,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { clearOfflineCache } from "./clear-offline-cache";
|
||||||
|
import { OFFLINE_CACHE_KEY } from "./query-persister";
|
||||||
|
|
||||||
|
// jsdom does not provide indexedDB.databases() or Cache Storage, so the browser
|
||||||
|
// globals are stubbed per-test. We restore them afterwards.
|
||||||
|
const originalIndexedDB = (globalThis as any).indexedDB;
|
||||||
|
const originalCaches = (globalThis as any).caches;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
h.clear.mockClear();
|
||||||
|
h.del.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
(globalThis as any).indexedDB = originalIndexedDB;
|
||||||
|
(globalThis as any).caches = originalCaches;
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("clearOfflineCache", () => {
|
||||||
|
it("resolves without throwing when the browser globals are absent", async () => {
|
||||||
|
(globalThis as any).indexedDB = undefined;
|
||||||
|
delete (globalThis as any).caches;
|
||||||
|
|
||||||
|
await expect(clearOfflineCache()).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
// The two store-agnostic steps still run.
|
||||||
|
expect(h.clear).toHaveBeenCalledTimes(1);
|
||||||
|
expect(h.del).toHaveBeenCalledWith(OFFLINE_CACHE_KEY);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes only `page.*` IndexedDB databases and only `api-get-cache` caches", async () => {
|
||||||
|
const deleteDatabase = vi.fn((_name: string) => {
|
||||||
|
const request: any = {};
|
||||||
|
// Resolve the deletion on the next microtask, like a real IDBRequest.
|
||||||
|
queueMicrotask(() => request.onsuccess && request.onsuccess());
|
||||||
|
return request;
|
||||||
|
});
|
||||||
|
(globalThis as any).indexedDB = {
|
||||||
|
databases: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue([
|
||||||
|
{ name: "page.aaa" },
|
||||||
|
{ name: "page.bbb" },
|
||||||
|
{ name: "keyval-store" },
|
||||||
|
{ name: undefined },
|
||||||
|
]),
|
||||||
|
deleteDatabase,
|
||||||
|
};
|
||||||
|
|
||||||
|
const cacheDelete = vi.fn().mockResolvedValue(true);
|
||||||
|
(globalThis as any).caches = {
|
||||||
|
keys: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue([
|
||||||
|
"workbox-runtime-https://app/api-get-cache",
|
||||||
|
"other-cache",
|
||||||
|
]),
|
||||||
|
delete: cacheDelete,
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(clearOfflineCache()).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
// Only the two page.* databases are deleted.
|
||||||
|
expect(deleteDatabase).toHaveBeenCalledTimes(2);
|
||||||
|
expect(deleteDatabase).toHaveBeenCalledWith("page.aaa");
|
||||||
|
expect(deleteDatabase).toHaveBeenCalledWith("page.bbb");
|
||||||
|
|
||||||
|
// Only the api-get-cache entry is deleted.
|
||||||
|
expect(cacheDelete).toHaveBeenCalledTimes(1);
|
||||||
|
expect(cacheDelete).toHaveBeenCalledWith(
|
||||||
|
"workbox-runtime-https://app/api-get-cache",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never throws even if a step rejects (best-effort)", async () => {
|
||||||
|
h.del.mockRejectedValueOnce(new Error("idb boom"));
|
||||||
|
(globalThis as any).indexedDB = {
|
||||||
|
databases: vi.fn().mockRejectedValue(new Error("databases boom")),
|
||||||
|
deleteDatabase: vi.fn(),
|
||||||
|
};
|
||||||
|
(globalThis as any).caches = {
|
||||||
|
keys: vi.fn().mockRejectedValue(new Error("caches boom")),
|
||||||
|
delete: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(clearOfflineCache()).resolves.toBeUndefined();
|
||||||
|
expect(h.clear).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { del } from "idb-keyval";
|
||||||
|
|
||||||
|
import { queryClient } from "@/main.tsx";
|
||||||
|
import {
|
||||||
|
OFFLINE_CACHE_KEY,
|
||||||
|
freezeOfflinePersistence,
|
||||||
|
unfreezeOfflinePersistence,
|
||||||
|
} from "./query-persister";
|
||||||
|
import { PAGE_YDOC_NAME_PREFIX } from "@/features/editor/page-ydoc-name";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort purge of all of the current user's offline data from the browser.
|
||||||
|
*
|
||||||
|
* On logout the previous user's private data would otherwise linger locally and
|
||||||
|
* be readable by the next person on the device. This clears the three offline
|
||||||
|
* stores the app writes:
|
||||||
|
* 1. the in-memory + IndexedDB-persisted TanStack Query cache (idb-keyval key
|
||||||
|
* `OFFLINE_CACHE_KEY`),
|
||||||
|
* 2. the Yjs page documents (IndexedDB databases named `page.<id>` created by
|
||||||
|
* y-indexeddb in make-offline.ts), and
|
||||||
|
* 3. any legacy service worker `api-get-cache` Cache Storage entry. The
|
||||||
|
* Workbox runtime no longer creates this cache (the GET /api NetworkFirst
|
||||||
|
* rule was removed — offline reads come from the persisted RQ cache), so
|
||||||
|
* this is now a defensive cleanup for caches left by older app versions.
|
||||||
|
*
|
||||||
|
* Fully best-effort: every step is isolated so a single failure neither blocks
|
||||||
|
* the remaining steps nor throws to the caller (logout must never be blocked on
|
||||||
|
* cache cleanup). Callers may ignore the resolved value.
|
||||||
|
*
|
||||||
|
* Limitations:
|
||||||
|
* - Deleting the Yjs page databases relies on `indexedDB.databases()`, which
|
||||||
|
* is unavailable in some browsers (notably Firefox). There we skip silently;
|
||||||
|
* those `page.<id>` databases are then left in place.
|
||||||
|
* - Cache Storage clearing only runs where `caches` exists (secure contexts /
|
||||||
|
* service-worker-capable browsers).
|
||||||
|
*/
|
||||||
|
export async function clearOfflineCache(): Promise<void> {
|
||||||
|
// Freeze the throttled persister BEFORE touching the cache so the
|
||||||
|
// queryClient.clear() below cannot trigger a late re-write of the (still
|
||||||
|
// nearly-full) dehydrated snapshot after we del() the key — which would
|
||||||
|
// otherwise resurrect the previous user's persisted data in IndexedDB.
|
||||||
|
// Re-enabled in `finally` so the next (sign-in) session persists normally.
|
||||||
|
freezeOfflinePersistence();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1a. Drop the in-memory query cache immediately.
|
||||||
|
try {
|
||||||
|
queryClient.clear();
|
||||||
|
} catch {
|
||||||
|
// best-effort: ignore in-memory cache reset failures
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1b. Delete the persisted RQ cache from IndexedDB.
|
||||||
|
try {
|
||||||
|
await del(OFFLINE_CACHE_KEY);
|
||||||
|
} catch {
|
||||||
|
// best-effort: ignore persisted-cache deletion failures
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Delete the Yjs page IndexedDB databases (`page.<id>`).
|
||||||
|
// `indexedDB.databases()` is not implemented everywhere (e.g. Firefox); when
|
||||||
|
// it is missing we cannot enumerate the page databases, so we skip silently.
|
||||||
|
try {
|
||||||
|
if (
|
||||||
|
typeof indexedDB !== "undefined" &&
|
||||||
|
typeof indexedDB.databases === "function"
|
||||||
|
) {
|
||||||
|
const dbs = await indexedDB.databases();
|
||||||
|
for (const db of dbs) {
|
||||||
|
const name = db?.name;
|
||||||
|
if (typeof name !== "string" || !name.startsWith(PAGE_YDOC_NAME_PREFIX))
|
||||||
|
continue;
|
||||||
|
try {
|
||||||
|
// Fire-and-forget delete; await a thin wrapper so a slow delete does
|
||||||
|
// not race the page teardown, but never reject on it.
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const request = indexedDB.deleteDatabase(name);
|
||||||
|
request.onsuccess = () => resolve();
|
||||||
|
request.onerror = () => resolve();
|
||||||
|
request.onblocked = () => resolve();
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// best-effort per database
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// best-effort: ignore enumeration/deletion failures
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Clear any legacy service worker API cache. Current builds no longer
|
||||||
|
// create it, but an older client may have left an "api-get-cache" entry
|
||||||
|
// (Workbox may prefix the name), so match by substring rather than exact name.
|
||||||
|
try {
|
||||||
|
if ("caches" in window) {
|
||||||
|
const keys = await caches.keys();
|
||||||
|
await Promise.all(
|
||||||
|
keys
|
||||||
|
.filter((key) => key.includes("api-get-cache"))
|
||||||
|
.map((key) => caches.delete(key)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// best-effort: ignore Cache Storage failures
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// Re-enable persistence for the next session (sign-in continues running in
|
||||||
|
// the same tab; logout reloads via window.location.replace, so this is a
|
||||||
|
// harmless no-op there).
|
||||||
|
unfreezeOfflinePersistence();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
|
||||||
|
// vi.mock factories are hoisted above imports, so any spy they reference must be
|
||||||
|
// declared with vi.hoisted (which is hoisted as well). These shared spies are
|
||||||
|
// inspected by the assertions below.
|
||||||
|
const h = vi.hoisted(() => ({
|
||||||
|
ydocDestroy: vi.fn(),
|
||||||
|
idbDestroy: vi.fn(),
|
||||||
|
providerOn: vi.fn(),
|
||||||
|
providerOff: vi.fn(),
|
||||||
|
providerDestroy: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// The module under test imports the app entry at load time — it must be mocked.
|
||||||
|
vi.mock("@/main.tsx", () => ({
|
||||||
|
queryClient: { setQueryData: vi.fn(), prefetchQuery: vi.fn() },
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/page/services/page-service", () => ({
|
||||||
|
getPageById: vi.fn(),
|
||||||
|
getPageBreadcrumbs: vi.fn(),
|
||||||
|
getSidebarPages: vi.fn(),
|
||||||
|
getAllSidebarPages: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/space/services/space-service.ts", () => ({
|
||||||
|
getSpaceById: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/comment/services/comment-service", () => ({
|
||||||
|
getPageComments: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Use the `function` form (not an arrow) so Vitest binds the constructor return
|
||||||
|
// value when the module under test calls `new Y.Doc()` etc.
|
||||||
|
vi.mock("yjs", () => ({
|
||||||
|
Doc: vi.fn(function () {
|
||||||
|
return { destroy: h.ydocDestroy };
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
vi.mock("y-indexeddb", () => ({
|
||||||
|
IndexeddbPersistence: vi.fn(function () {
|
||||||
|
return { destroy: h.idbDestroy };
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
vi.mock("@hocuspocus/provider", () => ({
|
||||||
|
HocuspocusProvider: vi.fn(function () {
|
||||||
|
return { on: h.providerOn, off: h.providerOff, destroy: h.providerDestroy };
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
warmInfiniteAll,
|
||||||
|
warmPageYdoc,
|
||||||
|
makePageAvailableOffline,
|
||||||
|
} from "./make-offline";
|
||||||
|
import { queryClient } from "@/main.tsx";
|
||||||
|
import {
|
||||||
|
getPageById,
|
||||||
|
getPageBreadcrumbs,
|
||||||
|
getSidebarPages,
|
||||||
|
} from "@/features/page/services/page-service";
|
||||||
|
import { getPageComments } from "@/features/comment/services/comment-service";
|
||||||
|
|
||||||
|
const setQueryData = (queryClient as any).setQueryData as ReturnType<
|
||||||
|
typeof vi.fn
|
||||||
|
>;
|
||||||
|
const prefetchQuery = (queryClient as any).prefetchQuery as ReturnType<
|
||||||
|
typeof vi.fn
|
||||||
|
>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Clear call history WITHOUT wiping the mock implementations the vi.mock
|
||||||
|
// factories installed (vi.clearAllMocks would drop the constructor return
|
||||||
|
// objects and break the provider/idb/yjs spies).
|
||||||
|
setQueryData.mockClear();
|
||||||
|
prefetchQuery.mockReset();
|
||||||
|
prefetchQuery.mockResolvedValue(undefined);
|
||||||
|
(getPageById as ReturnType<typeof vi.fn>).mockReset();
|
||||||
|
(getPageBreadcrumbs as ReturnType<typeof vi.fn>).mockReset();
|
||||||
|
(getSidebarPages as ReturnType<typeof vi.fn>).mockReset();
|
||||||
|
(getPageComments as ReturnType<typeof vi.fn>).mockReset();
|
||||||
|
h.ydocDestroy.mockClear();
|
||||||
|
h.idbDestroy.mockClear();
|
||||||
|
h.providerOn.mockClear();
|
||||||
|
h.providerOff.mockClear();
|
||||||
|
h.providerDestroy.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("warmInfiniteAll", () => {
|
||||||
|
it("warms a single page and writes the InfiniteData cache shape", async () => {
|
||||||
|
const res = { items: [{ id: 1 }], meta: { nextCursor: null } };
|
||||||
|
const fetchPage = vi.fn().mockResolvedValue(res);
|
||||||
|
|
||||||
|
await warmInfiniteAll(["comments", "p1"], fetchPage);
|
||||||
|
|
||||||
|
expect(fetchPage).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetchPage).toHaveBeenCalledWith(undefined);
|
||||||
|
expect(setQueryData).toHaveBeenCalledTimes(1);
|
||||||
|
expect(setQueryData).toHaveBeenCalledWith(["comments", "p1"], {
|
||||||
|
pages: [res],
|
||||||
|
pageParams: [undefined],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("walks the cursor chain across multiple pages", async () => {
|
||||||
|
const r0 = { items: [], meta: { nextCursor: "c1" } };
|
||||||
|
const r1 = { items: [], meta: { nextCursor: "c2" } };
|
||||||
|
const r2 = { items: [], meta: { nextCursor: null } };
|
||||||
|
const fetchPage = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(r0)
|
||||||
|
.mockResolvedValueOnce(r1)
|
||||||
|
.mockResolvedValueOnce(r2);
|
||||||
|
|
||||||
|
await warmInfiniteAll(["comments", "p1"], fetchPage);
|
||||||
|
|
||||||
|
expect(fetchPage).toHaveBeenCalledTimes(3);
|
||||||
|
expect(fetchPage.mock.calls.map((c) => c[0])).toEqual([
|
||||||
|
undefined,
|
||||||
|
"c1",
|
||||||
|
"c2",
|
||||||
|
]);
|
||||||
|
const payload = setQueryData.mock.calls[0][1];
|
||||||
|
expect(payload.pages).toEqual([r0, r1, r2]);
|
||||||
|
expect(payload.pageParams).toEqual([undefined, "c1", "c2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps pagination at maxPages and reports the truncation (returns false)", async () => {
|
||||||
|
// Always returns a non-null cursor — the cap is the only thing that stops it.
|
||||||
|
const fetchPage = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ items: [], meta: { nextCursor: "more" } });
|
||||||
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
|
// Hitting maxPages with a cursor still pending is a truncated warm: the
|
||||||
|
// (partial) cache is still written, but the result is reported as false.
|
||||||
|
await expect(
|
||||||
|
warmInfiniteAll(["comments", "p1"], fetchPage, 2),
|
||||||
|
).resolves.toBe(false);
|
||||||
|
|
||||||
|
expect(fetchPage).toHaveBeenCalledTimes(2);
|
||||||
|
const payload = setQueryData.mock.calls[0][1];
|
||||||
|
expect(payload.pages).toHaveLength(2);
|
||||||
|
expect(errorSpy).toHaveBeenCalled();
|
||||||
|
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true on success", async () => {
|
||||||
|
const fetchPage = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ items: [], meta: { nextCursor: null } });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
warmInfiniteAll(["comments", "p1"], fetchPage),
|
||||||
|
).resolves.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports errors (returns false) and never writes the cache on failure", async () => {
|
||||||
|
const fetchPage = vi.fn().mockRejectedValue(new Error("network"));
|
||||||
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
warmInfiniteAll(["comments", "p1"], fetchPage),
|
||||||
|
).resolves.toBe(false);
|
||||||
|
expect(setQueryData).not.toHaveBeenCalled();
|
||||||
|
expect(errorSpy).toHaveBeenCalled();
|
||||||
|
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("makePageAvailableOffline", () => {
|
||||||
|
const okPage = {
|
||||||
|
id: "uuid-1",
|
||||||
|
slugId: "slug-1",
|
||||||
|
space: { slug: "space-slug" },
|
||||||
|
};
|
||||||
|
|
||||||
|
it("returns ok:true with no failures when every step succeeds", async () => {
|
||||||
|
(getPageById as ReturnType<typeof vi.fn>).mockResolvedValue(okPage);
|
||||||
|
(getPageBreadcrumbs as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||||
|
(getSidebarPages as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
items: [],
|
||||||
|
meta: { nextCursor: null },
|
||||||
|
});
|
||||||
|
(getPageComments as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
items: [],
|
||||||
|
meta: { nextCursor: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await makePageAvailableOffline({
|
||||||
|
pageId: "uuid-1",
|
||||||
|
spaceId: "space-uuid",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: true, failed: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns ok:false with the failed step label when a warm step fails", async () => {
|
||||||
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
(getPageById as ReturnType<typeof vi.fn>).mockResolvedValue(okPage);
|
||||||
|
(getPageBreadcrumbs as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||||
|
(getSidebarPages as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
items: [],
|
||||||
|
meta: { nextCursor: null },
|
||||||
|
});
|
||||||
|
// Comments warm fails -> labeled "comments".
|
||||||
|
(getPageComments as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||||
|
new Error("network"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await makePageAvailableOffline({
|
||||||
|
pageId: "uuid-1",
|
||||||
|
spaceId: "space-uuid",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.failed).toContain("comments");
|
||||||
|
expect(errorSpy).toHaveBeenCalled();
|
||||||
|
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper: the page-ids passed to the sidebar-children warm (its query key is
|
||||||
|
// ["sidebar-pages", { pageId, spaceId }]) — i.e. which nodes were prefetched.
|
||||||
|
const warmedSidebarIds = () =>
|
||||||
|
prefetchQuery.mock.calls
|
||||||
|
.map((c) => c[0])
|
||||||
|
.filter((opts: any) => opts?.queryKey?.[0] === "sidebar-pages")
|
||||||
|
.map((opts: any) => opts.queryKey[1]?.pageId);
|
||||||
|
|
||||||
|
it("warms the page + every ancestor's children once and skips the self-ancestor guard", async () => {
|
||||||
|
(getPageById as ReturnType<typeof vi.fn>).mockResolvedValue(okPage);
|
||||||
|
// Breadcrumbs include two real ancestors, the page's OWN id (must be skipped
|
||||||
|
// by the ancestorId === pageId guard so it is not warmed twice), and a
|
||||||
|
// malformed entry with no id (also skipped).
|
||||||
|
(getPageBreadcrumbs as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||||
|
{ id: "anc-1" },
|
||||||
|
{ id: "uuid-1" }, // === pageId -> guard
|
||||||
|
{ id: "anc-2" },
|
||||||
|
{}, // no id -> skipped
|
||||||
|
]);
|
||||||
|
(getSidebarPages as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
items: [],
|
||||||
|
meta: { nextCursor: null },
|
||||||
|
});
|
||||||
|
(getPageComments as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
items: [],
|
||||||
|
meta: { nextCursor: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await makePageAvailableOffline({
|
||||||
|
pageId: "uuid-1",
|
||||||
|
spaceId: "space-uuid",
|
||||||
|
});
|
||||||
|
|
||||||
|
const ids = warmedSidebarIds();
|
||||||
|
// The page's own children (warmSidebarChildren(pageId)) plus each real
|
||||||
|
// ancestor — exactly once each. The self-ancestor (uuid-1 in breadcrumbs) is
|
||||||
|
// NOT a second warm: uuid-1 appears once (from the page's own children call).
|
||||||
|
expect(ids).toEqual(["uuid-1", "anc-1", "anc-2"]);
|
||||||
|
expect(ids.filter((id: string) => id === "uuid-1")).toHaveLength(1);
|
||||||
|
expect(result).toEqual({ ok: true, failed: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dedupes repeated tree failures into a single 'tree' label", async () => {
|
||||||
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
(getPageById as ReturnType<typeof vi.fn>).mockResolvedValue(okPage);
|
||||||
|
(getPageBreadcrumbs as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||||
|
{ id: "anc-1" },
|
||||||
|
{ id: "anc-2" },
|
||||||
|
]);
|
||||||
|
(getSidebarPages as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
items: [],
|
||||||
|
meta: { nextCursor: null },
|
||||||
|
});
|
||||||
|
(getPageComments as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
items: [],
|
||||||
|
meta: { nextCursor: null },
|
||||||
|
});
|
||||||
|
// Fail ONLY the sidebar-children prefetches (page-own + both ancestors = 3
|
||||||
|
// failures); the currentUser/space prefetches still resolve.
|
||||||
|
prefetchQuery.mockImplementation(async (opts: any) => {
|
||||||
|
if (opts?.queryKey?.[0] === "sidebar-pages") throw new Error("network");
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await makePageAvailableOffline({
|
||||||
|
pageId: "uuid-1",
|
||||||
|
spaceId: "space-uuid",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Three node warms failed but the contract collapses them to one "tree".
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.failed).toEqual(["tree"]);
|
||||||
|
expect(errorSpy).toHaveBeenCalled();
|
||||||
|
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records 'breadcrumbs' (not 'tree') when the breadcrumbs lookup rejects", async () => {
|
||||||
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
(getPageById as ReturnType<typeof vi.fn>).mockResolvedValue(okPage);
|
||||||
|
// Ancestor discovery fails -> the ancestor-walk is recorded as "breadcrumbs".
|
||||||
|
(getPageBreadcrumbs as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||||
|
new Error("network"),
|
||||||
|
);
|
||||||
|
(getSidebarPages as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
items: [],
|
||||||
|
meta: { nextCursor: null },
|
||||||
|
});
|
||||||
|
(getPageComments as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
items: [],
|
||||||
|
meta: { nextCursor: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await makePageAvailableOffline({
|
||||||
|
pageId: "uuid-1",
|
||||||
|
spaceId: "space-uuid",
|
||||||
|
});
|
||||||
|
|
||||||
|
// The page's own children still warmed fine (prefetch resolves), so the only
|
||||||
|
// failure is the breadcrumbs lookup.
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.failed).toEqual(["breadcrumbs"]);
|
||||||
|
expect(errorSpy).toHaveBeenCalled();
|
||||||
|
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("warmPageYdoc", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves on synced, detaches the listener once, and tears everything down (settle-once)", async () => {
|
||||||
|
const promise = warmPageYdoc("p1", "ws://x");
|
||||||
|
|
||||||
|
// Grab the synced handler the provider registered.
|
||||||
|
expect(h.providerOn).toHaveBeenCalledWith("synced", expect.any(Function));
|
||||||
|
const handler = h.providerOn.mock.calls.find(
|
||||||
|
(c) => c[0] === "synced",
|
||||||
|
)![1] as () => void;
|
||||||
|
|
||||||
|
handler();
|
||||||
|
await expect(promise).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
// Listener detached and everything cleaned up.
|
||||||
|
expect(h.providerOff).toHaveBeenCalledWith("synced", expect.any(Function));
|
||||||
|
expect(h.providerDestroy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(h.idbDestroy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(h.ydocDestroy).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Firing the handler again must NOT re-run cleanup (settled guard).
|
||||||
|
handler();
|
||||||
|
expect(h.providerDestroy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(h.idbDestroy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(h.ydocDestroy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves and cleans up after the timeout when synced never fires", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const promise = warmPageYdoc("p1", "ws://x");
|
||||||
|
|
||||||
|
// Do not fire "synced"; let the 8s safety timeout settle it.
|
||||||
|
await vi.advanceTimersByTimeAsync(8000);
|
||||||
|
await expect(promise).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
expect(h.providerDestroy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(h.idbDestroy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(h.ydocDestroy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
import * as Y from "yjs";
|
||||||
|
import { IndexeddbPersistence } from "y-indexeddb";
|
||||||
|
import { HocuspocusProvider } from "@hocuspocus/provider";
|
||||||
|
|
||||||
|
import { queryClient } from "@/main.tsx";
|
||||||
|
import {
|
||||||
|
getPageById,
|
||||||
|
getPageBreadcrumbs,
|
||||||
|
getSidebarPages,
|
||||||
|
} from "@/features/page/services/page-service";
|
||||||
|
import {
|
||||||
|
pageKeys,
|
||||||
|
sidebarPagesQueryOptions,
|
||||||
|
} from "@/features/page/queries/page-query";
|
||||||
|
import { spaceByIdQueryOptions } from "@/features/space/queries/space-query";
|
||||||
|
import { RQ_KEY } from "@/features/comment/queries/comment-query";
|
||||||
|
import { getPageComments } from "@/features/comment/services/comment-service";
|
||||||
|
import { getMyInfo } from "@/features/user/services/user-service";
|
||||||
|
import { userKeys } from "@/features/user/hooks/use-current-user";
|
||||||
|
import { IPage } from "@/features/page/types/page.types";
|
||||||
|
import { IPagination } from "@/lib/types.ts";
|
||||||
|
import { pageYdocName } from "@/features/editor/page-ydoc-name";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fully paginate an infinite query and write the @tanstack InfiniteData cache
|
||||||
|
* shape ({ pages, pageParams }) that the matching useInfiniteQuery hook reads.
|
||||||
|
*
|
||||||
|
* The default prefetchInfiniteQuery only warms the FIRST page, which leaves
|
||||||
|
* hooks that treat hasNextPage as still-loading (e.g. the comments panel)
|
||||||
|
* spinning forever offline, and silently truncates large lists. This walks the
|
||||||
|
* cursor chain until it runs out (or hits maxPages) so the whole list is cached.
|
||||||
|
*
|
||||||
|
* Best-effort: a failure does not throw (a partial/failed warm is still useful),
|
||||||
|
* but it is reported — the error is logged with context and `false` is returned
|
||||||
|
* so the caller can record the failed step instead of silently succeeding.
|
||||||
|
*
|
||||||
|
* Returns true ONLY if the cursor chain was fully exhausted and written. If the
|
||||||
|
* walk stops because it hit `maxPages` while a `nextCursor` is still pending,
|
||||||
|
* the cached list is truncated AND its last page keeps a nextCursor that cannot
|
||||||
|
* be re-fetched offline (hooks that gate on hasNextPage would spin forever), so
|
||||||
|
* that case is logged and returns false too — the caller records it as a failed
|
||||||
|
* warm instead of a silent truncated success. The (partial) cache is still
|
||||||
|
* written so what we did fetch is usable.
|
||||||
|
*
|
||||||
|
* Exported for unit testing of the cursor-walk / cache-write behavior.
|
||||||
|
*/
|
||||||
|
export async function warmInfiniteAll<T>(
|
||||||
|
queryKey: readonly unknown[],
|
||||||
|
fetchPage: (cursor: string | undefined) => Promise<IPagination<T>>,
|
||||||
|
maxPages = 50,
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const pages: IPagination<T>[] = [];
|
||||||
|
const pageParams: (string | undefined)[] = [];
|
||||||
|
let cursor: string | undefined = undefined;
|
||||||
|
let exhausted = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < maxPages; i++) {
|
||||||
|
const res = await fetchPage(cursor);
|
||||||
|
pages.push(res);
|
||||||
|
pageParams.push(cursor);
|
||||||
|
cursor = res?.meta?.nextCursor ?? undefined;
|
||||||
|
if (!cursor) {
|
||||||
|
exhausted = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
queryClient.setQueryData(queryKey, { pages, pageParams });
|
||||||
|
|
||||||
|
if (!exhausted) {
|
||||||
|
// Stopped at maxPages with a cursor still pending: the list is truncated
|
||||||
|
// and the last cached page's nextCursor is un-fetchable offline. Report it
|
||||||
|
// as a failed warm rather than a silent truncated success.
|
||||||
|
console.error("warmInfiniteAll truncated at maxPages", {
|
||||||
|
queryKey,
|
||||||
|
maxPages,
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("warmInfiniteAll failed", { queryKey, error });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MakePageAvailableOfflineParams {
|
||||||
|
pageId: string;
|
||||||
|
spaceId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outcome of {@link makePageAvailableOffline}. `ok` is true only when every warm
|
||||||
|
* step succeeded; `failed` lists the labels of the steps that failed (a subset
|
||||||
|
* of: "currentUser", "page", "space", "tree", "breadcrumbs", "comments").
|
||||||
|
*/
|
||||||
|
export interface MakePageAvailableOfflineResult {
|
||||||
|
ok: boolean;
|
||||||
|
failed: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort prefetch of a page's read queries so they get persisted to
|
||||||
|
* IndexedDB and become readable offline.
|
||||||
|
*
|
||||||
|
* Each step is isolated and this function does NOT throw — a partial warm is
|
||||||
|
* still useful. Instead of silently succeeding, every failed step is logged
|
||||||
|
* with a label and recorded in the returned result: `{ ok, failed }` where
|
||||||
|
* `ok` is true only if no step failed and `failed` lists the failed step
|
||||||
|
* labels. Only meaningful while online (the underlying requests must succeed).
|
||||||
|
*/
|
||||||
|
export async function makePageAvailableOffline({
|
||||||
|
pageId,
|
||||||
|
spaceId,
|
||||||
|
}: MakePageAvailableOfflineParams): Promise<MakePageAvailableOfflineResult> {
|
||||||
|
const failed: string[] = [];
|
||||||
|
|
||||||
|
// Warm the current user (['currentUser']) so the auth-gated <Layout> can
|
||||||
|
// hydrate offline. UserProvider blanks the whole app while useCurrentUser has
|
||||||
|
// no data, and the offline POST /api/users/me fails as a network error, so
|
||||||
|
// without a persisted user a pinned page still white-screens after relaunch
|
||||||
|
// (#238). Persisted via OFFLINE_PERSIST_ROOTS; warmed here so the persisted
|
||||||
|
// cache actually has an entry to restore.
|
||||||
|
try {
|
||||||
|
await queryClient.prefetchQuery({
|
||||||
|
queryKey: userKeys.currentUser(),
|
||||||
|
queryFn: () => getMyInfo(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("makePageAvailableOffline: currentUser step failed", {
|
||||||
|
pageId,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
failed.push("currentUser");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the page document ONCE and write it under BOTH cache keys, exactly
|
||||||
|
// like usePageQuery's onData effect. Every page consumer reads
|
||||||
|
// pageKeys.detail(slugId) (usePageQuery keys on the slugId for routed reads),
|
||||||
|
// so warming only the uuid key would leave the offline page blank.
|
||||||
|
let page: IPage | undefined;
|
||||||
|
try {
|
||||||
|
page = await getPageById({ pageId });
|
||||||
|
queryClient.setQueryData(pageKeys.detail(page.slugId), page);
|
||||||
|
queryClient.setQueryData(pageKeys.detail(page.id), page);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("makePageAvailableOffline: page step failed", {
|
||||||
|
pageId,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
failed.push("page");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warm the space — page.tsx renders nothing until the space query resolves
|
||||||
|
// (useGetSpaceBySlugQuery). Awaited (not the fire-and-forget prefetchSpace) so
|
||||||
|
// the space is actually persisted before the caller fires its toast. Shares
|
||||||
|
// spaceByIdQueryOptions so the key/fn cannot drift from the hook.
|
||||||
|
try {
|
||||||
|
const spaceSlug = page?.space?.slug;
|
||||||
|
if (spaceSlug) {
|
||||||
|
await queryClient.prefetchQuery(spaceByIdQueryOptions(spaceSlug));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("makePageAvailableOffline: space step failed", {
|
||||||
|
pageId,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
failed.push("space");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warm the sidebar tree root so the WHOLE root level renders offline (matches
|
||||||
|
// useGetRootSidebarPagesQuery's pageKeys.rootSidebar(spaceId) infinite cache).
|
||||||
|
// Fully paginated so large root levels are not truncated at 100.
|
||||||
|
if (spaceId) {
|
||||||
|
const ok = await warmInfiniteAll(pageKeys.rootSidebar(spaceId), (cursor) =>
|
||||||
|
getSidebarPages({ spaceId, cursor, limit: 100 }),
|
||||||
|
);
|
||||||
|
if (!ok) failed.push("tree");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warm the children of the page and of every ancestor so the path to this
|
||||||
|
// page is expandable offline. We MIRROR fetchAllAncestorChildren exactly via
|
||||||
|
// sidebarPagesQueryOptions — same pageKeys.sidebar({ pageId, spaceId }) key,
|
||||||
|
// same getAllSidebarPages fn (which aggregates ALL children pages, so nothing
|
||||||
|
// is truncated at 100), same 30min staleTime — otherwise the warmed cache
|
||||||
|
// would never be read by the offline tree.
|
||||||
|
const warmSidebarChildren = async (id: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
// Keep EXACTLY { pageId, spaceId } so the key hashes identically to
|
||||||
|
// fetchAllAncestorChildren's (no parentPageId, no extra fields).
|
||||||
|
const params = { pageId: id, spaceId };
|
||||||
|
await queryClient.prefetchQuery(sidebarPagesQueryOptions(params));
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("makePageAvailableOffline: tree node step failed", {
|
||||||
|
pageId: id,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// The page's own children.
|
||||||
|
if (!(await warmSidebarChildren(pageId))) failed.push("tree");
|
||||||
|
|
||||||
|
// Each ancestor's children. Use the breadcrumbs endpoint ONLY to discover the
|
||||||
|
// ancestor ids — we intentionally do NOT cache the breadcrumbs themselves
|
||||||
|
// (the UI derives the path from the tree).
|
||||||
|
try {
|
||||||
|
const ancestors = (await getPageBreadcrumbs(pageId)) as
|
||||||
|
| Array<{ id?: string }>
|
||||||
|
| undefined;
|
||||||
|
for (const ancestor of ancestors ?? []) {
|
||||||
|
const ancestorId = ancestor?.id;
|
||||||
|
if (!ancestorId || ancestorId === pageId) continue;
|
||||||
|
if (!(await warmSidebarChildren(ancestorId))) failed.push("tree");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("makePageAvailableOffline: breadcrumbs step failed", {
|
||||||
|
pageId,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
failed.push("breadcrumbs");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comments (matches useCommentsQuery's RQ_KEY(pageId) infinite cache).
|
||||||
|
// useCommentsQuery reports isLoading while hasNextPage is true, so warming
|
||||||
|
// only the first page leaves the offline comments panel spinning forever on
|
||||||
|
// pages with >100 comments. Fully paginate so the last cached page has no
|
||||||
|
// nextCursor and the panel settles offline.
|
||||||
|
const commentsOk = await warmInfiniteAll(RQ_KEY(pageId), (cursor) =>
|
||||||
|
getPageComments({ pageId, cursor, limit: 100 }),
|
||||||
|
);
|
||||||
|
if (!commentsOk) failed.push("comments");
|
||||||
|
|
||||||
|
// Dedupe — the tree label can be recorded once per failed node/ancestor.
|
||||||
|
const uniqueFailed = [...new Set(failed)];
|
||||||
|
return { ok: uniqueFailed.length === 0, failed: uniqueFailed };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort warm-up of the page's Yjs document into IndexedDB so the editor
|
||||||
|
* can open offline.
|
||||||
|
*
|
||||||
|
* Opens a local IndexeddbPersistence plus a transient HocuspocusProvider to
|
||||||
|
* pull the server state into IndexedDB, then tears both down once synced (or
|
||||||
|
* after a timeout). Entirely wrapped in try/catch — NEVER throws.
|
||||||
|
*
|
||||||
|
* Only meaningful when online at warm time; offline it is a no-op that resolves.
|
||||||
|
*/
|
||||||
|
export async function warmPageYdoc(
|
||||||
|
pageId: string,
|
||||||
|
collabUrl: string,
|
||||||
|
token?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
let ydoc: Y.Doc | null = null;
|
||||||
|
let local: IndexeddbPersistence | null = null;
|
||||||
|
let remote: HocuspocusProvider | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const documentName = pageYdocName(pageId);
|
||||||
|
ydoc = new Y.Doc();
|
||||||
|
local = new IndexeddbPersistence(documentName, ydoc);
|
||||||
|
remote = new HocuspocusProvider({
|
||||||
|
url: collabUrl,
|
||||||
|
name: documentName,
|
||||||
|
document: ydoc,
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
|
||||||
|
const provider = remote;
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
const finish = () => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
// Clear the pending timeout and detach the listener so neither leaks
|
||||||
|
// after we resolve.
|
||||||
|
if (timeoutId !== undefined) clearTimeout(timeoutId);
|
||||||
|
try {
|
||||||
|
provider.off("synced", finish);
|
||||||
|
} catch {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Resolve once the server state has synced into the local doc...
|
||||||
|
provider.on("synced", finish);
|
||||||
|
// ...or give up after a short timeout so we never hang.
|
||||||
|
timeoutId = setTimeout(finish, 8000);
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// best-effort
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
remote?.destroy();
|
||||||
|
} catch {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
local?.destroy();
|
||||||
|
} catch {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
ydoc?.destroy();
|
||||||
|
} catch {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { Button, Container, Group, Stack, Text, Title } from "@mantine/core";
|
||||||
|
import { Helmet } from "react-helmet-async";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { getAppName } from "@/lib/config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shown when the authenticated app shell cannot hydrate because the current
|
||||||
|
* user is unavailable AND there is no cached user to fall back on (e.g. an
|
||||||
|
* offline cold boot of a page that was never warmed for offline).
|
||||||
|
*
|
||||||
|
* Previously UserProvider returned a bare `<></>` in this situation, which
|
||||||
|
* white-screened the whole app on any offline reload (#237/#238). Rendering an
|
||||||
|
* explicit "you're offline" state with a retry instead gives the user a clear,
|
||||||
|
* non-blank fallback and a way to recover once the network returns.
|
||||||
|
*/
|
||||||
|
export function OfflineFallback() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Helmet>
|
||||||
|
<title>
|
||||||
|
{t("You're offline")} - {getAppName()}
|
||||||
|
</title>
|
||||||
|
</Helmet>
|
||||||
|
<Container size="sm" py={80}>
|
||||||
|
<Stack align="center" gap="md">
|
||||||
|
<Title order={2} ta="center">
|
||||||
|
{t("You're offline")}
|
||||||
|
</Title>
|
||||||
|
<Text c="dimmed" size="lg" ta="center">
|
||||||
|
{t(
|
||||||
|
"This page hasn't been saved for offline use, so it can't be loaded right now. Reconnect to the internet and try again.",
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
<Group justify="center">
|
||||||
|
<Button onClick={() => window.location.reload()} variant="subtle">
|
||||||
|
{t("Retry")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { QueryClient, hydrate, dehydrate } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
// Stub the network services so a replayed mutation hits a spy, not the network.
|
||||||
|
const h = vi.hoisted(() => ({
|
||||||
|
createPage: vi.fn(),
|
||||||
|
movePage: vi.fn(),
|
||||||
|
createComment: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/features/page/services/page-service", () => ({
|
||||||
|
createPage: h.createPage,
|
||||||
|
movePage: h.movePage,
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/comment/services/comment-service", () => ({
|
||||||
|
createComment: h.createComment,
|
||||||
|
}));
|
||||||
|
// page-query pulls in the app entry (queryClient) and a lot of UI deps via its
|
||||||
|
// cache helpers; we only need invalidateOnCreatePage to be a no-op here.
|
||||||
|
vi.mock("@/features/page/queries/page-query", () => ({
|
||||||
|
invalidateOnCreatePage: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
offlineMutationKeys,
|
||||||
|
registerOfflineMutationDefaults,
|
||||||
|
} from "./offline-mutations";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
h.createPage.mockReset().mockResolvedValue({ id: "new-page" });
|
||||||
|
h.movePage.mockReset().mockResolvedValue(undefined);
|
||||||
|
h.createComment.mockReset().mockResolvedValue({ id: "new-comment" });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("registerOfflineMutationDefaults", () => {
|
||||||
|
it("registers a default mutationFn for every offline mutation key", () => {
|
||||||
|
const qc = new QueryClient();
|
||||||
|
registerOfflineMutationDefaults(qc);
|
||||||
|
|
||||||
|
for (const key of Object.values(offlineMutationKeys)) {
|
||||||
|
const defaults = qc.getMutationDefaults(key);
|
||||||
|
expect(typeof defaults?.mutationFn).toBe("function");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// The headline durability guarantee: a paused mutation dehydrated into
|
||||||
|
// IndexedDB while offline must, after a reload, have a mutationFn so
|
||||||
|
// resumePausedMutations() actually replays the write on reconnect.
|
||||||
|
it("makes a rehydrated paused create replayable by resumePausedMutations", async () => {
|
||||||
|
// 1) Simulate the offline tab: a paused create mutation gets dehydrated.
|
||||||
|
const offlineClient = new QueryClient();
|
||||||
|
const observer = offlineClient.getMutationCache().build(offlineClient, {
|
||||||
|
mutationKey: offlineMutationKeys.createPage,
|
||||||
|
});
|
||||||
|
// Force the dehydrate-worthy paused state (offline = isPaused) with the
|
||||||
|
// payload the user submitted before losing connectivity.
|
||||||
|
observer.state.isPaused = true;
|
||||||
|
observer.state.status = "pending";
|
||||||
|
observer.state.variables = { spaceId: "s1", title: "Offline page" };
|
||||||
|
|
||||||
|
const dehydrated = dehydrate(offlineClient, {
|
||||||
|
shouldDehydrateMutation: () => true,
|
||||||
|
});
|
||||||
|
expect(dehydrated.mutations).toHaveLength(1);
|
||||||
|
// The dehydrated mutation carries NO mutationFn (functions aren't
|
||||||
|
// serializable) — only its key + variables survive the reload.
|
||||||
|
expect((dehydrated.mutations[0] as any).mutationFn).toBeUndefined();
|
||||||
|
|
||||||
|
// 2) Simulate the fresh page after reload: register defaults, then hydrate
|
||||||
|
// the persisted paused mutation back in.
|
||||||
|
const freshClient = new QueryClient();
|
||||||
|
registerOfflineMutationDefaults(freshClient);
|
||||||
|
hydrate(freshClient, dehydrated);
|
||||||
|
|
||||||
|
expect(freshClient.getMutationCache().getAll()).toHaveLength(1);
|
||||||
|
|
||||||
|
// 3) Reconnect: replay the paused mutations.
|
||||||
|
await freshClient.resumePausedMutations();
|
||||||
|
|
||||||
|
// The default mutationFn ran with the persisted variables — the write is
|
||||||
|
// NOT silently dropped.
|
||||||
|
expect(h.createPage).toHaveBeenCalledTimes(1);
|
||||||
|
expect(h.createPage).toHaveBeenCalledWith({
|
||||||
|
spaceId: "s1",
|
||||||
|
title: "Offline page",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("makes a rehydrated paused move replayable by resumePausedMutations", async () => {
|
||||||
|
const offlineClient = new QueryClient();
|
||||||
|
const observer = offlineClient.getMutationCache().build(offlineClient, {
|
||||||
|
mutationKey: offlineMutationKeys.movePage,
|
||||||
|
});
|
||||||
|
observer.state.isPaused = true;
|
||||||
|
observer.state.status = "pending";
|
||||||
|
observer.state.variables = { pageId: "p1", parentPageId: null, position: "a" };
|
||||||
|
|
||||||
|
const dehydrated = dehydrate(offlineClient, {
|
||||||
|
shouldDehydrateMutation: () => true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const freshClient = new QueryClient();
|
||||||
|
registerOfflineMutationDefaults(freshClient);
|
||||||
|
hydrate(freshClient, dehydrated);
|
||||||
|
await freshClient.resumePausedMutations();
|
||||||
|
|
||||||
|
expect(h.movePage).toHaveBeenCalledTimes(1);
|
||||||
|
expect(h.movePage).toHaveBeenCalledWith({
|
||||||
|
pageId: "p1",
|
||||||
|
parentPageId: null,
|
||||||
|
position: "a",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import type { QueryClient } from "@tanstack/react-query";
|
||||||
|
import { createPage, movePage } from "@/features/page/services/page-service";
|
||||||
|
import { createComment } from "@/features/comment/services/comment-service";
|
||||||
|
import { invalidateOnCreatePage } from "@/features/page/queries/page-query";
|
||||||
|
import type {
|
||||||
|
IMovePage,
|
||||||
|
IPage,
|
||||||
|
IPageInput,
|
||||||
|
} from "@/features/page/types/page.types";
|
||||||
|
import type { IComment } from "@/features/comment/types/comment.types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stable mutation keys for the offline-relevant structural mutations.
|
||||||
|
*
|
||||||
|
* When the browser goes offline, React Query PAUSES these mutations and the
|
||||||
|
* PersistQueryClientProvider dehydrates the paused mutation into IndexedDB. On a
|
||||||
|
* reload-while-offline the mutation is restored, but a restored mutation has NO
|
||||||
|
* observer (no component is mounted) — so its replay relies entirely on the
|
||||||
|
* `mutationFn` registered via `setMutationDefaults` for its `mutationKey`.
|
||||||
|
* Without that, `resumePausedMutations()` finds a paused mutation with no
|
||||||
|
* `mutationFn` and silently no-ops, dropping the offline create/move/comment
|
||||||
|
* (#237/#238). Each offline mutation hook tags itself with the matching key so
|
||||||
|
* the rehydrated paused mutation can find its default `mutationFn` and replay.
|
||||||
|
*/
|
||||||
|
export const offlineMutationKeys = {
|
||||||
|
createPage: ["create-page"] as const,
|
||||||
|
movePage: ["move-page"] as const,
|
||||||
|
createComment: ["create-comment"] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register default `mutationFn`s (and the minimal success side effects safe to
|
||||||
|
* run without a mounted component) for the offline-relevant mutation keys, so a
|
||||||
|
* paused mutation restored from IndexedDB after an offline reload is replayable
|
||||||
|
* by `resumePausedMutations()` on reconnect.
|
||||||
|
*
|
||||||
|
* Called once when the QueryClient is created (see main.tsx). The hooks still
|
||||||
|
* carry their own inline `mutationFn`/`onSuccess` for the live in-session path;
|
||||||
|
* these defaults only take over for a rehydrated paused mutation that lost its
|
||||||
|
* observer across the reload.
|
||||||
|
*/
|
||||||
|
export function registerOfflineMutationDefaults(queryClient: QueryClient): void {
|
||||||
|
queryClient.setMutationDefaults(offlineMutationKeys.createPage, {
|
||||||
|
mutationFn: (data: Partial<IPageInput>) => createPage(data),
|
||||||
|
// Re-converge the sidebar tree / recent-changes from the authoritative
|
||||||
|
// create response. Pure cache writes — safe with no component mounted.
|
||||||
|
onSuccess: (data: IPage) => {
|
||||||
|
invalidateOnCreatePage(data);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
queryClient.setMutationDefaults(offlineMutationKeys.movePage, {
|
||||||
|
// Replay the server-side move. The tree re-converges from the next online
|
||||||
|
// sidebar fetch / websocket `moveTreeNode` echo, so no cache write is
|
||||||
|
// needed here (the optimistic tree state was local-only anyway).
|
||||||
|
mutationFn: (data: IMovePage) => movePage(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
queryClient.setMutationDefaults(offlineMutationKeys.createComment, {
|
||||||
|
// Replay the server-side comment create. The comments list refetches on the
|
||||||
|
// online reload, so the replay only needs to persist the write.
|
||||||
|
mutationFn: (data: Partial<IComment>) => createComment(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { QueryClient, onlineManager } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
persistQueryClientRestore,
|
||||||
|
persistQueryClientSave,
|
||||||
|
} from "@tanstack/react-query-persist-client";
|
||||||
|
|
||||||
|
// Stub the network services so a replayed mutation hits a spy, not the network.
|
||||||
|
const h = vi.hoisted(() => ({
|
||||||
|
createPage: vi.fn(),
|
||||||
|
movePage: vi.fn(),
|
||||||
|
createComment: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/features/page/services/page-service", () => ({
|
||||||
|
createPage: h.createPage,
|
||||||
|
movePage: h.movePage,
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/comment/services/comment-service", () => ({
|
||||||
|
createComment: h.createComment,
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/page/queries/page-query", () => ({
|
||||||
|
invalidateOnCreatePage: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// In-memory idb-keyval so the REAL queryPersister round-trips through a fake
|
||||||
|
// store (the actual persist -> reload -> restore path, not a hand-built blob).
|
||||||
|
const store = new Map<string, string>();
|
||||||
|
vi.mock("idb-keyval", () => ({
|
||||||
|
get: vi.fn((k: string) => Promise.resolve(store.get(k) ?? undefined)),
|
||||||
|
set: vi.fn((k: string, v: string) => {
|
||||||
|
store.set(k, v);
|
||||||
|
return Promise.resolve();
|
||||||
|
}),
|
||||||
|
del: vi.fn((k: string) => {
|
||||||
|
store.delete(k);
|
||||||
|
return Promise.resolve();
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { queryPersister } from "./query-persister";
|
||||||
|
import {
|
||||||
|
offlineMutationKeys,
|
||||||
|
registerOfflineMutationDefaults,
|
||||||
|
} from "./offline-mutations";
|
||||||
|
|
||||||
|
const BUSTER = "test-buster";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
store.clear();
|
||||||
|
h.createPage.mockReset().mockResolvedValue({ id: "new-page" });
|
||||||
|
h.movePage.mockReset().mockResolvedValue(undefined);
|
||||||
|
h.createComment.mockReset().mockResolvedValue({ id: "new-comment" });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// onlineManager is a global singleton; leave it in the default online state.
|
||||||
|
onlineManager.setOnline(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("offline paused-mutation resume across a reload", () => {
|
||||||
|
// This is the #120 silent-data-loss reproduction: a paused mutation persisted
|
||||||
|
// to IndexedDB while offline, then the tab RELOADS while still offline, must
|
||||||
|
// resume on reconnect. It exercises the real persister round-trip plus the two
|
||||||
|
// boot-time fixes the app wiring relies on:
|
||||||
|
// (a) onlineManager seeded to the real offline state so the later reconnect
|
||||||
|
// is a true offline->online transition that auto-resumes, and
|
||||||
|
// (b) resumePausedMutations() called after the persister restores (what the
|
||||||
|
// PersistQueryClientProvider onSuccess does), with mutation defaults
|
||||||
|
// registered BEFORE the resume so the rehydrated mutation has a fn.
|
||||||
|
it("replays a rehydrated paused create on reconnect (mutationFn fires)", async () => {
|
||||||
|
// --- Tab 1, OFFLINE: user creates a page; it pauses and gets persisted. ---
|
||||||
|
onlineManager.setOnline(false); // (a) boot seeded offline
|
||||||
|
|
||||||
|
const client1 = new QueryClient();
|
||||||
|
registerOfflineMutationDefaults(client1);
|
||||||
|
const observer = client1.getMutationCache().build(client1, {
|
||||||
|
mutationKey: offlineMutationKeys.createPage,
|
||||||
|
});
|
||||||
|
observer.state.isPaused = true;
|
||||||
|
observer.state.status = "pending";
|
||||||
|
observer.state.variables = { spaceId: "s1", title: "Offline page" };
|
||||||
|
|
||||||
|
await persistQueryClientSave({
|
||||||
|
// Cast: persist-client-core and react-query may resolve to different
|
||||||
|
// @tanstack/query-core copies whose QueryClient brands are nominally
|
||||||
|
// incompatible (see query-persister.ts). Structurally identical at runtime.
|
||||||
|
queryClient: client1 as any,
|
||||||
|
persister: queryPersister,
|
||||||
|
buster: BUSTER,
|
||||||
|
dehydrateOptions: { shouldDehydrateMutation: () => true },
|
||||||
|
});
|
||||||
|
// The paused mutation is now in the persisted store.
|
||||||
|
expect(store.size).toBe(1);
|
||||||
|
|
||||||
|
// --- RELOAD while still offline: fresh client restores from the SAME
|
||||||
|
// persister. Defaults are registered BEFORE restore/resume. ---
|
||||||
|
const client2 = new QueryClient();
|
||||||
|
registerOfflineMutationDefaults(client2);
|
||||||
|
client2.mount(); // subscribes to onlineManager (auto-resume on reconnect)
|
||||||
|
|
||||||
|
await persistQueryClientRestore({
|
||||||
|
queryClient: client2 as any,
|
||||||
|
persister: queryPersister,
|
||||||
|
buster: BUSTER,
|
||||||
|
});
|
||||||
|
expect(client2.getMutationCache().getAll()).toHaveLength(1);
|
||||||
|
|
||||||
|
// (b) onSuccess wiring resumes after restore — but we are still OFFLINE, so
|
||||||
|
// the mutation must stay paused and NOT fire yet.
|
||||||
|
await client2.resumePausedMutations();
|
||||||
|
expect(h.createPage).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// --- RECONNECT: the offline->online transition auto-resumes the paused
|
||||||
|
// mutation and its registered default mutationFn finally fires. ---
|
||||||
|
onlineManager.setOnline(true);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(h.createPage).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
expect(h.createPage).toHaveBeenCalledWith({
|
||||||
|
spaceId: "s1",
|
||||||
|
title: "Offline page",
|
||||||
|
});
|
||||||
|
|
||||||
|
client2.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
|
||||||
|
// The query modules transitively import the app entry (@/main.tsx) for the
|
||||||
|
// shared queryClient; mock it so importing the key factories has no side effects.
|
||||||
|
import { vi } from "vitest";
|
||||||
|
vi.mock("@/main.tsx", () => ({
|
||||||
|
queryClient: { setQueryData: vi.fn(), getQueryData: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { OFFLINE_PERSIST_ROOTS } from "./query-persister";
|
||||||
|
import { pageKeys } from "@/features/page/queries/page-query";
|
||||||
|
import { spaceKeys } from "@/features/space/queries/space-query";
|
||||||
|
import { RQ_KEY } from "@/features/comment/queries/comment-query";
|
||||||
|
import { userKeys } from "@/features/user/hooks/use-current-user";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Architecture guard (#13): every string persisted via OFFLINE_PERSIST_ROOTS
|
||||||
|
* must be the ROOT (queryKey[0]) of some exported query-key factory. If a
|
||||||
|
* factory's root is renamed without updating the persist registry — or vice
|
||||||
|
* versa — offline persist/warm silently breaks (persisted keys never match the
|
||||||
|
* live queries). This turns that silent regression into a red build.
|
||||||
|
*
|
||||||
|
* Each factory is invoked with throwaway args; only queryKey[0] is inspected.
|
||||||
|
*/
|
||||||
|
function rootOf(key: readonly unknown[]): string {
|
||||||
|
return String(key[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const FACTORY_ROOTS = new Set<string>([
|
||||||
|
rootOf(pageKeys.detail("x")),
|
||||||
|
rootOf(pageKeys.sidebar({})),
|
||||||
|
rootOf(pageKeys.rootSidebar("x")),
|
||||||
|
rootOf(pageKeys.breadcrumbs("x")),
|
||||||
|
rootOf(pageKeys.recentChanges("x")),
|
||||||
|
rootOf(spaceKeys.detail("x")),
|
||||||
|
rootOf(spaceKeys.list()),
|
||||||
|
rootOf(RQ_KEY("x")),
|
||||||
|
rootOf(userKeys.currentUser()),
|
||||||
|
]);
|
||||||
|
|
||||||
|
describe("OFFLINE_PERSIST_ROOTS is backed by real query-key factories", () => {
|
||||||
|
it("maps every persisted root to an exported factory root", () => {
|
||||||
|
const unbacked = [...OFFLINE_PERSIST_ROOTS].filter(
|
||||||
|
(root) => !FACTORY_ROOTS.has(root),
|
||||||
|
);
|
||||||
|
expect(unbacked).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||||
|
|
||||||
|
// In-memory idb-keyval so we can observe whether the persister actually writes.
|
||||||
|
const h = vi.hoisted(() => ({
|
||||||
|
get: vi.fn(() => Promise.resolve(undefined)),
|
||||||
|
set: vi.fn(() => Promise.resolve()),
|
||||||
|
del: vi.fn(() => Promise.resolve()),
|
||||||
|
}));
|
||||||
|
vi.mock("idb-keyval", () => h);
|
||||||
|
|
||||||
|
import {
|
||||||
|
shouldDehydrateOfflineQuery,
|
||||||
|
OFFLINE_PERSIST_ROOTS,
|
||||||
|
queryPersister,
|
||||||
|
freezeOfflinePersistence,
|
||||||
|
unfreezeOfflinePersistence,
|
||||||
|
} from "./query-persister";
|
||||||
|
|
||||||
|
// Small helper to build the structural query shape the predicate reads.
|
||||||
|
const makeQuery = (status: string, queryKey: readonly unknown[]) =>
|
||||||
|
({ state: { status }, queryKey }) as any;
|
||||||
|
|
||||||
|
describe("shouldDehydrateOfflineQuery", () => {
|
||||||
|
it("returns true for a successful query whose root is in the allowlist", () => {
|
||||||
|
expect(shouldDehydrateOfflineQuery(makeQuery("success", ["pages", "abc"]))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
shouldDehydrateOfflineQuery(
|
||||||
|
makeQuery("success", ["sidebar-pages", { pageId: "p", spaceId: "s" }]),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
shouldDehydrateOfflineQuery(makeQuery("success", ["comments", "p1"])),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
shouldDehydrateOfflineQuery(makeQuery("success", ["space", "s"])),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
shouldDehydrateOfflineQuery(makeQuery("success", ["recent-changes"])),
|
||||||
|
).toBe(true);
|
||||||
|
// currentUser is persisted so the auth-gated Layout can hydrate offline.
|
||||||
|
expect(
|
||||||
|
shouldDehydrateOfflineQuery(makeQuery("success", ["currentUser"])),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when the status is not success (status gate)", () => {
|
||||||
|
expect(
|
||||||
|
shouldDehydrateOfflineQuery(makeQuery("pending", ["pages", "abc"])),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
shouldDehydrateOfflineQuery(makeQuery("error", ["pages", "abc"])),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for a successful query whose root is NOT in the allowlist (privacy gate)", () => {
|
||||||
|
expect(
|
||||||
|
shouldDehydrateOfflineQuery(makeQuery("success", ["collab-token", "ws"])),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
shouldDehydrateOfflineQuery(makeQuery("success", ["trash", "s"])),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
shouldDehydrateOfflineQuery(makeQuery("success", ["unknown"])),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for an empty/undefined queryKey", () => {
|
||||||
|
// String(undefined) is not a member of the allowlist.
|
||||||
|
expect(shouldDehydrateOfflineQuery(makeQuery("success", []))).toBe(false);
|
||||||
|
expect(
|
||||||
|
shouldDehydrateOfflineQuery(makeQuery("success", undefined as any)),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("OFFLINE_PERSIST_ROOTS", () => {
|
||||||
|
it("contains exactly the expected 9 navigation/read roots", () => {
|
||||||
|
const expected = [
|
||||||
|
"pages",
|
||||||
|
"sidebar-pages",
|
||||||
|
"root-sidebar-pages",
|
||||||
|
"breadcrumbs",
|
||||||
|
"comments",
|
||||||
|
"space",
|
||||||
|
"spaces",
|
||||||
|
"recent-changes",
|
||||||
|
"currentUser",
|
||||||
|
];
|
||||||
|
expect(OFFLINE_PERSIST_ROOTS.size).toBe(9);
|
||||||
|
for (const root of expected) {
|
||||||
|
expect(OFFLINE_PERSIST_ROOTS.has(root)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT contain volatile/auth keys", () => {
|
||||||
|
expect(OFFLINE_PERSIST_ROOTS.has("collab-token")).toBe(false);
|
||||||
|
expect(OFFLINE_PERSIST_ROOTS.has("trash")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("freeze/unfreeze persistence (logout no-late-write guard)", () => {
|
||||||
|
const dummyClient = {
|
||||||
|
timestamp: Date.now(),
|
||||||
|
buster: "",
|
||||||
|
clientState: { mutations: [], queries: [] },
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Always leave persistence enabled so other tests/sessions persist normally.
|
||||||
|
unfreezeOfflinePersistence();
|
||||||
|
h.set.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT write to storage while frozen", async () => {
|
||||||
|
freezeOfflinePersistence();
|
||||||
|
await queryPersister.persistClient(dummyClient);
|
||||||
|
expect(h.set).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resumes writing to storage once unfrozen", async () => {
|
||||||
|
freezeOfflinePersistence();
|
||||||
|
unfreezeOfflinePersistence();
|
||||||
|
await queryPersister.persistClient(dummyClient);
|
||||||
|
expect(h.set).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { get, set, del } from "idb-keyval";
|
||||||
|
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
|
||||||
|
|
||||||
|
// Structural subset of a TanStack Query we read when deciding what to persist.
|
||||||
|
// We avoid importing the branded `Query` class because the persist-client and
|
||||||
|
// react-query may resolve to different `@tanstack/query-core` copies, whose
|
||||||
|
// `Query` types are nominally incompatible (private brand). This structural
|
||||||
|
// shape stays assignable to whichever copy the persister expects.
|
||||||
|
type DehydratableQuery = {
|
||||||
|
state: { status: string };
|
||||||
|
queryKey: readonly unknown[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// idb-keyval key under which TanStack Query persists its dehydrated cache.
|
||||||
|
// Exported so the logout cache-clear logic deletes the exact same key (no
|
||||||
|
// magic-string drift between persist and purge).
|
||||||
|
export const OFFLINE_CACHE_KEY = "gitmost-rq-cache";
|
||||||
|
|
||||||
|
// IndexedDB-backed storage adapter for TanStack Query's async persister.
|
||||||
|
const idbStorage = {
|
||||||
|
getItem: (key: string) => get<string>(key).then((v) => v ?? null),
|
||||||
|
setItem: (key: string, value: string) => set(key, value),
|
||||||
|
removeItem: (key: string) => del(key),
|
||||||
|
};
|
||||||
|
|
||||||
|
const basePersister = createAsyncStoragePersister({
|
||||||
|
storage: idbStorage,
|
||||||
|
key: OFFLINE_CACHE_KEY,
|
||||||
|
throttleTime: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// When frozen, persistClient becomes a no-op so no new dehydrated snapshot is
|
||||||
|
// written to IndexedDB. This closes a logout data-leak race: clearing the cache
|
||||||
|
// (queryClient.clear()) fires `removed` cache events, each of which the persist
|
||||||
|
// subscription turns into a throttled persistClient call. The FIRST such call
|
||||||
|
// dehydrates a still-nearly-full snapshot and its async write can land AFTER the
|
||||||
|
// del() that clears the key, resurrecting the previous user's data (~180KB) in
|
||||||
|
// IndexedDB. Freezing before clear()/del() prevents any such rewrite. Re-enabled
|
||||||
|
// afterwards so the next (sign-in) session persists normally. See
|
||||||
|
// clear-offline-cache.ts.
|
||||||
|
let persistFrozen = false;
|
||||||
|
|
||||||
|
export function freezeOfflinePersistence(): void {
|
||||||
|
persistFrozen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unfreezeOfflinePersistence(): void {
|
||||||
|
persistFrozen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const queryPersister = {
|
||||||
|
persistClient: (persistedClient: Parameters<typeof basePersister.persistClient>[0]) =>
|
||||||
|
persistFrozen ? Promise.resolve() : basePersister.persistClient(persistedClient),
|
||||||
|
restoreClient: () => basePersister.restoreClient(),
|
||||||
|
removeClient: () => basePersister.removeClient(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only navigation/read query roots are persisted for offline reading.
|
||||||
|
// Volatile/auth queries (collab tokens, trash lists) are intentionally excluded.
|
||||||
|
//
|
||||||
|
// `currentUser` IS persisted: UserProvider gates the entire <Layout> subtree on
|
||||||
|
// useCurrentUser(), and offline the POST /api/users/me fails as a no-response
|
||||||
|
// network error. Without the persisted/hydrated user the gate blanked every
|
||||||
|
// authenticated route on an offline cold boot (#237/#238). It is the logged-in
|
||||||
|
// user's own profile (already mirrored to localStorage["currentUser"]), so
|
||||||
|
// persisting it to IndexedDB leaks nothing new while unlocking offline reads.
|
||||||
|
export const OFFLINE_PERSIST_ROOTS = new Set<string>([
|
||||||
|
"pages",
|
||||||
|
"sidebar-pages",
|
||||||
|
"root-sidebar-pages",
|
||||||
|
"breadcrumbs",
|
||||||
|
"comments",
|
||||||
|
"space",
|
||||||
|
"spaces",
|
||||||
|
"recent-changes",
|
||||||
|
"currentUser",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function shouldDehydrateOfflineQuery(query: DehydratableQuery): boolean {
|
||||||
|
return (
|
||||||
|
query.state.status === "success" &&
|
||||||
|
OFFLINE_PERSIST_ROOTS.has(String(query.queryKey?.[0]))
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
IconList,
|
IconList,
|
||||||
IconMarkdown,
|
IconMarkdown,
|
||||||
IconPrinter,
|
IconPrinter,
|
||||||
|
IconCloud,
|
||||||
|
IconCloudCheck,
|
||||||
IconStar,
|
IconStar,
|
||||||
IconStarFilled,
|
IconStarFilled,
|
||||||
IconTrash,
|
IconTrash,
|
||||||
@@ -39,6 +41,8 @@ import { Trans, useTranslation } from "react-i18next";
|
|||||||
import ExportModal from "@/components/common/export-modal";
|
import ExportModal from "@/components/common/export-modal";
|
||||||
import { htmlToMarkdown } from "@docmost/editor-ext";
|
import { htmlToMarkdown } from "@docmost/editor-ext";
|
||||||
import {
|
import {
|
||||||
|
isLocalSyncedAtom,
|
||||||
|
isRemoteSyncedAtom,
|
||||||
pageEditorAtom,
|
pageEditorAtom,
|
||||||
yjsConnectionStatusAtom,
|
yjsConnectionStatusAtom,
|
||||||
} from "@/features/editor/atoms/editor-atoms.ts";
|
} from "@/features/editor/atoms/editor-atoms.ts";
|
||||||
@@ -411,14 +415,16 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
|||||||
function ConnectionWarning() {
|
function ConnectionWarning() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const yjsConnectionStatus = useAtomValue(yjsConnectionStatusAtom);
|
const yjsConnectionStatus = useAtomValue(yjsConnectionStatusAtom);
|
||||||
|
const isLocalSynced = useAtomValue(isLocalSyncedAtom);
|
||||||
|
const isRemoteSynced = useAtomValue(isRemoteSyncedAtom);
|
||||||
const [showWarning, setShowWarning] = useState(false);
|
const [showWarning, setShowWarning] = useState(false);
|
||||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
const isDisconnected = ["disconnected", "connecting"].includes(
|
||||||
const isDisconnected = ["disconnected", "connecting"].includes(
|
yjsConnectionStatus,
|
||||||
yjsConnectionStatus,
|
);
|
||||||
);
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
if (isDisconnected) {
|
if (isDisconnected) {
|
||||||
if (!timeoutRef.current) {
|
if (!timeoutRef.current) {
|
||||||
timeoutRef.current = setTimeout(() => setShowWarning(true), 5000);
|
timeoutRef.current = setTimeout(() => setShowWarning(true), 5000);
|
||||||
@@ -430,7 +436,7 @@ function ConnectionWarning() {
|
|||||||
}
|
}
|
||||||
setShowWarning(false);
|
setShowWarning(false);
|
||||||
}
|
}
|
||||||
}, [yjsConnectionStatus]);
|
}, [isDisconnected]);
|
||||||
|
|
||||||
// Cleanup only on unmount
|
// Cleanup only on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -441,22 +447,59 @@ function ConnectionWarning() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (!showWarning) return null;
|
// State (1): offline/disconnected — changes are kept locally. Preserve the
|
||||||
|
// existing >5s debounce before surfacing this state.
|
||||||
|
if (isDisconnected) {
|
||||||
|
if (!showWarning) return null;
|
||||||
|
|
||||||
|
const offlineLabel = t(
|
||||||
|
"Offline — changes are saved locally and will sync when you reconnect",
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Tooltip label={offlineLabel} openDelay={250} withArrow>
|
||||||
|
<ThemeIcon
|
||||||
|
variant="default"
|
||||||
|
c="red"
|
||||||
|
role="status"
|
||||||
|
aria-label={offlineLabel}
|
||||||
|
style={{ border: "none" }}
|
||||||
|
>
|
||||||
|
<IconWifiOff size={20} stroke={2} />
|
||||||
|
</ThemeIcon>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// State (2): connected but the remote replica is not fully caught up yet.
|
||||||
|
if (!isRemoteSynced || !isLocalSynced) {
|
||||||
|
const syncingLabel = t("Syncing changes…");
|
||||||
|
return (
|
||||||
|
<Tooltip label={syncingLabel} openDelay={250} withArrow>
|
||||||
|
<ThemeIcon
|
||||||
|
variant="default"
|
||||||
|
c="dimmed"
|
||||||
|
role="status"
|
||||||
|
aria-label={syncingLabel}
|
||||||
|
style={{ border: "none" }}
|
||||||
|
>
|
||||||
|
<IconCloud size={20} stroke={2} />
|
||||||
|
</ThemeIcon>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// State (3): fully synced — subtle confirmation indicator.
|
||||||
|
const syncedLabel = t("All changes synced");
|
||||||
return (
|
return (
|
||||||
<Tooltip
|
<Tooltip label={syncedLabel} openDelay={250} withArrow>
|
||||||
label={t("Real-time editor connection lost. Retrying...")}
|
|
||||||
openDelay={250}
|
|
||||||
withArrow
|
|
||||||
>
|
|
||||||
<ThemeIcon
|
<ThemeIcon
|
||||||
variant="default"
|
variant="default"
|
||||||
c="red"
|
c="dimmed"
|
||||||
role="status"
|
role="status"
|
||||||
aria-label={t("Real-time editor connection lost. Retrying...")}
|
aria-label={syncedLabel}
|
||||||
style={{ border: "none" }}
|
style={{ border: "none" }}
|
||||||
>
|
>
|
||||||
<IconWifiOff size={20} stroke={2} />
|
<IconCloudCheck size={20} stroke={2} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
InfiniteData,
|
InfiniteData,
|
||||||
QueryKey,
|
QueryKey,
|
||||||
|
queryOptions,
|
||||||
useInfiniteQuery,
|
useInfiniteQuery,
|
||||||
UseInfiniteQueryResult,
|
UseInfiniteQueryResult,
|
||||||
useMutation,
|
useMutation,
|
||||||
@@ -42,12 +43,38 @@ import { treeModel } from "@/features/page/tree/model/tree-model";
|
|||||||
import { SpaceTreeNode } from "@/features/page/tree/types";
|
import { SpaceTreeNode } from "@/features/page/tree/types";
|
||||||
import { useQueryEmit } from "@/features/websocket/use-query-emit";
|
import { useQueryEmit } from "@/features/websocket/use-query-emit";
|
||||||
import { moveToTrashNotificationMessage } from "@/features/page/components/move-to-trash-notification";
|
import { moveToTrashNotificationMessage } from "@/features/page/components/move-to-trash-notification";
|
||||||
|
import { offlineMutationKeys } from "@/features/offline/offline-mutations";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Centralized React Query key factories for page queries. The hooks below and
|
||||||
|
* the offline warm path (features/offline/make-offline.ts) share these so the
|
||||||
|
* runtime keys can never silently drift apart.
|
||||||
|
*/
|
||||||
|
export const pageKeys = {
|
||||||
|
detail: (idOrSlug: string) => ["pages", idOrSlug] as const,
|
||||||
|
sidebar: (data: unknown) => ["sidebar-pages", data] as const,
|
||||||
|
rootSidebar: (spaceId: string) => ["root-sidebar-pages", spaceId] as const,
|
||||||
|
breadcrumbs: (pageId: string) => ["breadcrumbs", pageId] as const,
|
||||||
|
recentChanges: (spaceId?: string) => ["recent-changes", spaceId] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared queryOptions for the sidebar-pages (ancestor children) query. Both
|
||||||
|
* fetchAllAncestorChildren and the offline warm path consume this so the key,
|
||||||
|
* queryFn and staleTime stay identical.
|
||||||
|
*/
|
||||||
|
export const sidebarPagesQueryOptions = (params: SidebarPagesParams) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: pageKeys.sidebar(params),
|
||||||
|
queryFn: () => getAllSidebarPages(params),
|
||||||
|
staleTime: 30 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
export function usePageQuery(
|
export function usePageQuery(
|
||||||
pageInput: Partial<IPageInput>,
|
pageInput: Partial<IPageInput>,
|
||||||
): UseQueryResult<IPage, Error> {
|
): UseQueryResult<IPage, Error> {
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ["pages", pageInput.pageId],
|
queryKey: pageKeys.detail(pageInput.pageId),
|
||||||
queryFn: () => getPageById(pageInput),
|
queryFn: () => getPageById(pageInput),
|
||||||
enabled: !!pageInput.pageId,
|
enabled: !!pageInput.pageId,
|
||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
@@ -56,9 +83,9 @@ export function usePageQuery(
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (query.data) {
|
if (query.data) {
|
||||||
if (isValidUuid(pageInput.pageId)) {
|
if (isValidUuid(pageInput.pageId)) {
|
||||||
queryClient.setQueryData(["pages", query.data.slugId], query.data);
|
queryClient.setQueryData(pageKeys.detail(query.data.slugId), query.data);
|
||||||
} else {
|
} else {
|
||||||
queryClient.setQueryData(["pages", query.data.id], query.data);
|
queryClient.setQueryData(pageKeys.detail(query.data.id), query.data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [query.data]);
|
}, [query.data]);
|
||||||
@@ -69,6 +96,10 @@ export function usePageQuery(
|
|||||||
export function useCreatePageMutation() {
|
export function useCreatePageMutation() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return useMutation<IPage, Error, Partial<IPageInput>>({
|
return useMutation<IPage, Error, Partial<IPageInput>>({
|
||||||
|
// Stable key so a paused create restored from IndexedDB after an offline
|
||||||
|
// reload finds its default mutationFn (registerOfflineMutationDefaults) and
|
||||||
|
// is replayed by resumePausedMutations() on reconnect instead of being lost.
|
||||||
|
mutationKey: offlineMutationKeys.createPage,
|
||||||
mutationFn: (data) => createPage(data),
|
mutationFn: (data) => createPage(data),
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
invalidateOnCreatePage(data);
|
invalidateOnCreatePage(data);
|
||||||
@@ -80,18 +111,20 @@ export function useCreatePageMutation() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function updatePageData(data: IPage) {
|
export function updatePageData(data: IPage) {
|
||||||
const pageBySlug = queryClient.getQueryData<IPage>(["pages", data.slugId]);
|
const pageBySlug = queryClient.getQueryData<IPage>(
|
||||||
const pageById = queryClient.getQueryData<IPage>(["pages", data.id]);
|
pageKeys.detail(data.slugId),
|
||||||
|
);
|
||||||
|
const pageById = queryClient.getQueryData<IPage>(pageKeys.detail(data.id));
|
||||||
|
|
||||||
if (pageBySlug) {
|
if (pageBySlug) {
|
||||||
queryClient.setQueryData(["pages", data.slugId], {
|
queryClient.setQueryData(pageKeys.detail(data.slugId), {
|
||||||
...pageBySlug,
|
...pageBySlug,
|
||||||
...data,
|
...data,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pageById) {
|
if (pageById) {
|
||||||
queryClient.setQueryData(["pages", data.id], { ...pageById, ...data });
|
queryClient.setQueryData(pageKeys.detail(data.id), { ...pageById, ...data });
|
||||||
}
|
}
|
||||||
|
|
||||||
invalidateOnUpdatePage(
|
invalidateOnUpdatePage(
|
||||||
@@ -145,11 +178,11 @@ export function useRemovePageMutation() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Stamp deletedAt so a re-visit shows the trash banner, not stale state.
|
// Stamp deletedAt so a re-visit shows the trash banner, not stale state.
|
||||||
const cached = queryClient.getQueryData<IPage>(["pages", pageId]);
|
const cached = queryClient.getQueryData<IPage>(pageKeys.detail(pageId));
|
||||||
if (cached) {
|
if (cached) {
|
||||||
const stamped = { ...cached, deletedAt: new Date() };
|
const stamped = { ...cached, deletedAt: new Date() };
|
||||||
queryClient.setQueryData(["pages", cached.id], stamped);
|
queryClient.setQueryData(pageKeys.detail(cached.id), stamped);
|
||||||
queryClient.setQueryData(["pages", cached.slugId], stamped);
|
queryClient.setQueryData(pageKeys.detail(cached.slugId), stamped);
|
||||||
}
|
}
|
||||||
|
|
||||||
invalidateOnDeletePage(pageId);
|
invalidateOnDeletePage(pageId);
|
||||||
@@ -188,6 +221,9 @@ export function useDeletePageMutation() {
|
|||||||
|
|
||||||
export function useMovePageMutation() {
|
export function useMovePageMutation() {
|
||||||
return useMutation<void, Error, IMovePage>({
|
return useMutation<void, Error, IMovePage>({
|
||||||
|
// Stable key so a paused move restored from IndexedDB after an offline
|
||||||
|
// reload finds its default mutationFn and is replayed on reconnect.
|
||||||
|
mutationKey: offlineMutationKeys.movePage,
|
||||||
mutationFn: (data) => movePage(data),
|
mutationFn: (data) => movePage(data),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -267,8 +303,11 @@ export function useRestorePageMutation() {
|
|||||||
// Replace would strip space/permissions/content and break the editor.
|
// Replace would strip space/permissions/content and break the editor.
|
||||||
const merge = (cached: IPage | undefined) =>
|
const merge = (cached: IPage | undefined) =>
|
||||||
cached ? { ...cached, ...restoredPage } : cached;
|
cached ? { ...cached, ...restoredPage } : cached;
|
||||||
queryClient.setQueryData<IPage>(["pages", restoredPage.id], merge);
|
queryClient.setQueryData<IPage>(pageKeys.detail(restoredPage.id), merge);
|
||||||
queryClient.setQueryData<IPage>(["pages", restoredPage.slugId], merge);
|
queryClient.setQueryData<IPage>(
|
||||||
|
pageKeys.detail(restoredPage.slugId),
|
||||||
|
merge,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
notifications.show({
|
notifications.show({
|
||||||
@@ -283,7 +322,7 @@ export function useGetSidebarPagesQuery(
|
|||||||
data: SidebarPagesParams | null,
|
data: SidebarPagesParams | null,
|
||||||
): UseInfiniteQueryResult<InfiniteData<IPagination<IPage>, unknown>> {
|
): UseInfiniteQueryResult<InfiniteData<IPagination<IPage>, unknown>> {
|
||||||
return useInfiniteQuery({
|
return useInfiniteQuery({
|
||||||
queryKey: ["sidebar-pages", data],
|
queryKey: pageKeys.sidebar(data),
|
||||||
enabled: !!data?.pageId || !!data?.spaceId,
|
enabled: !!data?.pageId || !!data?.spaceId,
|
||||||
queryFn: ({ pageParam }) =>
|
queryFn: ({ pageParam }) =>
|
||||||
getSidebarPages({ ...data, cursor: pageParam, limit: 100 }),
|
getSidebarPages({ ...data, cursor: pageParam, limit: 100 }),
|
||||||
@@ -294,7 +333,7 @@ export function useGetSidebarPagesQuery(
|
|||||||
|
|
||||||
export function useGetRootSidebarPagesQuery(data: SidebarPagesParams) {
|
export function useGetRootSidebarPagesQuery(data: SidebarPagesParams) {
|
||||||
return useInfiniteQuery({
|
return useInfiniteQuery({
|
||||||
queryKey: ["root-sidebar-pages", data.spaceId],
|
queryKey: pageKeys.rootSidebar(data.spaceId),
|
||||||
queryFn: async ({ pageParam }) => {
|
queryFn: async ({ pageParam }) => {
|
||||||
return getSidebarPages({
|
return getSidebarPages({
|
||||||
spaceId: data.spaceId,
|
spaceId: data.spaceId,
|
||||||
@@ -320,7 +359,7 @@ export function usePageBreadcrumbsQuery(
|
|||||||
pageId: string,
|
pageId: string,
|
||||||
): UseQueryResult<Partial<IPage[]>, Error> {
|
): UseQueryResult<Partial<IPage[]>, Error> {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["breadcrumbs", pageId],
|
queryKey: pageKeys.breadcrumbs(pageId),
|
||||||
queryFn: () => getPageBreadcrumbs(pageId),
|
queryFn: () => getPageBreadcrumbs(pageId),
|
||||||
enabled: !!pageId,
|
enabled: !!pageId,
|
||||||
});
|
});
|
||||||
@@ -332,10 +371,12 @@ export async function fetchAllAncestorChildren(
|
|||||||
// refresh (#159 #8), which must NOT receive the 30-min-cached children.
|
// refresh (#159 #8), which must NOT receive the 30-min-cached children.
|
||||||
opts?: { fresh?: boolean },
|
opts?: { fresh?: boolean },
|
||||||
) {
|
) {
|
||||||
// not using a hook here, so we can call it inside a useEffect hook
|
// not using a hook here, so we can call it inside a useEffect hook. Reuse the
|
||||||
|
// shared sidebarPagesQueryOptions (key + queryFn) so the offline warm path and
|
||||||
|
// this fetch never drift, but override staleTime for the `fresh` reconnect
|
||||||
|
// refresh (#159 #8), which must force a server refetch (staleTime 0).
|
||||||
const response = await queryClient.fetchQuery({
|
const response = await queryClient.fetchQuery({
|
||||||
queryKey: ["sidebar-pages", params],
|
...sidebarPagesQueryOptions(params),
|
||||||
queryFn: () => getAllSidebarPages(params),
|
|
||||||
staleTime: opts?.fresh ? 0 : 30 * 60 * 1000,
|
staleTime: opts?.fresh ? 0 : 30 * 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -345,7 +386,7 @@ export async function fetchAllAncestorChildren(
|
|||||||
|
|
||||||
export function useRecentChangesQuery(spaceId?: string) {
|
export function useRecentChangesQuery(spaceId?: string) {
|
||||||
return useInfiniteQuery({
|
return useInfiniteQuery({
|
||||||
queryKey: ["recent-changes", spaceId],
|
queryKey: pageKeys.recentChanges(spaceId),
|
||||||
queryFn: ({ pageParam }) =>
|
queryFn: ({ pageParam }) =>
|
||||||
getRecentChanges({ spaceId, cursor: pageParam, limit: 15 }),
|
getRecentChanges({ spaceId, cursor: pageParam, limit: 15 }),
|
||||||
initialPageParam: undefined as string | undefined,
|
initialPageParam: undefined as string | undefined,
|
||||||
@@ -416,12 +457,12 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
|
|||||||
|
|
||||||
let queryKey: QueryKey = null;
|
let queryKey: QueryKey = null;
|
||||||
if (data.parentPageId === null) {
|
if (data.parentPageId === null) {
|
||||||
queryKey = ["root-sidebar-pages", data.spaceId];
|
queryKey = pageKeys.rootSidebar(data.spaceId);
|
||||||
} else {
|
} else {
|
||||||
queryKey = [
|
queryKey = pageKeys.sidebar({
|
||||||
"sidebar-pages",
|
pageId: data.parentPageId,
|
||||||
{ pageId: data.parentPageId, spaceId: data.spaceId },
|
spaceId: data.spaceId,
|
||||||
];
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
//update all sidebar pages
|
//update all sidebar pages
|
||||||
@@ -481,7 +522,7 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
|
|||||||
|
|
||||||
//update root sidebar pages haschildern
|
//update root sidebar pages haschildern
|
||||||
const rootSideBarMatches = queryClient.getQueriesData({
|
const rootSideBarMatches = queryClient.getQueriesData({
|
||||||
queryKey: ["root-sidebar-pages", data.spaceId],
|
queryKey: pageKeys.rootSidebar(data.spaceId),
|
||||||
exact: false,
|
exact: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -505,7 +546,7 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
|
|||||||
|
|
||||||
//update recent changes
|
//update recent changes
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["recent-changes", data.spaceId],
|
queryKey: pageKeys.recentChanges(data.spaceId),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -519,9 +560,9 @@ export function invalidateOnUpdatePage(
|
|||||||
invalidatePageTree();
|
invalidatePageTree();
|
||||||
let queryKey: QueryKey = null;
|
let queryKey: QueryKey = null;
|
||||||
if (parentPageId === null) {
|
if (parentPageId === null) {
|
||||||
queryKey = ["root-sidebar-pages", spaceId];
|
queryKey = pageKeys.rootSidebar(spaceId);
|
||||||
} else {
|
} else {
|
||||||
queryKey = ["sidebar-pages", { pageId: parentPageId, spaceId: spaceId }];
|
queryKey = pageKeys.sidebar({ pageId: parentPageId, spaceId: spaceId });
|
||||||
}
|
}
|
||||||
//update all sidebar pages
|
//update all sidebar pages
|
||||||
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(
|
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(
|
||||||
@@ -544,7 +585,7 @@ export function invalidateOnUpdatePage(
|
|||||||
|
|
||||||
//update recent changes
|
//update recent changes
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["recent-changes", spaceId],
|
queryKey: pageKeys.recentChanges(spaceId),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -559,8 +600,8 @@ export function updateCacheOnMovePage(
|
|||||||
// Remove page from old parent's cache
|
// Remove page from old parent's cache
|
||||||
const oldQueryKey =
|
const oldQueryKey =
|
||||||
oldParentId === null
|
oldParentId === null
|
||||||
? ["root-sidebar-pages", spaceId]
|
? pageKeys.rootSidebar(spaceId)
|
||||||
: ["sidebar-pages", { pageId: oldParentId, spaceId }];
|
: pageKeys.sidebar({ pageId: oldParentId, spaceId });
|
||||||
|
|
||||||
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(
|
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(
|
||||||
oldQueryKey,
|
oldQueryKey,
|
||||||
@@ -580,7 +621,7 @@ export function updateCacheOnMovePage(
|
|||||||
if (oldParentId !== null) {
|
if (oldParentId !== null) {
|
||||||
const oldParentCache = queryClient.getQueryData<
|
const oldParentCache = queryClient.getQueryData<
|
||||||
InfiniteData<IPagination<IPage>>
|
InfiniteData<IPagination<IPage>>
|
||||||
>(["sidebar-pages", { pageId: oldParentId, spaceId }]);
|
>(pageKeys.sidebar({ pageId: oldParentId, spaceId }));
|
||||||
|
|
||||||
const remainingChildren =
|
const remainingChildren =
|
||||||
oldParentCache?.pages.flatMap((p) => p.items).length ?? 0;
|
oldParentCache?.pages.flatMap((p) => p.items).length ?? 0;
|
||||||
@@ -618,8 +659,8 @@ export function updateCacheOnMovePage(
|
|||||||
// Add page to new parent's cache
|
// Add page to new parent's cache
|
||||||
const newQueryKey =
|
const newQueryKey =
|
||||||
newParentId === null
|
newParentId === null
|
||||||
? ["root-sidebar-pages", spaceId]
|
? pageKeys.rootSidebar(spaceId)
|
||||||
: ["sidebar-pages", { pageId: newParentId, spaceId }];
|
: pageKeys.sidebar({ pageId: newParentId, spaceId });
|
||||||
|
|
||||||
queryClient.setQueryData<InfiniteData<IPagination<Partial<IPage>>>>(
|
queryClient.setQueryData<InfiniteData<IPagination<Partial<IPage>>>>(
|
||||||
newQueryKey,
|
newQueryKey,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { notifications } from "@mantine/notifications";
|
|||||||
import {
|
import {
|
||||||
IconArrowRight,
|
IconArrowRight,
|
||||||
IconClockHour4,
|
IconClockHour4,
|
||||||
|
IconCloudDownload,
|
||||||
IconCopy,
|
IconCopy,
|
||||||
IconDotsVertical,
|
IconDotsVertical,
|
||||||
IconFileExport,
|
IconFileExport,
|
||||||
@@ -35,6 +36,12 @@ import {
|
|||||||
useToggleTemplateMutation,
|
useToggleTemplateMutation,
|
||||||
useToggleTemporaryMutation,
|
useToggleTemporaryMutation,
|
||||||
} from "@/features/page-embed/queries/page-embed-query";
|
} from "@/features/page-embed/queries/page-embed-query";
|
||||||
|
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
||||||
|
import { getCollaborationUrl } from "@/lib/config.ts";
|
||||||
|
import {
|
||||||
|
makePageAvailableOffline,
|
||||||
|
warmPageYdoc,
|
||||||
|
} from "@/features/offline/make-offline";
|
||||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||||
import { treeModel } from "@/features/page/tree/model/tree-model";
|
import { treeModel } from "@/features/page/tree/model/tree-model";
|
||||||
import { pageToTreeNode } from "@/features/page/tree/utils";
|
import { pageToTreeNode } from "@/features/page/tree/utils";
|
||||||
@@ -72,6 +79,46 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
|
|||||||
const isTemplate = !!node.isTemplate;
|
const isTemplate = !!node.isTemplate;
|
||||||
const toggleTemporary = useToggleTemporaryMutation();
|
const toggleTemporary = useToggleTemporaryMutation();
|
||||||
const isTemporary = !!node.temporaryExpiresAt;
|
const isTemporary = !!node.temporaryExpiresAt;
|
||||||
|
const { data: collabQuery } = useCollabToken();
|
||||||
|
|
||||||
|
const handleMakeAvailableOffline = async () => {
|
||||||
|
notifications.show({ message: t("Saving page for offline use...") });
|
||||||
|
try {
|
||||||
|
// Prefetch read queries so they get persisted to IndexedDB. The result
|
||||||
|
// reports whether every warm step succeeded.
|
||||||
|
const result = await makePageAvailableOffline({
|
||||||
|
pageId: node.id,
|
||||||
|
spaceId: node.spaceId,
|
||||||
|
});
|
||||||
|
// Best-effort: warm the page's Yjs document into IndexedDB.
|
||||||
|
await warmPageYdoc(node.id, getCollaborationUrl(), collabQuery?.token);
|
||||||
|
|
||||||
|
if (result.ok) {
|
||||||
|
notifications.show({ message: t("Page is now available offline") });
|
||||||
|
} else {
|
||||||
|
// Partial warm — the page may still be partly usable offline, but some
|
||||||
|
// queries failed to cache, so surface it as an error rather than a
|
||||||
|
// silent success. Name the failed step(s) (AGENTS.md: errors must be
|
||||||
|
// specific, never a bare generic string); `result.failed` carries them.
|
||||||
|
notifications.show({
|
||||||
|
message: `${t("Failed to make page available offline")}: ${result.failed.join(", ")}`,
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// makePageAvailableOffline no longer throws, but warmPageYdoc and other
|
||||||
|
// unexpected failures stay guarded here. Log the raw error and surface the
|
||||||
|
// real cause to the user instead of a bare generic string (AGENTS.md).
|
||||||
|
console.error("handleMakeAvailableOffline failed", err);
|
||||||
|
const reason =
|
||||||
|
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||||
|
?.message ?? (err instanceof Error ? err.message : String(err));
|
||||||
|
notifications.show({
|
||||||
|
message: `${t("Failed to make page available offline")}: ${reason}`,
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleToggleTemplate = async () => {
|
const handleToggleTemplate = async () => {
|
||||||
const next = !isTemplate;
|
const next = !isTemplate;
|
||||||
@@ -228,6 +275,17 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
|
|||||||
{t("Export")}
|
{t("Export")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<IconCloudDownload size={16} />}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
handleMakeAvailableOffline();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("Make available offline")}
|
||||||
|
</Menu.Item>
|
||||||
|
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<>
|
<>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
useCreatePageMutation,
|
useCreatePageMutation,
|
||||||
useRemovePageMutation,
|
useRemovePageMutation,
|
||||||
useMovePageMutation,
|
useMovePageMutation,
|
||||||
useUpdatePageMutation,
|
|
||||||
updateCacheOnMovePage,
|
updateCacheOnMovePage,
|
||||||
} from "@/features/page/queries/page-query.ts";
|
} from "@/features/page/queries/page-query.ts";
|
||||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||||
@@ -28,7 +27,6 @@ export type UseTreeMutation = {
|
|||||||
parentId: string | null,
|
parentId: string | null,
|
||||||
opts?: { temporary?: boolean },
|
opts?: { temporary?: boolean },
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
handleRename: (id: string, name: string) => Promise<void>;
|
|
||||||
handleDelete: (id: string) => Promise<void>;
|
handleDelete: (id: string) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -40,7 +38,6 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
|
|||||||
// children) and then immediately invokes a handler.
|
// children) and then immediately invokes a handler.
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
const createPageMutation = useCreatePageMutation();
|
const createPageMutation = useCreatePageMutation();
|
||||||
const updatePageMutation = useUpdatePageMutation();
|
|
||||||
const removePageMutation = useRemovePageMutation();
|
const removePageMutation = useRemovePageMutation();
|
||||||
const movePageMutation = useMovePageMutation();
|
const movePageMutation = useMovePageMutation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -222,20 +219,6 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRename = useCallback(
|
|
||||||
async (id: string, name: string) => {
|
|
||||||
setData((prev) =>
|
|
||||||
treeModel.update(prev, id, { name } as Partial<SpaceTreeNode>),
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
await updatePageMutation.mutateAsync({ pageId: id, title: name });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error updating page title:", error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[updatePageMutation, setData],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDelete = useCallback(
|
const handleDelete = useCallback(
|
||||||
async (id: string) => {
|
async (id: string) => {
|
||||||
const node = treeModel.find(
|
const node = treeModel.find(
|
||||||
@@ -281,7 +264,7 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
|
|||||||
[removePageMutation, setData, store, pageSlug, navigate, spaceSlug],
|
[removePageMutation, setData, store, pageSlug, navigate, spaceSlug],
|
||||||
);
|
);
|
||||||
|
|
||||||
return { handleMove, handleCreate, handleRename, handleDelete };
|
return { handleMove, handleCreate, handleDelete };
|
||||||
}
|
}
|
||||||
|
|
||||||
function isPageInNode(node: SpaceTreeNode, pageSlug: string): boolean {
|
function isPageInNode(node: SpaceTreeNode, pageSlug: string): boolean {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
keepPreviousData,
|
keepPreviousData,
|
||||||
|
queryOptions,
|
||||||
useInfiniteQuery,
|
useInfiniteQuery,
|
||||||
useMutation,
|
useMutation,
|
||||||
useQuery,
|
useQuery,
|
||||||
@@ -31,11 +32,37 @@ import { getRecentChanges } from "@/features/page/services/page-service.ts";
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { validate as isValidUuid } from "uuid";
|
import { validate as isValidUuid } from "uuid";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Centralized React Query key factories for space queries. The hooks below and
|
||||||
|
* the offline warm path (features/offline/make-offline.ts) share these so the
|
||||||
|
* runtime keys can never silently drift apart.
|
||||||
|
*/
|
||||||
|
export const spaceKeys = {
|
||||||
|
detail: (idOrSlug: string) => ["space", idOrSlug] as const,
|
||||||
|
list: (params?: QueryParams) => ["spaces", params] as const,
|
||||||
|
members: (spaceId: string, query?: string) =>
|
||||||
|
["spaceMembers", spaceId, query] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared queryOptions for fetching a space by id/slug. Both
|
||||||
|
* useGetSpaceBySlugQuery and the offline warm path consume this so the key,
|
||||||
|
* queryFn and staleTime stay identical. (`enabled` is intentionally omitted —
|
||||||
|
* prefetchQuery ignores it anyway and the warm path always passes a real id;
|
||||||
|
* the hook reapplies `enabled` itself.)
|
||||||
|
*/
|
||||||
|
export const spaceByIdQueryOptions = (spaceId: string) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: spaceKeys.detail(spaceId),
|
||||||
|
queryFn: () => getSpaceById(spaceId),
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
export function useGetSpacesQuery(
|
export function useGetSpacesQuery(
|
||||||
params?: QueryParams,
|
params?: QueryParams,
|
||||||
): UseQueryResult<IPagination<ISpace>, Error> {
|
): UseQueryResult<IPagination<ISpace>, Error> {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["spaces", params],
|
queryKey: spaceKeys.list(params),
|
||||||
queryFn: () => getSpaces(params),
|
queryFn: () => getSpaces(params),
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
refetchOnMount: true,
|
refetchOnMount: true,
|
||||||
@@ -44,16 +71,16 @@ export function useGetSpacesQuery(
|
|||||||
|
|
||||||
export function useSpaceQuery(spaceId: string): UseQueryResult<ISpace, Error> {
|
export function useSpaceQuery(spaceId: string): UseQueryResult<ISpace, Error> {
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ["space", spaceId],
|
queryKey: spaceKeys.detail(spaceId),
|
||||||
queryFn: () => getSpaceById(spaceId),
|
queryFn: () => getSpaceById(spaceId),
|
||||||
enabled: !!spaceId,
|
enabled: !!spaceId,
|
||||||
});
|
});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (query.data) {
|
if (query.data) {
|
||||||
if (isValidUuid(spaceId)) {
|
if (isValidUuid(spaceId)) {
|
||||||
queryClient.setQueryData(["space", query.data.slug], query.data);
|
queryClient.setQueryData(spaceKeys.detail(query.data.slug), query.data);
|
||||||
} else {
|
} else {
|
||||||
queryClient.setQueryData(["space", query.data.id], query.data);
|
queryClient.setQueryData(spaceKeys.detail(query.data.id), query.data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [query.data]);
|
}, [query.data]);
|
||||||
@@ -62,8 +89,11 @@ export function useSpaceQuery(spaceId: string): UseQueryResult<ISpace, Error> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const prefetchSpace = (spaceSlug: string, spaceId?: string) => {
|
export const prefetchSpace = (spaceSlug: string, spaceId?: string) => {
|
||||||
|
// Note: intentionally NOT using spaceByIdQueryOptions here — that factory sets
|
||||||
|
// a 5min staleTime which would let this prefetch skip fetching fresh data;
|
||||||
|
// prefetchSpace must always refetch (default staleTime: 0).
|
||||||
queryClient.prefetchQuery({
|
queryClient.prefetchQuery({
|
||||||
queryKey: ["space", spaceSlug],
|
queryKey: spaceKeys.detail(spaceSlug),
|
||||||
queryFn: () => getSpaceById(spaceSlug),
|
queryFn: () => getSpaceById(spaceSlug),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -100,10 +130,8 @@ export function useGetSpaceBySlugQuery(
|
|||||||
spaceId: string,
|
spaceId: string,
|
||||||
): UseQueryResult<ISpace, Error> {
|
): UseQueryResult<ISpace, Error> {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["space", spaceId],
|
...spaceByIdQueryOptions(spaceId),
|
||||||
queryFn: () => getSpaceById(spaceId),
|
|
||||||
enabled: !!spaceId,
|
enabled: !!spaceId,
|
||||||
staleTime: 5 * 60 * 1000,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,14 +144,16 @@ export function useUpdateSpaceMutation() {
|
|||||||
onSuccess: (data, variables) => {
|
onSuccess: (data, variables) => {
|
||||||
notifications.show({ message: t("Space updated successfully") });
|
notifications.show({ message: t("Space updated successfully") });
|
||||||
|
|
||||||
const space = queryClient.getQueryData([
|
const space = queryClient.getQueryData(
|
||||||
"space",
|
spaceKeys.detail(variables.spaceId),
|
||||||
variables.spaceId,
|
) as ISpace;
|
||||||
]) as ISpace;
|
|
||||||
if (space) {
|
if (space) {
|
||||||
const updatedSpace = { ...space, ...data };
|
const updatedSpace = { ...space, ...data };
|
||||||
queryClient.setQueryData(["space", variables.spaceId], updatedSpace);
|
queryClient.setQueryData(
|
||||||
queryClient.setQueryData(["space", data.slug], updatedSpace);
|
spaceKeys.detail(variables.spaceId),
|
||||||
|
updatedSpace,
|
||||||
|
);
|
||||||
|
queryClient.setQueryData(spaceKeys.detail(data.slug), updatedSpace);
|
||||||
}
|
}
|
||||||
|
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
@@ -148,7 +178,7 @@ export function useDeleteSpaceMutation() {
|
|||||||
|
|
||||||
if (variables.slug) {
|
if (variables.slug) {
|
||||||
queryClient.removeQueries({
|
queryClient.removeQueries({
|
||||||
queryKey: ["space", variables.slug],
|
queryKey: spaceKeys.detail(variables.slug),
|
||||||
exact: true,
|
exact: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -156,7 +186,7 @@ export function useDeleteSpaceMutation() {
|
|||||||
// Remove space-specific queries
|
// Remove space-specific queries
|
||||||
if (variables.id) {
|
if (variables.id) {
|
||||||
queryClient.removeQueries({
|
queryClient.removeQueries({
|
||||||
queryKey: ["space", variables.id],
|
queryKey: spaceKeys.detail(variables.id),
|
||||||
exact: true,
|
exact: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -196,7 +226,7 @@ export function useSpaceMembersInfiniteQuery(
|
|||||||
query?: string,
|
query?: string,
|
||||||
) {
|
) {
|
||||||
return useInfiniteQuery({
|
return useInfiniteQuery({
|
||||||
queryKey: ["spaceMembers", spaceId, query],
|
queryKey: spaceKeys.members(spaceId, query),
|
||||||
queryFn: ({ pageParam }) =>
|
queryFn: ({ pageParam }) =>
|
||||||
getSpaceMembers(spaceId, { cursor: pageParam, limit: 50, query }),
|
getSpaceMembers(spaceId, { cursor: pageParam, limit: 50, query }),
|
||||||
enabled: !!spaceId,
|
enabled: !!spaceId,
|
||||||
|
|||||||
@@ -2,9 +2,19 @@ import { useQuery, UseQueryResult } from "@tanstack/react-query";
|
|||||||
import { getMyInfo } from "@/features/user/services/user-service";
|
import { getMyInfo } from "@/features/user/services/user-service";
|
||||||
import { ICurrentUser } from "@/features/user/types/user.types";
|
import { ICurrentUser } from "@/features/user/types/user.types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Centralized React Query key factory for current-user queries. This hook and
|
||||||
|
* the offline warm path (features/offline/make-offline.ts) share it so the
|
||||||
|
* runtime key can never silently drift, and the OFFLINE_PERSIST_ROOTS guard
|
||||||
|
* test can assert the persisted "currentUser" root maps to a real factory.
|
||||||
|
*/
|
||||||
|
export const userKeys = {
|
||||||
|
currentUser: () => ["currentUser"] as const,
|
||||||
|
};
|
||||||
|
|
||||||
export default function useCurrentUser(): UseQueryResult<ICurrentUser> {
|
export default function useCurrentUser(): UseQueryResult<ICurrentUser> {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["currentUser"],
|
queryKey: userKeys.currentUser(),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
return await getMyInfo();
|
return await getMyInfo();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { MantineProvider } from "@mantine/core";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
import { HelmetProvider } from "react-helmet-async";
|
||||||
|
|
||||||
|
// Control useCurrentUser per test; stub the rest of UserProvider's network/
|
||||||
|
// socket dependencies so we only exercise its render-gating logic.
|
||||||
|
const h = vi.hoisted(() => ({ useCurrentUser: vi.fn() }));
|
||||||
|
|
||||||
|
vi.mock("@/features/user/hooks/use-current-user", () => ({
|
||||||
|
default: h.useCurrentUser,
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/auth/queries/auth-query.tsx", () => ({
|
||||||
|
useCollabToken: () => ({ data: undefined }),
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/websocket/use-query-subscription.ts", () => ({
|
||||||
|
useQuerySubscription: () => {},
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/websocket/use-tree-socket.ts", () => ({
|
||||||
|
useTreeSocket: () => {},
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/notification/hooks/use-notification-socket.ts", () => ({
|
||||||
|
useNotificationSocket: () => {},
|
||||||
|
}));
|
||||||
|
vi.mock("@/main.tsx", () => ({ queryClient: {} }));
|
||||||
|
vi.mock("@/features/user/connect-resync.ts", () => ({
|
||||||
|
makeConnectHandler: () => () => {},
|
||||||
|
}));
|
||||||
|
vi.mock("socket.io-client", () => ({
|
||||||
|
io: () => ({ on: vi.fn(), disconnect: vi.fn() }),
|
||||||
|
}));
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (k: string) => k,
|
||||||
|
i18n: {
|
||||||
|
changeLanguage: vi.fn(),
|
||||||
|
language: "en-US",
|
||||||
|
resolvedLanguage: "en-US",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { UserProvider } from "./user-provider";
|
||||||
|
|
||||||
|
const networkError = { message: "Network Error" }; // axios network error: no `response`
|
||||||
|
|
||||||
|
function renderProvider() {
|
||||||
|
return render(
|
||||||
|
<HelmetProvider>
|
||||||
|
<MemoryRouter>
|
||||||
|
<MantineProvider>
|
||||||
|
<UserProvider>
|
||||||
|
<div data-testid="app-child">app content</div>
|
||||||
|
</UserProvider>
|
||||||
|
</MantineProvider>
|
||||||
|
</MemoryRouter>
|
||||||
|
</HelmetProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
h.useCurrentUser.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("UserProvider offline render-gating", () => {
|
||||||
|
it("renders the app (cached children) when useCurrentUser errors offline but a cached user exists", () => {
|
||||||
|
// Offline reload: the persisted ['currentUser'] cache hydrates `data`, but
|
||||||
|
// the background POST /api/users/me refetch fails as a network error.
|
||||||
|
h.useCurrentUser.mockReturnValue({
|
||||||
|
data: {
|
||||||
|
user: { id: "u1", locale: "en" },
|
||||||
|
workspace: { id: "w1" },
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
error: networkError,
|
||||||
|
isError: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderProvider();
|
||||||
|
|
||||||
|
// The cached app must render — NOT a blank fragment (#237/#238).
|
||||||
|
expect(screen.getByTestId("app-child")).toBeDefined();
|
||||||
|
expect(screen.queryByText("You're offline")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the offline fallback (not a blank fragment) when erroring with no cached user", () => {
|
||||||
|
h.useCurrentUser.mockReturnValue({
|
||||||
|
data: undefined,
|
||||||
|
isLoading: false,
|
||||||
|
error: networkError,
|
||||||
|
isError: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { container } = renderProvider();
|
||||||
|
|
||||||
|
// Previously this returned `<></>` — a blank white screen. Now it must show
|
||||||
|
// an explicit offline fallback.
|
||||||
|
expect(screen.getByText("You're offline")).toBeDefined();
|
||||||
|
expect(screen.queryByTestId("app-child")).toBeNull();
|
||||||
|
expect(container.textContent?.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the app normally on a successful currentUser load", () => {
|
||||||
|
h.useCurrentUser.mockReturnValue({
|
||||||
|
data: {
|
||||||
|
user: { id: "u1", locale: "en" },
|
||||||
|
workspace: { id: "w1" },
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
isError: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderProvider();
|
||||||
|
expect(screen.getByTestId("app-child")).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,6 +11,7 @@ import { useTreeSocket } from "@/features/websocket/use-tree-socket.ts";
|
|||||||
import { useNotificationSocket } from "@/features/notification/hooks/use-notification-socket.ts";
|
import { useNotificationSocket } from "@/features/notification/hooks/use-notification-socket.ts";
|
||||||
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
||||||
import { Error404 } from "@/components/ui/error-404.tsx";
|
import { Error404 } from "@/components/ui/error-404.tsx";
|
||||||
|
import { OfflineFallback } from "@/features/offline/offline-fallback.tsx";
|
||||||
import { queryClient } from "@/main.tsx";
|
import { queryClient } from "@/main.tsx";
|
||||||
import { makeConnectHandler } from "@/features/user/connect-resync.ts";
|
import { makeConnectHandler } from "@/features/user/connect-resync.ts";
|
||||||
|
|
||||||
@@ -70,14 +71,30 @@ export function UserProvider({ children }: React.PropsWithChildren) {
|
|||||||
document.documentElement.lang = i18n.resolvedLanguage || i18n.language || "en-US";
|
document.documentElement.lang = i18n.resolvedLanguage || i18n.language || "en-US";
|
||||||
}, [i18n.language, i18n.resolvedLanguage]);
|
}, [i18n.language, i18n.resolvedLanguage]);
|
||||||
|
|
||||||
if (isLoading) return <></>;
|
// First load with no cached user yet: render nothing briefly while the
|
||||||
|
// persisted ['currentUser'] cache hydrates (avoids flashing the offline
|
||||||
|
// fallback before restore). Once we have a user we render the app even if a
|
||||||
|
// refetch is still in flight.
|
||||||
|
if (isLoading && !data) return <></>;
|
||||||
|
|
||||||
if (isError && error?.["response"]?.status === 404) {
|
if (isError && error?.["response"]?.status === 404) {
|
||||||
return <Error404 />;
|
return <Error404 />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// We have a (possibly cached/stale) user — render the app. Offline, the
|
||||||
|
// POST /api/users/me refetch fails as a network error, but the persisted/
|
||||||
|
// hydrated user is enough to render the cached UI. Previously `if (error)
|
||||||
|
// return <></>` blanked every authenticated route on an offline reload even
|
||||||
|
// though the cached data was present (#237/#238).
|
||||||
|
if (data) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No user AND an error (offline cold boot of a page never warmed for offline,
|
||||||
|
// or no persisted cache to restore): show an explicit offline fallback rather
|
||||||
|
// than a blank white screen.
|
||||||
if (error) {
|
if (error) {
|
||||||
return <></>;
|
return <OfflineFallback />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
|
|||||||
+976
-275
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,8 @@ import { MantineProvider } from "@mantine/core";
|
|||||||
import { BrowserRouter } from "react-router-dom";
|
import { BrowserRouter } from "react-router-dom";
|
||||||
import { ModalsProvider } from "@mantine/modals";
|
import { ModalsProvider } from "@mantine/modals";
|
||||||
import { Notifications } from "@mantine/notifications";
|
import { Notifications } from "@mantine/notifications";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, onlineManager } from "@tanstack/react-query";
|
||||||
|
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||||
import { HelmetProvider } from "react-helmet-async";
|
import { HelmetProvider } from "react-helmet-async";
|
||||||
import "./i18n";
|
import "./i18n";
|
||||||
import { PostHogProvider } from "posthog-js/react";
|
import { PostHogProvider } from "posthog-js/react";
|
||||||
@@ -21,6 +22,13 @@ import {
|
|||||||
isCloud,
|
isCloud,
|
||||||
isPostHogEnabled,
|
isPostHogEnabled,
|
||||||
} from "@/lib/config.ts";
|
} from "@/lib/config.ts";
|
||||||
|
import {
|
||||||
|
queryPersister,
|
||||||
|
shouldDehydrateOfflineQuery,
|
||||||
|
} from "@/features/offline/query-persister";
|
||||||
|
import { registerOfflineMutationDefaults } from "@/features/offline/offline-mutations";
|
||||||
|
import { PwaUpdatePrompt } from "@/pwa/pwa-update-prompt";
|
||||||
|
import { isCapacitorNativePlatform } from "@/pwa/is-capacitor";
|
||||||
import posthog from "posthog-js";
|
import posthog from "posthog-js";
|
||||||
import { initVitals } from "@/lib/telemetry/vitals";
|
import { initVitals } from "@/lib/telemetry/vitals";
|
||||||
|
|
||||||
@@ -31,10 +39,30 @@ export const queryClient = new QueryClient({
|
|||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
retry: false,
|
retry: false,
|
||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
|
// Keep cached read data around long enough to be persisted/restored for offline use.
|
||||||
|
gcTime: 1000 * 60 * 60 * 24,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Register default mutationFns for the offline-relevant structural mutations so
|
||||||
|
// a paused mutation restored from IndexedDB after an offline reload still has a
|
||||||
|
// mutationFn and is replayed by resumePausedMutations() on reconnect (instead
|
||||||
|
// of silently no-op'ing and dropping the offline create/move/comment). MUST run
|
||||||
|
// before any resumePausedMutations() so rehydrated paused mutations have a fn.
|
||||||
|
registerOfflineMutationDefaults(queryClient);
|
||||||
|
|
||||||
|
// Seed TanStack Query's onlineManager from the REAL connectivity state at boot.
|
||||||
|
// It defaults to `online: true` and only flips on window online/offline events,
|
||||||
|
// so a tab that COLD-BOOTS offline would wrongly believe it is online: paused
|
||||||
|
// mutations restored from IndexedDB would never get a later offline->online
|
||||||
|
// transition to trigger their replay, and the offline UI affordances could not
|
||||||
|
// tell they are offline. Seeding here makes the first real `online` event a true
|
||||||
|
// transition that auto-resumes the rehydrated paused mutations (#120 data loss).
|
||||||
|
if (typeof navigator !== "undefined" && "onLine" in navigator) {
|
||||||
|
onlineManager.setOnline(navigator.onLine);
|
||||||
|
}
|
||||||
|
|
||||||
if (isCloud() && isPostHogEnabled) {
|
if (isCloud() && isPostHogEnabled) {
|
||||||
posthog.init(getPostHogKey(), {
|
posthog.init(getPostHogKey(), {
|
||||||
api_host: getPostHogHost(),
|
api_host: getPostHogHost(),
|
||||||
@@ -55,15 +83,44 @@ root.render(
|
|||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<MantineProvider theme={theme} cssVariablesResolver={mantineCssResolver}>
|
<MantineProvider theme={theme} cssVariablesResolver={mantineCssResolver}>
|
||||||
<ModalsProvider>
|
<ModalsProvider>
|
||||||
<QueryClientProvider client={queryClient}>
|
<PersistQueryClientProvider
|
||||||
|
client={queryClient}
|
||||||
|
persistOptions={{
|
||||||
|
persister: queryPersister,
|
||||||
|
maxAge: 1000 * 60 * 60 * 24,
|
||||||
|
buster: APP_VERSION,
|
||||||
|
dehydrateOptions: {
|
||||||
|
shouldDehydrateQuery: shouldDehydrateOfflineQuery,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
// After the persister finishes rehydrating, replay any paused
|
||||||
|
// mutations restored from IndexedDB. If we are back online this fires
|
||||||
|
// them immediately; if still offline they stay paused and TanStack's
|
||||||
|
// onlineManager auto-resumes them on the next online transition (which
|
||||||
|
// is now a true transition thanks to the onlineManager seeding above).
|
||||||
|
// Without this, a paused mutation persisted while offline and then
|
||||||
|
// reloaded would never resume and the user's work would be lost (#120).
|
||||||
|
onSuccess={() => {
|
||||||
|
queryClient.resumePausedMutations();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Notifications position="bottom-center" limit={3} zIndex={10000} />
|
<Notifications position="bottom-center" limit={3} zIndex={10000} />
|
||||||
|
{/* Skip SW registration inside the Capacitor native WebView — the
|
||||||
|
native shell serves assets itself; a browser SW would conflict. */}
|
||||||
|
{!isCapacitorNativePlatform() && <PwaUpdatePrompt />}
|
||||||
<HelmetProvider>
|
<HelmetProvider>
|
||||||
<PostHogProvider client={posthog}>
|
<PostHogProvider client={posthog}>
|
||||||
<App />
|
<App />
|
||||||
</PostHogProvider>
|
</PostHogProvider>
|
||||||
</HelmetProvider>
|
</HelmetProvider>
|
||||||
</QueryClientProvider>
|
</PersistQueryClientProvider>
|
||||||
</ModalsProvider>
|
</ModalsProvider>
|
||||||
</MantineProvider>
|
</MantineProvider>
|
||||||
</BrowserRouter>,
|
</BrowserRouter>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Service worker registration is owned by <PwaUpdatePrompt /> above (via
|
||||||
|
// vite-plugin-pwa's useRegisterSW: Workbox precache + prompt-based updates,
|
||||||
|
// and skipped inside the Capacitor native WebView). The earlier hand-written
|
||||||
|
// /sw.js registration from the mobile bootstrap was removed here to avoid a
|
||||||
|
// double registration / competing service worker.
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts"
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { EmptyState } from "@/components/ui/empty-state.tsx";
|
import { EmptyState } from "@/components/ui/empty-state.tsx";
|
||||||
|
import { OfflineFallback } from "@/features/offline/offline-fallback.tsx";
|
||||||
import { IconAlertTriangle, IconFileOff } from "@tabler/icons-react";
|
import { IconAlertTriangle, IconFileOff } from "@tabler/icons-react";
|
||||||
import { Button } from "@mantine/core";
|
import { Button } from "@mantine/core";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
@@ -62,7 +63,19 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isError || !page) {
|
if (isError || !page) {
|
||||||
if ([401, 403, 404].includes(error?.["status"])) {
|
// An offline fetch of a page that was never saved for offline use yields a
|
||||||
|
// network error with NO HTTP status (status is undefined), which would
|
||||||
|
// otherwise fall through to the generic "Error fetching page data." state.
|
||||||
|
// When we are offline (or the failure is a network error with no status),
|
||||||
|
// show the dedicated "You're offline — this page isn't saved for offline"
|
||||||
|
// fallback instead, so the user understands why the page won't load.
|
||||||
|
const httpStatus = error?.["status"];
|
||||||
|
const isOffline =
|
||||||
|
typeof navigator !== "undefined" && navigator.onLine === false;
|
||||||
|
if (isOffline || (isError && httpStatus == null)) {
|
||||||
|
return <OfflineFallback />;
|
||||||
|
}
|
||||||
|
if ([401, 403, 404].includes(httpStatus)) {
|
||||||
return (
|
return (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={IconFileOff}
|
icon={IconFileOff}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { describe, it, expect, afterEach } from "vitest";
|
||||||
|
import { isCapacitorNativePlatform } from "./is-capacitor";
|
||||||
|
|
||||||
|
describe("isCapacitorNativePlatform", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
// Keep tests isolated from each other and from the rest of the suite.
|
||||||
|
delete (globalThis as any).Capacitor;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when Capacitor is undefined", () => {
|
||||||
|
expect(isCapacitorNativePlatform()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses isNativePlatform() when it is a function", () => {
|
||||||
|
(globalThis as any).Capacitor = { isNativePlatform: () => true };
|
||||||
|
expect(isCapacitorNativePlatform()).toBe(true);
|
||||||
|
|
||||||
|
(globalThis as any).Capacitor = { isNativePlatform: () => false };
|
||||||
|
expect(isCapacitorNativePlatform()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the boolean property when isNativePlatform is not a function", () => {
|
||||||
|
(globalThis as any).Capacitor = { isNativePlatform: true };
|
||||||
|
expect(isCapacitorNativePlatform()).toBe(true);
|
||||||
|
|
||||||
|
(globalThis as any).Capacitor = { isNativePlatform: false };
|
||||||
|
expect(isCapacitorNativePlatform()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when reading Capacitor throws (try/catch)", () => {
|
||||||
|
Object.defineProperty(globalThis, "Capacitor", {
|
||||||
|
configurable: true,
|
||||||
|
get() {
|
||||||
|
throw new Error("boom");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(isCapacitorNativePlatform()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* Detects whether the client is running inside a Capacitor native WebView
|
||||||
|
* (native iOS/Android shell from the feature/mobile-app-bootstrap branch).
|
||||||
|
*
|
||||||
|
* This is a pure runtime check against the global `Capacitor` object that the
|
||||||
|
* native bridge injects — no `@capacitor/*` dependency is added. On the plain
|
||||||
|
* browser / installed-PWA path `window.Capacitor` is undefined, so this returns
|
||||||
|
* false and the Workbox service worker registers normally.
|
||||||
|
*
|
||||||
|
* Inside the native WebView the SW must NOT register: it would layer a redundant
|
||||||
|
* (and conflicting) cache over Capacitor's own asset serving and interfere with
|
||||||
|
* the native auth/CORS flow.
|
||||||
|
*/
|
||||||
|
export function isCapacitorNativePlatform(): boolean {
|
||||||
|
try {
|
||||||
|
const cap = (globalThis as any)?.Capacitor;
|
||||||
|
return !!(cap && typeof cap.isNativePlatform === "function"
|
||||||
|
? cap.isNativePlatform()
|
||||||
|
: cap?.isNativePlatform);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { Button } from "@mantine/core";
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useRegisterSW } from "virtual:pwa-register/react";
|
||||||
|
|
||||||
|
// Stable notification id so we can show/hide a single update prompt.
|
||||||
|
const UPDATE_NOTIFICATION_ID = "pwa-update-available";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listens for a waiting service worker and surfaces a Mantine notification
|
||||||
|
* prompting the user to reload into the new version.
|
||||||
|
*
|
||||||
|
* Must be mounted inside the Mantine provider subtree (Notifications must be
|
||||||
|
* available). Renders nothing itself.
|
||||||
|
*/
|
||||||
|
export function PwaUpdatePrompt() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const {
|
||||||
|
needRefresh: [needRefresh],
|
||||||
|
updateServiceWorker,
|
||||||
|
} = useRegisterSW({
|
||||||
|
onRegisterError(error) {
|
||||||
|
// Best-effort: a failed registration must not break the app.
|
||||||
|
console.error("Service worker registration error:", error);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!needRefresh) return;
|
||||||
|
|
||||||
|
notifications.show({
|
||||||
|
id: UPDATE_NOTIFICATION_ID,
|
||||||
|
title: t("Update available"),
|
||||||
|
message: (
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
mt="xs"
|
||||||
|
onClick={() => updateServiceWorker(true)}
|
||||||
|
>
|
||||||
|
{t("Reload")}
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
autoClose: false,
|
||||||
|
withCloseButton: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hide the notification when the prompt is no longer needed / on cleanup.
|
||||||
|
return () => {
|
||||||
|
notifications.hide(UPDATE_NOTIFICATION_ID);
|
||||||
|
};
|
||||||
|
}, [needRefresh, t, updateServiceWorker]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PwaUpdatePrompt;
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { isApiPath, isCollabOrSocketPath } from "./sw-strategy";
|
||||||
|
|
||||||
|
describe("isApiPath", () => {
|
||||||
|
it("matches the /api segment and its subtree", () => {
|
||||||
|
expect(isApiPath("/api")).toBe(true);
|
||||||
|
expect(isApiPath("/api/")).toBe(true);
|
||||||
|
expect(isApiPath("/api/pages")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not over-match sibling paths", () => {
|
||||||
|
expect(isApiPath("/apidocs")).toBe(false);
|
||||||
|
expect(isApiPath("/apixyz")).toBe(false);
|
||||||
|
expect(isApiPath("/")).toBe(false);
|
||||||
|
expect(isApiPath("/pages")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isCollabOrSocketPath", () => {
|
||||||
|
it("matches the /collab and /socket.io segments and their subtrees", () => {
|
||||||
|
expect(isCollabOrSocketPath("/collab")).toBe(true);
|
||||||
|
expect(isCollabOrSocketPath("/collab/x")).toBe(true);
|
||||||
|
expect(isCollabOrSocketPath("/socket.io")).toBe(true);
|
||||||
|
expect(isCollabOrSocketPath("/socket.io/abc")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not over-match sibling paths", () => {
|
||||||
|
expect(isCollabOrSocketPath("/collaborators")).toBe(false);
|
||||||
|
expect(isCollabOrSocketPath("/collabx")).toBe(false);
|
||||||
|
expect(isCollabOrSocketPath("/socket.iox")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* Canonical service-worker routing predicates.
|
||||||
|
*
|
||||||
|
* IMPORTANT: With vite-plugin-pwa using Workbox `generateSW`, the
|
||||||
|
* `runtimeCaching[].urlPattern` functions are serialized standalone into the
|
||||||
|
* generated service worker and CANNOT reference imported symbols. The matching
|
||||||
|
* logic is therefore duplicated as inline regex literals in
|
||||||
|
* apps/client/vite.config.ts. This module is the testable source of truth, and
|
||||||
|
* the two MUST be kept in sync. This duplication is intentional and is the
|
||||||
|
* documented Workbox limitation.
|
||||||
|
*
|
||||||
|
* Matching is anchored to a path SEGMENT boundary (`^/<seg>(/|$)`) so that
|
||||||
|
* sibling paths like `/apidocs`, `/collaborators`, `/socket.iox` are NOT
|
||||||
|
* wrongly treated as API/realtime traffic.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when `pathname` is the `/api` segment or anything beneath it.
|
||||||
|
* `/api` and `/api/...` -> true; `/apidocs`, `/apixyz` -> false.
|
||||||
|
*/
|
||||||
|
export function isApiPath(pathname: string): boolean {
|
||||||
|
return /^\/api(\/|$)/.test(pathname);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when `pathname` is the `/collab` or `/socket.io` segment (or beneath it).
|
||||||
|
* `/collab`, `/collab/x`, `/socket.io`, `/socket.io/abc` -> true;
|
||||||
|
* `/collaborators`, `/collabx`, `/socket.iox` -> false.
|
||||||
|
*/
|
||||||
|
export function isCollabOrSocketPath(pathname: string): boolean {
|
||||||
|
return /^\/(collab|socket\.io)(\/|$)/.test(pathname);
|
||||||
|
}
|
||||||
Vendored
+2
@@ -1,2 +1,4 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
/// <reference types="vite-plugin-pwa/react" />
|
||||||
|
/// <reference types="vite-plugin-pwa/info" />
|
||||||
declare const APP_VERSION: string
|
declare const APP_VERSION: string
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { defineConfig, loadEnv } from "vite";
|
import { defineConfig, loadEnv } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { VitePWA } from "vite-plugin-pwa";
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
|
|
||||||
@@ -53,7 +54,55 @@ export default defineConfig(({ mode }) => {
|
|||||||
},
|
},
|
||||||
APP_VERSION: JSON.stringify(resolveAppVersion(envPath)),
|
APP_VERSION: JSON.stringify(resolveAppVersion(envPath)),
|
||||||
},
|
},
|
||||||
plugins: [react()],
|
plugins: [
|
||||||
|
react(),
|
||||||
|
VitePWA({
|
||||||
|
registerType: "prompt",
|
||||||
|
injectRegister: null,
|
||||||
|
strategies: "generateSW",
|
||||||
|
manifest: false,
|
||||||
|
workbox: {
|
||||||
|
globPatterns: ["**/*.{js,css,html,svg,png,ico,woff2,json}"],
|
||||||
|
navigateFallback: "index.html",
|
||||||
|
// Segment-anchored (`^/<seg>(/|$)`) so navigation requests to these
|
||||||
|
// segments are consistently excluded from the SPA fallback, mirroring
|
||||||
|
// the runtimeCaching urlPattern regexes below.
|
||||||
|
//
|
||||||
|
// `/share`, `/mcp`, `/l`, and `/robots.txt` mirror the server
|
||||||
|
// static-serve exclude list (apps/server/src/main.ts setGlobalPrefix
|
||||||
|
// `exclude`): robots.txt, the SEO/OG/analytics-injected public share
|
||||||
|
// HTML, the embedded MCP endpoint, and the `l/:alias` vanity short-link
|
||||||
|
// (a server 302 to a share page) are served by server controllers, so
|
||||||
|
// the SW must never shadow them with the precached index.html app shell.
|
||||||
|
// For `/l/:alias` the client router has NO matching route, so serving
|
||||||
|
// the app shell would dead-end on Error404 and break the public link;
|
||||||
|
// it must reach the server to perform the redirect.
|
||||||
|
navigateFallbackDenylist: [
|
||||||
|
/^\/api(\/|$)/,
|
||||||
|
/^\/collab(\/|$)/,
|
||||||
|
/^\/socket\.io(\/|$)/,
|
||||||
|
/^\/share(\/|$)/,
|
||||||
|
/^\/mcp(\/|$)/,
|
||||||
|
/^\/l(\/|$)/,
|
||||||
|
/^\/robots\.txt$/,
|
||||||
|
],
|
||||||
|
cleanupOutdatedCaches: true,
|
||||||
|
clientsClaim: true,
|
||||||
|
// The urlPattern regexes below mirror apps/client/src/pwa/sw-strategy.ts
|
||||||
|
// and MUST be kept in sync with it. Workbox `generateSW` serializes these
|
||||||
|
// functions standalone into the generated service worker, so they cannot
|
||||||
|
// import the module — the matching logic is intentionally duplicated as
|
||||||
|
// self-contained inline regex literals anchored to a path segment boundary.
|
||||||
|
runtimeCaching: [
|
||||||
|
{ urlPattern: ({ url }) => /^\/(collab|socket\.io)(\/|$)/.test(url.pathname), handler: "NetworkOnly" },
|
||||||
|
// All /api stays network-only; offline reads come from the persisted
|
||||||
|
// React Query cache (IndexedDB) + y-indexeddb, not the SW HTTP cache.
|
||||||
|
{ urlPattern: ({ url }) => /^\/api(\/|$)/.test(url.pathname), handler: "NetworkOnly" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
devOptions: { enabled: false },
|
||||||
|
}),
|
||||||
|
],
|
||||||
build: {
|
build: {
|
||||||
rolldownOptions: {
|
rolldownOptions: {
|
||||||
output: {
|
output: {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
"migration:reset": "tsx src/database/migrate.ts down-to NO_MIGRATIONS",
|
"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",
|
"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",
|
"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/prosemirror-markdown build",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:int": "jest --config test/jest-integration.json",
|
"test:int": "jest --config test/jest-integration.json",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
@@ -43,6 +43,7 @@
|
|||||||
"@clickhouse/client": "^1.18.2",
|
"@clickhouse/client": "^1.18.2",
|
||||||
"@docmost/mcp": "workspace:*",
|
"@docmost/mcp": "workspace:*",
|
||||||
"@docmost/pdf-inspector": "1.9.6",
|
"@docmost/pdf-inspector": "1.9.6",
|
||||||
|
"@docmost/prosemirror-markdown": "workspace:*",
|
||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
"@fastify/multipart": "^10.0.0",
|
"@fastify/multipart": "^10.0.0",
|
||||||
"@fastify/static": "^9.1.3",
|
"@fastify/static": "^9.1.3",
|
||||||
@@ -64,6 +65,7 @@
|
|||||||
"@nestjs/platform-fastify": "^11.1.19",
|
"@nestjs/platform-fastify": "^11.1.19",
|
||||||
"@nestjs/platform-socket.io": "^11.1.19",
|
"@nestjs/platform-socket.io": "^11.1.19",
|
||||||
"@nestjs/schedule": "^6.1.3",
|
"@nestjs/schedule": "^6.1.3",
|
||||||
|
"@nestjs/swagger": "^11.2.0",
|
||||||
"@nestjs/terminus": "^11.1.1",
|
"@nestjs/terminus": "^11.1.1",
|
||||||
"@nestjs/throttler": "^6.5.0",
|
"@nestjs/throttler": "^6.5.0",
|
||||||
"@nestjs/websockets": "^11.1.19",
|
"@nestjs/websockets": "^11.1.19",
|
||||||
@@ -175,7 +177,7 @@
|
|||||||
"/node_modules/"
|
"/node_modules/"
|
||||||
],
|
],
|
||||||
"transform": {
|
"transform": {
|
||||||
"happy-dom.+\\.js$": [
|
"(happy-dom.+|prosemirror-markdown/build/.+)\\.js$": [
|
||||||
"babel-jest",
|
"babel-jest",
|
||||||
{
|
{
|
||||||
"presets": [
|
"presets": [
|
||||||
@@ -193,7 +195,7 @@
|
|||||||
"^.+\\.(t|j)sx?$": "ts-jest"
|
"^.+\\.(t|j)sx?$": "ts-jest"
|
||||||
},
|
},
|
||||||
"transformIgnorePatterns": [
|
"transformIgnorePatterns": [
|
||||||
"/node_modules/(?!(\\.pnpm/)?(nanoid|uuid|image-dimensions|marked|happy-dom|lib0)(@|/))"
|
"/node_modules/(?!(\\.pnpm/)?(nanoid|uuid|image-dimensions|marked|happy-dom|lib0|@docmost/prosemirror-markdown)(@|/))"
|
||||||
],
|
],
|
||||||
"collectCoverageFrom": [
|
"collectCoverageFrom": [
|
||||||
"**/*.(t|j)s"
|
"**/*.(t|j)s"
|
||||||
@@ -204,7 +206,8 @@
|
|||||||
"^@docmost/db/(.*)$": "<rootDir>/database/$1",
|
"^@docmost/db/(.*)$": "<rootDir>/database/$1",
|
||||||
"^@docmost/transactional/(.*)$": "<rootDir>/integrations/transactional/$1",
|
"^@docmost/transactional/(.*)$": "<rootDir>/integrations/transactional/$1",
|
||||||
"^@docmost/ee/(.*)$": "<rootDir>/ee/$1",
|
"^@docmost/ee/(.*)$": "<rootDir>/ee/$1",
|
||||||
"^src/(.*)$": "<rootDir>/$1"
|
"^src/(.*)$": "<rootDir>/$1",
|
||||||
|
"^@tiptap/react$": "<rootDir>/../test/stubs/tiptap-react.js"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,10 @@ import { CollabWsAdapter } from './adapter/collab-ws.adapter';
|
|||||||
import {
|
import {
|
||||||
CollaborationHandler,
|
CollaborationHandler,
|
||||||
CollabEventHandlers,
|
CollabEventHandlers,
|
||||||
|
writeTitleFragment,
|
||||||
} from './collaboration.handler';
|
} from './collaboration.handler';
|
||||||
|
import { User } from '@docmost/db/types/entity.types';
|
||||||
|
import * as Y from 'yjs';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CollaborationGateway {
|
export class CollaborationGateway {
|
||||||
@@ -188,6 +191,70 @@ export class CollaborationGateway {
|
|||||||
return this.hocuspocus.openDirectConnection(documentName, context);
|
return this.hocuspocus.openDirectConnection(documentName, context);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write a new page title INTO the page's Yjs 'title' fragment, Redis-INDEPENDENT.
|
||||||
|
*
|
||||||
|
* Unlike the Redis-routed `handleYjsEvent` path — which routes through
|
||||||
|
* `redisSync?.handleEvent` and SILENTLY no-ops when Redis is disabled
|
||||||
|
* (COLLAB_DISABLE_REDIS=true → redisSync === null) — this goes straight
|
||||||
|
* through the local Hocuspocus `openDirectConnection`. The title sync
|
||||||
|
* therefore works in BOTH single-process (no Redis) and Redis-clustered
|
||||||
|
* deployments.
|
||||||
|
*
|
||||||
|
* openDirectConnection loads the doc from persistence when no editor is
|
||||||
|
* connected, so this works whether or not an editor is currently open: the
|
||||||
|
* clear+reseed lands on the loaded doc and is persisted by onStoreDocument.
|
||||||
|
*
|
||||||
|
* Provenance: when the caller is the agent, the actor/aiChatId are threaded
|
||||||
|
* into the connection `context` so onStoreDocument sees `context.actor ===
|
||||||
|
* 'agent'` for the resulting title store (mirrors the body/REST path). The
|
||||||
|
* resulting title store is usually a no-op anyway — PageService already wrote
|
||||||
|
* the same title to the page.title column, so onStoreDocument's
|
||||||
|
* `titleText !== page.title` guard skips the column write — but we wire the
|
||||||
|
* context for correctness regardless.
|
||||||
|
*/
|
||||||
|
async writePageTitle(
|
||||||
|
pageId: string,
|
||||||
|
title: string,
|
||||||
|
context?: { user?: User; actor?: string; aiChatId?: string },
|
||||||
|
): Promise<void> {
|
||||||
|
const documentName = `page.${pageId}`;
|
||||||
|
const connection = await this.hocuspocus.openDirectConnection(
|
||||||
|
documentName,
|
||||||
|
context ?? {},
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
// Write the new title into the in-memory 'title' fragment AND capture the
|
||||||
|
// resulting full doc state so we can persist it directly below.
|
||||||
|
let ydocState: Buffer | null = null;
|
||||||
|
await connection.transact((doc) => {
|
||||||
|
writeTitleFragment(doc, title);
|
||||||
|
ydocState = Buffer.from(Y.encodeStateAsUpdate(doc));
|
||||||
|
});
|
||||||
|
|
||||||
|
// F1 (variant C): persist the 'title' fragment to `page.ydoc` DIRECTLY,
|
||||||
|
// bypassing onStoreDocument. PageService.update already wrote the new title
|
||||||
|
// to the page.title COLUMN before calling this, so onStoreDocument's no-op
|
||||||
|
// fast-path (titleText === column) would NOT persist the in-memory fragment
|
||||||
|
// on disconnect — leaving the stored ydoc with the OLD title, which a later
|
||||||
|
// body edit would then revert the column back to. Writing the ydoc here
|
||||||
|
// makes BOTH column and persisted fragment consistent (NEW = NEW).
|
||||||
|
//
|
||||||
|
// Safe with or without a live editor: the write is idempotent and carries
|
||||||
|
// no tree snapshot (no double broadcast); when an editor is connected, the
|
||||||
|
// normal onStoreDocument flow still persists the (superset) state later and
|
||||||
|
// the live clients receive the title change through the transact above.
|
||||||
|
if (ydocState) {
|
||||||
|
await this.persistenceExtension.persistTitleFragmentYdoc(
|
||||||
|
pageId,
|
||||||
|
ydocState,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await connection.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
*Can be used before calling openDirectConnection directly
|
*Can be used before calling openDirectConnection directly
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import * as Y from 'yjs';
|
import * as Y from 'yjs';
|
||||||
import { CollaborationHandler } from './collaboration.handler';
|
import { CollaborationHandler, writeTitleFragment } from './collaboration.handler';
|
||||||
import * as yjsUtil from './yjs.util';
|
import * as yjsUtil from './yjs.util';
|
||||||
import { User } from '@docmost/db/types/entity.types';
|
import { User } from '@docmost/db/types/entity.types';
|
||||||
|
import { TiptapTransformer } from '@hocuspocus/transformer';
|
||||||
|
import { CollaborationGateway } from './collaboration.gateway';
|
||||||
|
import {
|
||||||
|
buildTitleSeedYdoc,
|
||||||
|
jsonToText,
|
||||||
|
tiptapExtensions,
|
||||||
|
} from './collaboration.util';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unit tests for the `applyCommentSuggestion` collab handler (phase 3 of #315).
|
* Unit tests for the `applyCommentSuggestion` collab handler (phase 3 of #315).
|
||||||
@@ -186,3 +193,149 @@ describe('CollaborationHandler.deleteCommentMark', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Read the plain text held in the doc's 'title' XmlFragment, the same way
|
||||||
|
// PersistenceExtension.onStoreDocument extracts it before writing page.title.
|
||||||
|
const readTitleText = (doc: Y.Doc): string => {
|
||||||
|
const titleJson = TiptapTransformer.fromYdoc(doc, 'title');
|
||||||
|
return titleJson ? jsonToText(titleJson).trim() : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('writeTitleFragment — the clear+seed title write (Bug 1)', () => {
|
||||||
|
it('replaces an OLD title fragment with EXACTLY the new title (no duplication)', () => {
|
||||||
|
// Seed the doc's 'title' fragment with an OLD title, like a real page.
|
||||||
|
const doc = new Y.Doc();
|
||||||
|
Y.applyUpdate(doc, Y.encodeStateAsUpdate(buildTitleSeedYdoc('Old Title')));
|
||||||
|
expect(readTitleText(doc)).toBe('Old Title');
|
||||||
|
|
||||||
|
writeTitleFragment(doc, 'New Title');
|
||||||
|
|
||||||
|
// The fragment must contain EXACTLY the new title — not "Old TitleNew Title"
|
||||||
|
// (append) or "New TitleNew Title" (duplication). A single heading node.
|
||||||
|
expect(readTitleText(doc)).toBe('New Title');
|
||||||
|
|
||||||
|
const titleJson = TiptapTransformer.fromYdoc(doc, 'title') as any;
|
||||||
|
expect(titleJson.content).toHaveLength(1);
|
||||||
|
expect(titleJson.content[0].type).toBe('heading');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('seeds the title fragment when it started empty', () => {
|
||||||
|
const doc = new Y.Doc();
|
||||||
|
// Force the 'title' fragment to exist but be empty.
|
||||||
|
doc.getXmlFragment('title');
|
||||||
|
expect(readTitleText(doc)).toBe('');
|
||||||
|
|
||||||
|
writeTitleFragment(doc, 'First Title');
|
||||||
|
|
||||||
|
expect(readTitleText(doc)).toBe('First Title');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not corrupt the body when rewriting the title', () => {
|
||||||
|
// A doc with both a body and an old title; the body must survive untouched.
|
||||||
|
const doc = new Y.Doc();
|
||||||
|
const bodyDoc = TiptapTransformer.toYdoc(
|
||||||
|
{
|
||||||
|
type: 'doc',
|
||||||
|
content: [
|
||||||
|
{ type: 'paragraph', content: [{ type: 'text', text: 'body text' }] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'default',
|
||||||
|
tiptapExtensions,
|
||||||
|
);
|
||||||
|
Y.applyUpdate(doc, Y.encodeStateAsUpdate(bodyDoc));
|
||||||
|
Y.applyUpdate(doc, Y.encodeStateAsUpdate(buildTitleSeedYdoc('Old')));
|
||||||
|
|
||||||
|
writeTitleFragment(doc, 'New');
|
||||||
|
|
||||||
|
expect(readTitleText(doc)).toBe('New');
|
||||||
|
const bodyJson = TiptapTransformer.fromYdoc(doc, 'default');
|
||||||
|
expect(jsonToText(bodyJson)).toContain('body text');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CollaborationGateway.writePageTitle — Redis-independent path', () => {
|
||||||
|
// Build a gateway with only its hocuspocus.openDirectConnection stubbed; the
|
||||||
|
// method must drive the clear+seed through that direct connection (NOT through
|
||||||
|
// redisSync), so the title write survives COLLAB_DISABLE_REDIS.
|
||||||
|
const makeGateway = (doc: Y.Doc) => {
|
||||||
|
const disconnect = jest.fn().mockResolvedValue(undefined);
|
||||||
|
const transact = jest.fn(async (fn: (d: Y.Doc) => void) => {
|
||||||
|
fn(doc);
|
||||||
|
});
|
||||||
|
const openDirectConnection = jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ transact, disconnect });
|
||||||
|
|
||||||
|
const gateway = Object.create(CollaborationGateway.prototype);
|
||||||
|
// redisSync is intentionally null — this is the no-Redis scenario.
|
||||||
|
gateway.redisSync = null;
|
||||||
|
gateway.hocuspocus = { openDirectConnection } as any;
|
||||||
|
// F1 (variant C): writePageTitle persists the 'title' fragment directly so a
|
||||||
|
// later body edit can't revert the rename (see title-rename-durability.spec).
|
||||||
|
const persistTitleFragmentYdoc = jest.fn().mockResolvedValue(undefined);
|
||||||
|
gateway.persistenceExtension = { persistTitleFragmentYdoc } as any;
|
||||||
|
|
||||||
|
return {
|
||||||
|
gateway,
|
||||||
|
openDirectConnection,
|
||||||
|
transact,
|
||||||
|
disconnect,
|
||||||
|
persistTitleFragmentYdoc,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
it('writes the new title via openDirectConnection and disconnects', async () => {
|
||||||
|
const doc = new Y.Doc();
|
||||||
|
Y.applyUpdate(doc, Y.encodeStateAsUpdate(buildTitleSeedYdoc('Old Title')));
|
||||||
|
|
||||||
|
const { gateway, openDirectConnection, disconnect, persistTitleFragmentYdoc } =
|
||||||
|
makeGateway(doc);
|
||||||
|
|
||||||
|
await gateway.writePageTitle('page-1', 'New Title', { user: { id: 'u1' } });
|
||||||
|
|
||||||
|
expect(openDirectConnection).toHaveBeenCalledWith(
|
||||||
|
'page.page-1',
|
||||||
|
expect.objectContaining({ user: { id: 'u1' } }),
|
||||||
|
);
|
||||||
|
expect(readTitleText(doc)).toBe('New Title');
|
||||||
|
// The renamed fragment is persisted directly to page.ydoc (F1 variant C).
|
||||||
|
expect(persistTitleFragmentYdoc).toHaveBeenCalledWith(
|
||||||
|
'page-1',
|
||||||
|
expect.any(Buffer),
|
||||||
|
);
|
||||||
|
expect(disconnect).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('threads agent provenance into the connection context', async () => {
|
||||||
|
const doc = new Y.Doc();
|
||||||
|
const { gateway, openDirectConnection } = makeGateway(doc);
|
||||||
|
|
||||||
|
await gateway.writePageTitle('page-1', 'Agent Title', {
|
||||||
|
user: { id: 'u1' },
|
||||||
|
actor: 'agent',
|
||||||
|
aiChatId: 'chat-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(openDirectConnection).toHaveBeenCalledWith(
|
||||||
|
'page.page-1',
|
||||||
|
expect.objectContaining({ actor: 'agent', aiChatId: 'chat-1' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disconnects even when the transaction throws', async () => {
|
||||||
|
const disconnect = jest.fn().mockResolvedValue(undefined);
|
||||||
|
const openDirectConnection = jest.fn().mockResolvedValue({
|
||||||
|
transact: jest.fn().mockRejectedValue(new Error('boom')),
|
||||||
|
disconnect,
|
||||||
|
});
|
||||||
|
const gateway = Object.create(CollaborationGateway.prototype);
|
||||||
|
gateway.redisSync = null;
|
||||||
|
gateway.hocuspocus = { openDirectConnection } as any;
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
gateway.writePageTitle('page-1', 'X', {}),
|
||||||
|
).rejects.toThrow('boom');
|
||||||
|
expect(disconnect).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
|
|||||||
import { Hocuspocus, Document } from '@hocuspocus/server';
|
import { Hocuspocus, Document } from '@hocuspocus/server';
|
||||||
import { TiptapTransformer } from '@hocuspocus/transformer';
|
import { TiptapTransformer } from '@hocuspocus/transformer';
|
||||||
import {
|
import {
|
||||||
|
buildTitleSeedYdoc,
|
||||||
prosemirrorNodeToYElement,
|
prosemirrorNodeToYElement,
|
||||||
tiptapExtensions,
|
tiptapExtensions,
|
||||||
} from './collaboration.util';
|
} from './collaboration.util';
|
||||||
@@ -19,6 +20,35 @@ export type CollabEventHandlers = ReturnType<
|
|||||||
CollaborationHandler['getHandlers']
|
CollaborationHandler['getHandlers']
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear+reseed the 'title' XmlFragment of `doc` so it holds EXACTLY `title`.
|
||||||
|
*
|
||||||
|
* Used by the gateway's direct `writePageTitle` method to write a new page
|
||||||
|
* title INTO the page's Yjs 'title' fragment. The title lives in the same
|
||||||
|
* Y.Doc as the body; onStoreDocument extracts it on every save, so a REST/MCP
|
||||||
|
* rename that only updated the page.title DB column would be reverted on the
|
||||||
|
* next collaborative save unless the Yjs 'title' fragment is kept in sync.
|
||||||
|
* The whole fragment is replaced (no merge/append),
|
||||||
|
* mirroring the 'replace' body path: the new title fully supersedes the old.
|
||||||
|
*
|
||||||
|
* DELIBERATE TRADE-OFF: because this does a FULL clear+replace of the 'title'
|
||||||
|
* fragment, a REST/MCP rename arriving while a user is actively editing the
|
||||||
|
* title in an open editor WILL overwrite that in-progress edit. This is
|
||||||
|
* acceptable — the title is a short, rarely-concurrently-edited field — and is
|
||||||
|
* preferable to leaving a stale Yjs title that onStoreDocument would revert the
|
||||||
|
* DB column to on the next save.
|
||||||
|
*/
|
||||||
|
export function writeTitleFragment(doc: Y.Doc, title: string): void {
|
||||||
|
const titleFragment = doc.getXmlFragment('title');
|
||||||
|
|
||||||
|
if (titleFragment.length > 0) {
|
||||||
|
titleFragment.delete(0, titleFragment.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newTitleDoc = buildTitleSeedYdoc(title);
|
||||||
|
Y.applyUpdate(doc, Y.encodeStateAsUpdate(newTitleDoc));
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CollaborationHandler {
|
export class CollaborationHandler {
|
||||||
private readonly logger = new Logger(CollaborationHandler.name);
|
private readonly logger = new Logger(CollaborationHandler.name);
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import * as Y from 'yjs';
|
import * as Y from 'yjs';
|
||||||
|
import { TiptapTransformer } from '@hocuspocus/transformer';
|
||||||
import {
|
import {
|
||||||
getPageId,
|
getPageId,
|
||||||
isEmptyParagraphDoc,
|
isEmptyParagraphDoc,
|
||||||
jsonToNode,
|
jsonToNode,
|
||||||
prosemirrorNodeToYElement,
|
prosemirrorNodeToYElement,
|
||||||
|
buildTitleSeedYdoc,
|
||||||
|
jsonToText,
|
||||||
} from './collaboration.util';
|
} from './collaboration.util';
|
||||||
import { Node } from '@tiptap/pm/model';
|
import { Node } from '@tiptap/pm/model';
|
||||||
|
|
||||||
@@ -241,3 +244,43 @@ describe('prosemirrorNodeToYElement', () => {
|
|||||||
expect(element.get(1).get(0).toString()).toBe('two');
|
expect(element.get(1).get(0).toString()).toBe('two');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('buildTitleSeedYdoc', () => {
|
||||||
|
it('builds a level-1 heading carrying the title text', () => {
|
||||||
|
const doc = buildTitleSeedYdoc('Hello World');
|
||||||
|
const json: any = TiptapTransformer.fromYdoc(doc, 'title');
|
||||||
|
|
||||||
|
const first = json.content?.[0];
|
||||||
|
expect(first.type).toBe('heading');
|
||||||
|
expect(first.attrs.level).toBe(1);
|
||||||
|
expect(jsonToText(json).trim()).toBe('Hello World');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces a non-empty title fragment for a non-empty title', () => {
|
||||||
|
const doc = buildTitleSeedYdoc('Some Title');
|
||||||
|
expect(doc.get('title', Y.XmlFragment).length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces a heading with no text child for an empty title', () => {
|
||||||
|
const doc = buildTitleSeedYdoc('');
|
||||||
|
const json: any = TiptapTransformer.fromYdoc(doc, 'title');
|
||||||
|
|
||||||
|
const first = json.content?.[0];
|
||||||
|
expect(first.type).toBe('heading');
|
||||||
|
// No text content for an empty title.
|
||||||
|
expect(first.content ?? []).toHaveLength(0);
|
||||||
|
expect(jsonToText(json).trim()).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips a title through build -> extract -> build -> extract', () => {
|
||||||
|
const title = 'Round Trip Title';
|
||||||
|
const doc1 = buildTitleSeedYdoc(title);
|
||||||
|
const text1 = jsonToText(TiptapTransformer.fromYdoc(doc1, 'title')).trim();
|
||||||
|
|
||||||
|
const doc2 = buildTitleSeedYdoc(text1);
|
||||||
|
const text2 = jsonToText(TiptapTransformer.fromYdoc(doc2, 'title')).trim();
|
||||||
|
|
||||||
|
expect(text1).toBe(title);
|
||||||
|
expect(text2).toBe(text1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ import {
|
|||||||
Column,
|
Column,
|
||||||
Status,
|
Status,
|
||||||
addUniqueIdsToDoc,
|
addUniqueIdsToDoc,
|
||||||
htmlToMarkdown,
|
|
||||||
TransclusionSource,
|
TransclusionSource,
|
||||||
TransclusionReference,
|
TransclusionReference,
|
||||||
FootnoteReference,
|
FootnoteReference,
|
||||||
@@ -51,6 +50,7 @@ import {
|
|||||||
FootnoteDefinition,
|
FootnoteDefinition,
|
||||||
PageEmbed,
|
PageEmbed,
|
||||||
} from '@docmost/editor-ext';
|
} from '@docmost/editor-ext';
|
||||||
|
import { convertProseMirrorToMarkdown } from '@docmost/prosemirror-markdown';
|
||||||
import { generateText, getSchema, JSONContent } from '@tiptap/core';
|
import { generateText, getSchema, JSONContent } from '@tiptap/core';
|
||||||
import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
|
import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
|
||||||
// @tiptap/html library works best for generating prosemirror json state but not HTML
|
// @tiptap/html library works best for generating prosemirror json state but not HTML
|
||||||
@@ -60,6 +60,7 @@ import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
|
|||||||
import { Node, Schema } from '@tiptap/pm/model';
|
import { Node, Schema } from '@tiptap/pm/model';
|
||||||
import * as Y from 'yjs';
|
import * as Y from 'yjs';
|
||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { TiptapTransformer } from '@hocuspocus/transformer';
|
||||||
|
|
||||||
export const tiptapExtensions = [
|
export const tiptapExtensions = [
|
||||||
StarterKit.configure({
|
StarterKit.configure({
|
||||||
@@ -145,6 +146,34 @@ export function jsonToText(tiptapJson: JSONContent) {
|
|||||||
return generateText(tiptapJson, tiptapExtensions);
|
return generateText(tiptapJson, tiptapExtensions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a standalone Y.Doc that holds ONLY the page title, in a dedicated Yjs
|
||||||
|
* fragment named exactly 'title' (the collaborative title-editor contract with
|
||||||
|
* the client). The ProseMirror shape is a doc with a single level-1 heading
|
||||||
|
* whose text is the title (empty title => heading with no text child).
|
||||||
|
*
|
||||||
|
* The encoded state of the returned doc can be merged into a body doc via
|
||||||
|
* `Y.applyUpdate(doc, Y.encodeStateAsUpdate(titleSeed))` to seed the title
|
||||||
|
* fragment for legacy pages. Seeding MUST be guarded by an emptiness check on
|
||||||
|
* the existing 'title' fragment to avoid the Yjs duplication trap.
|
||||||
|
*/
|
||||||
|
export function buildTitleSeedYdoc(title: string): Y.Doc {
|
||||||
|
return TiptapTransformer.toYdoc(
|
||||||
|
{
|
||||||
|
type: 'doc',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'heading',
|
||||||
|
attrs: { level: 1 },
|
||||||
|
content: title ? [{ type: 'text', text: title }] : [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'title',
|
||||||
|
tiptapExtensions,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function jsonToNode(tiptapJson: JSONContent) {
|
export function jsonToNode(tiptapJson: JSONContent) {
|
||||||
const schema = getSchema(tiptapExtensions);
|
const schema = getSchema(tiptapExtensions);
|
||||||
try {
|
try {
|
||||||
@@ -239,6 +268,10 @@ export function prosemirrorNodeToYElement(node: any): Y.XmlElement | Y.XmlText {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function jsonToMarkdown(tiptapJson: any): string {
|
export function jsonToMarkdown(tiptapJson: any): string {
|
||||||
const html = jsonToHtml(tiptapJson);
|
// Direct ProseMirror JSON -> Markdown via the canonical converter
|
||||||
return htmlToMarkdown(html);
|
// (`@docmost/prosemirror-markdown`) — no HTML intermediate, no second
|
||||||
|
// editor-ext markdown layer. Same serializer as the page/space export and the
|
||||||
|
// git-sync vault writer, so every server PM->MD path emits identical canonical
|
||||||
|
// markdown (issue #345).
|
||||||
|
return convertProseMirrorToMarkdown(tiptapJson);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
export const HISTORY_INTERVAL = 5 * 60 * 1000;
|
export const HISTORY_INTERVAL = 5 * 60 * 1000;
|
||||||
export const HISTORY_FAST_INTERVAL = 60 * 1000;
|
export const HISTORY_FAST_INTERVAL = 60 * 1000;
|
||||||
export const HISTORY_FAST_THRESHOLD = 5 * 60 * 1000;
|
export const HISTORY_FAST_THRESHOLD = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
// Redis pub/sub channel that bridges a PAGE_UPDATED tree snapshot (a title/icon
|
||||||
|
// rename) from the standalone collab process to the API process, which is the
|
||||||
|
// single broadcast authority. Imported by both halves of the bridge:
|
||||||
|
// PageTreeBridgePublisher (collab process) and PageTreeBridgeSubscriber (API process).
|
||||||
|
export const COLLAB_TREE_UPDATE_CHANNEL = 'collab:tree-update';
|
||||||
|
|||||||
@@ -0,0 +1,483 @@
|
|||||||
|
import * as Y from 'yjs';
|
||||||
|
import { TiptapTransformer } from '@hocuspocus/transformer';
|
||||||
|
import { PersistenceExtension } from './persistence.extension';
|
||||||
|
import { buildTitleSeedYdoc, tiptapExtensions } from '../collaboration.util';
|
||||||
|
|
||||||
|
// Direct instantiation with stub deps, mirroring the auth/env unit specs.
|
||||||
|
const bodyJson = {
|
||||||
|
type: 'doc',
|
||||||
|
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'hello' }] }],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build a body Y.Doc with a known JSON, plus a monkey-patched broadcastStateless
|
||||||
|
// (the real Hocuspocus Document supplies it; a bare Y.Doc does not).
|
||||||
|
const buildDoc = () => {
|
||||||
|
const d: any = TiptapTransformer.toYdoc(bodyJson, 'default', tiptapExtensions);
|
||||||
|
d.broadcastStateless = jest.fn();
|
||||||
|
return d;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cloneOut = (doc: any) =>
|
||||||
|
JSON.parse(JSON.stringify(TiptapTransformer.fromYdoc(doc, 'default')));
|
||||||
|
|
||||||
|
const addTitleFragment = (doc: any, title: string) =>
|
||||||
|
Y.applyUpdate(doc, Y.encodeStateAsUpdate(buildTitleSeedYdoc(title)));
|
||||||
|
|
||||||
|
describe('PersistenceExtension', () => {
|
||||||
|
let pageRepo: any;
|
||||||
|
let pageHistoryRepo: any;
|
||||||
|
let trx: any;
|
||||||
|
let db: any;
|
||||||
|
let aiQueue: any;
|
||||||
|
let historyQueue: any;
|
||||||
|
let notificationQueue: any;
|
||||||
|
let collabHistory: any;
|
||||||
|
let transclusionService: any;
|
||||||
|
let ext: PersistenceExtension;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
pageRepo = {
|
||||||
|
findById: jest.fn(),
|
||||||
|
updatePage: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
pageHistoryRepo = {
|
||||||
|
findPageLastHistory: jest.fn(),
|
||||||
|
saveHistory: jest.fn(),
|
||||||
|
};
|
||||||
|
trx = {};
|
||||||
|
db = { transaction: () => ({ execute: (fn: any) => fn(trx) }) };
|
||||||
|
aiQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
historyQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
notificationQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
collabHistory = { addContributors: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
transclusionService = {
|
||||||
|
syncPageTransclusions: jest.fn().mockResolvedValue(undefined),
|
||||||
|
syncPageReferences: jest.fn().mockResolvedValue(undefined),
|
||||||
|
syncPageTemplateReferences: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
|
||||||
|
ext = new PersistenceExtension(
|
||||||
|
pageRepo as any,
|
||||||
|
pageHistoryRepo as any,
|
||||||
|
db as any,
|
||||||
|
aiQueue as any,
|
||||||
|
historyQueue as any,
|
||||||
|
notificationQueue as any,
|
||||||
|
collabHistory as any,
|
||||||
|
transclusionService as any,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('seedTitleFragment', () => {
|
||||||
|
it('returns false for empty/whitespace/null titles', () => {
|
||||||
|
const doc = new Y.Doc();
|
||||||
|
expect((ext as any).seedTitleFragment(doc, '')).toBe(false);
|
||||||
|
expect((ext as any).seedTitleFragment(doc, ' ')).toBe(false);
|
||||||
|
expect((ext as any).seedTitleFragment(doc, null)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT re-seed an existing non-empty title fragment', () => {
|
||||||
|
const doc = new Y.Doc();
|
||||||
|
addTitleFragment(doc, 'Existing');
|
||||||
|
|
||||||
|
expect((ext as any).seedTitleFragment(doc, 'Other')).toBe(false);
|
||||||
|
|
||||||
|
const text = TiptapTransformer.fromYdoc(doc, 'title');
|
||||||
|
expect(JSON.stringify(text)).toContain('Existing');
|
||||||
|
expect(JSON.stringify(text)).not.toContain('Other');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('seeds an empty fragment from a non-empty title and returns true', () => {
|
||||||
|
const doc = new Y.Doc();
|
||||||
|
expect((ext as any).seedTitleFragment(doc, 'Hello')).toBe(true);
|
||||||
|
|
||||||
|
const json: any = TiptapTransformer.fromYdoc(doc, 'title');
|
||||||
|
expect(JSON.stringify(json)).toContain('Hello');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false (defensive) when reading the fragment throws', () => {
|
||||||
|
const fakeDoc = {
|
||||||
|
get: () => {
|
||||||
|
throw new Error('boom');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect((ext as any).seedTitleFragment(fakeDoc as any, 'X')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('onStoreDocument', () => {
|
||||||
|
const basePage = (overrides: any) => ({
|
||||||
|
id: 'PAGE_ID',
|
||||||
|
slugId: 'slug',
|
||||||
|
spaceId: 'space',
|
||||||
|
parentPageId: null,
|
||||||
|
creatorId: 'creator',
|
||||||
|
contributorIds: ['creator'],
|
||||||
|
workspaceId: 'ws',
|
||||||
|
title: 'whatever',
|
||||||
|
content: null,
|
||||||
|
lastUpdatedSource: 'user',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const context = { user: { id: 'u1', name: 'U', avatarUrl: null } };
|
||||||
|
|
||||||
|
it('no-op when neither body nor title changed', async () => {
|
||||||
|
const document = buildDoc();
|
||||||
|
const page = basePage({
|
||||||
|
content: cloneOut(document),
|
||||||
|
title: 'hello title',
|
||||||
|
});
|
||||||
|
pageRepo.findById.mockResolvedValue(page);
|
||||||
|
|
||||||
|
await ext.onStoreDocument({
|
||||||
|
documentName: 'page.PAGE_ID',
|
||||||
|
document,
|
||||||
|
context,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(pageRepo.updatePage).not.toHaveBeenCalled();
|
||||||
|
expect(document.broadcastStateless).not.toHaveBeenCalled();
|
||||||
|
expect(collabHistory.addContributors).not.toHaveBeenCalled();
|
||||||
|
expect(transclusionService.syncPageTransclusions).not.toHaveBeenCalled();
|
||||||
|
expect(aiQueue.add).not.toHaveBeenCalled();
|
||||||
|
expect(historyQueue.add).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('title-only change persists the title without body side-effects', async () => {
|
||||||
|
const document = buildDoc();
|
||||||
|
addTitleFragment(document, 'New Title');
|
||||||
|
const page = basePage({
|
||||||
|
content: cloneOut(document),
|
||||||
|
title: 'Old Title',
|
||||||
|
});
|
||||||
|
pageRepo.findById.mockResolvedValue(page);
|
||||||
|
|
||||||
|
await ext.onStoreDocument({
|
||||||
|
documentName: 'page.PAGE_ID',
|
||||||
|
document,
|
||||||
|
context,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
|
||||||
|
const call = pageRepo.updatePage.mock.calls[0];
|
||||||
|
expect(call[0].title).toBe('New Title');
|
||||||
|
expect(call[0].ydoc).toBeDefined();
|
||||||
|
expect(call[0].contributorIds).toBeDefined();
|
||||||
|
expect('content' in call[0]).toBe(false);
|
||||||
|
// Title-only must not touch the body-authorship provenance.
|
||||||
|
expect('lastUpdatedSource' in call[0]).toBe(false);
|
||||||
|
expect(call[1]).toBe('PAGE_ID');
|
||||||
|
expect(call[3].treeUpdate.title).toBe('New Title');
|
||||||
|
|
||||||
|
expect(collabHistory.addContributors).toHaveBeenCalledTimes(1);
|
||||||
|
expect(collabHistory.addContributors).toHaveBeenCalledWith(
|
||||||
|
'PAGE_ID',
|
||||||
|
expect.any(Array),
|
||||||
|
);
|
||||||
|
expect(document.broadcastStateless).toHaveBeenCalledTimes(1);
|
||||||
|
expect(transclusionService.syncPageTransclusions).not.toHaveBeenCalled();
|
||||||
|
expect(aiQueue.add).not.toHaveBeenCalled();
|
||||||
|
expect(historyQueue.add).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an EMPTY title fragment does NOT overwrite a non-empty page.title (anti-corruption guard, Bug 2)', async () => {
|
||||||
|
// The client can momentarily seed the 'title' fragment as an EMPTY heading
|
||||||
|
// (hasTitleFragment true, extracted text '') before the real title syncs.
|
||||||
|
// Body is unchanged here, so the only candidate write is the title -> the
|
||||||
|
// guard must turn this into a full no-op (no updatePage, no broadcast).
|
||||||
|
const document = buildDoc();
|
||||||
|
addTitleFragment(document, ''); // empty heading: length > 0 but text ''
|
||||||
|
const page = basePage({
|
||||||
|
content: cloneOut(document),
|
||||||
|
title: 'Real Title',
|
||||||
|
});
|
||||||
|
pageRepo.findById.mockResolvedValue(page);
|
||||||
|
|
||||||
|
await ext.onStoreDocument({
|
||||||
|
documentName: 'page.PAGE_ID',
|
||||||
|
document,
|
||||||
|
context,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
// No write at all: the empty title is not authoritative and the body is
|
||||||
|
// unchanged, so onStoreDocument must take the no-op fast path.
|
||||||
|
expect(pageRepo.updatePage).not.toHaveBeenCalled();
|
||||||
|
expect(document.broadcastStateless).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an EMPTY title fragment alongside a body change persists the body but NOT an empty title (anti-corruption guard, Bug 2)', async () => {
|
||||||
|
const document = buildDoc();
|
||||||
|
addTitleFragment(document, ''); // empty title fragment
|
||||||
|
const page = basePage({
|
||||||
|
content: { type: 'doc', content: [] }, // different body -> bodyChanged
|
||||||
|
title: 'Real Title',
|
||||||
|
});
|
||||||
|
pageRepo.findById.mockResolvedValue(page);
|
||||||
|
|
||||||
|
await ext.onStoreDocument({
|
||||||
|
documentName: 'page.PAGE_ID',
|
||||||
|
document,
|
||||||
|
context,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
|
||||||
|
const call = pageRepo.updatePage.mock.calls[0];
|
||||||
|
// Body is persisted, but the title is NOT included (empty == not
|
||||||
|
// authoritative) and no tree update is broadcast for the title.
|
||||||
|
expect(call[0].content).toBeTruthy();
|
||||||
|
expect('title' in call[0]).toBe(false);
|
||||||
|
expect(call[3]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('body + title change persists both with full body side-effects', async () => {
|
||||||
|
const document = buildDoc();
|
||||||
|
addTitleFragment(document, 'New Title');
|
||||||
|
const page = basePage({
|
||||||
|
content: { type: 'doc', content: [] },
|
||||||
|
title: 'Old Title',
|
||||||
|
});
|
||||||
|
pageRepo.findById.mockResolvedValue(page);
|
||||||
|
|
||||||
|
await ext.onStoreDocument({
|
||||||
|
documentName: 'page.PAGE_ID',
|
||||||
|
document,
|
||||||
|
context,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
|
||||||
|
const call = pageRepo.updatePage.mock.calls[0];
|
||||||
|
expect(call[0].content).toBeTruthy();
|
||||||
|
expect(call[0].title).toBe('New Title');
|
||||||
|
expect(call[0].ydoc).toBeDefined();
|
||||||
|
expect(call[0].lastUpdatedSource).toBe('user');
|
||||||
|
expect(call[3].treeUpdate).toBeDefined();
|
||||||
|
|
||||||
|
expect(transclusionService.syncPageTransclusions).toHaveBeenCalled();
|
||||||
|
expect(aiQueue.add).toHaveBeenCalled();
|
||||||
|
expect(historyQueue.add).toHaveBeenCalled();
|
||||||
|
expect(collabHistory.addContributors).toHaveBeenCalled();
|
||||||
|
expect(document.broadcastStateless).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('body-only change persists the body without a tree update', async () => {
|
||||||
|
const document = buildDoc();
|
||||||
|
const page = basePage({
|
||||||
|
content: { type: 'doc', content: [] },
|
||||||
|
title: 'whatever',
|
||||||
|
});
|
||||||
|
pageRepo.findById.mockResolvedValue(page);
|
||||||
|
|
||||||
|
await ext.onStoreDocument({
|
||||||
|
documentName: 'page.PAGE_ID',
|
||||||
|
document,
|
||||||
|
context,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
|
||||||
|
const call = pageRepo.updatePage.mock.calls[0];
|
||||||
|
expect(call[0].content).toBeTruthy();
|
||||||
|
expect('title' in call[0]).toBe(false);
|
||||||
|
// No treeUpdate for a body-only save.
|
||||||
|
expect(call[3]).toBeUndefined();
|
||||||
|
|
||||||
|
expect(transclusionService.syncPageTransclusions).toHaveBeenCalled();
|
||||||
|
expect(aiQueue.add).toHaveBeenCalled();
|
||||||
|
expect(historyQueue.add).toHaveBeenCalled();
|
||||||
|
expect(document.broadcastStateless).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('onLoadDocument', () => {
|
||||||
|
it('returns early (no DB read) when the document is not empty', async () => {
|
||||||
|
const document = { isEmpty: () => false };
|
||||||
|
const result = await ext.onLoadDocument({
|
||||||
|
documentName: 'page.PAGE_ID',
|
||||||
|
document,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
expect(pageRepo.findById).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined and does not persist when the page is null', async () => {
|
||||||
|
const document = { isEmpty: () => true };
|
||||||
|
pageRepo.findById.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const result = await ext.onLoadDocument({
|
||||||
|
documentName: 'page.PAGE_ID',
|
||||||
|
document,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
expect(pageRepo.updatePage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('seeds + persists under a lock when the persisted ydoc lacks a title fragment', async () => {
|
||||||
|
const src = TiptapTransformer.toYdoc(bodyJson, 'default', tiptapExtensions);
|
||||||
|
const page = {
|
||||||
|
id: 'PAGE_ID',
|
||||||
|
title: 'Legacy Title',
|
||||||
|
ydoc: Buffer.from(Y.encodeStateAsUpdate(src)),
|
||||||
|
content: null,
|
||||||
|
};
|
||||||
|
// Both the cheap pre-check and the locked re-read return the same row.
|
||||||
|
pageRepo.findById.mockResolvedValue(page);
|
||||||
|
|
||||||
|
const document = { isEmpty: () => true };
|
||||||
|
const result = await ext.onLoadDocument({
|
||||||
|
documentName: 'page.PAGE_ID',
|
||||||
|
document,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
// The locked re-read must take the row lock inside the tx.
|
||||||
|
const lockedReadCall = pageRepo.findById.mock.calls.find(
|
||||||
|
(c: any[]) => c[1]?.withLock,
|
||||||
|
);
|
||||||
|
expect(lockedReadCall).toBeDefined();
|
||||||
|
expect(lockedReadCall[1].trx).toBe(trx);
|
||||||
|
|
||||||
|
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
|
||||||
|
const call = pageRepo.updatePage.mock.calls[0];
|
||||||
|
expect(Buffer.isBuffer(call[0].ydoc)).toBe(true);
|
||||||
|
expect(call[1]).toBe('PAGE_ID');
|
||||||
|
// Persist must run inside the transaction.
|
||||||
|
expect(call[2]).toBe(trx);
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT lock or persist when the ydoc already has a title fragment', async () => {
|
||||||
|
const src = TiptapTransformer.toYdoc(bodyJson, 'default', tiptapExtensions);
|
||||||
|
Y.applyUpdate(src, Y.encodeStateAsUpdate(buildTitleSeedYdoc('Has Title')));
|
||||||
|
const page = {
|
||||||
|
id: 'PAGE_ID',
|
||||||
|
title: 'Has Title',
|
||||||
|
ydoc: Buffer.from(Y.encodeStateAsUpdate(src)),
|
||||||
|
content: null,
|
||||||
|
};
|
||||||
|
pageRepo.findById.mockResolvedValue(page);
|
||||||
|
|
||||||
|
const document = { isEmpty: () => true };
|
||||||
|
const result = await ext.onLoadDocument({
|
||||||
|
documentName: 'page.PAGE_ID',
|
||||||
|
document,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
// Hot path: only the cheap lock-free read, no locked re-read, no write.
|
||||||
|
expect(pageRepo.findById).toHaveBeenCalledTimes(1);
|
||||||
|
expect(pageRepo.findById.mock.calls[0][1]?.withLock).toBeFalsy();
|
||||||
|
expect(pageRepo.updatePage).not.toHaveBeenCalled();
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts legacy content -> ydoc inside a tx and persists a {ydoc} Buffer', async () => {
|
||||||
|
const page = {
|
||||||
|
id: 'PAGE_ID',
|
||||||
|
title: 'T',
|
||||||
|
ydoc: null,
|
||||||
|
content: bodyJson,
|
||||||
|
};
|
||||||
|
pageRepo.findById.mockResolvedValue(page);
|
||||||
|
|
||||||
|
const document = { isEmpty: () => true };
|
||||||
|
const result = await ext.onLoadDocument({
|
||||||
|
documentName: 'page.PAGE_ID',
|
||||||
|
document,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const lockedReadCall = pageRepo.findById.mock.calls.find(
|
||||||
|
(c: any[]) => c[1]?.withLock,
|
||||||
|
);
|
||||||
|
expect(lockedReadCall).toBeDefined();
|
||||||
|
expect(lockedReadCall[1].trx).toBe(trx);
|
||||||
|
|
||||||
|
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
|
||||||
|
const call = pageRepo.updatePage.mock.calls[0];
|
||||||
|
expect(Buffer.isBuffer(call[0].ydoc)).toBe(true);
|
||||||
|
expect(call[2]).toBe(trx);
|
||||||
|
// The rebuilt doc carries the body.
|
||||||
|
expect(JSON.stringify(cloneOut(result))).toContain('hello');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SKIPS rebuild when the locked re-read shows the ydoc was already healed', async () => {
|
||||||
|
// Simulate a concurrent process: the cheap pre-check sees ydoc=null (legacy
|
||||||
|
// rebuild path), but by the time we hold the lock another process has
|
||||||
|
// already persisted a healthy ydoc. We must adopt it, not rebuild/clobber.
|
||||||
|
const healed = TiptapTransformer.toYdoc(
|
||||||
|
{ type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'healed' }] }] },
|
||||||
|
'default',
|
||||||
|
tiptapExtensions,
|
||||||
|
);
|
||||||
|
Y.applyUpdate(healed, Y.encodeStateAsUpdate(buildTitleSeedYdoc('Healed Title')));
|
||||||
|
const healedYdoc = Buffer.from(Y.encodeStateAsUpdate(healed));
|
||||||
|
|
||||||
|
const preCheck = { id: 'PAGE_ID', title: 'T', ydoc: null, content: bodyJson };
|
||||||
|
const lockedRow = {
|
||||||
|
id: 'PAGE_ID',
|
||||||
|
title: 'Healed Title',
|
||||||
|
ydoc: healedYdoc,
|
||||||
|
content: bodyJson,
|
||||||
|
};
|
||||||
|
pageRepo.findById
|
||||||
|
.mockResolvedValueOnce(preCheck) // cheap pre-check
|
||||||
|
.mockResolvedValueOnce(lockedRow); // locked re-read
|
||||||
|
|
||||||
|
const document = { isEmpty: () => true };
|
||||||
|
const result = await ext.onLoadDocument({
|
||||||
|
documentName: 'page.PAGE_ID',
|
||||||
|
document,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
// The healthy ydoc had a title fragment already, so nothing was rebuilt or
|
||||||
|
// seeded -> no clobbering write.
|
||||||
|
expect(pageRepo.updatePage).not.toHaveBeenCalled();
|
||||||
|
// The returned doc is the healed body, NOT a fresh rebuild of bodyJson.
|
||||||
|
expect(JSON.stringify(cloneOut(result))).toContain('healed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('REJECTS the load when the rebuild persist fails (does not return an unpersisted doc)', async () => {
|
||||||
|
const page = { id: 'PAGE_ID', title: 'T', ydoc: null, content: bodyJson };
|
||||||
|
pageRepo.findById.mockResolvedValue(page);
|
||||||
|
pageRepo.updatePage.mockRejectedValue(new Error('db down'));
|
||||||
|
const errSpy = jest
|
||||||
|
.spyOn((ext as any).logger, 'error')
|
||||||
|
.mockImplementation(() => undefined);
|
||||||
|
|
||||||
|
const document = { isEmpty: () => true };
|
||||||
|
await expect(
|
||||||
|
ext.onLoadDocument({
|
||||||
|
documentName: 'page.PAGE_ID',
|
||||||
|
document,
|
||||||
|
} as any),
|
||||||
|
).rejects.toThrow('db down');
|
||||||
|
expect(errSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('seed-only persist FAILURE returns the doc from the existing ydoc (no throw)', async () => {
|
||||||
|
const src = TiptapTransformer.toYdoc(bodyJson, 'default', tiptapExtensions);
|
||||||
|
const page = {
|
||||||
|
id: 'PAGE_ID',
|
||||||
|
title: 'Legacy Title',
|
||||||
|
ydoc: Buffer.from(Y.encodeStateAsUpdate(src)),
|
||||||
|
content: null,
|
||||||
|
};
|
||||||
|
pageRepo.findById.mockResolvedValue(page);
|
||||||
|
pageRepo.updatePage.mockRejectedValue(new Error('db down'));
|
||||||
|
const errSpy = jest
|
||||||
|
.spyOn((ext as any).logger, 'error')
|
||||||
|
.mockImplementation(() => undefined);
|
||||||
|
|
||||||
|
const document = { isEmpty: () => true };
|
||||||
|
const result = await ext.onLoadDocument({
|
||||||
|
documentName: 'page.PAGE_ID',
|
||||||
|
document,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
// Non-fatal: we fall back to the doc loaded from the existing page.ydoc.
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
expect(JSON.stringify(cloneOut(result))).toContain('hello');
|
||||||
|
expect(errSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,6 +10,7 @@ import * as Y from 'yjs';
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { TiptapTransformer } from '@hocuspocus/transformer';
|
import { TiptapTransformer } from '@hocuspocus/transformer';
|
||||||
import {
|
import {
|
||||||
|
buildTitleSeedYdoc,
|
||||||
getPageId,
|
getPageId,
|
||||||
isEmptyParagraphDoc,
|
isEmptyParagraphDoc,
|
||||||
jsonToText,
|
jsonToText,
|
||||||
@@ -154,6 +155,10 @@ export class PersistenceExtension implements Extension {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cheap, lock-free pre-check (hot path stays lock-free). It tells us whether
|
||||||
|
// any heal (legacy rebuild and/or title seed) is needed; the heal itself
|
||||||
|
// re-reads the row FOR UPDATE and re-validates inside a transaction so it
|
||||||
|
// runs exactly once (see healUnderLock).
|
||||||
const page = await this.pageRepo.findById(pageId, {
|
const page = await this.pageRepo.findById(pageId, {
|
||||||
includeContent: true,
|
includeContent: true,
|
||||||
includeYdoc: true,
|
includeYdoc: true,
|
||||||
@@ -165,33 +170,193 @@ export class PersistenceExtension implements Extension {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (page.ydoc) {
|
if (page.ydoc) {
|
||||||
this.logger.debug(`ydoc loaded from db: ${pageId}`);
|
|
||||||
|
|
||||||
const doc = new Y.Doc();
|
const doc = new Y.Doc();
|
||||||
const dbState = new Uint8Array(page.ydoc);
|
Y.applyUpdate(doc, new Uint8Array(page.ydoc));
|
||||||
|
|
||||||
Y.applyUpdate(doc, dbState);
|
// Legacy pages persisted their title only in the `page.title` column; the
|
||||||
return doc;
|
// ydoc has no 'title' fragment. Decide cheaply (no lock) whether a seed is
|
||||||
|
// needed by inspecting the loaded doc's 'title' fragment. A seed is needed
|
||||||
|
// only when that fragment is empty AND there is a non-empty column title.
|
||||||
|
let titleSeedNeeded = false;
|
||||||
|
try {
|
||||||
|
const titleFrag = doc.get('title', Y.XmlFragment);
|
||||||
|
titleSeedNeeded = titleFrag.length === 0 && !!page.title?.trim();
|
||||||
|
} catch (err) {
|
||||||
|
// A malformed title fragment must not break loading; skip the seed.
|
||||||
|
this.logger.warn(`failed to inspect title fragment: ${err?.['message']}`);
|
||||||
|
titleSeedNeeded = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!titleSeedNeeded) {
|
||||||
|
// Fully healthy: a ydoc with a title fragment (or nothing to seed).
|
||||||
|
this.logger.debug(`ydoc loaded from db: ${pageId}`);
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SEED-ONLY heal: a valid page.ydoc already exists; we only need to add the
|
||||||
|
// title fragment. If the persist fails we must NOT hand out an unpersisted
|
||||||
|
// fresh-client-id seed (it could later duplicate the title), so we fall
|
||||||
|
// back to the healthy doc loaded from the EXISTING page.ydoc, without the
|
||||||
|
// seed. The title just won't render until a later successful heal —
|
||||||
|
// non-fatal, non-corrupting.
|
||||||
|
try {
|
||||||
|
return await this.healUnderLock(pageId);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to persist seeded ydoc for page ${pageId}; serving existing ydoc without title seed`,
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// if no ydoc state in db convert json in page.content to Ydoc.
|
// NOTE (offline-sync M1, Goal 2): this per-load self-heal converts +
|
||||||
|
// title-seeds + persists every legacy page (content set, ydoc null) on its
|
||||||
|
// first open, which neutralizes the duplication trap incrementally. A
|
||||||
|
// proactive one-shot BATCH migration over all such pages could be added
|
||||||
|
// later, but it requires the tiptap schema + TiptapTransformer (Node/Yjs),
|
||||||
|
// which a Kysely SQL migration cannot run; no runnable-task/CLI convention
|
||||||
|
// exists in this repo yet, so we deliberately avoid a fragile migration.
|
||||||
|
//
|
||||||
|
// If no ydoc state in db, REBUILD a Y.Doc from the JSON in page.content under
|
||||||
|
// a row lock (see healUnderLock).
|
||||||
if (page.content) {
|
if (page.content) {
|
||||||
this.logger.debug(`converting json to ydoc: ${pageId}`);
|
// REBUILD heal: surface failures. If the persist fails we REFUSE the load
|
||||||
|
// (re-throw) rather than hand out an unpersisted fresh-client-id rebuild —
|
||||||
const ydoc = TiptapTransformer.toYdoc(
|
// returning it would re-arm the duplication trap. A transient DB failure
|
||||||
page.content,
|
// means the client reconnects and retries: correctness over availability.
|
||||||
'default',
|
try {
|
||||||
tiptapExtensions,
|
return await this.healUnderLock(pageId);
|
||||||
);
|
} catch (err) {
|
||||||
|
this.logger.error(
|
||||||
Y.encodeStateAsUpdate(ydoc);
|
`Failed to persist rebuilt ydoc for page ${pageId}; refusing load`,
|
||||||
return ydoc;
|
err,
|
||||||
|
);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.debug(`creating fresh ydoc: ${pageId}`);
|
this.logger.debug(`creating fresh ydoc: ${pageId}`);
|
||||||
return new Y.Doc();
|
return new Y.Doc();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize the legacy self-heal (rebuild from page.content and/or seed the
|
||||||
|
* title fragment, then persist) so it runs exactly ONCE per page, closing the
|
||||||
|
* Yjs duplication trap. Both TiptapTransformer.toYdoc and buildTitleSeedYdoc
|
||||||
|
* mint FRESH Yjs client-ids every call, so two concurrent rebuilds (the API
|
||||||
|
* process via openDirectConnection AND the standalone collab process both
|
||||||
|
* seeing `ydoc IS NULL`) could each persist a different-client-id state and let
|
||||||
|
* a long-offline client merge-and-duplicate. We prevent that by re-reading the
|
||||||
|
* row FOR UPDATE inside a transaction and re-validating state under the lock:
|
||||||
|
* whoever wins the lock heals; the loser observes the healthy `ydoc` and adopts
|
||||||
|
* it instead of rebuilding. The persist happens IN THE SAME TX, so a failed
|
||||||
|
* write rolls back and propagates out (the caller then decides refuse vs.
|
||||||
|
* fall-back).
|
||||||
|
*/
|
||||||
|
private async healUnderLock(pageId: string): Promise<Y.Doc> {
|
||||||
|
return executeTx(this.db, async (trx) => {
|
||||||
|
const locked = await this.pageRepo.findById(pageId, {
|
||||||
|
withLock: true,
|
||||||
|
includeContent: true,
|
||||||
|
includeYdoc: true,
|
||||||
|
trx,
|
||||||
|
});
|
||||||
|
|
||||||
|
const doc = new Y.Doc();
|
||||||
|
let rebuilt = false;
|
||||||
|
|
||||||
|
if (locked?.ydoc) {
|
||||||
|
// Another process already healed (or the page always had a ydoc): adopt
|
||||||
|
// the healthy persisted state, do NOT rebuild.
|
||||||
|
Y.applyUpdate(doc, new Uint8Array(locked.ydoc));
|
||||||
|
} else if (locked?.content) {
|
||||||
|
this.logger.debug(`converting json to ydoc: ${pageId}`);
|
||||||
|
const built = TiptapTransformer.toYdoc(
|
||||||
|
locked.content,
|
||||||
|
'default',
|
||||||
|
tiptapExtensions,
|
||||||
|
);
|
||||||
|
Y.applyUpdate(doc, Y.encodeStateAsUpdate(built));
|
||||||
|
rebuilt = true;
|
||||||
|
}
|
||||||
|
// else: no ydoc and no content -> a fresh empty doc.
|
||||||
|
|
||||||
|
// Idempotent, emptiness-guarded title seed (safe to call always).
|
||||||
|
const seeded = this.seedTitleFragment(doc, locked?.title ?? null);
|
||||||
|
|
||||||
|
if (rebuilt || seeded) {
|
||||||
|
// Persist IN THE SAME TX. If this throws, the tx rolls back and the
|
||||||
|
// error propagates out of executeTx to the caller.
|
||||||
|
await this.pageRepo.updatePage(
|
||||||
|
{ ydoc: Buffer.from(Y.encodeStateAsUpdate(doc)) },
|
||||||
|
pageId,
|
||||||
|
trx,
|
||||||
|
);
|
||||||
|
this.logger.debug(`persisted rebuilt/seeded ydoc: ${pageId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return doc;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed the 'title' fragment of `doc` from the `page.title` column for legacy
|
||||||
|
* pages whose persisted ydoc has no title fragment yet.
|
||||||
|
*
|
||||||
|
* Guarded STRICTLY by emptiness: we only seed when the existing 'title'
|
||||||
|
* fragment is empty AND there is a non-empty column title. Seeding a non-empty
|
||||||
|
* fragment would re-introduce the Yjs duplication trap, so we never do it.
|
||||||
|
* Returns true when a seed was applied (so the caller can persist).
|
||||||
|
* Defensive: a malformed title must not break document loading.
|
||||||
|
*/
|
||||||
|
private seedTitleFragment(doc: Y.Doc, title: string | null): boolean {
|
||||||
|
const trimmed = (title ?? '').trim();
|
||||||
|
if (!trimmed) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const titleFrag = doc.get('title', Y.XmlFragment);
|
||||||
|
if (titleFrag.length !== 0) return false;
|
||||||
|
|
||||||
|
const titleSeed = buildTitleSeedYdoc(title);
|
||||||
|
Y.applyUpdate(doc, Y.encodeStateAsUpdate(titleSeed));
|
||||||
|
this.logger.debug('seeded title fragment from page.title column');
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`failed to seed title fragment: ${err?.['message']}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist an already-encoded Y.Doc state directly to `page.ydoc`, mirroring the
|
||||||
|
* `pageRepo.updatePage({ ydoc })` write that onStoreDocument uses.
|
||||||
|
*
|
||||||
|
* Used by the gateway's writePageTitle (F1, variant C). A REST/MCP/agent rename
|
||||||
|
* with no live editor writes the new title into the in-memory 'title' fragment,
|
||||||
|
* but onStoreDocument's no-op fast-path (page.title column already equals the
|
||||||
|
* new title) does NOT persist that in-memory fragment, so the stored `page.ydoc`
|
||||||
|
* keeps the OLD title — and a later body edit then reverts the rename (loads the
|
||||||
|
* OLD fragment, sees it differs from the column, overwrites the column back to
|
||||||
|
* OLD). Writing the ydoc here keeps the persisted fragment consistent with the
|
||||||
|
* column so the rename survives.
|
||||||
|
*
|
||||||
|
* Broadcast-safe / no double broadcast: this carries no `treeUpdate`, so the
|
||||||
|
* tree WS + redis listeners (which gate on `treeUpdate`) do NOT re-broadcast the
|
||||||
|
* rename — only PageService.update's own PAGE_UPDATED does. The only extra
|
||||||
|
* side-effect is an idempotent search reindex.
|
||||||
|
*
|
||||||
|
* Idempotent and lock-free, so it is safe whether or not a live editor is
|
||||||
|
* connected: Yjs state is cumulative, so a concurrent onStoreDocument simply
|
||||||
|
* persists a superset of this state later.
|
||||||
|
*/
|
||||||
|
async persistTitleFragmentYdoc(
|
||||||
|
pageId: string,
|
||||||
|
ydocState: Buffer,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.pageRepo.updatePage({ ydoc: ydocState }, pageId);
|
||||||
|
}
|
||||||
|
|
||||||
async onStoreDocument(data: onStoreDocumentPayload) {
|
async onStoreDocument(data: onStoreDocumentPayload) {
|
||||||
// #355 — time the full store (persist + post-store side effects) into
|
// #355 — time the full store (persist + post-store side effects) into
|
||||||
// collab_store_duration_seconds. No-op when METRICS_PORT is unset.
|
// collab_store_duration_seconds. No-op when METRICS_PORT is unset.
|
||||||
@@ -220,7 +385,34 @@ export class PersistenceExtension implements Extension {
|
|||||||
this.logger.warn('jsonToText' + err?.['message']);
|
this.logger.warn('jsonToText' + err?.['message']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Title lives in the SAME Y.Doc as the body, in a dedicated 'title' fragment
|
||||||
|
// (the collaborative title-editor contract with the client). Extract it
|
||||||
|
// defensively: a malformed title fragment must NOT crash the document store.
|
||||||
|
// `hasTitleFragment` distinguishes "the doc actually carries a title
|
||||||
|
// fragment" from "legacy doc with no title fragment" — only the former may
|
||||||
|
// write page.title, so a legacy doc never clobbers the column with ''.
|
||||||
|
let titleText = '';
|
||||||
|
let hasTitleFragment = false;
|
||||||
|
try {
|
||||||
|
const titleFrag = document.get('title', Y.XmlFragment);
|
||||||
|
hasTitleFragment = !!titleFrag && titleFrag.length > 0;
|
||||||
|
if (hasTitleFragment) {
|
||||||
|
const titleJson = TiptapTransformer.fromYdoc(document, 'title');
|
||||||
|
titleText = titleJson ? jsonToText(titleJson).trim() : '';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn('title extraction: ' + err?.['message']);
|
||||||
|
hasTitleFragment = false;
|
||||||
|
}
|
||||||
|
|
||||||
let page: Page = null;
|
let page: Page = null;
|
||||||
|
// Tracks whether the BODY ('default') changed in this store. The heavy
|
||||||
|
// body-only side-effects (transclusion sync, mentions, RAG, history) stay
|
||||||
|
// gated on this so a title-only change does not trigger them.
|
||||||
|
let bodyChanged = false;
|
||||||
|
// Tracks a successful title-only persist so the post-tx contributor folding
|
||||||
|
// (collabHistory.addContributors) runs for the title-only case too.
|
||||||
|
let titleOnlyPersisted = false;
|
||||||
const editingUserIds = this.consumeContributors(documentName);
|
const editingUserIds = this.consumeContributors(documentName);
|
||||||
// Sticky agent marker: 'agent' if any agent edit landed in this window, OR
|
// 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
|
// if the current writer is the agent (covers a store with no prior onChange
|
||||||
@@ -267,18 +459,88 @@ export class PersistenceExtension implements Extension {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isDeepStrictEqual(tiptapJson, page.content)) {
|
bodyChanged = !isDeepStrictEqual(tiptapJson, page.content);
|
||||||
|
// Only a populated 'title' fragment may update page.title; compare
|
||||||
|
// against the current column value (treat null as '').
|
||||||
|
//
|
||||||
|
// ANTI-CORRUPTION GUARD (Bug 2): the client's collaborative title-editor
|
||||||
|
// can momentarily initialize the 'title' fragment as an EMPTY heading
|
||||||
|
// (so `hasTitleFragment` is true, but the extracted `titleText` is '')
|
||||||
|
// BEFORE the server's real-title seed has synced. Writing that '' would
|
||||||
|
// silently wipe a non-empty page.title to "untitled". A wiki page is
|
||||||
|
// never legitimately retitled to empty via this path, so we treat an
|
||||||
|
// empty extracted title as "not authoritative" and never persist it.
|
||||||
|
// The `titleText.length > 0` clause makes this guard apply to BOTH the
|
||||||
|
// title-only branch and the body+title branch below.
|
||||||
|
//
|
||||||
|
// DELIBERATE: this intentionally makes it impossible to retitle a page
|
||||||
|
// to EMPTY via the collab path — a wiki page is never legitimately
|
||||||
|
// empty-titled. If a non-empty-title rule ever needs relaxing or
|
||||||
|
// enforcing differently, the REST UpdatePageDto is the place to validate
|
||||||
|
// the title, not this collab guard.
|
||||||
|
const titleChanged =
|
||||||
|
hasTitleFragment &&
|
||||||
|
titleText.length > 0 &&
|
||||||
|
titleText !== (page.title ?? '');
|
||||||
|
|
||||||
|
// No-op fast path: neither body nor title changed.
|
||||||
|
if (!bodyChanged && !titleChanged) {
|
||||||
page = null;
|
page = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Title-only change: the body is unchanged, so skip the heavy body
|
||||||
|
// history/contributor logic and persist just the new title and the
|
||||||
|
// ydoc (the title fragment edit lives in the same ydoc). The early-skip
|
||||||
|
// used to drop this case entirely, losing the title change.
|
||||||
|
if (!bodyChanged) {
|
||||||
|
// Fold the window's editing users into contributors the same way the
|
||||||
|
// body branch does, so a user who edited ONLY the title is not dropped
|
||||||
|
// from page.contributorIds.
|
||||||
|
const contributorIds = Array.from(
|
||||||
|
new Set([
|
||||||
|
...(page.contributorIds || []),
|
||||||
|
...editingUserIds,
|
||||||
|
page.creatorId,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
await this.pageRepo.updatePage(
|
||||||
|
{
|
||||||
|
title: titleText,
|
||||||
|
ydoc: ydocState,
|
||||||
|
lastUpdatedById: context.user.id,
|
||||||
|
contributorIds,
|
||||||
|
// A title-only change is not a body-authorship transition; leave
|
||||||
|
// lastUpdatedSource/aiChatId untouched so the user->agent history
|
||||||
|
// boundary in the body branch is not bypassed.
|
||||||
|
},
|
||||||
|
pageId,
|
||||||
|
trx,
|
||||||
|
// Mirror PageService.update's tree snapshot so a collaborative rename
|
||||||
|
// propagates to other users' sidebar/breadcrumbs like the REST rename.
|
||||||
|
{
|
||||||
|
treeUpdate: {
|
||||||
|
id: pageId,
|
||||||
|
slugId: page.slugId,
|
||||||
|
spaceId: page.spaceId,
|
||||||
|
parentPageId: page.parentPageId ?? null,
|
||||||
|
title: titleText,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
this.logger.debug(`Page title updated: ${pageId} - SlugId: ${page.slugId}`);
|
||||||
|
titleOnlyPersisted = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// #206 persist-6 / #248 — store-side empty-guard. A momentarily-empty
|
// #206 persist-6 / #248 — store-side empty-guard. A momentarily-empty
|
||||||
// live Y.Doc (a client/agent glitch, a bad merge, a transclusion that
|
// live Y.Doc (a client/agent glitch, a bad merge, a transclusion that
|
||||||
// emptied) must NOT overwrite non-empty persisted content. The LOAD
|
// emptied) must NOT overwrite non-empty persisted content. The LOAD
|
||||||
// path already guards emptiness (onLoadDocument only hydrates from db
|
// path already guards emptiness (onLoadDocument only hydrates from db
|
||||||
// when the live doc isEmpty); the STORE path did not, so an empty
|
// when the live doc isEmpty); the STORE path did not, so an empty
|
||||||
// serialization was written straight over the page, wiping it
|
// serialization was written straight over the page, wiping it
|
||||||
// silently.
|
// silently. Reached only when the body actually changed (the title-only
|
||||||
|
// fast path above already returned).
|
||||||
//
|
//
|
||||||
// #251 — the ONE legitimate empty-over-non-empty write is a user who
|
// #251 — the ONE legitimate empty-over-non-empty write is a user who
|
||||||
// deliberately clears the page. That intent arrives out-of-band as a
|
// deliberately clears the page. That intent arrives out-of-band as a
|
||||||
@@ -348,12 +610,8 @@ export class PersistenceExtension implements Extension {
|
|||||||
{ includeContent: true, trx },
|
{ includeContent: true, trx },
|
||||||
);
|
);
|
||||||
const humanBaselineMissing =
|
const humanBaselineMissing =
|
||||||
!lastHistory ||
|
!lastHistory || !isDeepStrictEqual(lastHistory.content, page.content);
|
||||||
!isDeepStrictEqual(lastHistory.content, page.content);
|
if (!isEmptyParagraphDoc(page.content as any) && humanBaselineMissing) {
|
||||||
if (
|
|
||||||
!isEmptyParagraphDoc(page.content as any) &&
|
|
||||||
humanBaselineMissing
|
|
||||||
) {
|
|
||||||
await this.pageHistoryRepo.saveHistory(page, {
|
await this.pageHistoryRepo.saveHistory(page, {
|
||||||
contributorIds: page.contributorIds ?? undefined,
|
contributorIds: page.contributorIds ?? undefined,
|
||||||
trx,
|
trx,
|
||||||
@@ -371,9 +629,27 @@ export class PersistenceExtension implements Extension {
|
|||||||
lastUpdatedSource,
|
lastUpdatedSource,
|
||||||
lastUpdatedAiChatId: context?.aiChatId ?? null,
|
lastUpdatedAiChatId: context?.aiChatId ?? null,
|
||||||
contributorIds: contributorIds,
|
contributorIds: contributorIds,
|
||||||
|
// Persist the title in the SAME transaction when the title fragment
|
||||||
|
// changed alongside the body.
|
||||||
|
...(titleChanged ? { title: titleText } : {}),
|
||||||
},
|
},
|
||||||
pageId,
|
pageId,
|
||||||
trx,
|
trx,
|
||||||
|
// Mirror PageService.update's tree snapshot so a collaborative rename
|
||||||
|
// propagates to other users' sidebar/breadcrumbs like the REST rename.
|
||||||
|
// Only attach when the title actually changed; a body-only save must
|
||||||
|
// not trigger a tree broadcast.
|
||||||
|
titleChanged
|
||||||
|
? {
|
||||||
|
treeUpdate: {
|
||||||
|
id: pageId,
|
||||||
|
slugId: page.slugId,
|
||||||
|
spaceId: page.spaceId,
|
||||||
|
parentPageId: page.parentPageId ?? null,
|
||||||
|
title: titleText,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
this.logger.debug(`Page updated: ${pageId} - SlugId: ${page.slugId}`);
|
this.logger.debug(`Page updated: ${pageId} - SlugId: ${page.slugId}`);
|
||||||
@@ -394,6 +670,8 @@ export class PersistenceExtension implements Extension {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `page` is truthy whenever anything was persisted (body OR title-only), so
|
||||||
|
// the page.updated broadcast fires for a title-only change too.
|
||||||
if (page) {
|
if (page) {
|
||||||
document.broadcastStateless(
|
document.broadcastStateless(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@@ -411,14 +689,26 @@ export class PersistenceExtension implements Extension {
|
|||||||
: undefined,
|
: undefined,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record the window's editing users in collab history for a title-only
|
||||||
|
// change too (the body branch does this below, gated on bodyChanged). Key
|
||||||
|
// contributors by the canonical page UUID (page.id), not the doc-name id
|
||||||
|
// which may be a slugId, so they MATCH the PAGE_HISTORY job which is
|
||||||
|
// enqueued with page.id and pops contributors by page.id (#260).
|
||||||
|
if (page && titleOnlyPersisted) {
|
||||||
|
await this.collabHistory.addContributors(page.id, editingUserIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Body-only side-effects: skip them for a title-only change (body unchanged).
|
||||||
|
if (page && bodyChanged) {
|
||||||
// Use the canonical page UUID (page.id), not the doc-name id, which may be
|
// Use the canonical page UUID (page.id), not the doc-name id, which may be
|
||||||
// a slugId for a `page.<slugId>` doc (#260). The transclusion/reference
|
// a slugId for a `page.<slugId>` doc (#260). The transclusion/reference
|
||||||
// syncs write uuid-typed columns, so a slugId here threw Postgres 22P02.
|
// syncs write uuid-typed columns, so a slugId here threw Postgres 22P02.
|
||||||
await this.syncTransclusion(page.id, page.workspaceId, tiptapJson);
|
await this.syncTransclusion(page.id, page.workspaceId, tiptapJson);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (page) {
|
if (page && bodyChanged) {
|
||||||
// Key contributors by the page UUID so they MATCH the PAGE_HISTORY job,
|
// Key contributors by the page UUID so they MATCH the PAGE_HISTORY job,
|
||||||
// which is enqueued with page.id and pops contributors by page.id (#260).
|
// which is enqueued with page.id and pops contributors by page.id (#260).
|
||||||
await this.collabHistory.addContributors(page.id, editingUserIds);
|
await this.collabHistory.addContributors(page.id, editingUserIds);
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { RedisService } from '@nestjs-labs/nestjs-ioredis';
|
||||||
|
import { PageTreeBridgePublisher } from './page-tree-bridge.publisher';
|
||||||
|
import { COLLAB_TREE_UPDATE_CHANNEL } from '../constants';
|
||||||
|
import {
|
||||||
|
PageEvent,
|
||||||
|
TreeUpdateSnapshot,
|
||||||
|
} from '../../database/listeners/page.listener';
|
||||||
|
|
||||||
|
const treeUpdate: TreeUpdateSnapshot = {
|
||||||
|
id: 'page-1',
|
||||||
|
slugId: 'slug-1',
|
||||||
|
spaceId: 'space-1',
|
||||||
|
parentPageId: null,
|
||||||
|
title: 'Renamed',
|
||||||
|
icon: '🚀',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('PageTreeBridgePublisher', () => {
|
||||||
|
let publisher: PageTreeBridgePublisher;
|
||||||
|
let redis: { publish: jest.Mock };
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
redis = { publish: jest.fn().mockResolvedValue(1) };
|
||||||
|
const redisService = { getOrThrow: () => redis } as unknown as RedisService;
|
||||||
|
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
PageTreeBridgePublisher,
|
||||||
|
{ provide: RedisService, useValue: redisService },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
publisher = module.get<PageTreeBridgePublisher>(PageTreeBridgePublisher);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('WITH a `treeUpdate`: publishes the JSON snapshot on the channel', async () => {
|
||||||
|
const event: PageEvent = {
|
||||||
|
pageIds: ['page-1'],
|
||||||
|
workspaceId: 'ws-1',
|
||||||
|
treeUpdate,
|
||||||
|
};
|
||||||
|
|
||||||
|
await publisher.onPageUpdated(event);
|
||||||
|
|
||||||
|
expect(redis.publish).toHaveBeenCalledTimes(1);
|
||||||
|
expect(redis.publish).toHaveBeenCalledWith(
|
||||||
|
COLLAB_TREE_UPDATE_CHANNEL,
|
||||||
|
JSON.stringify(treeUpdate),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('content-only save (NO `treeUpdate`): does NOT publish', async () => {
|
||||||
|
const event: PageEvent = {
|
||||||
|
pageIds: ['page-1'],
|
||||||
|
workspaceId: 'ws-1',
|
||||||
|
};
|
||||||
|
|
||||||
|
await publisher.onPageUpdated(event);
|
||||||
|
|
||||||
|
expect(redis.publish).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a publish rejection is caught (no throw)', async () => {
|
||||||
|
redis.publish.mockRejectedValueOnce(new Error('redis down'));
|
||||||
|
const errorSpy = jest
|
||||||
|
.spyOn(publisher['logger'], 'error')
|
||||||
|
.mockImplementation(() => undefined);
|
||||||
|
|
||||||
|
const event: PageEvent = {
|
||||||
|
pageIds: ['page-1'],
|
||||||
|
workspaceId: 'ws-1',
|
||||||
|
treeUpdate,
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(publisher.onPageUpdated(event)).resolves.toBeUndefined();
|
||||||
|
expect(errorSpy).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { OnEvent } from '@nestjs/event-emitter';
|
||||||
|
import { RedisService } from '@nestjs-labs/nestjs-ioredis';
|
||||||
|
import type { Redis } from 'ioredis';
|
||||||
|
import { EventName } from '../../common/events/event.contants';
|
||||||
|
import { PageEvent } from '../../database/listeners/page.listener';
|
||||||
|
import { COLLAB_TREE_UPDATE_CHANNEL } from '../constants';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collab-process half of the cross-process tree-update bridge.
|
||||||
|
*
|
||||||
|
* The standalone collab process bootstraps `CollabAppModule`, which does NOT
|
||||||
|
* import `WsModule`/`PageWsListener`. So when a collaborative title/icon rename
|
||||||
|
* persists and emits `EventName.PAGE_UPDATED` with a `treeUpdate` snapshot, there
|
||||||
|
* is no listener in this process to broadcast it — the live tree update would be
|
||||||
|
* lost for 2-process (COLLAB_URL set) deployments.
|
||||||
|
*
|
||||||
|
* This publisher fills that gap: it forwards the `treeUpdate` snapshot over a
|
||||||
|
* Redis pub/sub channel to the API process, which re-broadcasts it via
|
||||||
|
* `WsTreeService` (the single broadcast authority).
|
||||||
|
*
|
||||||
|
* It is registered ONLY in `CollabAppModule.providers`, so it never runs in the
|
||||||
|
* API process (where `PageWsListener` already broadcasts the same event locally).
|
||||||
|
* That module placement is what prevents a double broadcast. In single-process
|
||||||
|
* mode `CollabAppModule` is not loaded at all, so this publisher never runs.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class PageTreeBridgePublisher {
|
||||||
|
private readonly logger = new Logger(PageTreeBridgePublisher.name);
|
||||||
|
private readonly redis: Redis;
|
||||||
|
|
||||||
|
constructor(private readonly redisService: RedisService) {
|
||||||
|
this.redis = this.redisService.getOrThrow();
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnEvent(EventName.PAGE_UPDATED)
|
||||||
|
async onPageUpdated(event: PageEvent): Promise<void> {
|
||||||
|
// Mirror PageWsListener's gating: only title/icon changes carry a snapshot.
|
||||||
|
// Content-only saves leave `treeUpdate` undefined and are ignored.
|
||||||
|
if (!event.treeUpdate) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.redis.publish(
|
||||||
|
COLLAB_TREE_UPDATE_CHANNEL,
|
||||||
|
JSON.stringify(event.treeUpdate),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
// A Redis publish failure must not break the store path.
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to publish tree update to ${COLLAB_TREE_UPDATE_CHANNEL}`,
|
||||||
|
err instanceof Error ? err.stack : String(err),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import { CaslModule } from '../../core/casl/casl.module';
|
|||||||
import { ThrottleModule } from '../../integrations/throttle/throttle.module';
|
import { ThrottleModule } from '../../integrations/throttle/throttle.module';
|
||||||
import { CacheModule } from '@nestjs/cache-manager';
|
import { CacheModule } from '@nestjs/cache-manager';
|
||||||
import KeyvRedis from '@keyv/redis';
|
import KeyvRedis from '@keyv/redis';
|
||||||
|
import { PageTreeBridgePublisher } from '../listeners/page-tree-bridge.publisher';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -54,6 +55,6 @@ import KeyvRedis from '@keyv/redis';
|
|||||||
? [CollaborationController]
|
? [CollaborationController]
|
||||||
: []),
|
: []),
|
||||||
],
|
],
|
||||||
providers: [AppService],
|
providers: [AppService, PageTreeBridgePublisher],
|
||||||
})
|
})
|
||||||
export class CollabAppModule {}
|
export class CollabAppModule {}
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import * as Y from 'yjs';
|
||||||
|
import { TiptapTransformer } from '@hocuspocus/transformer';
|
||||||
|
import { CollaborationGateway } from './collaboration.gateway';
|
||||||
|
import { PersistenceExtension } from './extensions/persistence.extension';
|
||||||
|
import {
|
||||||
|
buildTitleSeedYdoc,
|
||||||
|
jsonToText,
|
||||||
|
tiptapExtensions,
|
||||||
|
} from './collaboration.util';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* F1 (variant C) — rename durability for a page with an already-persisted Yjs
|
||||||
|
* 'title' fragment and NO live editor (the REST/MCP/agent rename path).
|
||||||
|
*
|
||||||
|
* The bug: PageService.update writes the NEW title to the `page.title` COLUMN,
|
||||||
|
* then calls gateway.writePageTitle, which loads the page's ydoc (fragment =
|
||||||
|
* OLD) and overwrites it to NEW in memory. On disconnect, onStoreDocument sees
|
||||||
|
* titleText(NEW) === column(NEW) → no-op fast-path → it does NOT persist the
|
||||||
|
* in-memory fragment. So `page.ydoc` keeps the OLD title, and a LATER body edit
|
||||||
|
* loads the OLD fragment, sees it differs from the column, and silently reverts
|
||||||
|
* the column back to OLD.
|
||||||
|
*
|
||||||
|
* The fix: writePageTitle persists the 'title' fragment to `page.ydoc` DIRECTLY
|
||||||
|
* (via PersistenceExtension.persistTitleFragmentYdoc) after the transact, so the
|
||||||
|
* persisted fragment and the column stay consistent.
|
||||||
|
*
|
||||||
|
* This test drives the REAL writePageTitle + the REAL onStoreDocument against an
|
||||||
|
* in-memory page row, so it FAILS on the pre-fix no-op behaviour and PASSES after.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PAGE_ID = '550e8400-e29b-41d4-a716-446655440000';
|
||||||
|
const USER_ID = 'user-1';
|
||||||
|
const OLD_TITLE = 'Old Title';
|
||||||
|
const NEW_TITLE = 'Renamed Title';
|
||||||
|
|
||||||
|
const bodyJson = (text: string) => ({
|
||||||
|
type: 'doc',
|
||||||
|
content: [{ type: 'paragraph', content: [{ type: 'text', text }] }],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build the initial persisted ydoc carrying BOTH a 'title' fragment and a body.
|
||||||
|
const makeInitialYdoc = (title: string, body: any): Buffer => {
|
||||||
|
const doc = new Y.Doc();
|
||||||
|
Y.applyUpdate(doc, Y.encodeStateAsUpdate(buildTitleSeedYdoc(title)));
|
||||||
|
Y.applyUpdate(
|
||||||
|
doc,
|
||||||
|
Y.encodeStateAsUpdate(TiptapTransformer.toYdoc(body, 'default', tiptapExtensions)),
|
||||||
|
);
|
||||||
|
return Buffer.from(Y.encodeStateAsUpdate(doc));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load a doc from a persisted buffer (mirrors openDirectConnection loading from
|
||||||
|
// persistence when no editor is connected). hocuspocus augments the live doc
|
||||||
|
// with broadcastStateless(); a bare Y.Doc lacks it, so stub it.
|
||||||
|
const loadDoc = (buf: Buffer): Y.Doc => {
|
||||||
|
const doc = new Y.Doc();
|
||||||
|
if (buf) Y.applyUpdate(doc, new Uint8Array(buf));
|
||||||
|
(doc as any).broadcastStateless = jest.fn();
|
||||||
|
return doc;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Read the 'title' fragment text from a persisted buffer.
|
||||||
|
const readTitle = (buf: Buffer): string => {
|
||||||
|
const doc = loadDoc(buf);
|
||||||
|
const titleJson = TiptapTransformer.fromYdoc(doc, 'title');
|
||||||
|
return titleJson ? jsonToText(titleJson).trim() : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('rename durability (F1 variant C): persisted title fragment survives a body edit', () => {
|
||||||
|
it('persists the renamed title into page.ydoc so a later body edit does not revert it', async () => {
|
||||||
|
// In-memory page row = the DB.
|
||||||
|
const row: any = {
|
||||||
|
id: PAGE_ID,
|
||||||
|
slugId: 'slug-1',
|
||||||
|
spaceId: 'space-1',
|
||||||
|
workspaceId: 'ws-1',
|
||||||
|
creatorId: 'creator-1',
|
||||||
|
contributorIds: ['creator-1'],
|
||||||
|
createdAt: new Date('2020-01-01T00:00:00Z'),
|
||||||
|
lastUpdatedSource: 'user',
|
||||||
|
title: OLD_TITLE,
|
||||||
|
// content column mirrors the normalized body in the ydoc.
|
||||||
|
content: TiptapTransformer.fromYdoc(
|
||||||
|
loadDoc(makeInitialYdoc(OLD_TITLE, bodyJson('BODY V1'))),
|
||||||
|
'default',
|
||||||
|
),
|
||||||
|
ydoc: makeInitialYdoc(OLD_TITLE, bodyJson('BODY V1')),
|
||||||
|
};
|
||||||
|
|
||||||
|
const pageRepo = {
|
||||||
|
findById: jest.fn(async () => ({ ...row })),
|
||||||
|
updatePage: jest.fn(async (data: any, _pageId?: string) => {
|
||||||
|
Object.assign(row, data, { updatedAt: new Date() });
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const pageHistoryRepo = {
|
||||||
|
saveHistory: jest.fn().mockResolvedValue(undefined),
|
||||||
|
findPageLastHistory: jest.fn().mockResolvedValue(null),
|
||||||
|
};
|
||||||
|
const noopQueue = { 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),
|
||||||
|
};
|
||||||
|
// db whose transaction().execute(fn) runs fn with a trx stub (drives the
|
||||||
|
// real executeTx helper without a database).
|
||||||
|
const db = {
|
||||||
|
transaction: () => ({
|
||||||
|
execute: (fn: (trx: any) => Promise<any>) => fn({ __trx: true }),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const ext = new PersistenceExtension(
|
||||||
|
pageRepo as any,
|
||||||
|
pageHistoryRepo as any,
|
||||||
|
db as any,
|
||||||
|
noopQueue as any,
|
||||||
|
noopQueue as any,
|
||||||
|
noopQueue as any,
|
||||||
|
collabHistory as any,
|
||||||
|
transclusionService as any,
|
||||||
|
);
|
||||||
|
jest.spyOn(ext['logger'], 'debug').mockImplementation(() => undefined);
|
||||||
|
jest.spyOn(ext['logger'], 'warn').mockImplementation(() => undefined);
|
||||||
|
jest.spyOn(ext['logger'], 'error').mockImplementation(() => undefined);
|
||||||
|
|
||||||
|
const documentName = `page.${PAGE_ID}`;
|
||||||
|
// Fake hocuspocus: openDirectConnection loads a doc from the CURRENT persisted
|
||||||
|
// ydoc (no live editor) and, on disconnect, runs the real onStoreDocument —
|
||||||
|
// exactly the no-live-editor unload path.
|
||||||
|
const fakeHocuspocus = {
|
||||||
|
openDirectConnection: jest.fn(async (name: string, context: any) => {
|
||||||
|
const liveDoc = loadDoc(row.ydoc);
|
||||||
|
return {
|
||||||
|
transact: async (fn: (doc: Y.Doc) => void) => fn(liveDoc),
|
||||||
|
disconnect: async () => {
|
||||||
|
await ext.onStoreDocument({
|
||||||
|
documentName: name,
|
||||||
|
document: liveDoc,
|
||||||
|
context,
|
||||||
|
} as any);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const gateway: CollaborationGateway = Object.create(
|
||||||
|
CollaborationGateway.prototype,
|
||||||
|
);
|
||||||
|
(gateway as any).hocuspocus = fakeHocuspocus;
|
||||||
|
(gateway as any).persistenceExtension = ext;
|
||||||
|
|
||||||
|
// --- REST/service rename (no live editor) ---
|
||||||
|
// 1) PageService.update writes the NEW title to the column.
|
||||||
|
await pageRepo.updatePage({ title: NEW_TITLE }, PAGE_ID);
|
||||||
|
// 2) PageService.update syncs the Yjs 'title' fragment.
|
||||||
|
await gateway.writePageTitle(PAGE_ID, NEW_TITLE, {
|
||||||
|
user: { id: USER_ID } as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reload the persisted ydoc: the 'title' fragment must now be NEW.
|
||||||
|
// (Pre-fix this is still OLD — writePageTitle did not persist the fragment.)
|
||||||
|
expect(readTitle(row.ydoc)).toBe(NEW_TITLE);
|
||||||
|
|
||||||
|
// --- a later body edit must NOT revert the title ---
|
||||||
|
const editDoc = loadDoc(row.ydoc);
|
||||||
|
const frag = editDoc.getXmlFragment('default');
|
||||||
|
const p = new Y.XmlElement('paragraph');
|
||||||
|
const t = new Y.XmlText();
|
||||||
|
t.insert(0, 'appended');
|
||||||
|
p.insert(0, [t]);
|
||||||
|
frag.insert(frag.length, [p]);
|
||||||
|
|
||||||
|
await ext.onStoreDocument({
|
||||||
|
documentName,
|
||||||
|
document: editDoc,
|
||||||
|
context: { user: { id: USER_ID } },
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
// The body edit was persisted, and the title stayed NEW in BOTH the column
|
||||||
|
// and the persisted ydoc fragment (pre-fix the column reverts to OLD).
|
||||||
|
expect(row.title).toBe(NEW_TITLE);
|
||||||
|
expect(readTitle(row.ydoc)).toBe(NEW_TITLE);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -610,6 +610,63 @@ describe('AiAgentRolesService guards', () => {
|
|||||||
expect(repo.insert.mock.calls[0][0].name).toBe('Researcher (2)');
|
expect(repo.insert.mock.calls[0][0].name).toBe('Researcher (2)');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('createdRoles lists the installed role (no renamedTo when not renamed)', async () => {
|
||||||
|
const { service } = makeImportService({});
|
||||||
|
const res = await service.importFromCatalog('ws-1', 'u1', dto());
|
||||||
|
expect(res.createdRoles).toEqual([
|
||||||
|
{ slug: 'researcher', name: 'Researcher' },
|
||||||
|
]);
|
||||||
|
expect(res.skippedRoles).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('createdRoles carries renamedTo on a rename', async () => {
|
||||||
|
const existing = [makeRow({ id: 'r-x', name: 'Researcher' })];
|
||||||
|
const { service } = makeImportService({ existing });
|
||||||
|
const res = await service.importFromCatalog(
|
||||||
|
'ws-1',
|
||||||
|
'u1',
|
||||||
|
dto({ conflict: 'rename' }),
|
||||||
|
);
|
||||||
|
expect(res.createdRoles).toEqual([
|
||||||
|
{ slug: 'researcher', name: 'Researcher', renamedTo: 'Researcher (2)' },
|
||||||
|
]);
|
||||||
|
expect(res.skippedRoles).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skippedRoles: already-installed slug carries reason "already-installed"', async () => {
|
||||||
|
const existing = [
|
||||||
|
makeRow({
|
||||||
|
id: 'r-existing',
|
||||||
|
name: 'Old researcher',
|
||||||
|
source: { slug: 'researcher', language: 'en', version: 1 } as never,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
const { service } = makeImportService({ existing });
|
||||||
|
const res = await service.importFromCatalog('ws-1', 'u1', dto());
|
||||||
|
expect(res.skippedRoles).toEqual([
|
||||||
|
{
|
||||||
|
slug: 'researcher',
|
||||||
|
name: 'Researcher',
|
||||||
|
reason: 'already-installed',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(res.createdRoles).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skippedRoles: a name collision under conflict:skip carries reason "name-conflict"', async () => {
|
||||||
|
const existing = [makeRow({ id: 'r-x', name: 'Researcher' })];
|
||||||
|
const { service } = makeImportService({ existing });
|
||||||
|
const res = await service.importFromCatalog(
|
||||||
|
'ws-1',
|
||||||
|
'u1',
|
||||||
|
dto({ conflict: 'skip' }),
|
||||||
|
);
|
||||||
|
expect(res.skippedRoles).toEqual([
|
||||||
|
{ slug: 'researcher', name: 'Researcher', reason: 'name-conflict' },
|
||||||
|
]);
|
||||||
|
expect(res.createdRoles).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it('dto.slugs filters; an unknown slug becomes an error entry', async () => {
|
it('dto.slugs filters; an unknown slug becomes an error entry', async () => {
|
||||||
const { service, repo } = makeImportService({
|
const { service, repo } = makeImportService({
|
||||||
bundleRoles: [catalogRole()],
|
bundleRoles: [catalogRole()],
|
||||||
@@ -677,6 +734,15 @@ describe('AiAgentRolesService guards', () => {
|
|||||||
// 'a' converged on the concurrent install (skip); 'b' imported; no errors.
|
// 'a' converged on the concurrent install (skip); 'b' imported; no errors.
|
||||||
expect(res).toMatchObject({ created: 1, skipped: 1, renamed: 0 });
|
expect(res).toMatchObject({ created: 1, skipped: 1, renamed: 0 });
|
||||||
expect(res.errors).toEqual([]);
|
expect(res.errors).toEqual([]);
|
||||||
|
// The per-role list records 'a' as an already-installed skip (the UI reads
|
||||||
|
// skippedRoles, not the counter, to render its plaque — assert the array,
|
||||||
|
// not just the count).
|
||||||
|
expect(res.skippedRoles).toContainEqual({
|
||||||
|
slug: 'a',
|
||||||
|
name: 'A',
|
||||||
|
reason: 'already-installed',
|
||||||
|
});
|
||||||
|
expect(res.createdRoles.map((r) => r.slug)).toEqual(['b']);
|
||||||
// Both inserts were attempted (the batch did not abort on the 23505).
|
// Both inserts were attempted (the batch did not abort on the 23505).
|
||||||
expect(repo.insert).toHaveBeenCalledTimes(2);
|
expect(repo.insert).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -305,6 +305,16 @@ export class AiAgentRolesService {
|
|||||||
skipped: number;
|
skipped: number;
|
||||||
renamed: number;
|
renamed: number;
|
||||||
errors: { slug: string; message: string }[];
|
errors: { slug: string; message: string }[];
|
||||||
|
// Per-role lists alongside the counters (kept for back-compat). The redesigned
|
||||||
|
// catalog UI needs the actual roles — which were created (and any rename) and
|
||||||
|
// which were skipped and why — to render an inline result plaque with the
|
||||||
|
// conflicting role's name and a "Rename & install" affordance.
|
||||||
|
createdRoles: { slug: string; name: string; renamedTo?: string }[];
|
||||||
|
skippedRoles: {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
reason: 'name-conflict' | 'already-installed';
|
||||||
|
}[];
|
||||||
}> {
|
}> {
|
||||||
const { file, versions } = await this.loadBundleById(
|
const { file, versions } = await this.loadBundleById(
|
||||||
dto.bundleId,
|
dto.bundleId,
|
||||||
@@ -312,6 +322,13 @@ export class AiAgentRolesService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const errors: { slug: string; message: string }[] = [];
|
const errors: { slug: string; message: string }[] = [];
|
||||||
|
const createdRoles: { slug: string; name: string; renamedTo?: string }[] =
|
||||||
|
[];
|
||||||
|
const skippedRoles: {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
reason: 'name-conflict' | 'already-installed';
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
// Resolve the selected catalog roles (honor dto.slugs; flag unknown ones).
|
// Resolve the selected catalog roles (honor dto.slugs; flag unknown ones).
|
||||||
let selected = file.roles;
|
let selected = file.roles;
|
||||||
@@ -351,16 +368,27 @@ export class AiAgentRolesService {
|
|||||||
// Already installed from the catalog in THIS language => skip (use
|
// Already installed from the catalog in THIS language => skip (use
|
||||||
// update-from-catalog). A different language of the same slug still imports.
|
// update-from-catalog). A different language of the same slug still imports.
|
||||||
const installKey = `${role.slug}:${dto.language}`;
|
const installKey = `${role.slug}:${dto.language}`;
|
||||||
|
const originalName = role.name.trim();
|
||||||
if (installedKeys.has(installKey)) {
|
if (installedKeys.has(installKey)) {
|
||||||
skipped++;
|
skipped++;
|
||||||
|
skippedRoles.push({
|
||||||
|
slug: role.slug,
|
||||||
|
name: originalName,
|
||||||
|
reason: 'already-installed',
|
||||||
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let name = role.name.trim();
|
let name = originalName;
|
||||||
let didRename = false;
|
let didRename = false;
|
||||||
if (takenNames.has(name.toLowerCase())) {
|
if (takenNames.has(name.toLowerCase())) {
|
||||||
if (dto.conflict === 'skip') {
|
if (dto.conflict === 'skip') {
|
||||||
skipped++;
|
skipped++;
|
||||||
|
skippedRoles.push({
|
||||||
|
slug: role.slug,
|
||||||
|
name: originalName,
|
||||||
|
reason: 'name-conflict',
|
||||||
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// conflict === 'rename': find a free " (N)" suffix.
|
// conflict === 'rename': find a free " (N)" suffix.
|
||||||
@@ -380,6 +408,11 @@ export class AiAgentRolesService {
|
|||||||
});
|
});
|
||||||
created++;
|
created++;
|
||||||
if (didRename) renamed++;
|
if (didRename) renamed++;
|
||||||
|
createdRoles.push({
|
||||||
|
slug: role.slug,
|
||||||
|
name: originalName,
|
||||||
|
...(didRename ? { renamedTo: name } : {}),
|
||||||
|
});
|
||||||
takenNames.add(name.toLowerCase());
|
takenNames.add(name.toLowerCase());
|
||||||
installedKeys.add(installKey);
|
installedKeys.add(installKey);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -391,6 +424,11 @@ export class AiAgentRolesService {
|
|||||||
// skipped (already installed) and continue; do NOT abort or error.
|
// skipped (already installed) and continue; do NOT abort or error.
|
||||||
if (isSourceUniqueViolation(err)) {
|
if (isSourceUniqueViolation(err)) {
|
||||||
skipped++;
|
skipped++;
|
||||||
|
skippedRoles.push({
|
||||||
|
slug: role.slug,
|
||||||
|
name: originalName,
|
||||||
|
reason: 'already-installed',
|
||||||
|
});
|
||||||
installedKeys.add(installKey);
|
installedKeys.add(installKey);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -407,7 +445,7 @@ export class AiAgentRolesService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { created, skipped, renamed, errors };
|
return { created, skipped, renamed, errors, createdRoles, skippedRoles };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -539,3 +539,115 @@ describe('AiChatToolsService model-friendly input validation (#190)', () => {
|
|||||||
expect(result.error?.message).toContain('parameter "pageId": missing (required)');
|
expect(result.error?.message).toContain('parameter "pageId": missing (required)');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #294 F1 — the contract-parity test introspects only the ADVERTISED schema keys
|
||||||
|
* (buildShape), not the execute bodies. Most execs are unchanged pass-throughs,
|
||||||
|
* but two wirings actually CHANGED in the migration and are otherwise untested:
|
||||||
|
* - movePage now forwards the newly-added optional `position` field to the
|
||||||
|
* client (client.movePage(pageId, parentPageId, position));
|
||||||
|
* - the table trio unified its `tableRef` param to `table` and must forward it
|
||||||
|
* positionally. A field destructured under the wrong name would silently pass
|
||||||
|
* `undefined` to the client (execute is `any`-cast, so tsc won't catch it).
|
||||||
|
*/
|
||||||
|
describe('AiChatToolsService #294 changed execute wirings', () => {
|
||||||
|
const calls: Record<string, unknown[][]> = {
|
||||||
|
movePage: [],
|
||||||
|
tableInsertRow: [],
|
||||||
|
tableDeleteRow: [],
|
||||||
|
tableUpdateCell: [],
|
||||||
|
};
|
||||||
|
const fakeClient: Partial<DocmostClientLike> = {
|
||||||
|
movePage: (...args: unknown[]) => {
|
||||||
|
calls.movePage.push(args);
|
||||||
|
return Promise.resolve({ success: true });
|
||||||
|
},
|
||||||
|
tableInsertRow: (...args: unknown[]) => {
|
||||||
|
calls.tableInsertRow.push(args);
|
||||||
|
return Promise.resolve({ ok: true });
|
||||||
|
},
|
||||||
|
tableDeleteRow: (...args: unknown[]) => {
|
||||||
|
calls.tableDeleteRow.push(args);
|
||||||
|
return Promise.resolve({ ok: true });
|
||||||
|
},
|
||||||
|
tableUpdateCell: (...args: unknown[]) => {
|
||||||
|
calls.tableUpdateCell.push(args);
|
||||||
|
return Promise.resolve({ ok: true });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const tokenServiceStub = {
|
||||||
|
generateAccessToken: jest.fn().mockResolvedValue('access-token'),
|
||||||
|
generateCollabToken: jest.fn().mockResolvedValue('collab-token'),
|
||||||
|
};
|
||||||
|
let service: AiChatToolsService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
for (const k of Object.keys(calls)) calls[k].length = 0;
|
||||||
|
jest.spyOn(loader, 'loadDocmostMcp').mockResolvedValue(
|
||||||
|
mockLoaded(function () {
|
||||||
|
return fakeClient as DocmostClientLike;
|
||||||
|
} as unknown as loader.DocmostClientCtor),
|
||||||
|
);
|
||||||
|
service = new AiChatToolsService(
|
||||||
|
tokenServiceStub as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{
|
||||||
|
asSink: () => ({ put: jest.fn(), has: jest.fn(), evict: jest.fn() }),
|
||||||
|
} as never,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
afterEach(() => jest.restoreAllMocks());
|
||||||
|
|
||||||
|
const buildTools = () =>
|
||||||
|
service.forUser(
|
||||||
|
{ id: 'user-1', email: 'u@example.com', workspaceId: 'ws-1' } as never,
|
||||||
|
'session-1',
|
||||||
|
'ws-1',
|
||||||
|
'chat-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
it('movePage forwards the optional position to the client', async () => {
|
||||||
|
const tools = await buildTools();
|
||||||
|
await tools.movePage.execute(
|
||||||
|
{ pageId: 'p1', parentPageId: 'parent1', position: 'a5' } as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
expect(calls.movePage).toEqual([['p1', 'parent1', 'a5']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('movePage passes undefined position and null parent when omitted (unchanged behavior)', async () => {
|
||||||
|
const tools = await buildTools();
|
||||||
|
await tools.movePage.execute({ pageId: 'p2' } as never, {} as never);
|
||||||
|
expect(calls.movePage).toEqual([['p2', null, undefined]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tableInsertRow forwards the unified `table` param positionally', async () => {
|
||||||
|
const tools = await buildTools();
|
||||||
|
await tools.tableInsertRow.execute(
|
||||||
|
{ pageId: 'p1', table: '#0', cells: ['a', 'b'], index: 2 } as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
expect(calls.tableInsertRow).toEqual([['p1', '#0', ['a', 'b'], 2]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tableDeleteRow forwards `table` positionally', async () => {
|
||||||
|
const tools = await buildTools();
|
||||||
|
await tools.tableDeleteRow.execute(
|
||||||
|
{ pageId: 'p1', table: '#0', index: 1 } as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
expect(calls.tableDeleteRow).toEqual([['p1', '#0', 1]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tableUpdateCell forwards `table` positionally', async () => {
|
||||||
|
const tools = await buildTools();
|
||||||
|
await tools.tableUpdateCell.execute(
|
||||||
|
{ pageId: 'p1', table: '#0', row: 1, col: 2, text: 'x' } as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
expect(calls.tableUpdateCell).toEqual([['p1', '#0', 1, 2, 'x']]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -316,50 +316,27 @@ export class AiChatToolsService {
|
|||||||
execute: async () => resolveCurrentPageResult(openedPage),
|
execute: async () => resolveCurrentPageResult(openedPage),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getPage: tool({
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
description:
|
// The execute body keeps this layer's { title, markdown } projection.
|
||||||
'Fetch a single page as Markdown by its page id. Returns the page ' +
|
getPage: sharedTool(sharedToolSpecs.getPage, async ({ pageId }) => {
|
||||||
'title and its Markdown content. Inline <span data-comment-id> tags ' +
|
// getPage(pageId) -> { data: filterPage(page, markdown), success }.
|
||||||
'in the markdown are comment highlight anchors (also present for ' +
|
const result = await client.getPage(pageId);
|
||||||
'RESOLVED threads) — treat them as markup, not page text.',
|
const data = (result?.data ?? {}) as {
|
||||||
inputSchema: modelFriendlyInput({
|
title?: string;
|
||||||
pageId: z.string().describe('The id (or slugId) of the page.'),
|
content?: string;
|
||||||
}),
|
};
|
||||||
execute: async ({ pageId }) => {
|
return {
|
||||||
// getPage(pageId) -> { data: filterPage(page, markdown), success }.
|
title: data.title ?? '',
|
||||||
const result = await client.getPage(pageId);
|
markdown: typeof data.content === 'string' ? data.content : '',
|
||||||
const data = (result?.data ?? {}) as {
|
};
|
||||||
title?: string;
|
|
||||||
content?: string;
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
title: data.title ?? '',
|
|
||||||
markdown: typeof data.content === 'string' ? data.content : '',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// --- WRITE tools (all reversible — history/trash; §6.5 / D3) ---
|
// --- WRITE tools (all reversible — history/trash; §6.5 / D3) ---
|
||||||
|
|
||||||
createPage: tool({
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
description:
|
createPage: sharedTool(
|
||||||
'Create a new page with a Markdown body in a space, optionally under ' +
|
sharedToolSpecs.createPage,
|
||||||
'a parent page. Returns the new page id and title. Reversible: a page ' +
|
async ({ title, content, spaceId, parentPageId }) => {
|
||||||
'can be moved to trash later.',
|
|
||||||
inputSchema: modelFriendlyInput({
|
|
||||||
title: z.string().describe('The title of the new page.'),
|
|
||||||
content: z
|
|
||||||
.string()
|
|
||||||
.describe('The page body as Markdown (may be empty).'),
|
|
||||||
spaceId: z
|
|
||||||
.string()
|
|
||||||
.describe('The id of the space to create the page in.'),
|
|
||||||
parentPageId: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe('Optional parent page id to nest the new page under.'),
|
|
||||||
}),
|
|
||||||
execute: async ({ title, content, spaceId, parentPageId }) => {
|
|
||||||
// createPage(title, content, spaceId, parentPageId?) ->
|
// createPage(title, content, spaceId, parentPageId?) ->
|
||||||
// { data: filterPage(page, markdown), success }.
|
// { data: filterPage(page, markdown), success }.
|
||||||
const result = await client.createPage(
|
const result = await client.createPage(
|
||||||
@@ -375,7 +352,7 @@ export class AiChatToolsService {
|
|||||||
};
|
};
|
||||||
return { id: data.id ?? data.slugId, title: data.title ?? title };
|
return { id: data.id ?? data.slugId, title: data.title ?? title };
|
||||||
},
|
},
|
||||||
}),
|
),
|
||||||
|
|
||||||
updatePageContent: tool({
|
updatePageContent: tool({
|
||||||
description:
|
description:
|
||||||
@@ -399,115 +376,46 @@ export class AiChatToolsService {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
renamePage: tool({
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
description:
|
renamePage: sharedTool(
|
||||||
"Rename a page (change its title only; the body is untouched). " +
|
sharedToolSpecs.renamePage,
|
||||||
'Reversible: rename back at any time.',
|
async ({ pageId, title }) => {
|
||||||
inputSchema: modelFriendlyInput({
|
|
||||||
pageId: z.string().describe('The id of the page to rename.'),
|
|
||||||
title: z.string().describe('The new title.'),
|
|
||||||
}),
|
|
||||||
execute: async ({ pageId, title }) => {
|
|
||||||
// renamePage(pageId, title) -> { success, pageId, title }.
|
// renamePage(pageId, title) -> { success, pageId, title }.
|
||||||
await client.renamePage(pageId, title);
|
await client.renamePage(pageId, title);
|
||||||
return { pageId, title };
|
return { pageId, title };
|
||||||
},
|
},
|
||||||
}),
|
),
|
||||||
|
|
||||||
movePage: tool({
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
description:
|
// The shared schema adds the optional `position` field this layer lacked
|
||||||
'Move a page under a new parent page, or to the space root when no ' +
|
// before; the execute now forwards it (the client already accepted it).
|
||||||
'parent is given. Reversible: move it back at any time.',
|
movePage: sharedTool(
|
||||||
inputSchema: modelFriendlyInput({
|
sharedToolSpecs.movePage,
|
||||||
pageId: z.string().describe('The id of the page to move.'),
|
async ({ pageId, parentPageId, position }) => {
|
||||||
parentPageId: z
|
|
||||||
.string()
|
|
||||||
.nullable()
|
|
||||||
.optional()
|
|
||||||
.describe(
|
|
||||||
'Target parent page id. Null/omitted moves the page to the ' +
|
|
||||||
'space root.',
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
execute: async ({ pageId, parentPageId }) => {
|
|
||||||
// movePage(pageId, parentPageId, position?) -> raw move response.
|
// movePage(pageId, parentPageId, position?) -> raw move response.
|
||||||
await client.movePage(pageId, parentPageId ?? null);
|
await client.movePage(pageId, parentPageId ?? null, position);
|
||||||
return { pageId, parentPageId: parentPageId ?? null, moved: true };
|
return { pageId, parentPageId: parentPageId ?? null, moved: true };
|
||||||
},
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
|
// GUARDRAIL (§14 H4) preserved: the shared schema exposes ONLY pageId, so
|
||||||
|
// permanentlyDelete/forceDelete are never part of the input and can never
|
||||||
|
// be forwarded — the agent physically cannot permanently delete a page.
|
||||||
|
deletePage: sharedTool(sharedToolSpecs.deletePage, async ({ pageId }) => {
|
||||||
|
// deletePage(pageId) hits POST /pages/delete with { pageId } only,
|
||||||
|
// which is the soft-delete (trash) path on the server.
|
||||||
|
await client.deletePage(pageId);
|
||||||
|
return { pageId, trashed: true };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
deletePage: tool({
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
description:
|
// This layer keeps only its own execute-side guards (require a selection
|
||||||
'Move a page to the trash (SOFT delete only — fully reversible; the ' +
|
// for a top-level comment; reject suggestedText on a reply / without a
|
||||||
'page can be restored from trash). This NEVER permanently deletes.',
|
// selection) — the schema+description are shared.
|
||||||
inputSchema: modelFriendlyInput({
|
createComment: sharedTool(
|
||||||
pageId: z.string().describe('The id of the page to move to trash.'),
|
sharedToolSpecs.createComment,
|
||||||
}),
|
async ({
|
||||||
// GUARDRAIL (§14 H4): the only field ever passed to the client is
|
|
||||||
// pageId. permanentlyDelete/forceDelete are not part of the schema and
|
|
||||||
// are never forwarded, so the agent physically cannot permanently
|
|
||||||
// delete a page through this tool.
|
|
||||||
execute: async ({ pageId }) => {
|
|
||||||
// deletePage(pageId) hits POST /pages/delete with { pageId } only,
|
|
||||||
// which is the soft-delete (trash) path on the server.
|
|
||||||
await client.deletePage(pageId);
|
|
||||||
return { pageId, trashed: true };
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
|
|
||||||
// INTENTIONAL per-transport divergence (not shared): the description is
|
|
||||||
// tuned for the in-app agent (e.g. "retry with a corrected EXACT selection"
|
|
||||||
// and "Reversible via the comment UI"); the standalone MCP `create_comment`
|
|
||||||
// keeps its own wording. Kept per-layer.
|
|
||||||
createComment: tool({
|
|
||||||
description:
|
|
||||||
'Add an INLINE comment to a page, or reply to an existing top-level ' +
|
|
||||||
'comment (one level only — the backend rejects replies to replies). ' +
|
|
||||||
'The comment is anchored inline to the given exact `selection` text ' +
|
|
||||||
'(which gets highlighted); page-level comments are NOT supported. A ' +
|
|
||||||
"new top-level comment REQUIRES a `selection`. Replies inherit the " +
|
|
||||||
"parent's anchor and take no selection. If the call fails with a " +
|
|
||||||
'"selection not found" error, retry with a corrected EXACT selection ' +
|
|
||||||
'copied verbatim from a single paragraph/block. You may also attach a ' +
|
|
||||||
'`suggestedText` proposing a replacement for the `selection` (a human ' +
|
|
||||||
'applies it from the UI); when set, the `selection` must occur exactly ' +
|
|
||||||
'once in the page. Reversible via the comment UI.',
|
|
||||||
inputSchema: modelFriendlyInput({
|
|
||||||
pageId: z.string().describe('The id of the page to comment on.'),
|
|
||||||
content: z.string().describe('The comment body as Markdown.'),
|
|
||||||
selection: z
|
|
||||||
.string()
|
|
||||||
.min(1)
|
|
||||||
.max(250)
|
|
||||||
.optional()
|
|
||||||
.describe(
|
|
||||||
'EXACT contiguous text from a SINGLE paragraph/block to anchor ' +
|
|
||||||
'(highlight) the comment on (<=250 chars, avoid spanning across ' +
|
|
||||||
'formatting boundaries). Required for a new top-level comment; ' +
|
|
||||||
'omit only when replying via parentCommentId.',
|
|
||||||
),
|
|
||||||
parentCommentId: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe(
|
|
||||||
'Optional id of a TOP-LEVEL comment to reply to (one level ' +
|
|
||||||
'of replies only).',
|
|
||||||
),
|
|
||||||
suggestedText: z
|
|
||||||
.string()
|
|
||||||
.min(1)
|
|
||||||
.max(2000)
|
|
||||||
.optional()
|
|
||||||
.describe(
|
|
||||||
'Optional proposed replacement (PLAIN TEXT) for the `selection`, ' +
|
|
||||||
'applied by a human via the UI (never auto-applied). REQUIRES a ' +
|
|
||||||
'`selection`; NOT allowed on a reply. When set, the `selection` ' +
|
|
||||||
'must be UNIQUE in the page — expand it with surrounding context ' +
|
|
||||||
'(still <=250 chars) if it occurs more than once, or the call is ' +
|
|
||||||
'refused.',
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
execute: async ({
|
|
||||||
pageId,
|
pageId,
|
||||||
content,
|
content,
|
||||||
selection,
|
selection,
|
||||||
@@ -548,26 +456,17 @@ export class AiChatToolsService {
|
|||||||
const data = (result?.data ?? {}) as { id?: string };
|
const data = (result?.data ?? {}) as { id?: string };
|
||||||
return { commentId: data.id, pageId };
|
return { commentId: data.id, pageId };
|
||||||
},
|
},
|
||||||
}),
|
),
|
||||||
|
|
||||||
resolveComment: tool({
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
description:
|
resolveComment: sharedTool(
|
||||||
'Resolve or reopen a top-level comment thread (reversible — toggle ' +
|
sharedToolSpecs.resolveComment,
|
||||||
'the resolved flag). Only top-level comments can be resolved.',
|
async ({ commentId, resolved }) => {
|
||||||
inputSchema: modelFriendlyInput({
|
|
||||||
commentId: z
|
|
||||||
.string()
|
|
||||||
.describe('The id of the top-level comment to resolve/reopen.'),
|
|
||||||
resolved: z
|
|
||||||
.boolean()
|
|
||||||
.describe('true to resolve the thread, false to reopen it.'),
|
|
||||||
}),
|
|
||||||
execute: async ({ commentId, resolved }) => {
|
|
||||||
// resolveComment(commentId, resolved) -> { success, commentId, resolved }.
|
// resolveComment(commentId, resolved) -> { success, commentId, resolved }.
|
||||||
await client.resolveComment(commentId, resolved);
|
await client.resolveComment(commentId, resolved);
|
||||||
return { commentId, resolved };
|
return { commentId, resolved };
|
||||||
},
|
},
|
||||||
}),
|
),
|
||||||
|
|
||||||
// --- READ tools (added) ---
|
// --- READ tools (added) ---
|
||||||
|
|
||||||
@@ -585,33 +484,12 @@ export class AiChatToolsService {
|
|||||||
// hierarchy mode but is worded for the in-app agent; the standalone MCP
|
// hierarchy mode but is worded for the in-app agent; the standalone MCP
|
||||||
// `list_pages` carries its own wording. Kept per-layer so each side tunes
|
// `list_pages` carries its own wording. Kept per-layer so each side tunes
|
||||||
// its own guidance.
|
// its own guidance.
|
||||||
listPages: tool({
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
description:
|
listPages: sharedTool(
|
||||||
'List the most recent pages, optionally scoped to a single space. ' +
|
sharedToolSpecs.listPages,
|
||||||
'Returns a bounded list (default 50, max 100). Pass tree:true (with ' +
|
async ({ spaceId, limit, tree }) =>
|
||||||
"spaceId) to instead get the space's full page hierarchy as a nested tree.",
|
|
||||||
inputSchema: modelFriendlyInput({
|
|
||||||
spaceId: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe('Optional space id to scope the listing to.'),
|
|
||||||
limit: z
|
|
||||||
.number()
|
|
||||||
.int()
|
|
||||||
.min(1)
|
|
||||||
.max(100)
|
|
||||||
.optional()
|
|
||||||
.describe('Maximum number of pages (1-100).'),
|
|
||||||
tree: z
|
|
||||||
.boolean()
|
|
||||||
.optional()
|
|
||||||
.describe(
|
|
||||||
'When true, return the full page hierarchy of the given space as a nested tree (children arrays) instead of the recent-pages flat list. Requires spaceId; ignores limit.',
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
execute: async ({ spaceId, limit, tree }) =>
|
|
||||||
await client.listPages(spaceId, limit, tree),
|
await client.listPages(spaceId, limit, tree),
|
||||||
}),
|
),
|
||||||
|
|
||||||
listSidebarPages: tool({
|
listSidebarPages: tool({
|
||||||
description:
|
description:
|
||||||
@@ -656,41 +534,34 @@ export class AiChatToolsService {
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// NOT shared (kept inline): the MCP tool name `table_get` is noun-first
|
||||||
|
// while this key is `getTable` (verb-first), breaking the
|
||||||
|
// snake_case(inAppKey) convention the shared registry enforces. Its
|
||||||
|
// reference parameter is still named `table` (was `tableRef`) so it matches
|
||||||
|
// the migrated table row/cell tools below.
|
||||||
getTable: tool({
|
getTable: tool({
|
||||||
description:
|
description:
|
||||||
'Read a table as a matrix of cell texts (plus a parallel cellIds ' +
|
'Read a table as a matrix of cell texts (plus a parallel cellIds ' +
|
||||||
'matrix so cells can be addressed for rich edits).',
|
'matrix so cells can be addressed for rich edits).',
|
||||||
inputSchema: modelFriendlyInput({
|
inputSchema: modelFriendlyInput({
|
||||||
pageId: z.string().describe('The id of the page.'),
|
pageId: z.string().describe('The id of the page.'),
|
||||||
tableRef: z
|
table: z
|
||||||
.string()
|
.string()
|
||||||
.describe(
|
.describe(
|
||||||
'"#<index>" from getOutline, or a block id of any node inside ' +
|
'"#<index>" from the page outline, or a block id of any node ' +
|
||||||
'the table.',
|
'inside the table.',
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
execute: async ({ pageId, tableRef }) =>
|
execute: async ({ pageId, table }) =>
|
||||||
await client.getTable(pageId, tableRef),
|
await client.getTable(pageId, table),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
listComments: tool({
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
description:
|
listComments: sharedTool(
|
||||||
'List comments on a page in one call. By DEFAULT only ACTIVE ' +
|
sharedToolSpecs.listComments,
|
||||||
'threads are returned; resolved threads (a resolved top-level ' +
|
async ({ pageId, includeResolved }) =>
|
||||||
'comment and all its replies) are hidden and their count reported ' +
|
|
||||||
'as `resolvedThreadsHidden` so you can re-query with ' +
|
|
||||||
'`includeResolved: true` to see everything. Returns ' +
|
|
||||||
'`{ items, resolvedThreadsHidden }`. Content is returned as Markdown.',
|
|
||||||
inputSchema: modelFriendlyInput({
|
|
||||||
pageId: z.string().describe('The id of the page.'),
|
|
||||||
includeResolved: z
|
|
||||||
.boolean()
|
|
||||||
.optional()
|
|
||||||
.describe('default only active threads; true — include resolved'),
|
|
||||||
}),
|
|
||||||
execute: async ({ pageId, includeResolved }) =>
|
|
||||||
await client.listComments(pageId, includeResolved),
|
await client.listComments(pageId, includeResolved),
|
||||||
}),
|
),
|
||||||
|
|
||||||
getComment: tool({
|
getComment: tool({
|
||||||
description: 'Fetch a single comment by id (content as Markdown).',
|
description: 'Fetch a single comment by id (content as Markdown).',
|
||||||
@@ -700,26 +571,12 @@ export class AiChatToolsService {
|
|||||||
execute: async ({ commentId }) => await client.getComment(commentId),
|
execute: async ({ commentId }) => await client.getComment(commentId),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
checkNewComments: tool({
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
description:
|
checkNewComments: sharedTool(
|
||||||
'Find new comments across a space (optionally scoped to a subtree) ' +
|
sharedToolSpecs.checkNewComments,
|
||||||
'created after a given timestamp.',
|
async ({ spaceId, since, parentPageId }) =>
|
||||||
inputSchema: modelFriendlyInput({
|
|
||||||
spaceId: z.string().describe('The id of the space to scan.'),
|
|
||||||
since: z
|
|
||||||
.string()
|
|
||||||
.describe('An ISO-8601 timestamp; only comments created after it.'),
|
|
||||||
parentPageId: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe(
|
|
||||||
'Optional page id to scope the scan to that page and its ' +
|
|
||||||
'descendants.',
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
execute: async ({ spaceId, since, parentPageId }) =>
|
|
||||||
await client.checkNewComments(spaceId, since, parentPageId),
|
await client.checkNewComments(spaceId, since, parentPageId),
|
||||||
}),
|
),
|
||||||
|
|
||||||
listShares: sharedTool(
|
listShares: sharedTool(
|
||||||
sharedToolSpecs.listShares,
|
sharedToolSpecs.listShares,
|
||||||
@@ -749,19 +606,14 @@ export class AiChatToolsService {
|
|||||||
await client.diffPageVersions(pageId, from, to),
|
await client.diffPageVersions(pageId, from, to),
|
||||||
),
|
),
|
||||||
|
|
||||||
exportPageMarkdown: tool({
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
description:
|
exportPageMarkdown: sharedTool(
|
||||||
'Export a page to a single self-contained Docmost-flavoured ' +
|
sharedToolSpecs.exportPageMarkdown,
|
||||||
'Markdown file (meta + body + comment threads). Lossless round-trip ' +
|
async ({ pageId }) => {
|
||||||
'with importPageMarkdown.',
|
|
||||||
inputSchema: modelFriendlyInput({
|
|
||||||
pageId: z.string().describe('The id of the page to export.'),
|
|
||||||
}),
|
|
||||||
execute: async ({ pageId }) => {
|
|
||||||
const markdown = await client.exportPageMarkdown(pageId);
|
const markdown = await client.exportPageMarkdown(pageId);
|
||||||
return { markdown };
|
return { markdown };
|
||||||
},
|
},
|
||||||
}),
|
),
|
||||||
|
|
||||||
// --- WRITE tools (added; reversible via page history/trash) ---
|
// --- WRITE tools (added; reversible via page history/trash) ---
|
||||||
|
|
||||||
@@ -811,28 +663,12 @@ export class AiChatToolsService {
|
|||||||
async ({ pageId, nodeId }) => await client.deleteNode(pageId, nodeId),
|
async ({ pageId, nodeId }) => await client.deleteNode(pageId, nodeId),
|
||||||
),
|
),
|
||||||
|
|
||||||
updatePageJson: tool({
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
description:
|
// The execute body keeps this layer's content normalization (parity with
|
||||||
"Replace a page's body with a full ProseMirror document — a full " +
|
// the standalone MCP server, index.ts update_page_json).
|
||||||
'overwrite — and/or update its title. Minimal example content: ' +
|
updatePageJson: sharedTool(
|
||||||
'{"type":"doc","content":[{"type":"paragraph","content":' +
|
sharedToolSpecs.updatePageJson,
|
||||||
'[{"type":"text","text":"Hi"}]}]}. The content arg may be a JSON ' +
|
async ({ pageId, content, title }) => {
|
||||||
'object or a JSON string (both accepted). Omit content for a ' +
|
|
||||||
'title-only update. Reversible: the previous version is kept in page ' +
|
|
||||||
'history.',
|
|
||||||
inputSchema: modelFriendlyInput({
|
|
||||||
pageId: z.string().describe('The id of the page to update.'),
|
|
||||||
content: z
|
|
||||||
.any()
|
|
||||||
.optional()
|
|
||||||
.describe(
|
|
||||||
'Full ProseMirror doc {"type":"doc","content":[...]} (JSON ' +
|
|
||||||
'object or JSON string); omit for a title-only update.',
|
|
||||||
),
|
|
||||||
title: z.string().optional().describe('Optional new title.'),
|
|
||||||
}),
|
|
||||||
execute: async ({ pageId, content, title }) => {
|
|
||||||
// Parity with the standalone MCP server (index.ts update_page_json):
|
|
||||||
// undefined/null pass through as undefined (title-only / no-op); any
|
// undefined/null pass through as undefined (title-only / no-op); any
|
||||||
// string is JSON.parsed (so an empty string "" throws, matching the
|
// string is JSON.parsed (so an empty string "" throws, matching the
|
||||||
// MCP server); an object is passed through unchanged.
|
// MCP server); an object is passed through unchanged.
|
||||||
@@ -845,66 +681,29 @@ export class AiChatToolsService {
|
|||||||
}
|
}
|
||||||
return await client.updatePageJson(pageId, doc, title);
|
return await client.updatePageJson(pageId, doc, title);
|
||||||
},
|
},
|
||||||
}),
|
),
|
||||||
|
|
||||||
// NOT in the shared registry: this layer names the table argument
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
// `tableRef`, while the standalone MCP tool names it `table` (index.ts).
|
// The table reference parameter was unified to `table` (was `tableRef`).
|
||||||
// Sharing one buildShape would rename a model-facing parameter on one
|
tableInsertRow: sharedTool(
|
||||||
// transport, so the table row/cell tools stay per-layer by design.
|
sharedToolSpecs.tableInsertRow,
|
||||||
tableInsertRow: tool({
|
async ({ pageId, table, cells, index }) =>
|
||||||
description:
|
await client.tableInsertRow(pageId, table, cells, index),
|
||||||
'Insert a row of plain-text cells into a table. Reversible via ' +
|
),
|
||||||
'page history.',
|
|
||||||
inputSchema: modelFriendlyInput({
|
|
||||||
pageId: z.string().describe('The id of the page.'),
|
|
||||||
tableRef: z
|
|
||||||
.string()
|
|
||||||
.describe('"#<index>" from getOutline, or a block id in the table.'),
|
|
||||||
cells: z.array(z.string()).describe('The cell texts for the row.'),
|
|
||||||
index: z
|
|
||||||
.number()
|
|
||||||
.int()
|
|
||||||
.optional()
|
|
||||||
.describe('0-based insert position (omit/out-of-range to append).'),
|
|
||||||
}),
|
|
||||||
execute: async ({ pageId, tableRef, cells, index }) =>
|
|
||||||
await client.tableInsertRow(pageId, tableRef, cells, index),
|
|
||||||
}),
|
|
||||||
|
|
||||||
// NOT shared — same `tableRef` (here) vs `table` (MCP) parameter-name
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
// divergence as tableInsertRow.
|
tableDeleteRow: sharedTool(
|
||||||
tableDeleteRow: tool({
|
sharedToolSpecs.tableDeleteRow,
|
||||||
description:
|
async ({ pageId, table, index }) =>
|
||||||
'Delete a table row at a 0-based index. Reversible via page history.',
|
await client.tableDeleteRow(pageId, table, index),
|
||||||
inputSchema: modelFriendlyInput({
|
),
|
||||||
pageId: z.string().describe('The id of the page.'),
|
|
||||||
tableRef: z
|
|
||||||
.string()
|
|
||||||
.describe('"#<index>" from getOutline, or a block id in the table.'),
|
|
||||||
index: z.number().int().describe('0-based row index to delete.'),
|
|
||||||
}),
|
|
||||||
execute: async ({ pageId, tableRef, index }) =>
|
|
||||||
await client.tableDeleteRow(pageId, tableRef, index),
|
|
||||||
}),
|
|
||||||
|
|
||||||
// NOT shared — same `tableRef` (here) vs `table` (MCP) parameter-name
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
// divergence as tableInsertRow.
|
tableUpdateCell: sharedTool(
|
||||||
tableUpdateCell: tool({
|
sharedToolSpecs.tableUpdateCell,
|
||||||
description:
|
async ({ pageId, table, row, col, text }) =>
|
||||||
'Set the plain-text content of a table cell at [row, col] (0-based). ' +
|
await client.tableUpdateCell(pageId, table, row, col, text),
|
||||||
'Reversible via page history.',
|
),
|
||||||
inputSchema: modelFriendlyInput({
|
|
||||||
pageId: z.string().describe('The id of the page.'),
|
|
||||||
tableRef: z
|
|
||||||
.string()
|
|
||||||
.describe('"#<index>" from getOutline, or a block id in the table.'),
|
|
||||||
row: z.number().int().describe('0-based row index.'),
|
|
||||||
col: z.number().int().describe('0-based column index.'),
|
|
||||||
text: z.string().describe('The new cell text.'),
|
|
||||||
}),
|
|
||||||
execute: async ({ pageId, tableRef, row, col, text }) =>
|
|
||||||
await client.tableUpdateCell(pageId, tableRef, row, col, text),
|
|
||||||
}),
|
|
||||||
|
|
||||||
copyPageContent: sharedTool(
|
copyPageContent: sharedTool(
|
||||||
sharedToolSpecs.copyPageContent,
|
sharedToolSpecs.copyPageContent,
|
||||||
@@ -918,25 +717,14 @@ export class AiChatToolsService {
|
|||||||
await client.importPageMarkdown(pageId, markdown),
|
await client.importPageMarkdown(pageId, markdown),
|
||||||
),
|
),
|
||||||
|
|
||||||
// INTENTIONAL per-transport divergence (not shared): adds a security
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
// confirmation framing ("Only share when the user explicitly asked, since
|
// Both layers already carried the security-confirmation framing, so there
|
||||||
// this exposes the page to anyone with the link") for the in-app agent; the
|
// was no real divergence to preserve — only wording drift.
|
||||||
// standalone MCP `share_page` keeps the plain public-URL wording.
|
sharePage: sharedTool(
|
||||||
sharePage: tool({
|
sharedToolSpecs.sharePage,
|
||||||
description:
|
async ({ pageId, searchIndexing }) =>
|
||||||
'Make a page PUBLICLY accessible and return its public URL. ' +
|
|
||||||
'Reversible via unsharePage. Only share when the user explicitly ' +
|
|
||||||
'asked, since this exposes the page to anyone with the link.',
|
|
||||||
inputSchema: modelFriendlyInput({
|
|
||||||
pageId: z.string().describe('The id of the page to share.'),
|
|
||||||
searchIndexing: z
|
|
||||||
.boolean()
|
|
||||||
.optional()
|
|
||||||
.describe('Allow public search engines to index it (default true).'),
|
|
||||||
}),
|
|
||||||
execute: async ({ pageId, searchIndexing }) =>
|
|
||||||
await client.sharePage(pageId, searchIndexing),
|
await client.sharePage(pageId, searchIndexing),
|
||||||
}),
|
),
|
||||||
|
|
||||||
unsharePage: sharedTool(
|
unsharePage: sharedTool(
|
||||||
sharedToolSpecs.unsharePage,
|
sharedToolSpecs.unsharePage,
|
||||||
|
|||||||
@@ -100,54 +100,26 @@ export const INLINE_TOOL_TIERS: Record<
|
|||||||
tier: 'core',
|
tier: 'core',
|
||||||
catalogLine: 'getCurrentPage — the page the user is currently viewing.',
|
catalogLine: 'getCurrentPage — the page the user is currently viewing.',
|
||||||
},
|
},
|
||||||
getPage: {
|
// NOTE: getPage and listPages moved to @docmost/mcp's SHARED_TOOL_SPECS
|
||||||
tier: 'core',
|
// (#294); they carry their own tier ('core') + catalogLine there.
|
||||||
catalogLine: 'getPage — fetch a page as Markdown by its id.',
|
// NOTE: createComment, listComments and resolveComment moved to
|
||||||
},
|
// @docmost/mcp's SHARED_TOOL_SPECS (#294); they carry their own tier +
|
||||||
listPages: {
|
// catalogLine there. getComment stays inline (MCP-only shape divergence is
|
||||||
tier: 'core',
|
// n/a — it simply has no shared spec).
|
||||||
catalogLine: "listPages — list recent pages, or a space's full page tree.",
|
|
||||||
},
|
|
||||||
listComments: {
|
|
||||||
tier: 'core',
|
|
||||||
catalogLine: 'listComments — list all comments on a page (including resolved).',
|
|
||||||
},
|
|
||||||
getComment: {
|
getComment: {
|
||||||
tier: 'core',
|
tier: 'core',
|
||||||
catalogLine: 'getComment — fetch a single comment by id.',
|
catalogLine: 'getComment — fetch a single comment by id.',
|
||||||
},
|
},
|
||||||
createComment: {
|
|
||||||
tier: 'core',
|
|
||||||
catalogLine:
|
|
||||||
'createComment — add an inline comment (optionally with a suggested edit).',
|
|
||||||
},
|
|
||||||
resolveComment: {
|
|
||||||
tier: 'core',
|
|
||||||
catalogLine: 'resolveComment — resolve or reopen a comment thread.',
|
|
||||||
},
|
|
||||||
|
|
||||||
// --- deferred inline ---
|
// --- deferred inline ---
|
||||||
createPage: {
|
// NOTE: createPage, renamePage, movePage, deletePage, updatePageJson and
|
||||||
tier: 'deferred',
|
// exportPageMarkdown moved to @docmost/mcp's SHARED_TOOL_SPECS (#294); they
|
||||||
catalogLine: 'createPage — create a new page with a Markdown body in a space.',
|
// carry their own deferred tier + catalogLine there.
|
||||||
},
|
|
||||||
updatePageContent: {
|
updatePageContent: {
|
||||||
tier: 'deferred',
|
tier: 'deferred',
|
||||||
catalogLine:
|
catalogLine:
|
||||||
"updatePageContent — replace a page's body (and optionally title) with new Markdown.",
|
"updatePageContent — replace a page's body (and optionally title) with new Markdown.",
|
||||||
},
|
},
|
||||||
renamePage: {
|
|
||||||
tier: 'deferred',
|
|
||||||
catalogLine: "renamePage — change a page's title only (body untouched).",
|
|
||||||
},
|
|
||||||
movePage: {
|
|
||||||
tier: 'deferred',
|
|
||||||
catalogLine: 'movePage — move a page under a new parent or to the space root.',
|
|
||||||
},
|
|
||||||
deletePage: {
|
|
||||||
tier: 'deferred',
|
|
||||||
catalogLine: 'deletePage — move a page to trash (soft delete, reversible).',
|
|
||||||
},
|
|
||||||
listSidebarPages: {
|
listSidebarPages: {
|
||||||
tier: 'deferred',
|
tier: 'deferred',
|
||||||
catalogLine:
|
catalogLine:
|
||||||
@@ -157,42 +129,21 @@ export const INLINE_TOOL_TIERS: Record<
|
|||||||
tier: 'deferred',
|
tier: 'deferred',
|
||||||
catalogLine: 'getTable — read a table as a matrix of cell texts and cell ids.',
|
catalogLine: 'getTable — read a table as a matrix of cell texts and cell ids.',
|
||||||
},
|
},
|
||||||
checkNewComments: {
|
// NOTE: tableInsertRow, tableDeleteRow and tableUpdateCell moved to
|
||||||
tier: 'deferred',
|
// @docmost/mcp's SHARED_TOOL_SPECS (#294); they carry their own deferred tier +
|
||||||
catalogLine:
|
// catalogLine there. getTable stays inline (its MCP name table_get breaks the
|
||||||
'checkNewComments — find comments in a space created after a timestamp.',
|
// snake_case(inAppKey) convention, so it has no shared spec).
|
||||||
},
|
// NOTE: checkNewComments moved to @docmost/mcp's SHARED_TOOL_SPECS (#294);
|
||||||
|
// it carries its own deferred tier + catalogLine there.
|
||||||
getPageHistory: {
|
getPageHistory: {
|
||||||
tier: 'deferred',
|
tier: 'deferred',
|
||||||
catalogLine:
|
catalogLine:
|
||||||
'getPageHistory — fetch one page-history version with its ProseMirror content.',
|
'getPageHistory — fetch one page-history version with its ProseMirror content.',
|
||||||
},
|
},
|
||||||
exportPageMarkdown: {
|
// NOTE: sharePage moved to @docmost/mcp's SHARED_TOOL_SPECS (#294); it carries
|
||||||
tier: 'deferred',
|
// its own deferred tier + catalogLine there. transformPage stays inline (its
|
||||||
catalogLine:
|
// schema deliberately diverges — it omits the deleteComments field the MCP
|
||||||
'exportPageMarkdown — export a page to self-contained Markdown (body + comments).',
|
// docmost_transform exposes, a comment-deletion guardrail).
|
||||||
},
|
|
||||||
updatePageJson: {
|
|
||||||
tier: 'deferred',
|
|
||||||
catalogLine:
|
|
||||||
"updatePageJson — overwrite a page's body with a full ProseMirror document.",
|
|
||||||
},
|
|
||||||
tableInsertRow: {
|
|
||||||
tier: 'deferred',
|
|
||||||
catalogLine: 'tableInsertRow — insert a row of plain-text cells into a table.',
|
|
||||||
},
|
|
||||||
tableDeleteRow: {
|
|
||||||
tier: 'deferred',
|
|
||||||
catalogLine: 'tableDeleteRow — delete a table row at a 0-based index.',
|
|
||||||
},
|
|
||||||
tableUpdateCell: {
|
|
||||||
tier: 'deferred',
|
|
||||||
catalogLine: 'tableUpdateCell — set the text of a table cell at [row, col].',
|
|
||||||
},
|
|
||||||
sharePage: {
|
|
||||||
tier: 'deferred',
|
|
||||||
catalogLine: 'sharePage — make a page publicly accessible and return its URL.',
|
|
||||||
},
|
|
||||||
transformPage: {
|
transformPage: {
|
||||||
tier: 'deferred',
|
tier: 'deferred',
|
||||||
catalogLine: "transformPage — run a sandboxed JS transform over a page's document.",
|
catalogLine: "transformPage — run a sandboxed JS transform over a page's document.",
|
||||||
|
|||||||
@@ -19,4 +19,87 @@ describe('AuthController', () => {
|
|||||||
it('should be defined', () => {
|
it('should be defined', () => {
|
||||||
expect(controller).toBeDefined();
|
expect(controller).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The EE MFA module is absent in this repo, so require() throws and is caught;
|
||||||
|
// login falls through to authService.login -> setAuthCookie -> returnToken.
|
||||||
|
describe('login returnToken branch', () => {
|
||||||
|
const workspace = { id: 'ws1', enforceSso: false };
|
||||||
|
|
||||||
|
const makeController = () => {
|
||||||
|
const authService = {
|
||||||
|
login: jest.fn().mockResolvedValue('jwt-token-123'),
|
||||||
|
};
|
||||||
|
const environmentService = {
|
||||||
|
getCookieExpiresIn: jest.fn().mockReturnValue(new Date()),
|
||||||
|
isHttps: jest.fn().mockReturnValue(false),
|
||||||
|
};
|
||||||
|
const ctrl = new AuthController(
|
||||||
|
authService as any,
|
||||||
|
{} as any,
|
||||||
|
environmentService as any,
|
||||||
|
{} as any,
|
||||||
|
{} as any,
|
||||||
|
);
|
||||||
|
const res = { setCookie: jest.fn() };
|
||||||
|
return { ctrl, authService, res };
|
||||||
|
};
|
||||||
|
|
||||||
|
it('returns the body token and sets the cookie when returnToken is true', async () => {
|
||||||
|
const { ctrl, authService, res } = makeController();
|
||||||
|
const loginInput = {
|
||||||
|
email: 'a@b.com',
|
||||||
|
password: 'pw',
|
||||||
|
returnToken: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await ctrl.login(
|
||||||
|
workspace as any,
|
||||||
|
res as any,
|
||||||
|
loginInput as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({ authToken: 'jwt-token-123' });
|
||||||
|
expect(res.setCookie).toHaveBeenCalledTimes(1);
|
||||||
|
expect(res.setCookie).toHaveBeenCalledWith(
|
||||||
|
'authToken',
|
||||||
|
'jwt-token-123',
|
||||||
|
expect.objectContaining({ httpOnly: true }),
|
||||||
|
);
|
||||||
|
expect(authService.login).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns no body token but still sets the cookie when returnToken is omitted', async () => {
|
||||||
|
const { ctrl, res } = makeController();
|
||||||
|
const loginInput = { email: 'a@b.com', password: 'pw' };
|
||||||
|
|
||||||
|
const result = await ctrl.login(
|
||||||
|
workspace as any,
|
||||||
|
res as any,
|
||||||
|
loginInput as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
expect(res.setCookie).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Guards against an `!== undefined`-style bug: an explicit `false` must
|
||||||
|
// behave exactly like the omitted case (cookie set, no token in the body).
|
||||||
|
it('returns no body token but still sets the cookie when returnToken is false', async () => {
|
||||||
|
const { ctrl, res } = makeController();
|
||||||
|
const loginInput = {
|
||||||
|
email: 'a@b.com',
|
||||||
|
password: 'pw',
|
||||||
|
returnToken: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await ctrl.login(
|
||||||
|
workspace as any,
|
||||||
|
res as any,
|
||||||
|
loginInput as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
expect(res.setCookie).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -98,6 +98,12 @@ export class AuthController {
|
|||||||
} else if (mfaResult.authToken) {
|
} else if (mfaResult.authToken) {
|
||||||
// User doesn't have MFA and workspace doesn't require it
|
// User doesn't have MFA and workspace doesn't require it
|
||||||
this.setAuthCookie(res, mfaResult.authToken);
|
this.setAuthCookie(res, mfaResult.authToken);
|
||||||
|
// Opt-in body token for native clients (Bearer auth). The response is
|
||||||
|
// wrapped by TransformHttpResponseInterceptor, so clients read it at
|
||||||
|
// `data.authToken`. Web clients omit returnToken and keep the cookie.
|
||||||
|
if (loginInput.returnToken) {
|
||||||
|
return { authToken: mfaResult.authToken };
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -105,6 +111,12 @@ export class AuthController {
|
|||||||
|
|
||||||
const authToken = await this.authService.login(loginInput, workspace.id);
|
const authToken = await this.authService.login(loginInput, workspace.id);
|
||||||
this.setAuthCookie(res, authToken);
|
this.setAuthCookie(res, authToken);
|
||||||
|
// Opt-in body token for native clients (Bearer auth). The response is wrapped
|
||||||
|
// by TransformHttpResponseInterceptor, so clients read it at `data.authToken`.
|
||||||
|
// Web clients omit returnToken and keep using the httpOnly cookie only.
|
||||||
|
if (loginInput.returnToken) {
|
||||||
|
return { authToken };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(SetupGuard)
|
@UseGuards(SetupGuard)
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
|
import {
|
||||||
|
IsBoolean,
|
||||||
|
IsEmail,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
export class LoginDto {
|
export class LoginDto {
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@@ -8,4 +14,13 @@ export class LoginDto {
|
|||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@IsString()
|
@IsString()
|
||||||
password: string;
|
password: string;
|
||||||
|
|
||||||
|
// When true, the access token is returned in the response body (in addition
|
||||||
|
// to the httpOnly cookie) so native/mobile clients can store it in
|
||||||
|
// Keychain/Keystore and send it as 'Authorization: Bearer'. Web clients omit
|
||||||
|
// this flag and keep using the cookie. Opt-in only: the token is never put in
|
||||||
|
// the body otherwise.
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
returnToken?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,102 @@ describe('PageService', () => {
|
|||||||
expect(service).toBeDefined();
|
expect(service).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('update — title sync into collab doc (Bug 1)', () => {
|
||||||
|
const makeUpdateService = () => {
|
||||||
|
const pageRepo = {
|
||||||
|
updatePage: jest.fn().mockResolvedValue(undefined),
|
||||||
|
findById: jest.fn().mockResolvedValue({ id: 'page-1' }),
|
||||||
|
};
|
||||||
|
const generalQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
const collaborationGateway = {
|
||||||
|
writePageTitle: 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
|
||||||
|
collaborationGateway as any, // collaborationGateway
|
||||||
|
{} as any, // watcherService
|
||||||
|
{} as any, // transclusionService
|
||||||
|
);
|
||||||
|
|
||||||
|
return { svc, pageRepo, collaborationGateway };
|
||||||
|
};
|
||||||
|
|
||||||
|
const basePage = (): Page =>
|
||||||
|
({
|
||||||
|
id: 'page-1',
|
||||||
|
slugId: 'slug-1',
|
||||||
|
spaceId: 'space-1',
|
||||||
|
workspaceId: 'ws-1',
|
||||||
|
parentPageId: null,
|
||||||
|
title: 'Old Title',
|
||||||
|
icon: null,
|
||||||
|
contributorIds: [],
|
||||||
|
}) as any;
|
||||||
|
|
||||||
|
const user = { id: 'u1' } as any;
|
||||||
|
|
||||||
|
it('writes the new title into the collab doc when the title actually changed', async () => {
|
||||||
|
const { svc, collaborationGateway } = makeUpdateService();
|
||||||
|
|
||||||
|
await svc.update(basePage(), { title: 'New Title' } as any, user);
|
||||||
|
|
||||||
|
// Must use the Redis-independent writePageTitle (direct
|
||||||
|
// openDirectConnection), NOT handleYjsEvent which no-ops without Redis.
|
||||||
|
expect(collaborationGateway.writePageTitle).toHaveBeenCalledTimes(1);
|
||||||
|
expect(collaborationGateway.writePageTitle).toHaveBeenCalledWith(
|
||||||
|
'page-1',
|
||||||
|
'New Title',
|
||||||
|
expect.objectContaining({ user }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('threads agent provenance into the collab title write', async () => {
|
||||||
|
const { svc, collaborationGateway } = makeUpdateService();
|
||||||
|
|
||||||
|
await svc.update(basePage(), { title: 'New Title' } as any, user, {
|
||||||
|
actor: 'agent',
|
||||||
|
aiChatId: 'chat-1',
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(collaborationGateway.writePageTitle).toHaveBeenCalledWith(
|
||||||
|
'page-1',
|
||||||
|
'New Title',
|
||||||
|
expect.objectContaining({ actor: 'agent', aiChatId: 'chat-1' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT write into the collab doc when the title is unchanged', async () => {
|
||||||
|
const { svc, collaborationGateway } = makeUpdateService();
|
||||||
|
|
||||||
|
// Same title -> titleChanged is false; an icon-only change must not fire
|
||||||
|
// the title sync.
|
||||||
|
await svc.update(
|
||||||
|
basePage(),
|
||||||
|
{ title: 'Old Title', icon: '📄' } as any,
|
||||||
|
user,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(collaborationGateway.writePageTitle).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT write into the collab doc when the DTO omits the title', async () => {
|
||||||
|
const { svc, collaborationGateway } = makeUpdateService();
|
||||||
|
|
||||||
|
await svc.update(basePage(), { icon: '📄' } as any, user);
|
||||||
|
|
||||||
|
expect(collaborationGateway.writePageTitle).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('movePage cycle guard (#67)', () => {
|
describe('movePage cycle guard (#67)', () => {
|
||||||
// A valid fractional-indexing key — movePage validates `position` by feeding
|
// A valid fractional-indexing key — movePage validates `position` by feeding
|
||||||
// it to generateJitteredKeyBetween(position, null) before anything else.
|
// it to generateJitteredKeyBetween(position, null) before anything else.
|
||||||
|
|||||||
@@ -52,7 +52,9 @@ import {
|
|||||||
INTERNAL_LINK_REGEX,
|
INTERNAL_LINK_REGEX,
|
||||||
extractPageSlugId,
|
extractPageSlugId,
|
||||||
} from '../../../integrations/export/utils';
|
} from '../../../integrations/export/utils';
|
||||||
import { markdownToHtml, canonicalizeFootnotes } from '@docmost/editor-ext';
|
import { canonicalizeFootnotes } from '@docmost/editor-ext';
|
||||||
|
import { markdownToProseMirror } from '@docmost/prosemirror-markdown';
|
||||||
|
import { normalizeForeignMarkdown } from '../../../integrations/import/utils/foreign-markdown';
|
||||||
import { WatcherService } from '../../watcher/watcher.service';
|
import { WatcherService } from '../../watcher/watcher.service';
|
||||||
import { sql } from 'kysely';
|
import { sql } from 'kysely';
|
||||||
import { TransclusionService } from '../transclusion/transclusion.service';
|
import { TransclusionService } from '../transclusion/transclusion.service';
|
||||||
@@ -265,6 +267,8 @@ export class PageService {
|
|||||||
contributors.add(user.id);
|
contributors.add(user.id);
|
||||||
const contributorIds = Array.from(contributors);
|
const contributorIds = Array.from(contributors);
|
||||||
|
|
||||||
|
const isAgent = provenance?.actor === 'agent';
|
||||||
|
|
||||||
// Detect a real title/icon change so the WS tree listener can broadcast an
|
// Detect a real title/icon change so the WS tree listener can broadcast an
|
||||||
// `updateOne` to the space (rename / icon swap) WITHOUT re-broadcasting on a
|
// `updateOne` to the space (rename / icon swap) WITHOUT re-broadcasting on a
|
||||||
// content-only save. Only treat a field as changed when the DTO actually
|
// content-only save. Only treat a field as changed when the DTO actually
|
||||||
@@ -307,6 +311,43 @@ export class PageService {
|
|||||||
: undefined,
|
: undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Bug 1: a REST/MCP rename wrote the new title ONLY to the page.title DB
|
||||||
|
// column above. The title's source of truth is the Yjs 'title' fragment in
|
||||||
|
// the page's collab doc, which onStoreDocument re-extracts on every save —
|
||||||
|
// so leaving the fragment stale would REVERT this rename on the page's next
|
||||||
|
// collaborative save (and re-broadcast the old title). Push the new title
|
||||||
|
// into the Yjs 'title' fragment so Yjs stays in sync and never reverts.
|
||||||
|
//
|
||||||
|
// Use the gateway's writePageTitle (direct openDirectConnection) rather than
|
||||||
|
// a Redis-routed handleYjsEvent path: handleYjsEvent routes through
|
||||||
|
// redisSync and SILENTLY no-ops when Redis is disabled
|
||||||
|
// (COLLAB_DISABLE_REDIS=true), which would let the rename revert in a
|
||||||
|
// single-process deployment. writePageTitle is Redis-independent and
|
||||||
|
// openDirectConnection loads the doc from persistence when no editor is
|
||||||
|
// connected, so this also works for an offline page. Thread agent provenance
|
||||||
|
// into the context so onStoreDocument tags the title store 'agent' too.
|
||||||
|
if (titleChanged) {
|
||||||
|
try {
|
||||||
|
await this.collaborationGateway.writePageTitle(
|
||||||
|
page.id,
|
||||||
|
updatePageDto.title,
|
||||||
|
{
|
||||||
|
user,
|
||||||
|
...(isAgent
|
||||||
|
? { actor: 'agent', aiChatId: provenance.aiChatId }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
// The DB column write already succeeded (fast-read source stays
|
||||||
|
// correct); a failure to sync Yjs here must not fail the rename. Log so
|
||||||
|
// a persistent desync is visible.
|
||||||
|
this.logger.warn(
|
||||||
|
`Failed to sync renamed title into collab doc for page ${page.id}: ${err?.['message']}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.generalQueue
|
this.generalQueue
|
||||||
.add(QueueJob.ADD_PAGE_WATCHERS, {
|
.add(QueueJob.ADD_PAGE_WATCHERS, {
|
||||||
userIds: [user.id],
|
userIds: [user.id],
|
||||||
@@ -1301,8 +1342,14 @@ export class PageService {
|
|||||||
|
|
||||||
switch (format) {
|
switch (format) {
|
||||||
case 'markdown': {
|
case 'markdown': {
|
||||||
const html = await markdownToHtml(content as string);
|
// Canonical markdown -> ProseMirror JSON directly via
|
||||||
prosemirrorJson = htmlToJson(html as string);
|
// `@docmost/prosemirror-markdown` (issue #345) — no HTML intermediate,
|
||||||
|
// no editor-ext markdown layer. Foreign markdown surfaces the strict
|
||||||
|
// parser rejects (GFM `[^id]` reference footnotes) are normalized to the
|
||||||
|
// canonical inline form first.
|
||||||
|
prosemirrorJson = await markdownToProseMirror(
|
||||||
|
normalizeForeignMarkdown(content as string),
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'html': {
|
case 'html': {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { EventName } from '../../common/events/event.contants';
|
|||||||
import { InjectQueue } from '@nestjs/bullmq';
|
import { InjectQueue } from '@nestjs/bullmq';
|
||||||
import { QueueJob, QueueName } from '../../integrations/queue/constants';
|
import { QueueJob, QueueName } from '../../integrations/queue/constants';
|
||||||
import { Queue } from 'bullmq';
|
import { Queue } from 'bullmq';
|
||||||
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Thin snapshot of a page node carried inside domain events so the WebSocket
|
* Thin snapshot of a page node carried inside domain events so the WebSocket
|
||||||
@@ -112,48 +111,24 @@ export class PageListener {
|
|||||||
private readonly logger = new Logger(PageListener.name);
|
private readonly logger = new Logger(PageListener.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly environmentService: EnvironmentService,
|
|
||||||
@InjectQueue(QueueName.SEARCH_QUEUE) private searchQueue: Queue,
|
|
||||||
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
|
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@OnEvent(EventName.PAGE_CREATED)
|
@OnEvent(EventName.PAGE_CREATED)
|
||||||
async handlePageCreated(event: PageEvent) {
|
async handlePageCreated(event: PageEvent) {
|
||||||
const { pageIds, workspaceId } = event;
|
const { pageIds, workspaceId } = event;
|
||||||
if (this.isTypesense()) {
|
|
||||||
await this.searchQueue.add(QueueJob.PAGE_CREATED, {
|
|
||||||
pageIds,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.aiQueue.add(QueueJob.PAGE_CREATED, { pageIds, workspaceId });
|
await this.aiQueue.add(QueueJob.PAGE_CREATED, { pageIds, workspaceId });
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnEvent(EventName.PAGE_UPDATED)
|
|
||||||
async handlePageUpdated(event: PageEvent) {
|
|
||||||
const { pageIds } = event;
|
|
||||||
|
|
||||||
await this.searchQueue.add(QueueJob.PAGE_UPDATED, { pageIds });
|
|
||||||
}
|
|
||||||
|
|
||||||
@OnEvent(EventName.PAGE_DELETED)
|
@OnEvent(EventName.PAGE_DELETED)
|
||||||
async handlePageDeleted(event: PageEvent) {
|
async handlePageDeleted(event: PageEvent) {
|
||||||
const { pageIds, workspaceId } = event;
|
const { pageIds, workspaceId } = event;
|
||||||
if (this.isTypesense()) {
|
|
||||||
await this.searchQueue.add(QueueJob.PAGE_DELETED, { pageIds });
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.aiQueue.add(QueueJob.PAGE_DELETED, { pageIds, workspaceId });
|
await this.aiQueue.add(QueueJob.PAGE_DELETED, { pageIds, workspaceId });
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnEvent(EventName.PAGE_SOFT_DELETED)
|
@OnEvent(EventName.PAGE_SOFT_DELETED)
|
||||||
async handlePageSoftDeleted(event: PageEvent) {
|
async handlePageSoftDeleted(event: PageEvent) {
|
||||||
const { pageIds, workspaceId } = event;
|
const { pageIds, workspaceId } = event;
|
||||||
|
|
||||||
if (this.isTypesense()) {
|
|
||||||
await this.searchQueue.add(QueueJob.PAGE_SOFT_DELETED, { pageIds });
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.aiQueue.add(QueueJob.PAGE_SOFT_DELETED, {
|
await this.aiQueue.add(QueueJob.PAGE_SOFT_DELETED, {
|
||||||
pageIds,
|
pageIds,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@@ -163,14 +138,6 @@ export class PageListener {
|
|||||||
@OnEvent(EventName.PAGE_RESTORED)
|
@OnEvent(EventName.PAGE_RESTORED)
|
||||||
async handlePageRestored(event: PageEvent) {
|
async handlePageRestored(event: PageEvent) {
|
||||||
const { pageIds, workspaceId } = event;
|
const { pageIds, workspaceId } = event;
|
||||||
if (this.isTypesense()) {
|
|
||||||
await this.searchQueue.add(QueueJob.PAGE_RESTORED, { pageIds });
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.aiQueue.add(QueueJob.PAGE_RESTORED, { pageIds, workspaceId });
|
await this.aiQueue.add(QueueJob.PAGE_RESTORED, { pageIds, workspaceId });
|
||||||
}
|
}
|
||||||
|
|
||||||
isTypesense(): boolean {
|
|
||||||
return this.environmentService.getSearchDriver() === 'typesense';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { EventName } from '../../common/events/event.contants';
|
|||||||
import { InjectQueue } from '@nestjs/bullmq';
|
import { InjectQueue } from '@nestjs/bullmq';
|
||||||
import { QueueJob, QueueName } from '../../integrations/queue/constants';
|
import { QueueJob, QueueName } from '../../integrations/queue/constants';
|
||||||
import { Queue } from 'bullmq';
|
import { Queue } from 'bullmq';
|
||||||
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
|
||||||
|
|
||||||
export class SpaceEvent {
|
export class SpaceEvent {
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
@@ -15,22 +14,12 @@ export class SpaceListener {
|
|||||||
private readonly logger = new Logger(SpaceListener.name);
|
private readonly logger = new Logger(SpaceListener.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly environmentService: EnvironmentService,
|
|
||||||
@InjectQueue(QueueName.SEARCH_QUEUE) private searchQueue: Queue,
|
|
||||||
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
|
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@OnEvent(EventName.SPACE_DELETED)
|
@OnEvent(EventName.SPACE_DELETED)
|
||||||
async handleSpaceDeleted(event: SpaceEvent) {
|
async handleSpaceDeleted(event: SpaceEvent) {
|
||||||
const { spaceId } = event;
|
const { spaceId } = event;
|
||||||
if (this.isTypesense()) {
|
|
||||||
await this.searchQueue.add(QueueJob.SPACE_DELETED, { spaceId });
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.aiQueue.add(QueueJob.SPACE_DELETED, { spaceId });
|
await this.aiQueue.add(QueueJob.SPACE_DELETED, { spaceId });
|
||||||
}
|
}
|
||||||
|
|
||||||
isTypesense(): boolean {
|
|
||||||
return this.environmentService.getSearchDriver() === 'typesense';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { EventName } from '../../common/events/event.contants';
|
|||||||
import { InjectQueue } from '@nestjs/bullmq';
|
import { InjectQueue } from '@nestjs/bullmq';
|
||||||
import { QueueJob, QueueName } from '../../integrations/queue/constants';
|
import { QueueJob, QueueName } from '../../integrations/queue/constants';
|
||||||
import { Queue } from 'bullmq';
|
import { Queue } from 'bullmq';
|
||||||
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
|
||||||
|
|
||||||
export class WorkspaceEvent {
|
export class WorkspaceEvent {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
@@ -15,22 +14,12 @@ export class WorkspaceListener {
|
|||||||
private readonly logger = new Logger(WorkspaceListener.name);
|
private readonly logger = new Logger(WorkspaceListener.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly environmentService: EnvironmentService,
|
|
||||||
@InjectQueue(QueueName.SEARCH_QUEUE) private searchQueue: Queue,
|
|
||||||
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
|
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@OnEvent(EventName.WORKSPACE_DELETED)
|
@OnEvent(EventName.WORKSPACE_DELETED)
|
||||||
async handlePageDeleted(event: WorkspaceEvent) {
|
async handlePageDeleted(event: WorkspaceEvent) {
|
||||||
const { workspaceId } = event;
|
const { workspaceId } = event;
|
||||||
if (this.isTypesense()) {
|
|
||||||
await this.searchQueue.add(QueueJob.WORKSPACE_DELETED, { workspaceId });
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.aiQueue.add(QueueJob.WORKSPACE_DELETED, { workspaceId });
|
await this.aiQueue.add(QueueJob.WORKSPACE_DELETED, { workspaceId });
|
||||||
}
|
}
|
||||||
|
|
||||||
isTypesense(): boolean {
|
|
||||||
return this.environmentService.getSearchDriver() === 'typesense';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ const migrator = new Migrator({
|
|||||||
path,
|
path,
|
||||||
migrationFolder,
|
migrationFolder,
|
||||||
}),
|
}),
|
||||||
|
// Match the startup auto-migrator (migration.service.ts): a back-dated
|
||||||
|
// migration from a long-lived branch must be applied, not rejected as
|
||||||
|
// "corrupted migrations" (incident #361). See that file for the full rationale.
|
||||||
|
allowUnorderedMigrations: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
run(db, migrator, migrationFolder);
|
run(db, migrator, migrationFolder);
|
||||||
|
|||||||
@@ -19,6 +19,16 @@ export class MigrationService {
|
|||||||
path,
|
path,
|
||||||
migrationFolder: path.join(__dirname, '..', 'migrations'),
|
migrationFolder: path.join(__dirname, '..', 'migrations'),
|
||||||
}),
|
}),
|
||||||
|
// A long-lived branch can add a migration whose timestamped filename sorts
|
||||||
|
// BEFORE migrations already applied in prod (e.g. #234's 20260627 landing
|
||||||
|
// after 20260704 was live). With the default (ordered) setting the startup
|
||||||
|
// migrator then sees "corrupted migrations" — the applied set is no longer a
|
||||||
|
// prefix of the sorted list — throws, and the app crash-loops on boot
|
||||||
|
// (incident #361: 502s for ~11 min). allowUnorderedMigrations runs any
|
||||||
|
// not-yet-applied migration regardless of filename order, so a back-dated
|
||||||
|
// migration is applied instead of bricking startup. A CI order-gate still
|
||||||
|
// discourages back-dating; this is the runtime safety net.
|
||||||
|
allowUnorderedMigrations: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { error, results } = await migrator.migrateToLatest();
|
const { error, results } = await migrator.migrateToLatest();
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { streamText, Output } from 'ai';
|
||||||
|
import { MockLanguageModelV3, simulateReadableStream } from 'ai/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression tests for patches/ai@6.0.134.patch (server heap OOM on long
|
||||||
|
* autonomous agent runs, #184).
|
||||||
|
*
|
||||||
|
* Unpatched ai@6.0.134 substitutes the default text() output strategy even
|
||||||
|
* when the caller passes NO `output` option. Its createOutputTransformStream
|
||||||
|
* then accumulates the ENTIRE turn text and, on EVERY text-delta, enqueues a
|
||||||
|
* flat snapshot of all text so far as `partialOutput` (O(n^2) memory). Those
|
||||||
|
* snapshots pile up in the never-consumed leftover tee() branch of
|
||||||
|
* DefaultStreamTextResult.baseStream, which is what OOM'd production during a
|
||||||
|
* ~28k-chunk agent turn. The pnpm patch skips partialOutput production
|
||||||
|
* entirely when no output strategy was requested, while keeping per-delta
|
||||||
|
* streaming granularity.
|
||||||
|
*/
|
||||||
|
describe('ai@6.0.134 pnpm patch: no partialOutput accumulation without an output strategy', () => {
|
||||||
|
const makeModel = () =>
|
||||||
|
new MockLanguageModelV3({
|
||||||
|
doStream: async () => ({
|
||||||
|
stream: simulateReadableStream({
|
||||||
|
chunks: [
|
||||||
|
{ type: 'stream-start' as const, warnings: [] },
|
||||||
|
{ type: 'text-start' as const, id: '1' },
|
||||||
|
{ type: 'text-delta' as const, id: '1', delta: 'Hello' },
|
||||||
|
{ type: 'text-delta' as const, id: '1', delta: ', ' },
|
||||||
|
{ type: 'text-delta' as const, id: '1', delta: 'world!' },
|
||||||
|
{ type: 'text-end' as const, id: '1' },
|
||||||
|
{
|
||||||
|
type: 'finish' as const,
|
||||||
|
finishReason: { unified: 'stop' as const, raw: 'stop' },
|
||||||
|
usage: {
|
||||||
|
inputTokens: {
|
||||||
|
total: 1,
|
||||||
|
noCache: undefined,
|
||||||
|
cacheRead: undefined,
|
||||||
|
cacheWrite: undefined,
|
||||||
|
},
|
||||||
|
outputTokens: { total: 1, text: 1, reasoning: undefined },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves per-delta streaming granularity in textStream', async () => {
|
||||||
|
const result = streamText({ model: makeModel(), prompt: 'hi' });
|
||||||
|
|
||||||
|
const deltas: string[] = [];
|
||||||
|
for await (const delta of result.textStream) {
|
||||||
|
deltas.push(delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The patch must NOT coalesce or drop deltas: three model deltas arrive
|
||||||
|
// as three separate textStream chunks.
|
||||||
|
expect(deltas).toEqual(['Hello', ', ', 'world!']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits NO partialOutput values when the caller did not request an output strategy', async () => {
|
||||||
|
const result = streamText({ model: makeModel(), prompt: 'hi' });
|
||||||
|
|
||||||
|
// Fully consume the primary stream first (mirrors production usage).
|
||||||
|
for await (const _ of result.textStream) {
|
||||||
|
// drain
|
||||||
|
}
|
||||||
|
|
||||||
|
const partials: unknown[] = [];
|
||||||
|
for await (const partial of result.experimental_partialOutputStream) {
|
||||||
|
partials.push(partial);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TRIPWIRE: on unpatched ai@6.0.134 the default text() output strategy
|
||||||
|
// yields one cumulative partial per text-delta here (['Hello', 'Hello, ',
|
||||||
|
// 'Hello, world!']). An empty stream proves the patch is applied and no
|
||||||
|
// cumulative snapshots are being produced (and thus none can pile up in
|
||||||
|
// the leftover internal tee branch).
|
||||||
|
expect(partials).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves cumulative partialOutput when the caller DOES request an output strategy', async () => {
|
||||||
|
// PRESERVE-BRANCH GUARD: the patch only short-circuits partialOutput when
|
||||||
|
// `output == null`. When an output strategy IS set (here Output.text()),
|
||||||
|
// createOutputTransformStream must fall through to the ORIGINAL code path
|
||||||
|
// and keep publishing cumulative snapshots, so object/text-output consumers
|
||||||
|
// behave byte-identically to unpatched ai. A careless re-port that routed
|
||||||
|
// output-set calls into the skip branch would leave partialOutput empty and
|
||||||
|
// silently break those consumers — this test is the tripwire for that.
|
||||||
|
const result = streamText({
|
||||||
|
model: makeModel(),
|
||||||
|
prompt: 'hi',
|
||||||
|
experimental_output: Output.text(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drain the primary stream fully and accumulate the complete output text.
|
||||||
|
let fullText = '';
|
||||||
|
for await (const delta of result.textStream) {
|
||||||
|
fullText += delta;
|
||||||
|
}
|
||||||
|
|
||||||
|
const partials: string[] = [];
|
||||||
|
for await (const partial of result.experimental_partialOutputStream) {
|
||||||
|
partials.push(partial);
|
||||||
|
}
|
||||||
|
|
||||||
|
// With a strategy set, partialOutput must be PRESERVED (non-empty) and
|
||||||
|
// cumulative: the last emitted partial equals the full accumulated text.
|
||||||
|
expect(partials.length).toBeGreaterThan(0);
|
||||||
|
expect(partials[partials.length - 1]).toBe(fullText);
|
||||||
|
expect(fullText).toBe('Hello, world!');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('both installed dist builds (CJS and ESM) carry the patch marker', () => {
|
||||||
|
// Secondary guard: pins the patch to BOTH bundles the SDK ships, since
|
||||||
|
// the NestJS server consumes CJS while other tooling may load ESM.
|
||||||
|
const cjsPath = require.resolve('ai');
|
||||||
|
const mjsPath = cjsPath.replace(/index\.js$/, 'index.mjs');
|
||||||
|
expect(cjsPath).toMatch(/index\.js$/);
|
||||||
|
expect(readFileSync(cjsPath, 'utf8')).toContain('PATCH(docmost');
|
||||||
|
expect(readFileSync(mjsPath, 'utf8')).toContain('PATCH(docmost');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { buildCorsAllowlist, isOriginAllowed } from './cors.util';
|
||||||
|
|
||||||
|
const WEBVIEW_ORIGINS = [
|
||||||
|
'capacitor://localhost',
|
||||||
|
'ionic://localhost',
|
||||||
|
'https://localhost',
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('isOriginAllowed', () => {
|
||||||
|
const allowlist = buildCorsAllowlist({
|
||||||
|
appUrl: 'https://app.example',
|
||||||
|
configuredOrigins: ['https://other.example'],
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows requests with no Origin header', () => {
|
||||||
|
expect(isOriginAllowed(undefined, allowlist)).toBe(true);
|
||||||
|
expect(isOriginAllowed('', allowlist)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows an exact allowlisted origin', () => {
|
||||||
|
expect(isOriginAllowed('https://app.example', allowlist)).toBe(true);
|
||||||
|
expect(isOriginAllowed('https://other.example', allowlist)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows each native WebView origin', () => {
|
||||||
|
for (const origin of WEBVIEW_ORIGINS) {
|
||||||
|
expect(isOriginAllowed(origin, allowlist)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a foreign credentialed origin', () => {
|
||||||
|
// With credentials:true a foreign credentialed origin must be rejected.
|
||||||
|
expect(isOriginAllowed('https://evil.example', allowlist)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects the cleartext http://localhost origin', () => {
|
||||||
|
// The native shell uses the secure scheme (https://localhost) on Android and
|
||||||
|
// the capacitor:// custom scheme on iOS, so cleartext http://localhost must
|
||||||
|
// not be trusted.
|
||||||
|
expect(isOriginAllowed('http://localhost', allowlist)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a trailing-slash mismatch', () => {
|
||||||
|
expect(isOriginAllowed('https://app.example/', allowlist)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a host-case mismatch', () => {
|
||||||
|
expect(isOriginAllowed('https://APP.example', allowlist)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows no-Origin but rejects cross-origin with an empty allowlist', () => {
|
||||||
|
const empty: ReadonlySet<string> = new Set<string>();
|
||||||
|
expect(isOriginAllowed(undefined, empty)).toBe(true);
|
||||||
|
expect(isOriginAllowed('https://app.example', empty)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildCorsAllowlist', () => {
|
||||||
|
it('contains the app URL, each configured origin, and all WebView origins', () => {
|
||||||
|
const allowlist = buildCorsAllowlist({
|
||||||
|
appUrl: 'https://app.example',
|
||||||
|
configuredOrigins: ['https://a.example', 'https://b.example'],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(allowlist.has('https://app.example')).toBe(true);
|
||||||
|
expect(allowlist.has('https://a.example')).toBe(true);
|
||||||
|
expect(allowlist.has('https://b.example')).toBe(true);
|
||||||
|
for (const origin of WEBVIEW_ORIGINS) {
|
||||||
|
expect(allowlist.has(origin)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deduplicates when a configured origin coincides with the app URL', () => {
|
||||||
|
const allowlist = buildCorsAllowlist({
|
||||||
|
appUrl: 'https://app.example',
|
||||||
|
configuredOrigins: ['https://app.example'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// app URL + WebView origins, the duplicate configured origin collapses.
|
||||||
|
expect(allowlist.size).toBe(1 + WEBVIEW_ORIGINS.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('always includes every WebView origin even with no configured origins', () => {
|
||||||
|
const allowlist = buildCorsAllowlist({
|
||||||
|
appUrl: 'https://app.example',
|
||||||
|
configuredOrigins: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const origin of WEBVIEW_ORIGINS) {
|
||||||
|
expect(allowlist.has(origin)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// CORS trust boundary helpers. `buildCorsAllowlist` produces the exact set of
|
||||||
|
// origins the API trusts, and `isOriginAllowed` is the predicate the enableCors
|
||||||
|
// origin callback uses to accept/reject each request. With credentials:true a
|
||||||
|
// foreign credentialed origin must never be allowed, so anything not in the
|
||||||
|
// allowlist (apart from no-Origin requests) is rejected.
|
||||||
|
|
||||||
|
// Native WebView origins used by the Capacitor/Ionic mobile shell. Always
|
||||||
|
// trusted so the native client can call the API.
|
||||||
|
//
|
||||||
|
// - `capacitor://localhost` — iOS native custom scheme.
|
||||||
|
// - `ionic://localhost` — legacy native custom scheme.
|
||||||
|
// - `https://localhost` — Android default secure scheme.
|
||||||
|
//
|
||||||
|
// The cleartext `http://localhost` origin is intentionally NOT trusted: the
|
||||||
|
// Capacitor shell uses the secure scheme (capacitor.config.ts sets
|
||||||
|
// `cleartext: false` and does not override `androidScheme`, so Capacitor's
|
||||||
|
// default Android scheme is `https` => origin `https://localhost`), and iOS runs
|
||||||
|
// in hosted mode (`server.url` = CAP_SERVER_URL, whose origin is the app URL
|
||||||
|
// already in the allowlist). No native client legitimately uses
|
||||||
|
// `http://localhost`, so allowing it would only widen the credentialed-CORS
|
||||||
|
// surface to arbitrary local http content.
|
||||||
|
const NATIVE_WEBVIEW_ORIGINS = [
|
||||||
|
'capacitor://localhost',
|
||||||
|
'ionic://localhost',
|
||||||
|
'https://localhost',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// Build the CORS allowlist: the app URL, all configured cross-origin clients,
|
||||||
|
// and the native WebView origins. Dedup is automatic via Set.
|
||||||
|
export function buildCorsAllowlist(input: {
|
||||||
|
appUrl: string;
|
||||||
|
configuredOrigins: readonly string[];
|
||||||
|
}): Set<string> {
|
||||||
|
return new Set<string>([
|
||||||
|
input.appUrl,
|
||||||
|
...input.configuredOrigins,
|
||||||
|
...NATIVE_WEBVIEW_ORIGINS,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decide whether a request's Origin is allowed. A missing Origin header (curl,
|
||||||
|
// server-to-server, some native WebViews) is allowed; otherwise the origin must
|
||||||
|
// be present in the allowlist.
|
||||||
|
export function isOriginAllowed(
|
||||||
|
origin: string | undefined,
|
||||||
|
allowlist: ReadonlySet<string>,
|
||||||
|
): boolean {
|
||||||
|
if (!origin) return true;
|
||||||
|
return allowlist.has(origin);
|
||||||
|
}
|
||||||
@@ -5,6 +5,13 @@ import { EnvironmentService } from './environment.service';
|
|||||||
describe('EnvironmentService', () => {
|
describe('EnvironmentService', () => {
|
||||||
let service: EnvironmentService;
|
let service: EnvironmentService;
|
||||||
|
|
||||||
|
// Build a service over a stub ConfigService whose get(key, def) returns
|
||||||
|
// values from the supplied env map (falling back to the provided default).
|
||||||
|
const makeService = (env: Record<string, string>) =>
|
||||||
|
new EnvironmentService({
|
||||||
|
get: (k: string, d?: string) => (k in env ? env[k] : d),
|
||||||
|
} as any);
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
service = new EnvironmentService(
|
service = new EnvironmentService(
|
||||||
{} as any, // configService
|
{} as any, // configService
|
||||||
@@ -158,4 +165,72 @@ describe('EnvironmentService', () => {
|
|||||||
).toBe('https://app.example.com');
|
).toBe('https://app.example.com');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getCorsAllowedOrigins', () => {
|
||||||
|
it('splits, trims, and drops empty entries', () => {
|
||||||
|
const svc = makeService({
|
||||||
|
CORS_ALLOWED_ORIGINS: 'https://a.com, https://b.com ,, https://c.com',
|
||||||
|
});
|
||||||
|
expect(svc.getCorsAllowedOrigins()).toEqual([
|
||||||
|
'https://a.com',
|
||||||
|
'https://b.com',
|
||||||
|
'https://c.com',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty array when the var is absent', () => {
|
||||||
|
const svc = makeService({});
|
||||||
|
expect(svc.getCorsAllowedOrigins()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty array for an empty string', () => {
|
||||||
|
const svc = makeService({ CORS_ALLOWED_ORIGINS: '' });
|
||||||
|
expect(svc.getCorsAllowedOrigins()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a single origin unchanged', () => {
|
||||||
|
const svc = makeService({
|
||||||
|
CORS_ALLOWED_ORIGINS: 'https://app.example',
|
||||||
|
});
|
||||||
|
expect(svc.getCorsAllowedOrigins()).toEqual(['https://app.example']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Adversarial case: leading/trailing/duplicate commas with surrounding
|
||||||
|
// spaces must be dropped, exercising both .map(trim) and .filter(Boolean).
|
||||||
|
it('drops leading/trailing commas with surrounding spaces', () => {
|
||||||
|
const svc = makeService({ CORS_ALLOWED_ORIGINS: ' , a , , b ' });
|
||||||
|
expect(svc.getCorsAllowedOrigins()).toEqual(['a', 'b']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isSwaggerEnabled', () => {
|
||||||
|
// Case-insensitive: "true" in any casing enables Swagger.
|
||||||
|
it.each(['true', 'TRUE', 'True'])('is true for "%s"', (value) => {
|
||||||
|
expect(
|
||||||
|
makeService({ SWAGGER_ENABLED: value }).isSwaggerEnabled(),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to false when absent', () => {
|
||||||
|
expect(makeService({}).isSwaggerEnabled()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is false for non-"true" values', () => {
|
||||||
|
expect(makeService({ SWAGGER_ENABLED: '0' }).isSwaggerEnabled()).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(makeService({ SWAGGER_ENABLED: 'yes' }).isSwaggerEnabled()).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(makeService({ SWAGGER_ENABLED: 'false' }).isSwaggerEnabled()).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(makeService({ SWAGGER_ENABLED: '' }).isSwaggerEnabled()).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(makeService({ SWAGGER_ENABLED: '1' }).isSwaggerEnabled()).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user