Compare commits

...

8 Commits

Author SHA1 Message Date
claude code agent 227
cf6b78bca1 fix(share): order swap delete-before-update and distinguish unique violations
Addresses review on PR #227.

- setAlias confirmed-reassign branch: DELETE the target page's existing
  alias row(s) BEFORE retargeting `byName` onto the page, instead of after.
  The new partial unique index `(workspace_id, page_id)` is non-deferrable
  and checked at each statement, so retargeting first momentarily left two
  rows for the page -> immediate 23505 -> rolled-back tx surfaced as a
  misleading "Alias already taken" (regressing a previously-working swap onto
  a page that already had its own alias). The reordered branch needs no
  trailing self-heal. JSDoc updated to describe the real ordering.

- catch block: the postgres@3.x driver exposes the violated index as
  `err.constraint_name` (with `.constraint` as a fallback). Map
  `share_aliases_workspace_id_alias_unique` -> "Alias already taken" and the
  new `share_aliases_workspace_id_page_id_unique` -> a distinct ALIAS_PAGE_RACE
  outcome (a concurrent same-page write, not a name clash). Always log the
  constraint name on any 23505 so the race is diagnosable.

- migration 20260627T120000: document that the dedup DELETE is intended,
  irreversible data loss (old duplicate `/l/<old>` links start 404ing after
  upgrade; `down()` cannot restore the rows). Same note added to CHANGELOG
  [Unreleased] Fixed.

Tests:
- integration: confirmed reassign onto a page that ALREADY has its own alias
  (RED before the reorder); migration up() dedup scoping across pages and a
  second workspace; mid-transaction error -> BadRequest with clean rollback.
- unit: constraint_name distinguishing (alias index, page_id index, fallback
  `.constraint`, no-info default) and non-unique error -> BadRequest; retarget
  test now asserts delete-before-update order.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 02:49:48 +03:00
