Compare commits

..

2 Commits

Author SHA1 Message Date
claude code agent 227
e99c00a9ee test(review): pin full-transcript history past 50 rows + changelog (PR #202)
Address the PR #202 review (approve-with-comments). The only actionable
non-blocking item was the test-coverage suggestion: the source switch in
AiChatService.handle from findRecent(chatId, ws, 50) to findAllByChat(chatId,
ws) was not pinned by a test. handle() is a streaming method the project marks
as not unit-testable, so cover the behavioral guarantee it now relies on at the
repo/integration level — seed a chat of 60 messages and assert the default
findAllByChat (exactly how handle calls it) returns the FULL transcript in
chronological order, including the first turn the old 50-window would have
dropped.

Also document the behavior change under CHANGELOG [Unreleased] -> Changed.

The two stability items (token-budget trim before streamText; O(N) history
rebuild per turn) are deferred: the reviewer flagged both as non-blocking
conscious trade-offs aligned with the PR's stated goal, and the trim is a
larger architecture change out of scope for this follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 20:39:30 +03:00
claude_code
1f459d8d26 feat(ai-chat): load full transcript for model history (drop 50-msg window)
The per-turn model conversation was rebuilt via findRecent(chatId, ws, 50),
a sliding window that dropped the beginning of any chat longer than ~50 stored
rows. Switch streamChat to the existing findAllByChat, which loads the full
non-deleted transcript chronologically with a 5000-row memory-safety backstop
(keeps the newest rows + logs a warning on overflow) — a safety net, not a
conversational limit. Remove the now-unused findRecent method and update the
comments/log text that referenced it (findAllByChat now feeds both the Markdown
export and the model history).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:39:30 +03:00
41 changed files with 81 additions and 1271 deletions

View File

@@ -32,16 +32,6 @@ per-workspace rolling-day token budget.
foreign key is `ON DELETE SET NULL`, so deleting the target leaves a dangling
alias any workspace member can reclaim. (#205)
- **Temporary notes — auto-move to Trash after a workspace lifetime.** A note can
be marked temporary so it auto-moves to Trash once a configurable workspace
lifetime elapses (default `DEFAULT_TEMPORARY_NOTE_HOURS` = 24h) unless made
permanent first. The deadline is frozen at creation time, so later changes to
the workspace setting never reschedule existing notes; an hourly background
sweep trashes notes past their deadline (children ride along). An open
temporary note shows a banner with a "Make permanent" rescue action; restoring
a note from Trash disarms the timer so it is not immediately re-trashed.
Operators configure the lifetime per workspace. (#201)
- **Persistent AI-chat history as the source of truth + server-side export.**
An assistant turn is now persisted to the database step by step: the row is
inserted upfront as `streaming` and updated as each agent step finishes, then
@@ -85,6 +75,15 @@ per-workspace rolling-day token budget.
### Changed
- **AI chat now feeds the model the full stored transcript.** The per-turn model
conversation was rebuilt from a sliding window of the 50 most recent stored
rows, which silently dropped the beginning of any longer chat. It is now
rebuilt from the complete non-deleted transcript in chronological order, so
the model sees every turn (a 5000-row backstop guards process memory — a
safety net far above any realistic chat, not a conversational limit). On a
very long chat this can eventually reach the model's context window; the
client already surfaces that as "start a new chat". (#202)
- **AI chat default provider is now `openai-compatible` (reasoning surfaced).**
For the `openai` driver the chat provider defaults to the openai-compatible
implementation, so a workspace pointing at z.ai/GLM/DeepSeek now streams the

View File

@@ -598,17 +598,6 @@
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.",
"Restore '{{title}}' and its sub-pages?": "Restore '{{title}}' and its sub-pages?",
"Move to trash": "Move to trash",
"Make temporary": "Make temporary",
"Make permanent": "Make permanent",
"New temporary note": "New temporary note",
"Temporary note": "Temporary note",
"Temporary notes": "Temporary notes",
"Temporary note — moves to trash unless made permanent": "Temporary note — moves to trash unless made permanent",
"Note will move to trash unless made permanent": "Note will move to trash unless made permanent",
"Note is now permanent": "Note is now permanent",
"Temporary note lifetime (hours)": "Temporary note lifetime (hours)",
"A temporary note is automatically moved to trash after this many hours unless it is made permanent. The deadline is fixed when the note is created.": "A temporary note is automatically moved to trash after this many hours unless it is made permanent. The deadline is fixed when the note is created.",
"This temporary note moves to trash {{time}} (with its sub-pages) unless made permanent.": "This temporary note moves to trash {{time}} (with its sub-pages) unless made permanent.",
"Move this page to trash?": "Move this page to trash?",
"Restore page": "Restore page",
"Permanently delete": "Permanently delete",

View File

@@ -607,17 +607,6 @@
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "Вы уверены, что хотите окончательно удалить '{{title}}'? Это действие невозможно отменить.",
"Restore '{{title}}' and its sub-pages?": "Восстановить '{{title}}' и её подстраницы?",
"Move to trash": "Переместить в корзину",
"Make temporary": "Сделать временной",
"Make permanent": "Сделать постоянной",
"New temporary note": "Новая временная заметка",
"Temporary note": "Временная заметка",
"Temporary notes": "Временные заметки",
"Temporary note — moves to trash unless made permanent": "Временная заметка — уедет в корзину, если не сделать постоянной",
"Note will move to trash unless made permanent": "Заметка уедет в корзину, если не сделать её постоянной",
"Note is now permanent": "Заметка теперь постоянная",
"Temporary note lifetime (hours)": "Время жизни временной заметки (часы)",
"A temporary note is automatically moved to trash after this many hours unless it is made permanent. The deadline is fixed when the note is created.": "Временная заметка автоматически уезжает в корзину через указанное число часов, если не сделать её постоянной. Дедлайн фиксируется при создании заметки.",
"This temporary note moves to trash {{time}} (with its sub-pages) unless made permanent.": "Эта временная заметка уедет в корзину {{time}} (вместе с подстраницами), если не сделать её постоянной.",
"Move this page to trash?": "Переместить эту страницу в корзину?",
"Restore page": "Восстановить страницу",
"Permanently delete": "Удалить навсегда",

View File

@@ -26,7 +26,6 @@ import { FixedToolbar } from "@/features/editor/components/fixed-toolbar/fixed-t
import { PageEditMode } from "@/features/user/types/user.types.ts";
import { useAsideTriggerProps } from "@/hooks/use-toggle-aside.tsx";
import { DeletedPageBanner } from "@/features/page/trash/components/deleted-page-banner.tsx";
import { TemporaryNoteBanner } from "@/features/page/components/temporary-note-banner.tsx";
import clsx from "clsx";
import {
currentPageEditModeAtom,
@@ -38,7 +37,6 @@ const MemoizedTitleEditor = React.memo(TitleEditor);
const MemoizedPageEditor = React.memo(PageEditor);
const MemoizedFixedToolbar = React.memo(FixedToolbar);
const MemoizedDeletedPageBanner = React.memo(DeletedPageBanner);
const MemoizedTemporaryNoteBanner = React.memo(TemporaryNoteBanner);
type PageUser = {
id: string;
@@ -105,7 +103,6 @@ export function FullEditor({
<MemoizedFixedToolbar />
)}
<MemoizedDeletedPageBanner slugId={slugId} />
<MemoizedTemporaryNoteBanner slugId={slugId} />
<MemoizedTitleEditor
pageId={pageId}
slugId={slugId}

View File

@@ -1,40 +1,7 @@
import { useMutation } from "@tanstack/react-query";
import { notifications } from "@mantine/notifications";
import {
toggleTemplate,
toggleTemporary,
} from "@/features/page-embed/services/page-embed-api";
import type {
ToggleTemplateResponse,
ToggleTemporaryResponse,
} from "@/features/page-embed/types/page-embed.types";
import { queryClient } from "@/main.tsx";
/**
* After toggling a note's temporary state, mirror the new deadline into the
* shared page cache (keyed by both slugId and id) and refresh the sidebar so the
* menu label, the in-page banner, and the tree icon all reflect the change.
* Centralised here so the header menu and the banner can't drift apart on the
* cache-key plumbing.
*/
export function syncTemporaryExpiresInCache(
page: { id: string; slugId: string },
temporaryExpiresAt: string | null,
) {
for (const key of [page.slugId, page.id]) {
const cached = queryClient.getQueryData<any>(["pages", key]);
if (cached) {
queryClient.setQueryData(["pages", key], {
...cached,
temporaryExpiresAt,
});
}
}
queryClient.invalidateQueries({
predicate: (item) =>
["sidebar-pages"].includes(item.queryKey[0] as string),
});
}
import { toggleTemplate } from "@/features/page-embed/services/page-embed-api";
import type { ToggleTemplateResponse } from "@/features/page-embed/types/page-embed.types";
export function useToggleTemplateMutation() {
return useMutation<
@@ -51,20 +18,3 @@ export function useToggleTemplateMutation() {
},
});
}
export function useToggleTemporaryMutation() {
return useMutation<
ToggleTemporaryResponse,
Error,
{ pageId: string; temporary?: boolean }
>({
mutationFn: (data) => toggleTemporary(data),
onError: (err: any) => {
notifications.show({
message:
err?.response?.data?.message || "Failed to update temporary note",
color: "red",
});
},
});
}

View File

@@ -2,7 +2,6 @@ import api from "@/lib/api-client";
import type {
PageTemplateLookup,
ToggleTemplateResponse,
ToggleTemporaryResponse,
} from "../types/page-embed.types";
export async function lookupTemplate(params: {
@@ -19,11 +18,3 @@ export async function toggleTemplate(params: {
const r = await api.post("/pages/toggle-template", params);
return r.data;
}
export async function toggleTemporary(params: {
pageId: string;
temporary?: boolean;
}): Promise<ToggleTemporaryResponse> {
const r = await api.post("/pages/toggle-temporary", params);
return r.data;
}

View File

@@ -14,9 +14,3 @@ export type ToggleTemplateResponse = {
pageId: string;
isTemplate: boolean;
};
export type ToggleTemporaryResponse = {
pageId: string;
// null => the note was made permanent; ISO string => armed deadline.
temporaryExpiresAt: string | null;
};

View File

@@ -2,7 +2,6 @@ import { ActionIcon, Button, Group, Menu, Text, ThemeIcon, Tooltip } from "@mant
import {
IconArrowRight,
IconArrowsHorizontal,
IconClockHour4,
IconDots,
IconEye,
IconEyeOff,
@@ -25,10 +24,6 @@ import { useDisclosure, useHotkeys } from "@mantine/hooks";
import { useClipboard } from "@/hooks/use-clipboard";
import { useParams } from "react-router-dom";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
import {
useToggleTemporaryMutation,
syncTemporaryExpiresInCache,
} from "@/features/page-embed/queries/page-embed-query.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { notifications } from "@mantine/notifications";
import { getAppUrl } from "@/lib/config.ts";
@@ -165,29 +160,6 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
const { data: watchStatus } = useWatchStatusQuery(page?.id);
const watchPage = useWatchPageMutation();
const unwatchPage = useUnwatchPageMutation();
const toggleTemporary = useToggleTemporaryMutation();
const isTemporary = !!page?.temporaryExpiresAt;
const handleToggleTemporary = async () => {
if (!page?.id) return;
const next = !isTemporary;
try {
const res = await toggleTemporary.mutateAsync({
pageId: page.id,
temporary: next,
});
// Reflect the new deadline in the page cache so the menu label flips and
// any banner updates. The sidebar icon refreshes via its own query.
syncTemporaryExpiresInCache(page, res.temporaryExpiresAt);
notifications.show({
message: next
? t("Note will move to trash unless made permanent")
: t("Note is now permanent"),
});
} catch {
// mutation surfaces the error via notifications
}
};
const handleCopyLink = () => {
const pageUrl =
@@ -337,12 +309,6 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
{!readOnly && (
<>
<Menu.Divider />
<Menu.Item
leftSection={<IconClockHour4 size={16} />}
onClick={handleToggleTemporary}
>
{isTemporary ? t("Make permanent") : t("Make temporary")}
</Menu.Item>
<Menu.Item
color={"red"}
leftSection={<IconTrash size={16} />}

View File

@@ -1,87 +0,0 @@
import { Button, Group, Paper, Text } from "@mantine/core";
import { IconClockHour4 } from "@tabler/icons-react";
import { Trans, useTranslation } from "react-i18next";
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
import {
useToggleTemporaryMutation,
syncTemporaryExpiresInCache,
} from "@/features/page-embed/queries/page-embed-query.ts";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
import {
SpaceCaslAction,
SpaceCaslSubject,
} from "@/features/space/permissions/permissions.type.ts";
type TemporaryNoteBannerProps = {
slugId: string;
};
/**
* Banner shown on an open temporary note ("structure or die"). Mirrors
* DeletedPageBanner: it reads the page from the shared query cache and offers
* the explicit rescue action — "Make permanent". Children ride along to trash
* with the note, which is noted in the copy.
*/
export function TemporaryNoteBanner({ slugId }: TemporaryNoteBannerProps) {
const { t } = useTranslation();
const { data: page } = usePageQuery({ pageId: slugId });
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
const spaceAbility = useSpaceAbility(space?.membership?.permissions);
const expiresTimeAgo = useTimeAgo(page?.temporaryExpiresAt);
const toggleTemporary = useToggleTemporaryMutation();
// Don't show on a note that is already in trash; the deleted-page banner
// owns that state.
if (!page?.temporaryExpiresAt || page?.deletedAt) return null;
const canEdit = spaceAbility.can(SpaceCaslAction.Edit, SpaceCaslSubject.Page);
const handleMakePermanent = async () => {
try {
const res = await toggleTemporary.mutateAsync({
pageId: page.id,
temporary: false,
});
syncTemporaryExpiresInCache(page, res.temporaryExpiresAt);
} catch {
// mutation surfaces the error via notifications
}
};
return (
<Paper radius="sm" mb="md" px="md" py="xs" bg="orange.0">
<Group justify="space-between" wrap="wrap" gap="sm">
<Group gap="xs" wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
<IconClockHour4
size={18}
stroke={1.5}
style={{
flexShrink: 0,
color: "var(--mantine-color-orange-7)",
}}
/>
<Text size="sm">
<Trans
i18nKey="This temporary note moves to trash {{time}} (with its sub-pages) unless made permanent."
values={{ time: expiresTimeAgo }}
/>
</Text>
</Group>
{canEdit && (
<Button
size="xs"
variant="light"
color="orange"
leftSection={<IconClockHour4 size={16} />}
onClick={handleMakePermanent}
loading={toggleTemporary.isPending}
>
{t("Make permanent")}
</Button>
)}
</Group>
</Paper>
);
}

View File

@@ -6,7 +6,6 @@ import { useDisclosure } from "@mantine/hooks";
import { notifications } from "@mantine/notifications";
import {
IconArrowRight,
IconClockHour4,
IconCopy,
IconDotsVertical,
IconFileExport,
@@ -31,10 +30,7 @@ import {
useRemoveFavoriteMutation,
} from "@/features/favorite/queries/favorite-query";
import {
useToggleTemplateMutation,
useToggleTemporaryMutation,
} from "@/features/page-embed/queries/page-embed-query";
import { useToggleTemplateMutation } from "@/features/page-embed/queries/page-embed-query";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
import { treeModel } from "@/features/page/tree/model/tree-model";
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
@@ -69,8 +65,6 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
const isFavorited = favoriteIds.has(node.id);
const toggleTemplate = useToggleTemplateMutation();
const isTemplate = !!node.isTemplate;
const toggleTemporary = useToggleTemporaryMutation();
const isTemporary = !!node.temporaryExpiresAt;
const handleToggleTemplate = async () => {
const next = !isTemplate;
@@ -90,29 +84,6 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
}
};
const handleToggleTemporary = async () => {
const next = !isTemporary;
try {
const res = await toggleTemporary.mutateAsync({
pageId: node.id,
temporary: next,
});
// Reflect the new deadline locally so the icon/menu update immediately.
setData((prev) =>
treeModel.update(prev, node.id, {
temporaryExpiresAt: res.temporaryExpiresAt,
} as any),
);
notifications.show({
message: next
? t("Note will move to trash unless made permanent")
: t("Note is now permanent"),
});
} catch {
// mutation surfaces the error via notifications
}
};
const handleCopyLink = () => {
const pageUrl =
getAppUrl() + buildPageUrl(spaceSlug, node.slugId, node.name);
@@ -277,17 +248,6 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
{isTemplate ? t("Unset as template") : t("Make template")}
</Menu.Item>
<Menu.Item
leftSection={<IconClockHour4 size={16} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleToggleTemporary();
}}
>
{isTemporary ? t("Make permanent") : t("Make temporary")}
</Menu.Item>
<Menu.Divider />
<Menu.Item
c="red"

View File

@@ -6,7 +6,6 @@ import { ActionIcon, rem, Tooltip } from "@mantine/core";
import {
IconChevronDown,
IconChevronRight,
IconClockHour4,
IconFileDescription,
IconPlus,
IconPointFilled,
@@ -192,28 +191,6 @@ export function SpaceTreeRow({
</Tooltip>
)}
{node.temporaryExpiresAt && (
<Tooltip
// Children ride along to trash with the note (recursive removePage).
label={t("Temporary note — moves to trash unless made permanent")}
withArrow
>
<IconClockHour4
size={14}
stroke={1.5}
// Same visual-only indicator pattern as the template icon, but
// orange to flag the impending death timer.
style={{
flexShrink: 0,
marginLeft: rem(4),
color: "var(--mantine-color-orange-6)",
}}
aria-label={t("Temporary note")}
role="img"
/>
</Tooltip>
)}
<div className={classes.actions}>
<NodeMenu node={node} canEdit={canEdit} />

View File

@@ -22,10 +22,7 @@ import { getSpaceUrl } from "@/lib/config.ts";
export type UseTreeMutation = {
handleMove: (sourceId: string, op: DropOp) => Promise<void>;
handleCreate: (
parentId: string | null,
opts?: { temporary?: boolean },
) => Promise<void>;
handleCreate: (parentId: string | null) => Promise<void>;
handleRename: (id: string, name: string) => Promise<void>;
handleDelete: (id: string) => Promise<void>;
};
@@ -122,15 +119,9 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
);
const handleCreate = useCallback(
async (parentId: string | null, opts?: { temporary?: boolean }) => {
const payload: {
spaceId: string;
parentPageId?: string;
temporary?: boolean;
} = { spaceId };
async (parentId: string | null) => {
const payload: { spaceId: string; parentPageId?: string } = { spaceId };
if (parentId) payload.parentPageId = parentId;
// Ask the server to arm the death timer for a "temporary note".
if (opts?.temporary) payload.temporary = true;
let createdPage: IPage;
try {
@@ -147,8 +138,6 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
spaceId: createdPage.spaceId,
parentPageId: createdPage.parentPageId,
hasChildren: false,
// Show the temporary-note icon immediately on optimistic insert.
temporaryExpiresAt: createdPage.temporaryExpiresAt,
children: [],
};

View File

@@ -9,7 +9,5 @@ export type SpaceTreeNode = {
hasChildren: boolean;
canEdit?: boolean;
isTemplate?: boolean;
// Death-timer deadline. null/absent => permanent; ISO string => temporary note.
temporaryExpiresAt?: string | null;
children: SpaceTreeNode[];
};

View File

@@ -26,7 +26,6 @@ export function buildTree(pages: IPage[]): SpaceTreeNode[] {
parentPageId: page.parentPageId,
canEdit: page.canEdit ?? page.permissions?.canEdit,
isTemplate: page.isTemplate,
temporaryExpiresAt: page.temporaryExpiresAt,
children: [],
};
});

View File

@@ -13,10 +13,6 @@ export interface IPage {
workspaceId: string;
isLocked: boolean;
isTemplate?: boolean;
// Death-timer deadline. null/absent => permanent; ISO string => temporary note.
temporaryExpiresAt?: string | null;
// Create-only input flag: ask the server to arm the timer on a new page.
temporary?: boolean;
lastUpdatedById: string;
createdAt: Date;
updatedAt: Date;

View File

@@ -13,7 +13,6 @@ import {
IconEye,
IconEyeOff,
IconFileExport,
IconHourglass,
IconPlus,
IconSettings,
IconStar,
@@ -72,10 +71,6 @@ export function SpaceSidebar() {
handleCreate(null);
}
function handleCreateTemporaryPage() {
handleCreate(null, { temporary: true });
}
return (
<>
<div className={classes.navbar}>
@@ -116,12 +111,7 @@ export function SpaceSidebar() {
SpaceCaslAction.Manage,
SpaceCaslSubject.Page,
) && (
<>
<Tooltip
label={t("Create page")}
withArrow
position="right"
>
<Tooltip label={t("Create page")} withArrow position="right">
<ActionIcon
variant="default"
size={18}
@@ -131,24 +121,6 @@ export function SpaceSidebar() {
<IconPlus />
</ActionIcon>
</Tooltip>
{/* Standalone second button: a "temporary note" auto-moves to
trash after the workspace lifetime unless made permanent. */}
<Tooltip
label={t("New temporary note")}
withArrow
position="right"
>
<ActionIcon
variant="default"
size={18}
onClick={handleCreateTemporaryPage}
aria-label={t("New temporary note")}
>
<IconHourglass />
</ActionIcon>
</Tooltip>
</>
)}
</Group>
</Group>

View File

@@ -1,86 +0,0 @@
import { useState } from "react";
import { useAtom } from "jotai";
import {
Button,
Group,
NumberInput,
Paper,
Stack,
Text,
} from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
import useUserRole from "@/hooks/use-user-role.tsx";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { IWorkspace } from "@/features/workspace/types/workspace.types.ts";
// Mirrors DEFAULT_TEMPORARY_NOTE_HOURS on the server. Shown when the workspace
// has no explicit value configured yet.
const DEFAULT_TEMPORARY_NOTE_HOURS = 24;
/**
* Workspace-level editor for the temporary-note lifetime, in HOURS. The deadline
* is frozen per-note at creation, so changing this only affects notes created
* afterwards. `temporaryNoteHours` is a top-level workspace column (like
* trashRetentionDays), not a nested setting.
*/
export default function TemporaryNoteSettings() {
const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const { isAdmin } = useUserRole();
const [isLoading, setIsLoading] = useState(false);
const [value, setValue] = useState<number>(
workspace?.temporaryNoteHours ?? DEFAULT_TEMPORARY_NOTE_HOURS,
);
async function handleSave() {
if (!value || value < 1) return;
setIsLoading(true);
try {
const updated = await updateWorkspace({
temporaryNoteHours: value,
} as Partial<IWorkspace>);
setWorkspace({ ...updated, temporaryNoteHours: value });
notifications.show({ message: t("Updated successfully") });
} catch (err) {
notifications.show({
message:
(err as any)?.response?.data?.message ?? t("Failed to update data"),
color: "red",
});
} finally {
setIsLoading(false);
}
}
return (
<Stack mt="sm">
<Text fw={700} size="lg">
{t("Temporary notes")}
</Text>
<Paper withBorder radius="md" p="lg">
<Text size="xs" c="dimmed" mb="xs">
{t(
"A temporary note is automatically moved to trash after this many hours unless it is made permanent. The deadline is fixed when the note is created.",
)}
</Text>
<NumberInput
label={t("Temporary note lifetime (hours)")}
min={1}
allowDecimal={false}
value={value}
onChange={(v) => setValue(typeof v === "number" ? v : Number(v))}
disabled={!isAdmin || isLoading}
w={220}
/>
<Group justify="flex-end" mt="md">
<Button onClick={handleSave} loading={isLoading} disabled={!isAdmin}>
{t("Save")}
</Button>
</Group>
</Paper>
</Stack>
);
}

View File

@@ -28,8 +28,6 @@ export interface IWorkspace {
aiDictationStreaming?: boolean;
aiPublicShareAssistant?: boolean;
trashRetentionDays?: number;
// Default lifetime (HOURS) for new temporary notes; frozen per-note at creation.
temporaryNoteHours?: number;
restrictApiToAdmins?: boolean;
allowMemberTemplates?: boolean;
isScimEnabled?: boolean;

View File

@@ -3,7 +3,6 @@ import WorkspaceNameForm from "@/features/workspace/components/settings/componen
import WorkspaceIcon from "@/features/workspace/components/settings/components/workspace-icon.tsx";
import HtmlEmbedSettings from "@/features/workspace/components/settings/components/html-embed-settings.tsx";
import TrackerSettings from "@/features/workspace/components/settings/components/tracker-settings.tsx";
import TemporaryNoteSettings from "@/features/workspace/components/settings/components/temporary-note-settings.tsx";
import { useTranslation } from "react-i18next";
import { getAppName } from "@/lib/config.ts";
import { Helmet } from "react-helmet-async";
@@ -20,7 +19,6 @@ export default function WorkspaceSettings() {
<WorkspaceNameForm />
<HtmlEmbedSettings />
<TrackerSettings />
<TemporaryNoteSettings />
</>
);
}

View File

@@ -240,7 +240,7 @@ describe('prepareAgentStep', () => {
* write path. It runs identically for the upfront insert (empty steps,
* 'streaming'), every per-step update, and the terminal finalize — so a future
* background worker can call the same function. These tests pin the four status
* shapes and the `metadata.parts` shape that rowToUiMessage/findRecent depend on
* shapes and the `metadata.parts` shape that rowToUiMessage/findAllByChat depend on
* (per-step text + tool parts via assistantParts, in-progress text appended).
*/
describe('flushAssistant', () => {

View File

@@ -322,12 +322,14 @@ export class AiChatService implements OnModuleInit {
// Rebuild the conversation from persisted history (not the client payload),
// so the model always sees the authoritative server-side transcript. Load
// the most RECENT tail (oldest -> newest) so chats longer than one page do
// not drop recent turns (incl. the user message just inserted above).
const history = await this.aiChatMessageRepo.findRecent(
// the FULL history in chronological order (oldest -> newest, incl. the user
// message just inserted above) so NO turns are dropped — there is no
// recent-tail window anymore. `findAllByChat` keeps a 5000-row memory-safety
// backstop (on overflow it keeps the NEWEST rows and logs a warning); that
// is a safety net far above any realistic chat, not a conversational limit.
const history = await this.aiChatMessageRepo.findAllByChat(
chatId,
workspace.id,
50,
);
const uiMessages = history.map(rowToUiMessage);
// convertToModelMessages is async in ai@6.0.134 (returns Promise<ModelMessage[]>).
@@ -1215,7 +1217,7 @@ export async function applyFinalize(
*
* `metadata.parts` is built by assistantParts over the finished steps, then the
* in-progress text appended as a trailing text part, so rowToUiMessage /
* findRecent keep replaying the turn unchanged. `metadata.finishReason`,
* findAllByChat keep replaying the turn unchanged. `metadata.finishReason`,
* `metadata.error`, `metadata.usage`, `metadata.contextTokens` and
* `metadata.maxContextTokens` are attached only when provided/relevant, matching
* the pre-#183 onFinish/onError records.

View File

@@ -1,5 +0,0 @@
// Default lifetime for a temporary note, in HOURS, used when the workspace has
// no `temporaryNoteHours` configured (NULL). Mirrors the trash-cleanup
// DEFAULT_RETENTION_DAYS fallback. After this many hours a temporary note is
// auto-moved to trash unless it was made permanent first.
export const DEFAULT_TEMPORARY_NOTE_HOURS = 24;

View File

@@ -1,5 +1,4 @@
import {
IsBoolean,
IsIn,
IsOptional,
IsString,
@@ -33,10 +32,4 @@ export class CreatePageDto {
@Transform(({ value }) => value?.toLowerCase() ?? 'json')
@IsIn(['json', 'markdown', 'html'])
format?: ContentFormat;
// When true, create the page as a temporary note: arm its death timer
// (now + workspace temporaryNoteHours) at creation.
@IsOptional()
@IsBoolean()
temporary?: boolean;
}

View File

@@ -3,7 +3,6 @@ import { PageService } from './services/page.service';
import { PageController } from './page.controller';
import { PageHistoryService } from './services/page-history.service';
import { TrashCleanupService } from './services/trash-cleanup.service';
import { TemporaryNoteCleanupService } from './services/temporary-note-cleanup.service';
import { BacklinkService } from './services/backlink.service';
import { StorageModule } from '../../integrations/storage/storage.module';
import { CollaborationModule } from '../../collaboration/collaboration.module';
@@ -17,7 +16,6 @@ import { LabelModule } from '../label/label.module';
PageService,
PageHistoryService,
TrashCleanupService,
TemporaryNoteCleanupService,
BacklinkService,
],
exports: [PageService, PageHistoryService],

View File

@@ -2,7 +2,6 @@ import { BadRequestException } from '@nestjs/common';
import { PageService } from './page.service';
import { MovePageDto } from '../dto/move-page.dto';
import { Page } from '@docmost/db/types/entity.types';
import { DEFAULT_TEMPORARY_NOTE_HOURS } from '../constants/temporary-note.constants';
// Direct instantiation with stub deps. The Test.createTestingModule form failed
// to resolve the @InjectKysely()/@InjectQueue() tokens at compile(), and this
@@ -421,79 +420,4 @@ describe('PageService', () => {
});
});
});
describe('create() temporary deadline (#201)', () => {
// db stub for the workspaces.temporaryNoteHours lookup:
// selectFrom('workspaces').select(['temporaryNoteHours']).where(...).executeTakeFirst()
const makeDb = (workspaceRow: any) => {
const builder: any = {
selectFrom: jest.fn(() => builder),
select: jest.fn(() => builder),
where: jest.fn(() => builder),
executeTakeFirst: jest.fn().mockResolvedValue(workspaceRow),
};
return builder;
};
const makeGeneralQueue = () =>
({ add: jest.fn().mockReturnValue({ catch: jest.fn() }) }) as any;
const run = async (dto: any, workspaceRow: any) => {
const pageRepo = {
insertPage: jest.fn().mockResolvedValue({ id: 'p1' }),
};
const db = makeDb(workspaceRow);
const svc = new PageService(
pageRepo as any, // pageRepo
{} as any, // pagePermissionRepo
{} as any, // attachmentRepo
db as any, // db
{} as any, // storageService
{} as any, // attachmentQueue
{} as any, // aiQueue
makeGeneralQueue(), // generalQueue
{} as any, // eventEmitter
{} as any, // collaborationGateway
{} as any, // watcherService
{} as any, // transclusionService
);
// nextPagePosition runs a real db query; stub it out.
jest.spyOn(svc, 'nextPagePosition').mockResolvedValue('a0' as any);
await svc.create('u1', 'w1', dto, undefined);
return { payload: pageRepo.insertPage.mock.calls[0][0], db };
};
afterEach(() => jest.useRealTimers());
it('freezes temporaryExpiresAt at now + workspace hours when temporary', async () => {
jest.useFakeTimers().setSystemTime(new Date('2026-06-26T00:00:00.000Z'));
const { payload } = await run(
{ title: 't', spaceId: 's1', temporary: true },
{ temporaryNoteHours: 5 },
);
expect(payload.temporaryExpiresAt).toEqual(
new Date(Date.now() + 5 * 60 * 60 * 1000),
);
});
it('falls back to DEFAULT_TEMPORARY_NOTE_HOURS when the workspace hours are null', async () => {
jest.useFakeTimers().setSystemTime(new Date('2026-06-26T00:00:00.000Z'));
const { payload } = await run(
{ title: 't', spaceId: 's1', temporary: true },
{ temporaryNoteHours: null },
);
expect(payload.temporaryExpiresAt).toEqual(
new Date(Date.now() + DEFAULT_TEMPORARY_NOTE_HOURS * 60 * 60 * 1000),
);
});
it('leaves temporaryExpiresAt undefined and skips the workspace lookup for a non-temporary page', async () => {
const { payload, db } = await run(
{ title: 't', spaceId: 's1' },
{ temporaryNoteHours: 5 },
);
expect(payload.temporaryExpiresAt).toBeUndefined();
expect(db.selectFrom).not.toHaveBeenCalled();
});
});
});

View File

@@ -61,7 +61,6 @@ import {
AuthProvenanceData,
agentSourceFields,
} from '../../../common/decorators/auth-provenance.decorator';
import { DEFAULT_TEMPORARY_NOTE_HOURS } from '../constants/temporary-note.constants';
// Hard upper bound on how deep the recursive page-tree CTEs (ancestor /
// descendant traversals) may walk. Real page trees are only a handful of levels
@@ -141,20 +140,6 @@ export class PageService {
parentPageId = parentPage.id;
}
// Freeze the death timer here so later changes to the workspace setting
// never reschedule existing temporary notes. NULL => permanent page.
let temporaryExpiresAt: Date | undefined;
if (createPageDto.temporary) {
const workspace = await this.db
.selectFrom('workspaces')
.select(['temporaryNoteHours'])
.where('id', '=', workspaceId)
.executeTakeFirst();
const hours =
workspace?.temporaryNoteHours ?? DEFAULT_TEMPORARY_NOTE_HOURS;
temporaryExpiresAt = new Date(Date.now() + hours * 60 * 60 * 1000);
}
let content = undefined;
let textContent = undefined;
let ydoc = undefined;
@@ -187,7 +172,6 @@ export class PageService {
// (creatorId/lastUpdatedById); these only annotate the source. A normal
// user request leaves the column default ('user').
...agentSourceFields(provenance, 'lastUpdatedSource', 'lastUpdatedAiChatId'),
temporaryExpiresAt,
content,
textContent,
ydoc,
@@ -372,7 +356,6 @@ export class PageService {
'spaceId',
'creatorId',
'isTemplate',
'temporaryExpiresAt',
'deletedAt',
])
.select((eb) => this.pageRepo.withHasChildren(eb))

View File

@@ -1,154 +0,0 @@
import { TemporaryNoteCleanupService } from '../temporary-note-cleanup.service';
/**
* Chainable Kysely stub that records every `.where(...)` call so the test can
* assert the sweep only selects armed, expired, not-yet-trashed notes. The
* terminal `.execute()` resolves the configured expired rows (the batch SELECT);
* `.executeTakeFirst()` resolves the per-row deadline re-read done just before
* each `removePage`. By default the re-read reports the note as still armed and
* still expired (epoch deadline < now), so the sweep proceeds to delete it;
* tests override `reReadFirst` to simulate a concurrent "Make permanent".
*/
function makeDbStub(expiredRows: any[]) {
const whereCalls: any[][] = [];
const reReadFirst = jest
.fn()
.mockResolvedValue({ temporaryExpiresAt: new Date(0), deletedAt: null });
const builder: any = {
selectFrom: jest.fn(() => builder),
select: jest.fn(() => builder),
where: jest.fn((...args: any[]) => {
whereCalls.push(args);
return builder;
}),
limit: jest.fn(() => builder),
execute: jest.fn().mockResolvedValue(expiredRows),
executeTakeFirst: reReadFirst,
};
return { builder, whereCalls, reReadFirst };
}
describe('TemporaryNoteCleanupService.sweepExpiredTemporaryNotes', () => {
it('selects only armed, expired, not-yet-trashed notes', async () => {
const { builder, whereCalls } = makeDbStub([]);
const pageRepo = { removePage: jest.fn() } as any;
const service = new TemporaryNoteCleanupService(builder, pageRepo);
await service.sweepExpiredTemporaryNotes();
// temporaryExpiresAt IS NOT NULL, temporaryExpiresAt < now, deletedAt IS NULL
const cols = whereCalls.map((c) => c[0]);
const ops = whereCalls.map((c) => c[1]);
expect(cols).toEqual([
'temporaryExpiresAt',
'temporaryExpiresAt',
'deletedAt',
]);
expect(ops).toEqual(['is not', '<', 'is']);
// last operand is the trash filter -> null
expect(whereCalls[2][2]).toBeNull();
// The batch SELECT is capped so a large backlog is not pulled at once.
expect(builder.limit).toHaveBeenCalledTimes(1);
expect(builder.limit.mock.calls[0][0]).toBeGreaterThan(0);
});
it('soft-deletes each expired note via removePage, attributed to its creator', async () => {
const expired = [
{ id: 'p1', creatorId: 'u1', workspaceId: 'w1' },
{ id: 'p2', creatorId: 'u2', workspaceId: 'w1' },
];
const { builder } = makeDbStub(expired);
const pageRepo = { removePage: jest.fn().mockResolvedValue(undefined) } as any;
const service = new TemporaryNoteCleanupService(builder, pageRepo);
await service.sweepExpiredTemporaryNotes();
expect(pageRepo.removePage).toHaveBeenCalledTimes(2);
expect(pageRepo.removePage).toHaveBeenNthCalledWith(1, 'p1', 'u1', 'w1');
expect(pageRepo.removePage).toHaveBeenNthCalledWith(2, 'p2', 'u2', 'w1');
});
it('continues past a failing note (one bad removePage does not abort the sweep)', async () => {
const expired = [
{ id: 'bad', creatorId: 'u1', workspaceId: 'w1' },
{ id: 'good', creatorId: 'u2', workspaceId: 'w1' },
];
const { builder } = makeDbStub(expired);
const pageRepo = {
removePage: jest
.fn()
.mockRejectedValueOnce(new Error('boom'))
.mockResolvedValueOnce(undefined),
} as any;
const service = new TemporaryNoteCleanupService(builder, pageRepo);
await expect(
service.sweepExpiredTemporaryNotes(),
).resolves.toBeUndefined();
expect(pageRepo.removePage).toHaveBeenCalledTimes(2);
expect(pageRepo.removePage).toHaveBeenNthCalledWith(2, 'good', 'u2', 'w1');
});
it('does NOT trash a note made permanent in the race window', async () => {
// The batch SELECT saw the note as expired, but before its turn in the loop
// the user clicked "Make permanent" (temporary_expires_at -> null). The
// deadline re-read must catch this and skip the delete so the keep wins.
const expired = [{ id: 'p1', creatorId: 'u1', workspaceId: 'w1' }];
const { builder, reReadFirst } = makeDbStub(expired);
reReadFirst.mockResolvedValueOnce({
temporaryExpiresAt: null,
deletedAt: null,
});
const pageRepo = { removePage: jest.fn() } as any;
const service = new TemporaryNoteCleanupService(builder, pageRepo);
await service.sweepExpiredTemporaryNotes();
expect(reReadFirst).toHaveBeenCalledTimes(1);
expect(pageRepo.removePage).not.toHaveBeenCalled();
});
it('skips a note already trashed since the batch SELECT', async () => {
const expired = [{ id: 'p1', creatorId: 'u1', workspaceId: 'w1' }];
const { builder, reReadFirst } = makeDbStub(expired);
reReadFirst.mockResolvedValueOnce({
temporaryExpiresAt: new Date(0),
deletedAt: new Date(),
});
const pageRepo = { removePage: jest.fn() } as any;
const service = new TemporaryNoteCleanupService(builder, pageRepo);
await service.sweepExpiredTemporaryNotes();
expect(pageRepo.removePage).not.toHaveBeenCalled();
});
it('does NOT trash a note re-armed to a future deadline in the race window', async () => {
// The batch SELECT saw the note as expired, but before its turn in the loop
// the user disarmed it and re-armed it to a fresh, still-future deadline
// (temporary_expires_at -> now + 1h). The deadline re-read must catch that
// the note is no longer expired and skip the delete so the keep wins.
const expired = [{ id: 'p1', creatorId: 'u1', workspaceId: 'w1' }];
const { builder, reReadFirst } = makeDbStub(expired);
reReadFirst.mockResolvedValueOnce({
temporaryExpiresAt: new Date(Date.now() + 60 * 60 * 1000),
deletedAt: null,
});
const pageRepo = { removePage: jest.fn() } as any;
const service = new TemporaryNoteCleanupService(builder, pageRepo);
await service.sweepExpiredTemporaryNotes();
expect(reReadFirst).toHaveBeenCalledTimes(1);
expect(pageRepo.removePage).not.toHaveBeenCalled();
});
it('does nothing when no notes are expired', async () => {
const { builder } = makeDbStub([]);
const pageRepo = { removePage: jest.fn() } as any;
const service = new TemporaryNoteCleanupService(builder, pageRepo);
await service.sweepExpiredTemporaryNotes();
expect(pageRepo.removePage).not.toHaveBeenCalled();
});
});

View File

@@ -1,105 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { Interval } from '@nestjs/schedule';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
/**
* Background sweeper for temporary notes ("structure or die"). A note whose
* frozen deadline (`pages.temporary_expires_at`) has passed is auto-moved to
* trash via the exact same soft-delete path as a manual delete. Modelled on
* TrashCleanupService; `@nestjs/schedule` is already enabled globally.
*/
@Injectable()
export class TemporaryNoteCleanupService {
private readonly logger = new Logger(TemporaryNoteCleanupService.name);
// Cap a single sweep so a large backlog (e.g. many notes created during
// downtime under a short lifetime) is not loaded into memory at once. The
// remainder is drained on the next hourly run; sub-hour overshoot is fine.
private static readonly SWEEP_BATCH_LIMIT = 500;
constructor(
@InjectKysely() private readonly db: KyselyDB,
private readonly pageRepo: PageRepo,
) {}
// Hourly granularity: lifetimes are configured in hours, so a sub-hour
// overshoot past the deadline is acceptable.
@Interval('temporary-note-cleanup', 60 * 60 * 1000)
async sweepExpiredTemporaryNotes() {
try {
const now = new Date();
const expired = await this.db
.selectFrom('pages')
.select(['id', 'creatorId', 'workspaceId'])
.where('temporaryExpiresAt', 'is not', null)
.where('temporaryExpiresAt', '<', now)
.where('deletedAt', 'is', null) // not already in trash
.limit(TemporaryNoteCleanupService.SWEEP_BATCH_LIMIT)
.execute();
let trashed = 0;
for (const page of expired) {
try {
// Re-check the deadline at deletion time. The SELECT above is not
// transactional, so a user may click "Make permanent"
// (toggleTemporary sets temporary_expires_at = null) in the window
// between the SELECT and this per-row removePage. removePage deletes
// by id with only a `deletedAt IS NULL` filter and never re-reads the
// deadline, so without this guard a concurrently-kept note would
// still be trashed. Re-read the row and skip it unless it is still
// armed AND still expired, so a concurrent make-permanent wins.
const current = await this.db
.selectFrom('pages')
.select(['temporaryExpiresAt', 'deletedAt'])
.where('id', '=', page.id)
.executeTakeFirst();
if (
!current ||
current.deletedAt !== null ||
current.temporaryExpiresAt === null ||
new Date(current.temporaryExpiresAt) >= now
) {
// Made permanent, already trashed, or no longer expired since the
// SELECT — leave it alone.
continue;
}
// Reuse the exact soft-delete path: recursive over children, removes
// shares in a transaction, and emits PAGE_SOFT_DELETED (tree
// invalidation + watcher notifications). Attribute the automatic
// deletion to the note's creator (no schema change). Both the SELECT
// above and removePage filter `deletedAt IS NULL`, so a double sweep
// is idempotent.
await this.pageRepo.removePage(
page.id,
// creatorId is set on every created page; a temporary note always
// has one. Cast to satisfy the non-null deletedById parameter.
page.creatorId as string,
page.workspaceId,
);
trashed++;
} catch (error) {
this.logger.error(
`Failed to trash expired temporary note ${page.id}`,
error instanceof Error ? error.stack : undefined,
);
}
}
if (trashed > 0) {
this.logger.debug(
`Temporary-note cleanup completed: ${trashed} notes trashed`,
);
}
} catch (error) {
this.logger.error(
'Temporary-note cleanup job failed',
error instanceof Error ? error.stack : undefined,
);
}
}
}

View File

@@ -1,15 +0,0 @@
import { IsBoolean, IsOptional, IsUUID } from 'class-validator';
export class ToggleTemporaryDto {
@IsUUID()
pageId!: string;
/**
* When omitted, the temporary state is toggled relative to its current value.
* true -> arm the timer (now + workspace temporaryNoteHours);
* false -> clear it (make permanent — "structure and survive").
*/
@IsOptional()
@IsBoolean()
temporary?: boolean;
}

View File

@@ -16,12 +16,8 @@ import { TemplateLookupDto } from './dto/template-lookup.dto';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { PageAccessService } from '../page-access/page-access.service';
import { ToggleTemplateDto } from './dto/toggle-template.dto';
import { ToggleTemporaryDto } from './dto/toggle-temporary.dto';
import { UserThrottlerGuard } from '../../../integrations/throttle/user-throttler.guard';
import { PAGE_TEMPLATE_THROTTLER } from '../../../integrations/throttle/throttler-names';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { DEFAULT_TEMPORARY_NOTE_HOURS } from '../constants/temporary-note.constants';
@UseGuards(JwtAuthGuard)
@Controller('pages')
@@ -30,7 +26,6 @@ export class PageTemplateController {
private readonly transclusionService: TransclusionService,
private readonly pageRepo: PageRepo,
private readonly pageAccessService: PageAccessService,
@InjectKysely() private readonly db: KyselyDB,
) {}
/**
@@ -87,54 +82,4 @@ export class PageTemplateController {
return { pageId: page.id, isTemplate };
}
/**
* Arm or disarm the "death timer" on a page (`pages.temporary_expires_at`).
* Mirror of toggle-template: requires Edit on the page/space (CASL enforced in
* `validateCanEdit`). Arming freezes the deadline at now + the workspace's
* temporaryNoteHours; disarming ("Make permanent") clears it. Same workspace
* defense-in-depth as toggle-template (NotFound, never Forbidden, on mismatch).
*/
@UseGuards(JwtAuthGuard, UserThrottlerGuard)
@Throttle({ [PAGE_TEMPLATE_THROTTLER]: { limit: 30, ttl: 60000 } })
@HttpCode(HttpStatus.OK)
@Post('toggle-temporary')
async toggleTemporary(
@Body() dto: ToggleTemporaryDto,
@AuthUser() user: User,
) {
const page = await this.pageRepo.findById(dto.pageId);
if (!page || page.deletedAt) {
throw new NotFoundException('Page not found');
}
if (page.workspaceId !== user.workspaceId) {
// Defense-in-depth: never act on a page outside the caller's workspace.
// Use NotFound (not Forbidden) to avoid leaking cross-workspace existence.
throw new NotFoundException('Page not found');
}
await this.pageAccessService.validateCanEdit(page, user);
const makeTemporary =
typeof dto.temporary === 'boolean'
? dto.temporary
: page.temporaryExpiresAt == null;
let temporaryExpiresAt: Date | null = null;
if (makeTemporary) {
const workspace = await this.db
.selectFrom('workspaces')
.select(['temporaryNoteHours'])
.where('id', '=', user.workspaceId)
.executeTakeFirst();
const hours =
workspace?.temporaryNoteHours ?? DEFAULT_TEMPORARY_NOTE_HOURS;
temporaryExpiresAt = new Date(Date.now() + hours * 60 * 60 * 1000);
}
await this.pageRepo.updatePage({ temporaryExpiresAt }, page.id);
return { pageId: page.id, temporaryExpiresAt };
}
}

View File

@@ -9,7 +9,6 @@ import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { PageAccessService } from '../../page-access/page-access.service';
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
import { UserThrottlerGuard } from '../../../../integrations/throttle/user-throttler.guard';
import { KYSELY_MODULE_CONNECTION_TOKEN } from 'nestjs-kysely';
describe('PageTemplateController.toggleTemplate', () => {
let controller: PageTemplateController;
@@ -41,8 +40,6 @@ describe('PageTemplateController.toggleTemplate', () => {
{ provide: TransclusionService, useValue: transclusionService },
{ provide: PageRepo, useValue: pageRepo },
{ provide: PageAccessService, useValue: pageAccessService },
// toggleTemporary reads the workspace lifetime; toggleTemplate ignores it.
{ provide: KYSELY_MODULE_CONNECTION_TOKEN(), useValue: {} },
],
})
.overrideGuard(JwtAuthGuard)

View File

@@ -1,220 +0,0 @@
import { Test } from '@nestjs/testing';
import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { KYSELY_MODULE_CONNECTION_TOKEN } from 'nestjs-kysely';
import { PageTemplateController } from '../page-template.controller';
import { TransclusionService } from '../transclusion.service';
import { ToggleTemporaryDto } from '../dto/toggle-temporary.dto';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { PageAccessService } from '../../page-access/page-access.service';
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
import { UserThrottlerGuard } from '../../../../integrations/throttle/user-throttler.guard';
import { DEFAULT_TEMPORARY_NOTE_HOURS } from '../../constants/temporary-note.constants';
/**
* Minimal chainable Kysely stub: every builder method returns `this`, and the
* terminal `executeTakeFirst` resolves the configured workspace row.
*/
function makeDbStub(workspaceRow: { temporaryNoteHours: number | null } | undefined) {
const builder: any = {
selectFrom: () => builder,
select: () => builder,
where: () => builder,
executeTakeFirst: jest.fn().mockResolvedValue(workspaceRow),
};
return builder;
}
describe('PageTemplateController.toggleTemporary', () => {
let controller: PageTemplateController;
let pageRepo: { findById: jest.Mock; updatePage: jest.Mock };
let pageAccessService: { validateCanEdit: jest.Mock };
const user = { id: 'u1', workspaceId: 'w1' } as any;
async function buildController(
page: any,
workspaceRow: { temporaryNoteHours: number | null } | undefined = {
temporaryNoteHours: null,
},
) {
pageRepo = {
findById: jest.fn().mockResolvedValue(page),
updatePage: jest.fn().mockResolvedValue(undefined),
};
pageAccessService = {
validateCanEdit: jest.fn().mockResolvedValue(undefined),
};
const module = await Test.createTestingModule({
controllers: [PageTemplateController],
providers: [
{ provide: TransclusionService, useValue: { lookupTemplate: jest.fn() } },
{ provide: PageRepo, useValue: pageRepo },
{ provide: PageAccessService, useValue: pageAccessService },
{
provide: KYSELY_MODULE_CONNECTION_TOKEN(),
useValue: makeDbStub(workspaceRow),
},
],
})
.overrideGuard(JwtAuthGuard)
.useValue({ canActivate: () => true })
.overrideGuard(UserThrottlerGuard)
.useValue({ canActivate: () => true })
.compile();
controller = module.get(PageTemplateController);
}
beforeEach(() => {
jest.useFakeTimers().setSystemTime(new Date('2026-06-26T00:00:00.000Z'));
});
afterEach(() => {
jest.useRealTimers();
});
it('throws NotFound and does not touch the page when missing', async () => {
await buildController(null);
await expect(
controller.toggleTemporary({ pageId: 'p1' } as any, user),
).rejects.toBeInstanceOf(NotFoundException);
expect(pageAccessService.validateCanEdit).not.toHaveBeenCalled();
expect(pageRepo.updatePage).not.toHaveBeenCalled();
});
it('throws NotFound (not Forbidden) for a cross-workspace page', async () => {
await buildController({
id: 'p1',
workspaceId: 'OTHER',
deletedAt: null,
temporaryExpiresAt: null,
});
await expect(
controller.toggleTemporary({ pageId: 'p1' } as any, user),
).rejects.toBeInstanceOf(NotFoundException);
expect(pageRepo.updatePage).not.toHaveBeenCalled();
});
it('enforces CASL edit: when validateCanEdit throws, the timer is NOT changed', async () => {
await buildController({
id: 'p1',
workspaceId: 'w1',
deletedAt: null,
temporaryExpiresAt: null,
});
pageAccessService.validateCanEdit.mockRejectedValue(new ForbiddenException());
await expect(
controller.toggleTemporary({ pageId: 'p1' } as any, user),
).rejects.toBeInstanceOf(ForbiddenException);
expect(pageRepo.updatePage).not.toHaveBeenCalled();
});
it('arms the timer (toggle) using the default hours when the page is permanent', async () => {
await buildController({
id: 'p1',
workspaceId: 'w1',
deletedAt: null,
temporaryExpiresAt: null,
});
const out = await controller.toggleTemporary({ pageId: 'p1' } as any, user);
const expected = new Date(
Date.now() + DEFAULT_TEMPORARY_NOTE_HOURS * 60 * 60 * 1000,
);
expect(pageAccessService.validateCanEdit).toHaveBeenCalled();
expect(pageRepo.updatePage).toHaveBeenCalledWith(
{ temporaryExpiresAt: expected },
'p1',
);
expect(out).toEqual({ pageId: 'p1', temporaryExpiresAt: expected });
});
it('uses the workspace temporaryNoteHours override when set', async () => {
await buildController(
{
id: 'p1',
workspaceId: 'w1',
deletedAt: null,
temporaryExpiresAt: null,
},
{ temporaryNoteHours: 3 },
);
const out = await controller.toggleTemporary({ pageId: 'p1' } as any, user);
const expected = new Date(Date.now() + 3 * 60 * 60 * 1000);
expect(pageRepo.updatePage).toHaveBeenCalledWith(
{ temporaryExpiresAt: expected },
'p1',
);
expect(out.temporaryExpiresAt).toEqual(expected);
});
it('clears the timer (make permanent) when toggling an armed note', async () => {
await buildController({
id: 'p1',
workspaceId: 'w1',
deletedAt: null,
temporaryExpiresAt: new Date('2026-06-27T00:00:00.000Z'),
});
const out = await controller.toggleTemporary({ pageId: 'p1' } as any, user);
expect(pageRepo.updatePage).toHaveBeenCalledWith(
{ temporaryExpiresAt: null },
'p1',
);
expect(out).toEqual({ pageId: 'p1', temporaryExpiresAt: null });
});
it('respects an explicit temporary:false instead of toggling', async () => {
await buildController({
id: 'p1',
workspaceId: 'w1',
deletedAt: null,
temporaryExpiresAt: null, // already permanent, but explicit false
});
const out = await controller.toggleTemporary(
{ pageId: 'p1', temporary: false } as any,
user,
);
expect(pageRepo.updatePage).toHaveBeenCalledWith(
{ temporaryExpiresAt: null },
'p1',
);
expect(out.temporaryExpiresAt).toBeNull();
});
});
describe('ToggleTemporaryDto validation (class-validator)', () => {
const uuid = '00000000-0000-4000-8000-000000000001';
it('accepts a valid UUID with no flag (toggle)', async () => {
const dto = plainToInstance(ToggleTemporaryDto, { pageId: uuid });
expect(await validate(dto)).toHaveLength(0);
});
it('accepts an explicit boolean temporary', async () => {
const dto = plainToInstance(ToggleTemporaryDto, {
pageId: uuid,
temporary: true,
});
expect(await validate(dto)).toHaveLength(0);
});
it('rejects a non-UUID pageId', async () => {
const dto = plainToInstance(ToggleTemporaryDto, { pageId: 'nope' });
const errors = await validate(dto);
expect(errors).toHaveLength(1);
expect(errors[0].constraints).toHaveProperty('isUuid');
});
it('rejects a non-boolean temporary', async () => {
const dto = plainToInstance(ToggleTemporaryDto, {
pageId: uuid,
temporary: 'yes',
});
const errors = await validate(dto);
expect(errors).toHaveLength(1);
expect(errors[0].constraints).toHaveProperty('isBoolean');
});
});

View File

@@ -84,13 +84,6 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
@Min(1)
trashRetentionDays: number;
// Default lifetime for new temporary notes, in HOURS. Frozen per-note at
// creation, so changing this never reschedules existing notes.
@IsOptional()
@IsInt()
@Min(1)
temporaryNoteHours: number;
@IsOptional()
@IsBoolean()
allowMemberTemplates: boolean;

View File

@@ -330,7 +330,6 @@ export class WorkspaceService {
if (
typeof updateWorkspaceDto.disablePublicSharing !== 'undefined' ||
typeof updateWorkspaceDto.trashRetentionDays !== 'undefined' ||
typeof updateWorkspaceDto.temporaryNoteHours !== 'undefined' ||
typeof updateWorkspaceDto.mcpEnabled !== 'undefined' ||
typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined' ||
typeof updateWorkspaceDto.allowMemberTemplates !== 'undefined' ||
@@ -338,13 +337,7 @@ export class WorkspaceService {
) {
const ws = await this.db
.selectFrom('workspaces')
.select([
'id',
'licenseKey',
'plan',
'trashRetentionDays',
'temporaryNoteHours',
])
.select(['id', 'licenseKey', 'plan', 'trashRetentionDays'])
.where('id', '=', workspaceId)
.executeTakeFirst();
@@ -385,14 +378,6 @@ export class WorkspaceService {
before.trashRetentionDays = ws.trashRetentionDays;
after.trashRetentionDays = updateWorkspaceDto.trashRetentionDays;
}
if (
typeof updateWorkspaceDto.temporaryNoteHours !== 'undefined' &&
updateWorkspaceDto.temporaryNoteHours !== ws.temporaryNoteHours
) {
before.temporaryNoteHours = ws.temporaryNoteHours;
after.temporaryNoteHours = updateWorkspaceDto.temporaryNoteHours;
}
}
if (updateWorkspaceDto.aiSearch) {

View File

@@ -1,40 +0,0 @@
import { type Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
// "Death timer" column. NULL = permanent page; non-NULL = temporary note,
// value is the exact moment the note auto-moves to trash. The deadline is
// frozen at creation, so changing the workspace setting never reschedules
// existing notes.
await db.schema
.alterTable('pages')
.addColumn('temporary_expires_at', 'timestamptz', (col) => col)
.execute();
// Partial index backing the cleanup sweep: only armed, not-yet-trashed notes.
await sql`
CREATE INDEX pages_temporary_expires_at_idx
ON pages (temporary_expires_at)
WHERE temporary_expires_at IS NOT NULL AND deleted_at IS NULL
`.execute(db);
// Default lifetime for new temporary notes, in HOURS. Frozen per-note at
// creation. NULL falls back to the in-code DEFAULT_TEMPORARY_NOTE_HOURS.
await db.schema
.alterTable('workspaces')
.addColumn('temporary_note_hours', 'int8', (col) => col)
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('workspaces')
.dropColumn('temporary_note_hours')
.execute();
await db.schema.dropIndex('pages_temporary_expires_at_idx').execute();
await db.schema
.alterTable('pages')
.dropColumn('temporary_expires_at')
.execute();
}

View File

@@ -18,7 +18,8 @@ import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagin
// (multi-instance deploy).
const SWEEP_STREAMING_STALE_MS = 10 * 60 * 1000; // 10 minutes
// Hard upper bound on the rows materialized by `findAllByChat` (export path).
// Hard upper bound on the rows materialized by `findAllByChat`, which now feeds
// BOTH the Markdown export and the per-turn model history.
// A generous cap so a pathologically huge chat cannot load an unbounded result
// into memory; far above any realistic transcript length.
const FIND_ALL_BY_CHAT_LIMIT = 5000;
@@ -78,14 +79,17 @@ export class AiChatMessageRepo {
}
// Load ALL (non-deleted) messages of a chat in ascending chronological order
// (oldest -> newest), unpaginated. Used by the server-side Markdown export
// (#183), where the DB is the single source of truth and the whole transcript
// must be rendered in one pass (findByChat is cursor-paginated and would only
// return the first page).
// (oldest -> newest), unpaginated. Two callers, both treating the DB as the
// single source of truth and needing the whole transcript in one pass
// (findByChat is cursor-paginated and would only return the first page):
// - the server-side Markdown export (#183);
// - the per-turn model history, rebuilt fresh on every turn so the model
// sees the full authoritative transcript.
//
// Hard-capped at FIND_ALL_BY_CHAT_LIMIT rows (a generous bound, far above any
// realistic transcript) so exporting a pathologically huge chat cannot
// materialize an unbounded result set in memory.
// realistic transcript) — a shared memory-safety backstop for BOTH paths so a
// pathologically huge chat cannot materialize an unbounded result set in
// memory. On overflow the NEWEST rows are kept and a warning is logged.
async findAllByChat(
chatId: string,
workspaceId: string,
@@ -93,9 +97,9 @@ export class AiChatMessageRepo {
limit: number = FIND_ALL_BY_CHAT_LIMIT,
): Promise<AiChatMessage[]> {
// Fetch newest-first (+1 to DETECT truncation), so on overflow we keep the
// NEWEST `limit` messages — the recent conversation matters most for an
// export — rather than silently dropping the tail (#183 review). Reverse back
// to chronological for rendering, like findRecent.
// NEWEST `limit` messages — the recent conversation matters most — rather
// than silently dropping the tail (#183 review). Then reverse back to
// chronological order (oldest -> newest) for rendering / model replay.
const rows = await this.db
.selectFrom('aiChatMessages')
.select(this.baseFields)
@@ -110,38 +114,13 @@ export class AiChatMessageRepo {
if (rows.length > limit) {
rows.length = limit; // keep the newest `limit` (rows are newest-first here)
this.logger.warn(
`Chat ${chatId} export truncated to the newest ${limit} messages ` +
`Chat ${chatId} truncated to the newest ${limit} messages ` +
`(older messages omitted).`,
);
}
return rows.reverse();
}
// Load the most RECENT `limit` messages for a chat and return them in
// ascending chronological order (oldest -> newest), as the model expects.
// `findByChat` returns the FIRST page ASC (the OLDEST messages), which loses
// recent turns once a chat grows beyond a page; this rebuilds the model
// history from the tail instead. Plain query (no cursor pagination).
async findRecent(
chatId: string,
workspaceId: string,
limit: number,
): Promise<AiChatMessage[]> {
const rows = await this.db
.selectFrom('aiChatMessages')
.select(this.baseFields)
.where('chatId', '=', chatId)
.where('workspaceId', '=', workspaceId)
.where('deletedAt', 'is', null)
.orderBy('createdAt', 'desc')
.orderBy('id', 'desc')
.limit(limit)
.execute();
// Selected newest-first for the limit; reverse to oldest-first for the model.
return rows.reverse();
}
async insert(
insertable: InsertableAiChatMessage,
trx?: KyselyTransaction,

View File

@@ -1,64 +0,0 @@
import { PageRepo } from './page.repo';
/**
* Regression guard for #201: restorePage must disarm the temporary-note death
* timer by setting `temporaryExpiresAt = null` alongside the un-delete fields.
* Otherwise a restored note whose frozen deadline already passed would be
* re-trashed by the very next cleanup sweep. There is no real DB here — a
* chainable Kysely proxy records every `.set(...)` payload so we can assert the
* single restore UPDATE clears the deadline.
*/
function makeRestoreDbStub(opts: {
pageToRestore: any;
descendants: any[];
}) {
const setCalls: any[] = [];
const proxy: any = new Proxy(function () {}, {
get(_t, prop) {
if (prop === 'then') return undefined;
if (prop === 'set')
return (payload: any) => {
setCalls.push(payload);
return proxy;
};
if (prop === 'executeTakeFirst')
return () => Promise.resolve(opts.pageToRestore);
if (prop === 'execute') return () => Promise.resolve(opts.descendants);
if (prop === 'withRecursive')
return (_name: string, cb: any) => {
// Exercise the recursive CTE builder against the proxy without a DB.
try {
cb(proxy);
} catch {
// builder shape only; ignore
}
return proxy;
};
return () => proxy;
},
});
return { proxy, setCalls };
}
describe('PageRepo.restorePage temporary-timer disarm (#201)', () => {
it('clears temporaryExpiresAt together with the un-delete fields', async () => {
const { proxy, setCalls } = makeRestoreDbStub({
// No parent => the deleted-parent lookup and detach branch are skipped, so
// the only UPDATE is the bulk restore we assert on.
pageToRestore: { id: 'p1', parentPageId: null, spaceId: 's1' },
descendants: [{ id: 'p1' }],
});
const eventEmitter = { emit: jest.fn() } as any;
const repo = new PageRepo(proxy, {} as any, eventEmitter);
await repo.restorePage('p1', 'w1');
expect(setCalls).toHaveLength(1);
expect(setCalls[0]).toEqual({
deletedById: null,
deletedAt: null,
temporaryExpiresAt: null,
});
});
});

View File

@@ -51,7 +51,6 @@ export class PageRepo {
'workspaceId',
'isLocked',
'isTemplate',
'temporaryExpiresAt',
'createdAt',
'updatedAt',
'deletedAt',
@@ -426,10 +425,7 @@ export class PageRepo {
// Restore all pages, but only detach the root page if its parent is deleted
await this.db
.updateTable('pages')
// On restore, disarm the death timer: pulling a note out of trash means
// "keep it". Otherwise a deadline now in the past would re-trash it on the
// next cleanup sweep.
.set({ deletedById: null, deletedAt: null, temporaryExpiresAt: null })
.set({ deletedById: null, deletedAt: null })
.where('id', 'in', pageIds)
.execute();

View File

@@ -58,7 +58,6 @@ export class WorkspaceRepo {
'plan',
'enforceMfa',
'trashRetentionDays',
'temporaryNoteHours',
'isScimEnabled',
];
constructor(@InjectKysely() private readonly db: KyselyDB) {}

View File

@@ -297,7 +297,6 @@ export interface Pages {
position: string | null;
slugId: string;
spaceId: string;
temporaryExpiresAt: Timestamp | null;
textContent: string | null;
title: string | null;
tsv: string | null;
@@ -420,7 +419,6 @@ export interface WorkspaceInvitations {
export interface Workspaces {
auditRetentionDays: Generated<number>;
trashRetentionDays: Generated<number>;
temporaryNoteHours: Generated<number>;
billingEmail: string | null;
createdAt: Generated<Timestamp>;
customDomain: string | null;

View File

@@ -267,4 +267,36 @@ describe('AiChatMessageRepo.update + sweepStreaming [integration]', () => {
const all = await repo.findAllByChat(cappedChat, workspaceId, 100);
expect(all.map((r) => r.content)).toEqual(['m1-oldest', 'm2', 'm3-newest']);
});
it('default findAllByChat returns the FULL transcript past 50 rows — no recent-tail window (#202)', async () => {
// PR #202 swapped the model-history rebuild in AiChatService.handle from
// findRecent(chatId, ws, 50) to findAllByChat(chatId, ws) WITHOUT a limit
// arg. This pins the behavioral guarantee that switch relies on: a chat
// longer than the old 50-msg window comes back in FULL (oldest -> newest),
// so no early turns are silently dropped from what the model sees. The old
// 50-cap would have returned only the last 50 of these 60 rows.
const longChat = (
await createChat(db, { workspaceId, creatorId: userId })
).id;
const base = Date.now();
const total = 60;
for (let i = 0; i < total; i++) {
await createMessage(db, {
workspaceId,
chatId: longChat,
content: `msg-${i}`,
// Strictly increasing timestamps so ordering is deterministic.
createdAt: new Date(base + i * 1000),
});
}
// Default args == exactly how handle() calls it now.
const history = await repo.findAllByChat(longChat, workspaceId);
expect(history).toHaveLength(total);
expect(history.map((r) => r.content)).toEqual(
Array.from({ length: total }, (_, i) => `msg-${i}`),
);
// The very first turn (which the old 50-window would have dropped) is present.
expect(history[0]!.content).toBe('msg-0');
});
});