claude code agent 227
c18abf84c6 fix(share): keep exactly one custom address per page on alias edit (#226)
Editing an existing share alias (e.g. slug `te` -> `ted`) failed to update
the displayed `/l/<alias>` link: `setAlias()` looked the requested slug up by
name and, if free, INSERTed a brand-new row, leaving the page with multiple
alias rows. The modal then read via `findByPageId().executeTakeFirst()` with no
`ORDER BY`, so Postgres returned an arbitrary (in practice the oldest, stale)
row. Every edit also spawned an orphan row that kept a live `/l/<old>` link
forever. Regression of #205.

Enforce the invariant "a page has EXACTLY ONE custom address":
- `setAlias()` now resolves the page's current alias row and RENAMES it in
  place when the requested name is free (insert only when the page has none),
  keeps the same-name no-op and the cross-page 409 `ALIAS_REASSIGN_REQUIRED`
  + confirmed-retarget flow, and after any successful write DELETEs all other
  alias rows for the page (self-heal). Runs in one transaction so the page is
  never transiently empty or duplicated.
- repo: add `updateAlias` (rename) and `deleteOthersForPage`; make
  `findByPageId` deterministic with `ORDER BY created_at DESC, id DESC`.
- migration: dedup existing rows (keep newest per page) + a PARTIAL unique
  index `(workspace_id, page_id) WHERE page_id IS NOT NULL` so dangling
  aliases still coexist while live ones are one-per-page.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 00:56:37 +03:00
claude_code
e9409e245b style(share): drop divider line from custom-address prefix
The right border on the address prefix read as a stray vertical line
between the domain and the slug. Remove it and rely on the subtle
prefix background alone to separate the two parts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 22:33:08 +03:00
claude_code
fa6a87e22d test(ai-chat): cover MessageList parent-side signature snapshot (#224)
PR #224 fixed an AI-chat streaming-render regression by moving the React.memo
content signature into the parent: MessageList now snapshots
messageSignature(message) per render and passes it to MessageItem as the
immutable `signature` prop. The existing memo tests only SIMULATED that
parent half by hardcoding `signature={messageSignature(message)}` in their
harness; the real MessageList was never exercised (chat-thread.test.tsx mocks
it out, and there was no message-list.test).

Add message-list.test.tsx that mounts the REAL MessageList (without mocking
MessageItem or messageSignature) and asserts that an in-place mutation of a
reused message object surfaces on re-render. This guards the parent-side
contract: re-caching the signature on message identity (stable across deltas
while parts mutate) would refreeze the row, and this test would fail.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 22:33:01 +03:00
claude_code
0fc9c4a998 Merge branch 'develop' of https://gitea.vvzvlad.xyz/vvzvlad/gitmost into develop 2026-06-26 22:09:22 +03:00
claude_code
40b8f7922a feat(client): quick-create regular and temporary notes from Home and Space screens
Add fast note-creation entry points alongside the existing space-sidebar
actions.

- Home: refactor new-note-button.tsx into a reusable inner CreateNoteButton
  (parametrized by `temporary`/label/icon, keeps the 0/1/many writable-space
  resolution and space-picker dropdown) and render two equal-width buttons via
  `Group grow` — a regular note and a temporary note (IconHourglass).
- Space overview: new SpaceCreateNoteButtons component with two buttons that
  create a regular/temporary note directly in the current space and open it,
  reusing useTreeMutation.handleCreate (optimistic sidebar-tree insert +
  navigation). Permission-gated to members who can manage pages; a local
  pending state shows a per-button spinner and disables both to prevent a
  double-create. Wired into space-home.tsx above the tabs.
- Reuse existing i18n keys (no new strings): "New note", "New temporary note",
  "Create in space".
- Docs: add a CHANGELOG [Unreleased] entry and a "Temporary notes" roadmap
  bullet to README.md and README.ru.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 22:09:09 +03:00
08c70cf550 Merge pull request 'fix(ai-chat): assistant turn renders nothing — memo signature defeated by AI-SDK in-place part mutation (#182 regression)' (#224) from fix/ai-chat-empty-render into develop
Reviewed-on: #224
2026-06-26 22:09:05 +03:00
claude_code
276ccc0783 refactor(ai): drop Generative AI flag, gate title generation on AI chat
Remove the separate, un-toggleable `settings.ai.generative` workspace flag
(and its write-side alias `generativeAi`) along with the dead "Ask AI"
generative editor menu, and re-gate the AI page-title generation on the
general AI chat flag (`settings.ai.chat`) — the same toggle that enables
the chat agent and the chat stream endpoint.

Why: the `generative` flag had no UI toggle (its switch was already removed,
leaving orphaned i18n strings), so the title-generation button was
unreachable on self-hosted. The "Ask AI" menu was dead — its atom was never
rendered. Consolidating onto the AI chat flag makes the title button follow
the one AI switch users actually have.

Changes:
- server: title-gen endpoint gate generative -> chat (ai-chat.controller.ts);
  remove generativeAi from update DTO and workspace service (update block,
  delete line, cloud default now { ai: { chat: true } }); fix repo comment;
  migrate generate-page-title spec assertions generative -> chat.
- client: title-gen gate -> settings.ai.chat (full-editor.tsx); remove the
  dead Ask AI button + showAiMenu wiring from bubble-menu; remove AskAiGroup
  usage/import and commented block from fixed-toolbar; delete ask-ai-group.tsx;
  remove showAiMenuAtom; drop generative/generativeAi from workspace types.
- i18n: remove 3 orphaned generative-AI keys from all 12 locales.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 21:35:30 +03:00
38 changed files with 1184 additions and 222 deletions

View File

@@ -12,6 +12,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- **Quick-create regular and temporary notes from the Home and Space screens.**
The Home screen now shows a second action next to "New note" that creates a
*temporary* note (one that auto-moves to Trash after the workspace lifetime),
resolving the target space the same way the regular button does — created
directly when you can write to a single space, or via a space picker when
several. Each space overview screen gains two buttons — "New note" and "New
temporary note" — that create the page directly in that space and open it,
mirroring the existing space-sidebar actions and shown only to members who can
manage pages.
- **Interrupt the AI agent and send a queued message now.** A queued AI-chat
message gains a "send now" action that interrupts the streaming turn and
immediately sends that message, keeping the agent's partial output. The
@@ -19,6 +28,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
answer was cut off and builds on it instead of restarting; the rest of the
queue still flushes normally afterward. (#198)
### Fixed
- **A shared page now keeps EXACTLY ONE custom address (`/l/:alias`).** Editing a
page's vanity slug previously inserted a second `share_aliases` row instead of
renaming the existing one, leaving the old `/l/<old>` link live forever and
making the share modal's lookup nondeterministic. Slug edits and confirmed
reassigns now rename/retarget the single row, and a new partial unique index on
`(workspace_id, page_id)` enforces the invariant in the database. **Upgrade
note:** the accompanying migration `20260627T120000` IRREVERSIBLY deletes the
orphaned duplicate alias rows the old bug created (keeping the newest per
page), so any previously-live duplicate `/l/<old>` link begins returning the
generic 404 after upgrade — intended, but not undoable by `down()`. (#226,
#227)
## [0.94.0] - 2026-06-26
This release makes AI chat durable and fast: assistant turns are persisted to

View File

@@ -104,6 +104,7 @@ community feature, with no enterprise license. Open it from the page header; the
-**Page templates** — flag a page as a template and embed its whole content live into other pages; edits to the template propagate to every place it is inserted (whole-page transclusion on top of the existing synced blocks).
-**Public-share AI assistant** — anonymous visitors of a shared page can ask the AI agent, scoped strictly to that share's page tree (read-only, share-scoped search), behind a workspace toggle.
-**Footnotes** — academic-style footnotes: a numbered superscript reference inline (read it in place via a hover popover), with the note text living as a real, editable block at the bottom of the page; auto-numbered, collaboration-safe, and round-trips through Markdown export/import and the AI agent / MCP.
-**Temporary notes** — mark a note as temporary and it auto-moves to Trash after a configurable per-workspace lifetime (default 24h) unless made permanent first; create one in a click from the Home screen, any space overview, or the space sidebar, with a "Make permanent" rescue banner on the open note.
### In progress

View File

@@ -105,6 +105,7 @@ real-time-коллаборации Docmost, поэтому запись нико
-**Шаблоны страниц** — пометить страницу шаблоном и вставлять её содержимое живой ссылкой в другие страницы; правки шаблона распространяются на все места вставки (whole-page-транслюзия поверх существующих synced-блоков).
-**AI-ассистент на публичных шарах** — анонимный зритель расшаренной страницы может спросить AI-агента, который ищет строго по дереву этой шары (read-only, share-scoped поиск), за тумблером воркспейса.
-**Сноски** — сноски академического вида: нумерованная ссылка-надстрочник прямо в тексте (читается на месте во всплывающем окне по наведению), а текст сноски живёт реальным редактируемым блоком внизу страницы; авто-нумерация, безопасна для совместного редактирования, переживает экспорт/импорт Markdown и доступна AI-агенту / MCP.
-**Временные заметки** — пометьте заметку временной, и она автоматически уедет в корзину по истечении настраиваемого срока жизни воркспейса (по умолчанию 24 ч), если её предварительно не сделать постоянной; создать такую можно в один клик с домашнего экрана, с обзора любого пространства или из сайдбара пространства, а на открытой заметке есть баннер «Сделать постоянной».
### В процессе

View File

@@ -665,9 +665,6 @@
"AI-powered search (AI Answers)": "KI-unterstützte Suche (KI-Antworten)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "Die KI-Suche verwendet Vektor-Einbettungen, um semantische Suchfunktionen in Ihrem Arbeitsbereich bereitzustellen.",
"Toggle AI search": "KI-Suche umschalten",
"Generative AI (Ask AI)": "Generative KI (KI fragen)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Aktivieren Sie die KI-unterstützte Inhaltserstellung im Editor. Ermöglicht Benutzern das Erzeugen, Verbessern, Übersetzen und Transformieren von Text.",
"Toggle generative AI": "Generative KI umschalten",
"Upgrade your plan": "Upgrade Ihres Plans",
"Available with a paid license": "Verfügbar mit einer kostenpflichtigen Lizenz",
"Upgrade your license tier.": "Stufen Sie Ihre Lizenz hoch.",

View File

@@ -687,9 +687,6 @@
"AI-powered search (AI Answers)": "AI-powered search (AI Answers)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI search uses vector embeddings to provide semantic search capabilities across your workspace content.",
"Toggle AI search": "Toggle AI search",
"Generative AI (Ask AI)": "Generative AI (Ask AI)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.",
"Toggle generative AI": "Toggle generative AI",
"Upgrade your plan": "Upgrade your plan",
"Available with a paid license": "Available with a paid license",
"Upgrade your license tier.": "Upgrade your license tier.",

View File

@@ -665,9 +665,6 @@
"AI-powered search (AI Answers)": "Búsqueda impulsada por IA (Respuestas de IA)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "La búsqueda de IA utiliza incrustaciones vectoriales para proporcionar capacidades de búsqueda semántica en todo el contenido de su espacio de trabajo.",
"Toggle AI search": "Alternar búsqueda de IA",
"Generative AI (Ask AI)": "IA generativa (Preguntar a la IA)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Habilitar la generación de contenido impulsada por IA en el editor. Permite a los usuarios generar, mejorar, traducir y transformar texto.",
"Toggle generative AI": "Activar IA generativa",
"Upgrade your plan": "Mejora tu plan",
"Available with a paid license": "Disponible con una licencia de pago",
"Upgrade your license tier.": "Mejora el nivel de tu licencia.",

View File

@@ -665,9 +665,6 @@
"AI-powered search (AI Answers)": "Recherche propulsée par IA (Réponses IA)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "La recherche IA utilise des incorporations vectorielles pour fournir des capacités de recherche sémantique à travers le contenu de votre espace de travail.",
"Toggle AI search": "Basculer la recherche IA",
"Generative AI (Ask AI)": "IA générative (Demandez à l'IA)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Activer la génération de contenu assistée par IA dans l'éditeur. Permet aux utilisateurs de générer, améliorer, traduire et transformer du texte.",
"Toggle generative AI": "Activer/désactiver l'IA générative",
"Upgrade your plan": "Mettez à niveau votre forfait",
"Available with a paid license": "Disponible avec une licence payante",
"Upgrade your license tier.": "Mettez à niveau votre niveau de licence.",

View File

@@ -665,9 +665,6 @@
"AI-powered search (AI Answers)": "Ricerca con AI (Risposte AI)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "La ricerca AI utilizza embeddings vettoriali per fornire capacità di ricerca semantica nel contenuto della tua area di lavoro.",
"Toggle AI search": "Attiva/disattiva ricerca AI",
"Generative AI (Ask AI)": "AI generativa (Chiedi AI)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Abilita la generazione di contenuti con AI nell'editor. Consente agli utenti di generare, migliorare, tradurre e trasformare il testo.",
"Toggle generative AI": "Attiva/Disattiva AI generativa",
"Upgrade your plan": "Aggiorna il tuo piano",
"Available with a paid license": "Disponibile con una licenza a pagamento",
"Upgrade your license tier.": "Aggiorna il livello della tua licenza.",

View File

@@ -665,9 +665,6 @@
"AI-powered search (AI Answers)": "AI搭載検索 (AI回答)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI検索はベクター埋め込みを使用してワークスペース全体の意味検索を実現します",
"Toggle AI search": "AI検索を切り替え",
"Generative AI (Ask AI)": "生成AI (Ask AI)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "エディターでAIを活用したコンテンツ生成を有効にします。ユーザーがテキストの生成、改善、翻訳、および変換を行うことができます。",
"Toggle generative AI": "生成AIを切り替える",
"Upgrade your plan": "プランをアップグレードする",
"Available with a paid license": "有料ライセンスで利用可能",
"Upgrade your license tier.": "ライセンスタイアをアップグレードしてください。",

View File

@@ -665,9 +665,6 @@
"AI-powered search (AI Answers)": "AI 구동 검색 (AI 답변)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI 검색은 벡터 임베딩을 사용하여 작업공간 콘텐츠에 대한 의미 검색 기능을 제공합니다.",
"Toggle AI search": "AI 검색 전환",
"Generative AI (Ask AI)": "생성 AI (Ask AI)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "편집기에서 AI 구동 콘텐츠 생성을 활성화합니다. 사용자가 텍스트를 생성, 개선, 번역 및 변환할 수 있습니다.",
"Toggle generative AI": "생성 AI 토글",
"Upgrade your plan": "요금제를 업그레이드하세요",
"Available with a paid license": "유료 라이선스에서만 사용 가능합니다",
"Upgrade your license tier.": "라이선스 등급을 업그레이드하세요.",

View File

@@ -665,9 +665,6 @@
"AI-powered search (AI Answers)": "AI-gestuurde zoekopdracht (AI Antwoorden)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI-zoekopdracht maakt gebruik van vectorembeddings om semantische zoekmogelijkheden te bieden in uw werkruimte-inhoud.",
"Toggle AI search": "Schakel AI-zoekopdracht in/uit",
"Generative AI (Ask AI)": "Generatieve AI (Vraag het AI)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Schakel AI-gestuurde inhoudsgeneratie in de editor in. Hiermee kunnen gebruikers tekst genereren, verbeteren, vertalen en transformeren.",
"Toggle generative AI": "Generatieve AI schakelen",
"Upgrade your plan": "Upgrade je abonnement",
"Available with a paid license": "Beschikbaar met een betaalde licentie",
"Upgrade your license tier.": "Upgrade je licentieniveau.",

View File

@@ -665,9 +665,6 @@
"AI-powered search (AI Answers)": "Pesquisa com IA (Respostas de IA)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "A pesquisa IA usa vetores de incorporação para fornecer capacidades de pesquisa semântica em todo o conteúdo do seu espaço de trabalho.",
"Toggle AI search": "Alternar pesquisa de IA",
"Generative AI (Ask AI)": "IA generativa (Perguntar à IA)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Habilitar geração de conteúdo com IA no editor. Permite aos usuários gerar, melhorar, traduzir e transformar texto.",
"Toggle generative AI": "Alternar IA generativa",
"Upgrade your plan": "Faça upgrade do seu plano",
"Available with a paid license": "Disponível com uma licença paga",
"Upgrade your license tier.": "Faça upgrade do seu nível de licença.",

View File

@@ -749,9 +749,6 @@
"AI-powered search (AI Answers)": "Поиск на базе ИИ (Ответы ИИ)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "Поиск ИИ использует векторные встраивания для обеспечения семантического поиска по содержимому вашего рабочего пространства.",
"Toggle AI search": "Переключить поиск ИИ",
"Generative AI (Ask AI)": "Генеративный ИИ (Спросить ИИ)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Включите создание контента на базе ИИ в редакторе. Позволяет пользователям генерировать, улучшать, переводить и преобразовывать текст.",
"Toggle generative AI": "Переключить генеративный ИИ",
"Upgrade your plan": "Обновите свой тарифный план",
"Available with a paid license": "Доступно с платной лицензией",
"Upgrade your license tier.": "Обновите уровень вашей лицензии.",

View File

@@ -665,9 +665,6 @@
"AI-powered search (AI Answers)": "Пошук на базі ШІ (Відповіді ШІ)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "Пошук з ШІ використовує векторні вбудовування для надання можливостей семантичного пошуку у вашому робочому вмісті.",
"Toggle AI search": "Переключити пошук з ШІ",
"Generative AI (Ask AI)": "Генеративний ШІ (Запитати ШІ)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Увімкнути генерацію контенту за допомогою ШІ в редакторі. Дозволяє користувачам генерувати, покращувати, перекладати та трансформувати текст.",
"Toggle generative AI": "Переключити генеративний ШІ",
"Upgrade your plan": "Оновіть свій тарифний план",
"Available with a paid license": "Доступно за платною ліцензією",
"Upgrade your license tier.": "Оновіть рівень своєї ліцензії.",

View File

@@ -665,9 +665,6 @@
"AI-powered search (AI Answers)": "AI驱动的搜索 (AI答案)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI搜索使用向量嵌入提供跨工作空间内容的语义搜索功能。",
"Toggle AI search": "切换AI搜索",
"Generative AI (Ask AI)": "生成型AI (询问AI)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "在编辑器中启用AI驱动的内容生成。允许用户生成、改进、翻译和转换文本。",
"Toggle generative AI": "切换生成型AI",
"Upgrade your plan": "升级您的方案",
"Available with a paid license": "需付费许可才可用",
"Upgrade your license tier.": "升级您的许可等级。",

View File

@@ -0,0 +1,119 @@
import { describe, expect, it, vi } from "vitest";
import { render } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import type { UIMessage } from "@ai-sdk/react";
// Stub react-i18next (MessageList and TypingIndicator read `useTranslation`).
// Mirrors the t-mock pattern used by the other component tests in this folder
// (reasoning-block.test.tsx, message-item-memo.test.tsx).
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
// Spy on `renderChatMarkdown` exactly as message-item-memo.test.tsx does: keep
// every OTHER named export of markdown.ts intact via `importActual`, and override
// only `renderChatMarkdown` with a `vi.fn()` that returns simple HTML. This makes
// assertions synchronous (no async marked + DOMPurify pass) and lets us count
// parses by argument. `vi.hoisted` so the spy exists when the hoisted `vi.mock`
// factory runs.
const { renderChatMarkdownSpy } = vi.hoisted(() => ({
renderChatMarkdownSpy: vi.fn((text: string) => `<p>${text}</p>`),
}));
vi.mock("@/features/ai-chat/utils/markdown.ts", async () => {
const actual = await vi.importActual<
typeof import("@/features/ai-chat/utils/markdown.ts")
>("@/features/ai-chat/utils/markdown.ts");
return { ...actual, renderChatMarkdown: renderChatMarkdownSpy };
});
// IMPORTANT: do NOT mock MessageItem and do NOT mock messageSignature — exercising
// the REAL MessageList -> real MessageItem -> real messageSignature wiring is the
// whole point of this file (it closes the parent-side coverage gap left by the
// memo tests, which simulate the parent by hardcoding `signature={...}` in their
// harness). Use the relative import for the component under test, mirroring how
// message-list.tsx itself imports `MessageItem from "./message-item"`.
import MessageList from "./message-list";
// matchMedia / localStorage / sessionStorage (read by MantineProvider and app
// code) are stubbed globally in vitest.setup.ts — do NOT re-stub those here.
//
// MessageList renders Mantine's ScrollArea, which constructs a `ResizeObserver`.
// jsdom does not implement it, so install a minimal no-op stub BEFORE rendering.
vi.stubGlobal(
"ResizeObserver",
class {
observe() {}
unobserve() {}
disconnect() {}
},
);
// One assistant message wrapping the given `parts`. Reused across renders in the
// regression test to model how the AI SDK hands back the SAME message object.
const msg = (parts: UIMessage["parts"]): UIMessage =>
({ id: "m1", role: "assistant", parts }) as UIMessage;
describe("MessageList", () => {
it("wires the real MessageItem and supplies a valid signature end-to-end", () => {
renderChatMarkdownSpy.mockClear();
const { queryByText } = render(
<MantineProvider>
<MessageList
messages={[msg([{ type: "text", text: "hello world" }])]}
isStreaming={false}
/>
</MantineProvider>,
);
// The assistant text renders, which proves MessageList mounted the real
// MessageItem and handed it a valid `signature` prop (computed from the real
// `messageSignature`) — the full parent -> child -> markdown path is live.
expect(queryByText("hello world")).not.toBeNull();
});
// REGRESSION (PR #224, the empty-render freeze). The AI SDK streams a turn by
// MUTATING the same `parts` array IN PLACE and handing back a NEW array each
// delta that REUSES the same message object. The fix moved the content signature
// to the PARENT: MessageList must recompute `messageSignature(message)` FRESH on
// every render and forward it as the immutable `signature` prop, so MessageItem's
// memo (which compares that prop snapshot) sees it change and re-renders the row.
//
// This test exercises the PARENT half that the memo tests only simulate: if
// MessageList ever cached/memoized the signature keyed on the message object's
// identity (which stays stable across deltas while its `parts` mutate in place),
// the snapshot would never change, MessageItem's memo would skip every delta, and
// the row would freeze at its empty mount — exactly the regression class. That
// would make this test fail. See message-item.tsx (`signature` prop +
// `arePropsEqual`) and message-list.tsx (the `signature={messageSignature(...)}`
// snapshot at render time).
it("reflects in-place part mutation of a reused message object across renders", () => {
renderChatMarkdownSpy.mockClear();
// Reuse ONE message object across renders (as the SDK does). The empty text
// part means MessageItem renders nothing visible initially.
const message = msg([{ type: "text", text: "" }]);
const { rerender, queryByText } = render(
<MantineProvider>
<MessageList messages={[message]} isStreaming />
</MantineProvider>,
);
// Nothing streamed yet.
expect(queryByText("streamed answer")).toBeNull();
// SDK delta: mutate the SAME part in place on the SAME message object...
(message.parts[0] as { text: string }).text = "streamed answer";
// ...then re-render with a NEW array literal that still holds the SAME mutated
// message object (this mirrors useChat handing back a fresh array of reused
// message objects on each delta).
rerender(
<MantineProvider>
<MessageList messages={[message]} isStreaming />
</MantineProvider>,
);
// The grown text now renders: MessageList re-snapshotted the signature, so the
// row re-rendered instead of freezing at its empty mount.
expect(queryByText("streamed answer")).not.toBeNull();
expect(
renderChatMarkdownSpy.mock.calls.some((c) => c[0] === "streamed answer"),
).toBe(true);
});
});

View File

@@ -10,8 +10,6 @@ export const readOnlyEditorAtom = atom<Editor | null>(null);
export const yjsConnectionStatusAtom = atom<string>("");
export const showAiMenuAtom = atom(false);
export const showLinkMenuAtom = atom(false);
// Current page's edit mode — initialized from the user's saved preference on

View File

@@ -9,11 +9,10 @@ import {
IconStrikethrough,
IconUnderline,
IconMessage,
IconSparkles,
} from "@tabler/icons-react";
import clsx from "clsx";
import classes from "./bubble-menu.module.css";
import { ActionIcon, Button, rem, Tooltip } from "@mantine/core";
import { ActionIcon, rem, Tooltip } from "@mantine/core";
import { ColorSelector } from "./color-selector";
import { NodeSelector } from "./node-selector";
import { TextAlignmentSelector } from "./text-alignment-selector";
@@ -26,8 +25,8 @@ import { v7 as uuid7 } from "uuid";
import { isCellSelection, isTextSelected } from "@docmost/editor-ext";
import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector.tsx";
import { useTranslation } from "react-i18next";
import { showAiMenuAtom, showLinkMenuAtom } from "@/features/editor/atoms/editor-atoms";
import { userAtom, workspaceAtom } from "@/features/user/atoms/current-user-atom";
import { showLinkMenuAtom } from "@/features/editor/atoms/editor-atoms";
import { userAtom } from "@/features/user/atoms/current-user-atom";
export interface BubbleMenuItem {
name: string;
@@ -44,16 +43,12 @@ type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children" | "editor"> & {
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
const { templateMode = false } = props;
const { t } = useTranslation();
const [showAiMenu, setShowAiMenu] = useAtom(showAiMenuAtom);
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
const workspace = useAtomValue(workspaceAtom);
const isGenerativeAiEnabled = workspace?.settings?.ai?.generative === true;
const user = useAtomValue(userAtom);
const editorToolbarEnabled =
user?.settings?.preferences?.editorToolbar ?? false;
const [, setDraftCommentId] = useAtom(draftCommentIdAtom);
const showCommentPopupRef = useRef(showCommentPopup);
const showAiMenuRef = useRef(showAiMenu);
const [showLinkMenu] = useAtom(showLinkMenuAtom);
const showLinkMenuRef = useRef(showLinkMenu);
@@ -61,10 +56,6 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
showCommentPopupRef.current = showCommentPopup;
}, [showCommentPopup]);
useEffect(() => {
showAiMenuRef.current = showAiMenu;
}, [showAiMenu]);
useEffect(() => {
showLinkMenuRef.current = showLinkMenu;
}, [showLinkMenu]);
@@ -145,7 +136,6 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
empty ||
isNodeSelection(selection) ||
isCellSelection(selection) ||
showAiMenuRef.current ||
showLinkMenuRef.current ||
showCommentPopupRef?.current
) {
@@ -168,8 +158,8 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
const [isTextAlignmentSelectorOpen, setIsTextAlignmentOpen] = useState(false);
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
// Hide the bubble menu immediately when AI menu is shown
if (showAiMenu || showLinkMenu) return;
// Hide the bubble menu immediately when the link menu is shown
if (showLinkMenu) return;
return (
<BubbleMenu
@@ -177,22 +167,6 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
style={{ zIndex: 199, position: "relative" }}
>
<div className={classes.bubbleMenu}>
{isGenerativeAiEnabled && (
<>
<Button
variant="default"
className={clsx(classes.buttonRoot)}
radius="0"
leftSection={<IconSparkles size={16} />}
onClick={() => {
setShowAiMenu(true);
}}
>
{t("Ask AI")}
</Button>
<div className={classes.divider} />
</>
)}
{!editorToolbarEnabled && (
<>
<NodeSelector

View File

@@ -12,8 +12,6 @@ import { MediaGroup } from "./groups/media-group";
import { QuickInsertsGroup } from "./groups/quick-inserts-group";
import { MoreInsertsGroup } from "./groups/more-inserts-group";
import { HistoryGroup } from "./groups/history-group";
import { AskAiGroup } from "./groups/ask-ai-group";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
import classes from "./fixed-toolbar.module.css";
type FixedToolbarProps = {
@@ -28,8 +26,6 @@ export const FixedToolbar: FC<FixedToolbarProps> = ({
const editorFromAtom = useAtomValue(pageEditorAtom);
const editor = editorProp ?? editorFromAtom;
const state = useToolbarState(editor);
const workspace = useAtomValue(workspaceAtom);
const isGenerativeAiEnabled = workspace?.settings?.ai?.generative === true;
if (!editor || !state) return null;
@@ -43,12 +39,6 @@ export const FixedToolbar: FC<FixedToolbarProps> = ({
onMouseDown={(e) => e.preventDefault()}
>
<div className={classes.inner}>
{/* {isGenerativeAiEnabled && (
<>
<AskAiGroup />
<div className={classes.divider} />
</>
)} */}
<BlockTypeGroup editor={editor} />
<div className={classes.divider} />
<InlineMarksGroup editor={editor} state={state} />

View File

@@ -1,23 +0,0 @@
import { FC } from "react";
import { Button } from "@mantine/core";
import { IconSparkles } from "@tabler/icons-react";
import { useSetAtom } from "jotai";
import { useTranslation } from "react-i18next";
import { showAiMenuAtom } from "@/features/editor/atoms/editor-atoms";
export const AskAiGroup: FC = () => {
const { t } = useTranslation();
const setShowAiMenu = useSetAtom(showAiMenuAtom);
return (
<Button
variant="subtle"
color="dark"
size="xs"
leftSection={<IconSparkles size={14} />}
onClick={() => setShowAiMenu(true)}
>
{t("Ask AI")}
</Button>
);
};

View File

@@ -13,7 +13,7 @@ interface Props {
/**
* AI "generate title" button (#199). Reads the live editor content and applies a
* model-suggested title immediately. Rendered in the page byline, only in edit
* mode and when the workspace's generative AI flag is on.
* mode and when the workspace's AI chat flag is on.
*/
export const GenerateTitleGroup: FC<Props> = ({
pageId,

View File

@@ -77,9 +77,9 @@ export function FullEditor({
const [user] = useAtom(userAtom);
const workspace = useAtomValue(workspaceAtom);
const isDictationEnabled = workspace?.settings?.ai?.dictation === true;
// AI title generation reuses the generative AI flag (same gate as the on-page
// generative menu); the server enforces it too (#199).
const isTitleGenEnabled = workspace?.settings?.ai?.generative === true;
// 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).
const isTitleGenEnabled = workspace?.settings?.ai?.chat === true;
const fullPageWidth = user.settings?.preferences?.fullPageWidth;
const editorToolbarEnabled =
user.settings?.preferences?.editorToolbar ?? false;
@@ -254,7 +254,7 @@ function PageByline({
{showDictation && editor && (
<DictationGroup editor={editor} color="gray" iconSize={20} />
)}
{/* Shown only in edit mode when the workspace's generative AI flag is on,
{/* Shown only in edit mode when the workspace's AI chat flag is on,
so AI title generation stays reachable from the byline (#199). */}
{showTitleGen && (
<GenerateTitleGroup pageId={pageId} color="gray" iconSize={20} />

View File

@@ -1,5 +1,6 @@
import { Button, Menu, Text } from "@mantine/core";
import { IconPlus } from "@tabler/icons-react";
import { Button, Group, Menu, Text } from "@mantine/core";
import { IconHourglass, IconPlus } from "@tabler/icons-react";
import { ReactNode } from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
@@ -10,24 +11,34 @@ import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
import { canCreatePage } from "./can-create-page.ts";
// Prominent home-screen action to create a new note (page). Because the home
// screen has no active space, the target space is resolved from the user's
// writable spaces: created directly when there is one, picked from a dropdown
// when there are several.
export default function NewNoteButton() {
// A single create-note action, parametrized by `temporary`. Self-contained: it
// owns its own create mutation so the regular and temporary buttons show
// independent loading state, while the list of writable spaces is resolved once
// by the parent and passed in. With exactly one writable space it creates
// directly; with several it shows a target-space picker.
function CreateNoteButton({
writableSpaces,
temporary,
label,
icon,
}: {
writableSpaces: ISpace[];
temporary: boolean;
label: string;
icon: ReactNode;
}) {
const { t } = useTranslation();
const navigate = useNavigate();
const createPageMutation = useCreatePageMutation();
const { data } = useGetSpacesQuery({ limit: 100 });
const writableSpaces = (data?.items ?? []).filter(canCreatePage);
const createNote = async (space: ISpace) => {
try {
// `spaceId` is accepted by the create-page endpoint but is not part of
// the shared `IPageInput` type; cast to satisfy the mutation signature.
// `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 createdPage = await createPageMutation.mutateAsync({
spaceId: space.id,
...(temporary ? { temporary: true } : {}),
} as any);
navigate(buildPageUrl(space.slug, createdPage.slugId, createdPage.title));
} catch {
@@ -35,24 +46,20 @@ export default function NewNoteButton() {
}
};
// No writable space → nothing to create in; render nothing.
if (writableSpaces.length === 0) return null;
const isPending = createPageMutation.isPending;
// Exactly one writable space → create directly, no picker needed.
if (writableSpaces.length === 1) {
return (
<Button
fullWidth
size="md"
variant="light"
color="gray"
leftSection={<IconPlus size={18} />}
leftSection={icon}
loading={isPending}
onClick={() => createNote(writableSpaces[0])}
>
{t("New note")}
{label}
</Button>
);
}
@@ -62,14 +69,13 @@ export default function NewNoteButton() {
<Menu shadow="md" width="target" position="bottom-start">
<Menu.Target>
<Button
fullWidth
size="md"
variant="light"
color="gray"
leftSection={<IconPlus size={18} />}
leftSection={icon}
loading={isPending}
>
{t("New note")}
{label}
</Button>
</Menu.Target>
<Menu.Dropdown>
@@ -99,3 +105,35 @@ export default function NewNoteButton() {
</Menu>
);
}
// Prominent home-screen actions to create a new note (page). Because the home
// screen has no active space, the target space is resolved from the user's
// writable spaces: created directly when there is one, picked from a dropdown
// when there are several. Renders two equal-width buttons: a regular note and a
// temporary note (which auto-moves to Trash after the workspace lifetime).
export default function NewNoteButton() {
const { t } = useTranslation();
const { data } = useGetSpacesQuery({ limit: 100 });
const writableSpaces = (data?.items ?? []).filter(canCreatePage);
// No writable space → nothing to create in; render nothing.
if (writableSpaces.length === 0) return null;
return (
<Group grow gap="sm">
<CreateNoteButton
writableSpaces={writableSpaces}
temporary={false}
label={t("New note")}
icon={<IconPlus size={18} />}
/>
<CreateNoteButton
writableSpaces={writableSpaces}
temporary={true}
label={t("New temporary note")}
icon={<IconHourglass size={18} />}
/>
</Group>
);
}

View File

@@ -185,7 +185,6 @@ export default function ShareAliasSection({
fontSize: "var(--mantine-font-size-xs)",
color: "var(--mantine-color-dimmed)",
backgroundColor: "var(--mantine-color-default-hover)",
borderRight: "1px solid var(--mantine-color-default-border)",
borderTopLeftRadius: "var(--input-radius)",
borderBottomLeftRadius: "var(--input-radius)",
}}

View File

@@ -0,0 +1,74 @@
import { useState } from "react";
import { Button, Group } from "@mantine/core";
import { IconHourglass, IconPlus } from "@tabler/icons-react";
import { useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
import {
SpaceCaslAction,
SpaceCaslSubject,
} from "@/features/space/permissions/permissions.type.ts";
// Space-overview quick actions: create a regular note or a temporary note
// (which auto-moves to Trash after the workspace lifetime) directly in the
// current space and open it. Mirrors the sidebar's create buttons but lives on
// the space overview screen, reusing `useTreeMutation.handleCreate` so the new
// page is optimistically inserted into the sidebar tree and navigated to.
export default function SpaceCreateNoteButtons() {
const { t } = useTranslation();
const { spaceSlug } = useParams();
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
const spaceAbility = useSpaceAbility(space?.membership?.permissions);
// `handleCreate` is read unconditionally to keep hook order stable; it is
// only invoked after the permission guard below confirms a loaded space.
const { handleCreate } = useTreeMutation(space?.id ?? "");
// Which create action is in flight: drives the per-button spinner and the
// shared disabled state so a slow create round-trip cannot be double-fired.
const [pending, setPending] = useState<"regular" | "temporary" | null>(null);
// Render nothing until the space loads, or when the user cannot manage pages.
if (!space) return null;
if (spaceAbility.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
return null;
}
const createNote = (temporary: boolean) => {
if (pending) return;
setPending(temporary ? "temporary" : "regular");
// handleCreate creates the page then navigates away (unmounting this
// component); the create mutation already shows a red notification on
// failure, so swallow the rejection and just clear the pending flag.
handleCreate(null, temporary ? { temporary: true } : undefined)
.catch(() => {})
.finally(() => setPending(null));
};
return (
<Group grow gap="sm">
<Button
size="md"
variant="light"
color="gray"
leftSection={<IconPlus size={18} />}
loading={pending === "regular"}
disabled={pending !== null}
onClick={() => createNote(false)}
>
{t("New note")}
</Button>
<Button
size="md"
variant="light"
color="gray"
leftSection={<IconHourglass size={18} />}
loading={pending === "temporary"}
disabled={pending !== null}
onClick={() => createNote(true)}
>
{t("New temporary note")}
</Button>
</Group>
);
}

View File

@@ -20,7 +20,6 @@ export interface IWorkspace {
plan?: string;
enforceMfa?: boolean;
aiSearch?: boolean;
generativeAi?: boolean;
disablePublicSharing?: boolean;
mcpEnabled?: boolean;
aiChat?: boolean;
@@ -61,7 +60,6 @@ export interface IWorkspaceApiSettings {
export interface IWorkspaceAiSettings {
search?: boolean;
generative?: boolean;
mcp?: boolean;
chat?: boolean;
dictation?: boolean;

View File

@@ -1,5 +1,6 @@
import {Container} from "@mantine/core";
import {Container, Space} from "@mantine/core";
import SpaceHomeTabs from "@/features/space/components/space-home-tabs.tsx";
import SpaceCreateNoteButtons from "@/features/space/components/space-create-note-buttons.tsx";
import {useParams} from "react-router-dom";
import {useGetSpaceBySlugQuery} from "@/features/space/queries/space-query.ts";
import {getAppName} from "@/lib/config.ts";
@@ -15,7 +16,13 @@ export default function SpaceHome() {
<title>{space?.name || 'Overview'} - {getAppName()}</title>
</Helmet>
<Container size={"900"} pt="xl">
{space && <SpaceHomeTabs/>}
{space && (
<>
<SpaceCreateNoteButtons/>
<Space h="md"/>
<SpaceHomeTabs/>
</>
)}
</Container>
</>
);

View File

@@ -319,8 +319,8 @@ export class AiChatController {
/**
* Generate a page title from supplied note content (#199). One-shot,
* non-streaming. Gated by the workspace AI flag (reusing settings.ai.generative,
* the same flag that gates the on-page generative AI menu); returns { title }.
* non-streaming. Gated by the AI chat flag (settings.ai.chat, the same toggle
* that enables the chat agent); returns { title }.
* The endpoint NEVER writes the page — the client applies the title via the
* existing /pages/update route (which enforces edit permission), so access
* checks are not duplicated here. Throttled per user via AI_CHAT_THROTTLER.
@@ -334,9 +334,9 @@ export class AiChatController {
@AuthWorkspace() workspace: Workspace,
): Promise<{ title: string }> {
const settings = (workspace.settings ?? {}) as {
ai?: { generative?: boolean };
ai?: { chat?: boolean };
};
if (settings.ai?.generative !== true) {
if (settings.ai?.chat !== true) {
throw new ForbiddenException('AI title generation is disabled');
}
try {

View File

@@ -42,7 +42,7 @@ describe('cleanGeneratedTitle', () => {
/**
* Wiring spec for the #199 `POST /ai-chat/generate-page-title` endpoint. It must:
* gate on settings.ai.generative (403 when off), delegate to the service when on,
* gate on settings.ai.chat (403 when off), delegate to the service when on,
* rethrow HttpExceptions verbatim (e.g. AiNotConfiguredException -> 503), and map
* any other provider/transport fault to a 503. Exercised by instantiating the
* controller with hand-rolled mocks — no Nest graph, no DB.
@@ -50,7 +50,7 @@ describe('cleanGeneratedTitle', () => {
describe('AiChatController.generatePageTitle', () => {
const enabledWorkspace = {
id: 'ws1',
settings: { ai: { generative: true } },
settings: { ai: { chat: true } },
} as unknown as Workspace;
function makeController(generate: jest.Mock) {
@@ -64,7 +64,7 @@ describe('AiChatController.generatePageTitle', () => {
return { controller, aiChatService };
}
it('forbids when the generative AI flag is off', async () => {
it('forbids when the AI chat flag is off', async () => {
const generate = jest.fn();
const { controller } = makeController(generate);
const disabled = { id: 'ws1', settings: {} } as unknown as Workspace;
@@ -74,12 +74,12 @@ describe('AiChatController.generatePageTitle', () => {
expect(generate).not.toHaveBeenCalled();
});
it('forbids when settings.ai.generative is anything but exactly true', async () => {
it('forbids when settings.ai.chat is anything but exactly true', async () => {
const generate = jest.fn();
const { controller } = makeController(generate);
const ws = {
id: 'ws1',
settings: { ai: { generative: 'yes' } },
settings: { ai: { chat: 'yes' } },
} as unknown as Workspace;
await expect(
controller.generatePageTitle({ content: 'body' }, ws),

View File

@@ -7,13 +7,18 @@ import { ShareAliasService } from './share-alias.service';
* request-time readable-target resolution (which re-runs the share boundary).
*/
describe('ShareAliasService', () => {
// Sentinel handed to repo calls so tests can assert they ran inside the tx.
const trx = { __trx: true };
function makeService() {
const shareAliasRepo = {
findByAliasAndWorkspace: jest.fn(),
findByPageId: jest.fn(),
findById: jest.fn(),
insert: jest.fn(),
updateAlias: jest.fn(),
updatePageId: jest.fn(),
deleteOthersForPage: jest.fn(),
delete: jest.fn(),
};
const pageRepo = { findById: jest.fn() };
@@ -21,12 +26,19 @@ describe('ShareAliasService', () => {
resolveReadableSharePage: jest.fn(),
isSharingAllowed: jest.fn(),
};
// Fake kysely db: only .transaction().execute(cb) is used by setAlias.
const db = {
transaction: jest.fn(() => ({
execute: jest.fn(async (cb: any) => cb(trx)),
})),
};
const service = new ShareAliasService(
shareAliasRepo as any,
pageRepo as any,
shareService as any,
db as any,
);
return { service, shareAliasRepo, pageRepo, shareService };
return { service, shareAliasRepo, pageRepo, shareService, db };
}
describe('setAlias', () => {
@@ -43,9 +55,10 @@ describe('ShareAliasService', () => {
expect(shareAliasRepo.findByAliasAndWorkspace).not.toHaveBeenCalled();
});
it('normalizes then inserts a brand-new alias', async () => {
it('normalizes then inserts a brand-new alias (page has none yet)', async () => {
const { service, shareAliasRepo } = makeService();
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue(undefined);
shareAliasRepo.findByPageId.mockResolvedValue(undefined);
shareAliasRepo.insert.mockResolvedValue({ id: 'a-1', alias: 'my-page' });
const res = await service.setAlias({
@@ -58,17 +71,70 @@ describe('ShareAliasService', () => {
expect(shareAliasRepo.findByAliasAndWorkspace).toHaveBeenCalledWith(
'my-page',
'ws-1',
trx,
);
expect(shareAliasRepo.insert).toHaveBeenCalledWith(
{
workspaceId: 'ws-1',
alias: 'my-page',
pageId: 'p-1',
creatorId: 'u-1',
},
trx,
);
expect(shareAliasRepo.updateAlias).not.toHaveBeenCalled();
// self-heal still runs, keeping just the inserted row
expect(shareAliasRepo.deleteOthersForPage).toHaveBeenCalledWith(
'p-1',
'a-1',
'ws-1',
trx,
);
expect(shareAliasRepo.insert).toHaveBeenCalledWith({
workspaceId: 'ws-1',
alias: 'my-page',
pageId: 'p-1',
creatorId: 'u-1',
});
expect(res).toMatchObject({ id: 'a-1' });
});
it('is a no-op when the alias already points at the same page', async () => {
it('renames the existing row in place when editing to a free name (te -> ted)', async () => {
const { service, shareAliasRepo } = makeService();
// The new slug is free...
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue(undefined);
// ...but the page already owns an alias named `te`.
shareAliasRepo.findByPageId.mockResolvedValue({
id: 'a-1',
alias: 'te',
pageId: 'p-1',
});
shareAliasRepo.updateAlias.mockResolvedValue({
id: 'a-1',
alias: 'ted',
pageId: 'p-1',
});
const res = await service.setAlias({
workspaceId: 'ws-1',
pageId: 'p-1',
creatorId: 'u-1',
alias: 'ted',
});
// RENAME, not INSERT a second row.
expect(shareAliasRepo.insert).not.toHaveBeenCalled();
expect(shareAliasRepo.updateAlias).toHaveBeenCalledWith(
'a-1',
'ted',
'ws-1',
trx,
);
// ...and any other row for the page is reaped, so `te` cannot survive.
expect(shareAliasRepo.deleteOthersForPage).toHaveBeenCalledWith(
'p-1',
'a-1',
'ws-1',
trx,
);
expect(res).toMatchObject({ id: 'a-1', alias: 'ted' });
});
it('is a no-op when the alias already points at the same page (and self-heals)', async () => {
const { service, shareAliasRepo } = makeService();
const existing = { id: 'a-1', alias: 'foo', pageId: 'p-1' };
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue(existing);
@@ -82,7 +148,45 @@ describe('ShareAliasService', () => {
expect(res).toBe(existing);
expect(shareAliasRepo.insert).not.toHaveBeenCalled();
expect(shareAliasRepo.updateAlias).not.toHaveBeenCalled();
expect(shareAliasRepo.updatePageId).not.toHaveBeenCalled();
// self-heal reaps any legacy duplicate rows for the page
expect(shareAliasRepo.deleteOthersForPage).toHaveBeenCalledWith(
'p-1',
'a-1',
'ws-1',
trx,
);
});
it('self-heals a page with pre-existing duplicate rows down to one', async () => {
const { service, shareAliasRepo } = makeService();
// Name free; the page already has a (legacy) alias row we rename.
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue(undefined);
shareAliasRepo.findByPageId.mockResolvedValue({
id: 'a-keep',
alias: 'old',
pageId: 'p-1',
});
shareAliasRepo.updateAlias.mockResolvedValue({
id: 'a-keep',
alias: 'new',
pageId: 'p-1',
});
await service.setAlias({
workspaceId: 'ws-1',
pageId: 'p-1',
creatorId: 'u-1',
alias: 'new',
});
expect(shareAliasRepo.deleteOthersForPage).toHaveBeenCalledWith(
'p-1',
'a-keep',
'ws-1',
trx,
);
});
it('throws 409 with current target when name is taken and not confirmed', async () => {
@@ -134,15 +238,128 @@ describe('ShareAliasService', () => {
'a-1',
'p-1',
'ws-1',
trx,
);
// ORDER MATTERS: the target page's existing alias row(s) are reaped BEFORE
// the retarget, so the non-deferrable (workspace_id, page_id) index never
// sees two rows for the page mid-statement. There is no trailing self-heal.
expect(shareAliasRepo.deleteOthersForPage).toHaveBeenCalledWith(
'p-1',
'a-1',
'ws-1',
trx,
);
expect(shareAliasRepo.deleteOthersForPage).toHaveBeenCalledTimes(1);
const deleteOrder =
shareAliasRepo.deleteOthersForPage.mock.invocationCallOrder[0];
const updateOrder =
shareAliasRepo.updatePageId.mock.invocationCallOrder[0];
expect(deleteOrder).toBeLessThan(updateOrder);
expect(res).toMatchObject({ pageId: 'p-1' });
});
it('maps a unique-violation race to 409', async () => {
it('maps a unique-violation race (no constraint info) to 409 "Alias already taken"', async () => {
const { service, shareAliasRepo } = makeService();
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue(undefined);
shareAliasRepo.insert.mockRejectedValue({ code: '23505' });
try {
await service.setAlias({
workspaceId: 'ws-1',
pageId: 'p-1',
creatorId: 'u-1',
alias: 'foo',
});
fail('expected ConflictException');
} catch (err) {
expect(err).toBeInstanceOf(ConflictException);
expect((err as ConflictException).getResponse()).toMatchObject({
message: 'Alias already taken',
});
}
});
it('maps the (workspace_id, alias) index violation to "Alias already taken"', async () => {
const { service, shareAliasRepo } = makeService();
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue(undefined);
// postgres@3.x driver exposes the index name as `constraint_name`.
shareAliasRepo.insert.mockRejectedValue({
code: '23505',
constraint_name: 'share_aliases_workspace_id_alias_unique',
});
try {
await service.setAlias({
workspaceId: 'ws-1',
pageId: 'p-1',
creatorId: 'u-1',
alias: 'foo',
});
fail('expected ConflictException');
} catch (err) {
expect((err as ConflictException).getResponse()).toMatchObject({
message: 'Alias already taken',
});
}
});
it('maps the (workspace_id, page_id) index violation to a DISTINCT page-race outcome', async () => {
const { service, shareAliasRepo } = makeService();
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue(undefined);
shareAliasRepo.insert.mockRejectedValue({
code: '23505',
constraint_name: 'share_aliases_workspace_id_page_id_unique',
});
try {
await service.setAlias({
workspaceId: 'ws-1',
pageId: 'p-1',
creatorId: 'u-1',
alias: 'foo',
});
fail('expected ConflictException');
} catch (err) {
expect(err).toBeInstanceOf(ConflictException);
// NOT the misleading "Alias already taken" — a separate, page-scoped code.
expect((err as ConflictException).getResponse()).toMatchObject({
code: 'ALIAS_PAGE_RACE',
});
expect((err as ConflictException).getResponse()).not.toMatchObject({
message: 'Alias already taken',
});
}
});
it('reads the index name from `.constraint` when `.constraint_name` is absent', async () => {
const { service, shareAliasRepo } = makeService();
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue(undefined);
// Fallback path for non-postgres@3.x drivers.
shareAliasRepo.insert.mockRejectedValue({
code: '23505',
constraint: 'share_aliases_workspace_id_page_id_unique',
});
try {
await service.setAlias({
workspaceId: 'ws-1',
pageId: 'p-1',
creatorId: 'u-1',
alias: 'foo',
});
fail('expected ConflictException');
} catch (err) {
expect((err as ConflictException).getResponse()).toMatchObject({
code: 'ALIAS_PAGE_RACE',
});
}
});
it('maps a non-unique-violation db error to BadRequest (Failed to set alias)', async () => {
const { service, shareAliasRepo } = makeService();
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue(undefined);
shareAliasRepo.insert.mockRejectedValue({ code: '08006' }); // connection error
await expect(
service.setAlias({
workspaceId: 'ws-1',
@@ -150,7 +367,7 @@ describe('ShareAliasService', () => {
creatorId: 'u-1',
alias: 'foo',
}),
).rejects.toBeInstanceOf(ConflictException);
).rejects.toBeInstanceOf(BadRequestException);
});
});

View File

@@ -9,10 +9,25 @@ import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { ShareService } from './share.service';
import { Page, ShareAlias } from '@docmost/db/types/entity.types';
import { isValidShareAlias, normalizeShareAlias } from './share-alias.util';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { executeTx } from '@docmost/db/utils';
/** Postgres unique_violation; the (workspace_id, alias) constraint races here. */
/** Postgres unique_violation. Two unique indexes can raise it on this table. */
const PG_UNIQUE_VIOLATION = '23505';
/**
* Unique index names from the share_aliases migrations. The `postgres@3.x`
* driver (kysely-postgres-js) surfaces the violated constraint as
* `err.constraint_name` (NOT `.constraint`); we keep `.constraint` only as a
* defensive fallback for other drivers.
* - ALIAS: `(workspace_id, alias)` -> the vanity NAME is taken.
* - PAGE_ID: partial `(workspace_id, page_id) WHERE page_id IS NOT NULL`
* -> a concurrent writer already gave THIS page an alias.
*/
const UNIQUE_ALIAS_INDEX = 'share_aliases_workspace_id_alias_unique';
const UNIQUE_PAGE_ID_INDEX = 'share_aliases_workspace_id_page_id_unique';
export interface ResolvedAliasTarget {
share: NonNullable<
Awaited<ReturnType<ShareService['resolveReadableSharePage']>>
@@ -28,16 +43,30 @@ export class ShareAliasService {
private readonly shareAliasRepo: ShareAliasRepo,
private readonly pageRepo: PageRepo,
private readonly shareService: ShareService,
@InjectKysely() private readonly db: KyselyDB,
) {}
/**
* Create or retarget a vanity alias. The alias is workspace-scoped:
* - no row for this name -> INSERT a new pointer
* - row already points at pageId -> no-op (idempotent)
* - row points elsewhere -> the "swap". Without confirmReassign we
* throw 409 carrying the current target so the client can confirm; with
* it we UPDATE the single row's page_id (every /l/<alias> link follows the
* 302 to the new page instantly — no stale 301 cache).
* Create, RENAME or retarget a page's vanity alias. INVARIANT: a page has
* EXACTLY ONE custom address. The alias name is workspace-scoped:
* - name free, page has no alias yet -> INSERT a new pointer
* - name free, page already has one -> RENAME that row in place (the slug
* edit, e.g. `te` -> `ted`); we never spawn a second row, so no orphan
* `/l/<old>` link survives
* - name already points at pageId -> no-op (idempotent)
* - name points at ANOTHER page -> the "swap". Without confirmReassign
* we throw 409 carrying the current target so the client can confirm;
* with it we UPDATE the single row's page_id (every /l/<alias> link
* follows the 302 to the new page instantly — no stale cache).
*
* To keep the invariant self-healing we DELETE every other alias row still
* pointing at this page (a legacy duplicate, or the target page's own former
* alias during a swap). The whole thing runs in one transaction. Because the
* `(workspace_id, page_id)` unique index is NON-deferrable (checked at the end
* of each statement), the swap branch DELETEs the target page's existing row
* BEFORE retargeting, so the page is never transiently carried by two rows;
* the other branches self-heal AFTER their write. Either way the page never
* ends a statement with duplicate rows.
*
* Caller is responsible for authorizing the page (edit rights + public
* readability); this method owns only the alias-name semantics.
@@ -57,48 +86,121 @@ export class ShareAliasService {
);
}
const existing = await this.shareAliasRepo.findByAliasAndWorkspace(
alias,
workspaceId,
);
if (!existing) {
try {
return await this.shareAliasRepo.insert({
workspaceId,
try {
return await executeTx(this.db, async (trx) => {
const byName = await this.shareAliasRepo.findByAliasAndWorkspace(
alias,
pageId,
creatorId,
});
} catch (err: any) {
// Lost a uniqueness race: another request claimed the name first.
if (err?.code === PG_UNIQUE_VIOLATION) {
throw new ConflictException({ message: 'Alias already taken' });
workspaceId,
trx,
);
// The name is occupied by a DIFFERENT (or dangling) target page.
if (byName && byName.pageId !== pageId) {
if (!confirmReassign) {
const currentPage = byName.pageId
? await this.pageRepo.findById(byName.pageId)
: null;
throw new ConflictException({
message: 'Alias already in use',
code: 'ALIAS_REASSIGN_REQUIRED',
currentPageId: byName.pageId,
currentPageTitle: currentPage?.title ?? null,
});
}
// Confirmed swap. ORDER MATTERS: the partial unique index on
// `(workspace_id, page_id)` is NON-deferrable, so it is checked at the
// end of EVERY statement. If we retargeted `byName` onto `pageId`
// first while `pageId` still had its OWN alias row, there would
// momentarily be two rows with this page_id -> immediate 23505 and a
// rolled-back tx (a misleading "Alias already taken"). So we FIRST drop
// the target page's existing alias row(s), THEN retarget. `byName.id`
// still points at its old page here, so excluding it via `keepId` is
// harmless; after the retarget it is the page's only row, so no
// trailing self-heal is needed.
await this.shareAliasRepo.deleteOthersForPage(
pageId,
byName.id,
workspaceId,
trx,
);
return await this.shareAliasRepo.updatePageId(
byName.id,
pageId,
workspaceId,
trx,
);
}
this.logger.error(err);
throw new BadRequestException('Failed to set alias');
}
}
// Already points at this page -> nothing to do.
if (existing.pageId === pageId) {
return existing;
}
// The name is FREE, or already points at THIS page. Ensure the page has
// a single row carrying this name: rename its current one, or insert.
const current =
byName ??
(await this.shareAliasRepo.findByPageId(pageId, workspaceId, trx));
// Name occupied by a different (or dangling) target: require confirmation.
if (!confirmReassign) {
const currentPage = existing.pageId
? await this.pageRepo.findById(existing.pageId)
: null;
throw new ConflictException({
message: 'Alias already in use',
code: 'ALIAS_REASSIGN_REQUIRED',
currentPageId: existing.pageId,
currentPageTitle: currentPage?.title ?? null,
let row: ShareAlias;
if (current) {
row =
current.alias === alias
? current // same-name no-op
: await this.shareAliasRepo.updateAlias(
current.id,
alias,
workspaceId,
trx,
);
} else {
row = await this.shareAliasRepo.insert(
{ workspaceId, alias, pageId, creatorId },
trx,
);
}
// Self-heal: a page keeps EXACTLY ONE custom address.
await this.shareAliasRepo.deleteOthersForPage(
pageId,
row.id,
workspaceId,
trx,
);
return row;
});
} catch (err: any) {
if (
err instanceof ConflictException ||
err instanceof BadRequestException
) {
throw err;
}
// A unique index fired. Which one decides the message — always log the
// constraint so the race is diagnosable.
if (err?.code === PG_UNIQUE_VIOLATION) {
const constraint: string | undefined =
err?.constraint_name ?? err?.constraint;
this.logger.warn(
`share alias unique violation on ${constraint ?? '<unknown>'}`,
);
// `(workspace_id, page_id)`: a concurrent request already gave this page
// an alias. The page still has exactly one custom address (the racing
// writer's), so this is not a user-facing name clash — surface a
// distinct, non-misleading message instead of "Alias already taken".
if (constraint === UNIQUE_PAGE_ID_INDEX) {
throw new ConflictException({
message: 'This page is being given an address by another request',
code: 'ALIAS_PAGE_RACE',
});
}
// `(workspace_id, alias)` (UNIQUE_ALIAS_INDEX) or any other/unknown
// unique index: treat as the vanity name being claimed first.
if (constraint && constraint !== UNIQUE_ALIAS_INDEX) {
this.logger.warn(
`unexpected unique index ${constraint} mapped to "Alias already taken"`,
);
}
throw new ConflictException({ message: 'Alias already taken' });
}
this.logger.error(err);
throw new BadRequestException('Failed to set alias');
}
return this.shareAliasRepo.updatePageId(existing.id, pageId, workspaceId);
}
/** Free a vanity name (no history kept). */

View File

@@ -31,10 +31,6 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
@IsBoolean()
aiSearch: boolean;
@IsOptional()
@IsBoolean()
generativeAi: boolean;
@IsOptional()
@IsBoolean()
disablePublicSharing: boolean;

View File

@@ -145,7 +145,7 @@ export class WorkspaceService {
status = WorkspaceStatus.Active;
plan = 'standard';
billingEmail = user.email;
settings = { ai: { generative: true, chat: true } };
settings = { ai: { chat: true } };
}
// create workspace
@@ -439,20 +439,6 @@ export class WorkspaceService {
);
}
if (typeof updateWorkspaceDto.generativeAi !== 'undefined') {
const prev = settingsBefore?.ai?.generative ?? false;
if (prev !== updateWorkspaceDto.generativeAi) {
before.generativeAi = prev;
after.generativeAi = updateWorkspaceDto.generativeAi;
}
await this.workspaceRepo.updateAiSettings(
workspaceId,
'generative',
updateWorkspaceDto.generativeAi,
trx,
);
}
if (typeof updateWorkspaceDto.disablePublicSharing !== 'undefined') {
const prev = settingsBefore?.sharing?.disabled ?? false;
if (prev !== updateWorkspaceDto.disablePublicSharing) {
@@ -587,7 +573,6 @@ export class WorkspaceService {
delete updateWorkspaceDto.restrictApiToAdmins;
delete updateWorkspaceDto.aiSearch;
delete updateWorkspaceDto.generativeAi;
delete updateWorkspaceDto.disablePublicSharing;
delete updateWorkspaceDto.mcpEnabled;
delete updateWorkspaceDto.allowMemberTemplates;

View File

@@ -0,0 +1,48 @@
import { type Kysely, sql } from 'kysely';
/**
* Enforce "a page has EXACTLY ONE custom address" at the DB level. The original
* `share_aliases` table only had a unique index on `(workspace_id, alias)`, so a
* page could accumulate several alias rows (every slug edit used to INSERT a new
* one), leaving orphan `/l/<old>` links live forever and making the share
* modal's `findByPageId` lookup nondeterministic.
*
* We first dedup any pre-existing rows (keeping the NEWEST per page — the same
* "current" choice the read path now makes), then add a PARTIAL unique index on
* `(workspace_id, page_id)`. It is partial (`WHERE page_id IS NOT NULL`) so that
* multiple DANGLING aliases (target page deleted -> `page_id` SET NULL) can
* still coexist without colliding.
*
* ⚠️ IRREVERSIBLE DATA LOSS (intended): the dedup DELETE below permanently drops
* every alias row but the newest per page. Those duplicates were live `/l/<old>`
* pointers (resolved by name via `findByAliasAndWorkspace`, not by page), so
* after this upgrade any such OLD vanity link starts returning the SPA 404. This
* is the point — it kills the orphan rows the pre-invariant bug accumulated —
* but `down()` only drops the unique index; it CANNOT restore the deleted rows.
*/
export async function up(db: Kysely<any>): Promise<void> {
// Reap legacy duplicates: for each (workspace_id, page_id) keep only the row
// with the greatest (created_at, id) — matches ShareAliasRepo.findByPageId.
await sql`
DELETE FROM share_aliases sa
USING share_aliases keep
WHERE sa.page_id IS NOT NULL
AND sa.workspace_id = keep.workspace_id
AND sa.page_id = keep.page_id
AND (keep.created_at, keep.id) > (sa.created_at, sa.id)
`.execute(db);
await db.schema
.createIndex('share_aliases_workspace_id_page_id_unique')
.on('share_aliases')
.columns(['workspace_id', 'page_id'])
.unique()
.where('page_id', 'is not', null)
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema
.dropIndex('share_aliases_workspace_id_page_id_unique')
.execute();
}

View File

@@ -10,16 +10,21 @@ import type { KyselyDB } from '../../types/kysely.types';
describe('ShareAliasRepo', () => {
function makeSelectRepo(result: unknown) {
const where = jest.fn();
const orderBy = jest.fn();
const builder: any = {
select: jest.fn(() => builder),
where: jest.fn((...args: unknown[]) => {
where(...args);
return builder;
}),
orderBy: jest.fn((...args: unknown[]) => {
orderBy(...args);
return builder;
}),
executeTakeFirst: jest.fn().mockResolvedValue(result),
};
const db = { selectFrom: jest.fn(() => builder) } as unknown as KyselyDB;
return { repo: new ShareAliasRepo(db), db, where, builder };
return { repo: new ShareAliasRepo(db), db, where, orderBy, builder };
}
it('findByAliasAndWorkspace scopes by alias AND workspace', async () => {
@@ -34,11 +39,15 @@ describe('ShareAliasRepo', () => {
expect(where).toHaveBeenCalledWith('workspaceId', '=', 'ws-1');
});
it('findByPageId scopes by page AND workspace', async () => {
const { repo, where } = makeSelectRepo(undefined);
it('findByPageId scopes by page AND workspace, deterministically ordered', async () => {
const { repo, where, orderBy } = makeSelectRepo(undefined);
await repo.findByPageId('p-1', 'ws-1');
expect(where).toHaveBeenCalledWith('pageId', '=', 'p-1');
expect(where).toHaveBeenCalledWith('workspaceId', '=', 'ws-1');
// Explicit ORDER BY removes the nondeterministic heap order for any legacy
// duplicate rows (newest createdAt wins, id as a stable tiebreak).
expect(orderBy).toHaveBeenCalledWith('createdAt', 'desc');
expect(orderBy).toHaveBeenCalledWith('id', 'desc');
});
it('insert writes the provided columns and returns the row', async () => {
@@ -99,6 +108,56 @@ describe('ShareAliasRepo', () => {
expect(where).toHaveBeenCalledWith('workspaceId', '=', 'ws-1');
});
it('updateAlias renames a single row scoped by id + workspace', async () => {
const set = jest.fn();
const where = jest.fn();
const builder: any = {
set: jest.fn((s: unknown) => {
set(s);
return builder;
}),
where: jest.fn((...args: unknown[]) => {
where(...args);
return builder;
}),
returning: jest.fn(() => builder),
executeTakeFirst: jest.fn().mockResolvedValue({ id: 'a-1', alias: 'ted' }),
};
const db = { updateTable: jest.fn(() => builder) } as unknown as KyselyDB;
const repo = new ShareAliasRepo(db);
const res = await repo.updateAlias('a-1', 'ted', 'ws-1');
expect(db.updateTable).toHaveBeenCalledWith('shareAliases');
expect(set.mock.calls[0][0].alias).toBe('ted');
expect(set.mock.calls[0][0].updatedAt).toBeInstanceOf(Date);
// a rename must NOT touch page_id (the page's pointer is preserved)
expect(set.mock.calls[0][0]).not.toHaveProperty('pageId');
expect(where).toHaveBeenCalledWith('id', '=', 'a-1');
expect(where).toHaveBeenCalledWith('workspaceId', '=', 'ws-1');
expect(res).toMatchObject({ alias: 'ted' });
});
it('deleteOthersForPage reaps every row for the page except keepId', async () => {
const where = jest.fn();
const builder: any = {
where: jest.fn((...args: unknown[]) => {
where(...args);
return builder;
}),
execute: jest.fn().mockResolvedValue(undefined),
};
const db = { deleteFrom: jest.fn(() => builder) } as unknown as KyselyDB;
const repo = new ShareAliasRepo(db);
await repo.deleteOthersForPage('p-1', 'a-keep', 'ws-1');
expect(db.deleteFrom).toHaveBeenCalledWith('shareAliases');
expect(where).toHaveBeenCalledWith('pageId', '=', 'p-1');
expect(where).toHaveBeenCalledWith('workspaceId', '=', 'ws-1');
expect(where).toHaveBeenCalledWith('id', '!=', 'a-keep');
});
it('delete scopes by id + workspace', async () => {
const where = jest.fn();
const builder: any = {

View File

@@ -41,7 +41,14 @@ export class ShareAliasRepo {
.executeTakeFirst();
}
/** The alias currently pointing at a page (for the share modal). */
/**
* The alias currently pointing at a page (for the share modal). The service
* enforces a single alias row per page, but legacy rows (pre-invariant) may
* still exist until self-healed; the explicit ORDER BY makes the "current"
* choice DETERMINISTIC (newest wins — i.e. the most recently created address,
* which is the one the user last asked for) instead of an arbitrary Postgres
* heap order.
*/
async findByPageId(
pageId: string,
workspaceId: string,
@@ -52,6 +59,8 @@ export class ShareAliasRepo {
.select(this.baseFields)
.where('pageId', '=', pageId)
.where('workspaceId', '=', workspaceId)
.orderBy('createdAt', 'desc')
.orderBy('id', 'desc')
.executeTakeFirst();
}
@@ -79,6 +88,45 @@ export class ShareAliasRepo {
.executeTakeFirst();
}
/**
* Rename an existing alias row in place (the vanity-slug edit, e.g.
* `te` -> `ted`). Keeps the row's id/page_id/creator so the page's single
* alias pointer is preserved — only the human-readable name changes.
*/
async updateAlias(
id: string,
alias: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<ShareAlias> {
return dbOrTx(this.db, trx)
.updateTable('shareAliases')
.set({ alias, updatedAt: new Date() })
.where('id', '=', id)
.where('workspaceId', '=', workspaceId)
.returning(this.baseFields)
.executeTakeFirst();
}
/**
* Self-heal helper: drop every OTHER alias row still pointing at a page,
* keeping only `keepId`. Enforces the "exactly one custom address per page"
* invariant after a rename/retarget and reaps any legacy duplicates.
*/
async deleteOthersForPage(
pageId: string,
keepId: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<void> {
await dbOrTx(this.db, trx)
.deleteFrom('shareAliases')
.where('pageId', '=', pageId)
.where('workspaceId', '=', workspaceId)
.where('id', '!=', keepId)
.execute();
}
/** Retarget an existing alias to a new page (the "swap" operation). */
async updatePageId(
id: string,

View File

@@ -246,7 +246,7 @@ export class WorkspaceRepo {
* otherwise re-serialize a `JSON.stringify`'d string, yielding a jsonb string
* that `||` turns into an array). A `jsonb_typeof = 'object'` CASE self-heals
* workspaces whose `settings.ai.provider` was previously corrupted into an
* array/string. Sibling `settings.ai.*` keys (search / generative / chat / mcp
* array/string. Sibling `settings.ai.*` keys (search / chat / mcp
* / systemPrompt) and provider fields absent from the partial are preserved via
* jsonb `||` merge.
*/

View File

@@ -0,0 +1,344 @@
import { Kysely, sql } from 'kysely';
import { randomUUID } from 'node:crypto';
import { BadRequestException, ConflictException } from '@nestjs/common';
import { ShareAliasRepo } from '@docmost/db/repos/share-alias/share-alias.repo';
import { ShareAliasService } from 'src/core/share/share-alias.service';
import * as onePerPageMigration from 'src/database/migrations/20260627T120000-share-aliases-one-per-page';
import {
getTestDb,
destroyTestDb,
createWorkspace,
createSpace,
createPage,
} from './db';
/**
* Issue #226 (regression of #205): "a page has EXACTLY ONE custom address".
* Exercises against real Postgres:
* - the partial unique index `(workspace_id, page_id) WHERE page_id IS NOT NULL`
* (migration 20260627T120000) — one alias per page, but dangling aliases
* (page_id NULL) may coexist;
* - the migration's dedup DELETE keeps the NEWEST row per page;
* - ShareAliasService.setAlias renames in place (te -> ted) instead of
* spawning a second row, and self-heals the page down to one alias.
*/
describe('share_aliases one-per-page invariant [integration]', () => {
let db: Kysely<any>;
let repo: ShareAliasRepo;
let service: ShareAliasService;
let wsId: string;
let spaceId: string;
// setAlias only consults pageRepo on the unconfirmed-reassign (409) path.
const pageRepo = {
findById: async (id: string) => ({ id, title: `title-${id}` }),
};
beforeAll(async () => {
db = getTestDb();
repo = new ShareAliasRepo(db as any);
service = new ShareAliasService(
repo as any,
pageRepo as any,
{} as any, // shareService — unused by setAlias
db as any,
);
wsId = (await createWorkspace(db)).id;
spaceId = (await createSpace(db, wsId)).id;
});
afterAll(async () => {
await destroyTestDb();
});
const newPage = async (): Promise<string> =>
(await createPage(db, { workspaceId: wsId, spaceId })).id;
const aliasRowsForWs = (pageId: string, workspaceId: string) =>
db
.selectFrom('shareAliases')
.select(['id', 'alias'])
.where('pageId', '=', pageId)
.where('workspaceId', '=', workspaceId)
.orderBy('alias')
.execute();
const aliasRowsFor = (pageId: string) => aliasRowsForWs(pageId, wsId);
it('partial unique index rejects a second alias for the same page (23505)', async () => {
const pageId = await newPage();
await repo.insert({ workspaceId: wsId, alias: 'first', pageId });
let code: string | undefined;
try {
await repo.insert({ workspaceId: wsId, alias: 'second', pageId });
} catch (err: any) {
code = err?.code ?? err?.cause?.code;
}
expect(code).toBe('23505');
});
it('allows multiple DANGLING aliases (page_id NULL) — partial index excludes them', async () => {
const a = await repo.insert({
workspaceId: wsId,
alias: `dangling-${randomUUID().slice(0, 8)}`,
pageId: null as any,
});
const b = await repo.insert({
workspaceId: wsId,
alias: `dangling-${randomUUID().slice(0, 8)}`,
pageId: null as any,
});
expect(a.id).toBeDefined();
expect(b.id).toBeDefined();
expect(a.id).not.toBe(b.id);
});
it("migration dedup DELETE keeps the page's NEWEST alias row", async () => {
const pageId = await newPage();
// Temporarily drop the guard so we can seed the legacy duplicate shape.
await sql`DROP INDEX share_aliases_workspace_id_page_id_unique`.execute(db);
try {
const mk = async (alias: string, createdAt: string): Promise<string> => {
const id = randomUUID();
await db
.insertInto('shareAliases')
.values({ id, workspaceId: wsId, alias, pageId, createdAt })
.execute();
return id;
};
await mk('oldest', '2026-01-01T00:00:00Z');
await mk('middle', '2026-02-01T00:00:00Z');
const newest = await mk('newest', '2026-03-01T00:00:00Z');
// Exact dedup statement from the migration.
await sql`
DELETE FROM share_aliases sa
USING share_aliases keep
WHERE sa.page_id IS NOT NULL
AND sa.workspace_id = keep.workspace_id
AND sa.page_id = keep.page_id
AND (keep.created_at, keep.id) > (sa.created_at, sa.id)
`.execute(db);
const rows = await aliasRowsFor(pageId);
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({ id: newest, alias: 'newest' });
} finally {
await sql`
CREATE UNIQUE INDEX share_aliases_workspace_id_page_id_unique
ON share_aliases (workspace_id, page_id)
WHERE page_id IS NOT NULL
`.execute(db);
}
});
it('migration up() dedups per page and leaves OTHER pages and workspaces untouched', async () => {
// Seed legacy duplicates for two pages in this workspace AND a page in a
// SECOND workspace, then run the real migration up() (not an inlined copy of
// its SQL) and assert it scopes the DELETE to (workspace_id, page_id).
const ws2 = (await createWorkspace(db)).id;
const space2 = (await createSpace(db, ws2)).id;
const pageA = await newPage();
const pageB = await newPage();
const pageC = (await createPage(db, { workspaceId: ws2, spaceId: space2 }))
.id;
// Drop the guard so we can seed the pre-invariant duplicate shape.
await sql`DROP INDEX share_aliases_workspace_id_page_id_unique`.execute(db);
const seed = async (
workspaceId: string,
pageId: string,
alias: string,
createdAt: string,
): Promise<string> => {
const id = randomUUID();
await db
.insertInto('shareAliases')
.values({ id, workspaceId, alias, pageId, createdAt })
.execute();
return id;
};
await seed(wsId, pageA, 'a-old', '2026-01-01T00:00:00Z');
const aNew = await seed(wsId, pageA, 'a-new', '2026-03-01T00:00:00Z');
await seed(wsId, pageB, 'b-old', '2026-01-01T00:00:00Z');
const bNew = await seed(wsId, pageB, 'b-new', '2026-03-01T00:00:00Z');
await seed(ws2, pageC, 'c-old', '2026-01-01T00:00:00Z');
const cNew = await seed(ws2, pageC, 'c-new', '2026-03-01T00:00:00Z');
// Run the migration. It dedups AND recreates the unique index.
await onePerPageMigration.up(db as any);
const aliasesOf = async (pageId: string) =>
(await aliasRowsForWs(pageId, wsId)).map((r) => r.alias);
const aRows = await aliasRowsForWs(pageA, wsId);
expect(aRows).toEqual([{ id: aNew, alias: 'a-new' }]);
const bRows = await aliasRowsForWs(pageB, wsId);
expect(bRows).toEqual([{ id: bNew, alias: 'b-new' }]);
// The other workspace's page keeps only ITS newest row, untouched by wsId.
const cRows = await aliasRowsForWs(pageC, ws2);
expect(cRows).toEqual([{ id: cNew, alias: 'c-new' }]);
expect(await aliasesOf(pageA)).toEqual(['a-new']);
});
it('setAlias renames te -> ted in place: page ends with ONE row named ted', async () => {
const pageId = await newPage();
const creatorId = null as any;
const first = await service.setAlias({
workspaceId: wsId,
pageId,
creatorId,
alias: 'te',
});
expect(first.alias).toBe('te');
const renamed = await service.setAlias({
workspaceId: wsId,
pageId,
creatorId,
alias: 'ted',
});
// Same row id — a RENAME, not a new insert.
expect(renamed.id).toBe(first.id);
expect(renamed.alias).toBe('ted');
const rows = await aliasRowsFor(pageId);
expect(rows).toHaveLength(1);
expect(rows[0].alias).toBe('ted'); // the stale `te` row is gone
// The modal read resolves the current (only) row deterministically.
const shown = await service.getAliasForPage(pageId, wsId);
expect(shown?.alias).toBe('ted');
});
it('setAlias inserts the first alias, then is a no-op for the same name', async () => {
const pageId = await newPage();
const inserted = await service.setAlias({
workspaceId: wsId,
pageId,
creatorId: null as any,
alias: 'hello',
});
const again = await service.setAlias({
workspaceId: wsId,
pageId,
creatorId: null as any,
alias: 'hello',
});
expect(again.id).toBe(inserted.id);
expect(await aliasRowsFor(pageId)).toHaveLength(1);
});
it('a mid-transaction error becomes BadRequestException and rolls back cleanly', async () => {
// A non-23505 failure inside the tx must surface as BadRequest AND leave NO
// partial alias state behind (the whole executeTx unit rolls back).
const pageId = await newPage();
const boom = new Error('disk on fire'); // not a unique-violation
// Wrap the real repo so the INSERT succeeds but the trailing self-heal
// throws — the row inserted earlier in the tx must not survive.
const flakyRepo = Object.create(repo);
flakyRepo.deleteOthersForPage = async () => {
throw boom;
};
const flakyService = new ShareAliasService(
flakyRepo as any,
pageRepo as any,
{} as any,
db as any,
);
await expect(
flakyService.setAlias({
workspaceId: wsId,
pageId,
creatorId: null as any,
alias: 'rollback-me',
}),
).rejects.toBeInstanceOf(BadRequestException);
// Rolled back: neither the page nor the name has any row.
expect(await aliasRowsFor(pageId)).toHaveLength(0);
expect(
await db
.selectFrom('shareAliases')
.select('id')
.where('alias', '=', 'rollback-me')
.where('workspaceId', '=', wsId)
.execute(),
).toHaveLength(0);
});
it('cross-page collision throws 409, and confirmReassign moves the single row', async () => {
const pageA = await newPage();
const pageB = await newPage();
await service.setAlias({
workspaceId: wsId,
pageId: pageA,
creatorId: null as any,
alias: 'shared',
});
await expect(
service.setAlias({
workspaceId: wsId,
pageId: pageB,
creatorId: null as any,
alias: 'shared',
}),
).rejects.toBeInstanceOf(ConflictException);
const moved = await service.setAlias({
workspaceId: wsId,
pageId: pageB,
creatorId: null as any,
alias: 'shared',
confirmReassign: true,
});
expect(moved.alias).toBe('shared');
// The name now belongs to pageB only; pageA has no alias.
expect(await aliasRowsFor(pageA)).toHaveLength(0);
const bRows = await aliasRowsFor(pageB);
expect(bRows).toHaveLength(1);
expect(bRows[0].alias).toBe('shared');
});
it('confirmReassign onto a page that ALREADY has its own alias: target ends with ONE row', async () => {
// Regression guard for the operation-order bug: A has `shared`, B has its
// OWN alias `bee`. Moving `shared` onto B must FIRST drop B's `bee` row,
// THEN retarget, or the NON-deferrable (workspace_id, page_id) index fires a
// 23505 mid-transaction (two rows momentarily carry page_id = B) and the tx
// rolls back into a misleading "Alias already taken".
// Distinct names: the workspace is shared across tests, so reuse of an
// earlier test's `shared` would trip the 409 guard before we get here.
const pageA = await newPage();
const pageB = await newPage();
await service.setAlias({
workspaceId: wsId,
pageId: pageA,
creatorId: null as any,
alias: 'shared-target',
});
await service.setAlias({
workspaceId: wsId,
pageId: pageB,
creatorId: null as any,
alias: 'bee',
});
const moved = await service.setAlias({
workspaceId: wsId,
pageId: pageB,
creatorId: null as any,
alias: 'shared-target',
confirmReassign: true,
});
expect(moved.alias).toBe('shared-target');
// B now carries exactly `shared-target` (its old `bee` is gone); A has none.
const bRows = await aliasRowsFor(pageB);
expect(bRows).toHaveLength(1);
expect(bRows[0].alias).toBe('shared-target');
expect(await aliasRowsFor(pageA)).toHaveLength(0);
});
});