Compare commits
4 Commits
5141279e42
...
feat/201-t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53cbec9354 | ||
|
|
7d64b11045 | ||
|
|
983f2fa654 | ||
|
|
eb5b696431 |
40
.env.example
40
.env.example
@@ -195,43 +195,3 @@ MCP_DOCMOST_PASSWORD=
|
||||
# FAILS CLOSED if Redis is unavailable (default: 1,000,000 tokens per workspace
|
||||
# per rolling day).
|
||||
# SHARE_AI_WORKSPACE_TOKEN_BUDGET_PER_DAY=1000000
|
||||
|
||||
# --- GIT-SYNC (native two-way Docmost <-> git Markdown sync) ---
|
||||
# Master switch. Off by default. When 'true', GIT_SYNC_SERVICE_USER_ID below is
|
||||
# REQUIRED (the service account that git-originated create/move/rename/delete are
|
||||
# attributed to) — the server refuses to boot with sync enabled and no user id.
|
||||
# GIT_SYNC_ENABLED=false
|
||||
#
|
||||
# Serve the per-space vaults over smart-HTTP (the /git host). Defaults to
|
||||
# GIT_SYNC_ENABLED when unset.
|
||||
# GIT_SYNC_HTTP_ENABLED=false
|
||||
#
|
||||
# REQUIRED when GIT_SYNC_ENABLED=true: id of the user that git-originated page
|
||||
# operations (create / move / rename / delete) are attributed to.
|
||||
# GIT_SYNC_SERVICE_USER_ID=
|
||||
#
|
||||
# Where the per-space working vaults live (non-bare repos; the engine needs a
|
||||
# working tree).
|
||||
# Defaults to "<DATA_DIR or ./data>/git-sync".
|
||||
# GIT_SYNC_DATA_DIR=
|
||||
#
|
||||
# Optional remote URL template to mirror each space's vault to (e.g. a git host).
|
||||
# Leave unset to keep vaults local-only.
|
||||
# GIT_SYNC_REMOTE_TEMPLATE=
|
||||
#
|
||||
# Path to the SSH private key used when pushing to GIT_SYNC_REMOTE_TEMPLATE.
|
||||
# GIT_SYNC_SSH_KEY_PATH=
|
||||
#
|
||||
# Poll-safety interval in ms — the cadence of the background reconcile cycle
|
||||
# (default: 15000).
|
||||
# GIT_SYNC_POLL_INTERVAL_MS=15000
|
||||
#
|
||||
# Debounce window in ms for collapsing bursts of page edits into one sync cycle
|
||||
# (default: 2000).
|
||||
# GIT_SYNC_DEBOUNCE_MS=2000
|
||||
#
|
||||
# Watchdog timeout in ms for the spawned `git http-backend` process serving a
|
||||
# git smart-HTTP push (default: 120000). A stalled/hung receive-pack is killed
|
||||
# after this deadline so it cannot hold the per-space lock forever.
|
||||
# GIT_SYNC_BACKEND_TIMEOUT_MS=120000
|
||||
#
|
||||
|
||||
7
.github/workflows/test.yml
vendored
7
.github/workflows/test.yml
vendored
@@ -68,13 +68,6 @@ jobs:
|
||||
- name: Build editor-ext
|
||||
run: pnpm --filter @docmost/editor-ext build
|
||||
|
||||
# git-sync and mcp are no longer committed in built form (build/ is
|
||||
# gitignored), so CI must compile them: the server resolves both via their
|
||||
# built build/index.js. The server pretest also builds them, but building
|
||||
# here keeps it explicit and independent of pnpm lifecycle ordering.
|
||||
- name: Build git-sync and mcp
|
||||
run: pnpm --filter @docmost/git-sync build && pnpm --filter @docmost/mcp build
|
||||
|
||||
- name: Run unit tests
|
||||
run: pnpm -r test
|
||||
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -5,12 +5,6 @@ data
|
||||
# compiled output
|
||||
/dist
|
||||
/node_modules
|
||||
# workspace package node_modules (pnpm symlinks — never commit; they bake
|
||||
# machine-local store paths) and the git-sync compiled output (built in CI/Docker
|
||||
# via `pnpm build`, never committed, so src/ and prod can never silently diverge).
|
||||
packages/*/node_modules/
|
||||
packages/git-sync/build/
|
||||
packages/mcp/build/
|
||||
|
||||
# Logs
|
||||
logs
|
||||
|
||||
@@ -182,7 +182,7 @@ tea issues create --repo vvzvlad/gitmost --labels feature \
|
||||
|
||||
## Monorepo layout
|
||||
|
||||
pnpm workspace (`pnpm@10.4.0`) orchestrated by **Nx**. Five workspace packages:
|
||||
pnpm workspace (`pnpm@10.4.0`) orchestrated by **Nx**. Four workspace packages:
|
||||
|
||||
| Path | Name | Stack | Role |
|
||||
| --- | --- | --- | --- |
|
||||
@@ -190,7 +190,6 @@ pnpm workspace (`pnpm@10.4.0`) orchestrated by **Nx**. Five workspace packages:
|
||||
| `apps/client` | `client` | React 18 + Vite + Mantine 8 + TanStack Query + Jotai | SPA frontend |
|
||||
| `packages/editor-ext` | `@docmost/editor-ext` | Tiptap/ProseMirror | Shared Tiptap node/mark extensions, imported by both the client and the server |
|
||||
| `packages/mcp` | `@docmost/mcp` | MCP SDK, Tiptap, Yjs | Standalone MCP server, also bundled into the server at `/mcp`. Does **not** import `editor-ext` — it keeps its own vendored mirror of the schema in `packages/mcp/src/lib/` |
|
||||
| `packages/git-sync` | `@docmost/git-sync` | Tiptap/ProseMirror, Yjs, git | Pure ProseMirror↔Markdown converter plus the two-way Docmost↔git Markdown sync engine. Bundled into the server (loaded over the ESM bridge), built in CI and the Dockerfile. Does **not** import `editor-ext` — it keeps its own vendored mirror of the document schema (kept in sync with `editor-ext`). |
|
||||
|
||||
`build` targets are Nx-cached and dependency-ordered (`dependsOn: ["^build"]`), so `editor-ext` builds before the apps. `nx.json` sets `affected.defaultBase: main`.
|
||||
|
||||
@@ -264,7 +263,7 @@ The API server is a Fastify app with a global `/api` prefix (`main.ts` excludes
|
||||
### Client structure
|
||||
Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirrors the server domains: `page`, `space`, `comment`, `ai-chat`, `editor`, …). Conventions:
|
||||
- **TanStack Query** for server state (one `queries/` file per feature), **Jotai** atoms for local/shared UI state, **Mantine 8** + CSS modules (`*.module.css`) + `postcss-preset-mantine` for UI.
|
||||
- The editor is Tiptap; shared node/mark extensions live in `packages/editor-ext` and are imported by **both the client and the server** (collaboration, import/export) — editor schema changes often need to be made in `editor-ext`, not just the client. Note neither `packages/mcp` nor `packages/git-sync` depends on `editor-ext`; each carries its own mirrored copy of the schema. There are now **three** independent copies (`editor-ext` is canonical, plus `packages/mcp` and `packages/git-sync`), so keep all three in sync manually when the document schema changes.
|
||||
- The editor is Tiptap; shared node/mark extensions live in `packages/editor-ext` and are imported by **both the client and the server** (collaboration, import/export) — editor schema changes often need to be made in `editor-ext`, not just the client. Note `packages/mcp` does *not* depend on `editor-ext`; it carries its own mirrored copy of the schema, so keep the two in sync manually when the document schema changes.
|
||||
- API access goes through `apps/client/src/lib/api-client.ts` (axios). The `@` alias maps to `apps/client/src`.
|
||||
- Runtime config is injected at build time by `vite.config.ts` via `define` (`APP_URL`, `COLLAB_URL`, `APP_VERSION`, …) — these come from the root `.env`, not from `import.meta.env`.
|
||||
|
||||
|
||||
10
CHANGELOG.md
10
CHANGELOG.md
@@ -32,6 +32,16 @@ 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
|
||||
|
||||
@@ -17,9 +17,8 @@ RUN pnpm build
|
||||
|
||||
FROM base AS installer
|
||||
|
||||
# git: required by the git-sync VaultGit (shells out to git)
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl bash git \
|
||||
&& apt-get install -y --no-install-recommends curl bash \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
@@ -34,11 +33,6 @@ COPY --from=builder /app/packages/editor-ext/dist /app/packages/editor-ext/dist
|
||||
COPY --from=builder /app/packages/editor-ext/package.json /app/packages/editor-ext/package.json
|
||||
COPY --from=builder /app/packages/mcp/build /app/packages/mcp/build
|
||||
COPY --from=builder /app/packages/mcp/package.json /app/packages/mcp/package.json
|
||||
# git-sync: the server requires @docmost/git-sync at runtime; without these the
|
||||
# image starts and crashes on `require('@docmost/git-sync')`. Built fresh by the
|
||||
# builder's `pnpm build` (nx builds the package's tsc `build` target).
|
||||
COPY --from=builder /app/packages/git-sync/build /app/packages/git-sync/build
|
||||
COPY --from=builder /app/packages/git-sync/package.json /app/packages/git-sync/package.json
|
||||
|
||||
# Copy root package files
|
||||
COPY --from=builder /app/package.json /app/package.json
|
||||
|
||||
@@ -598,6 +598,17 @@
|
||||
"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",
|
||||
@@ -1207,8 +1218,6 @@
|
||||
"Ran tool {{name}}": "Ran tool {{name}}",
|
||||
"AI-agent": "AI-agent",
|
||||
"Edited by AI agent on behalf of {{name}}": "Edited by AI agent on behalf of {{name}}",
|
||||
"Git sync": "Git sync",
|
||||
"Synced from Git on behalf of {{name}}": "Synced from Git on behalf of {{name}}",
|
||||
"Endpoints": "Endpoints",
|
||||
"where we fetch models": "where we fetch models",
|
||||
"All endpoints are OpenAI-compatible. Point the Base URL at OpenAI, OpenRouter, a local Ollama, or any self-hosted server.": "All endpoints are OpenAI-compatible. Point the Base URL at OpenAI, OpenRouter, a local Ollama, or any self-hosted server.",
|
||||
@@ -1233,8 +1242,6 @@
|
||||
"MCP server": "MCP server",
|
||||
"expose the workspace": "expose the workspace",
|
||||
"Enable MCP server": "Enable MCP server",
|
||||
"Enable Git sync": "Enable Git sync",
|
||||
"Sync this space's pages to a Git repository.": "Sync this space's pages to a Git repository.",
|
||||
"Exposes the workspace as an MCP server at /mcp — this provides a capability, it doesn't consume a model.": "Exposes the workspace as an MCP server at /mcp — this provides a capability, it doesn't consume a model.",
|
||||
"Resolves to {{url}}": "Resolves to {{url}}",
|
||||
"Model": "Model",
|
||||
|
||||
@@ -607,6 +607,17 @@
|
||||
"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": "Удалить навсегда",
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { Badge, Tooltip } from "@mantine/core";
|
||||
import { IconGitMerge } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface GitSyncBadgeProps {
|
||||
authorName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Badge marking a version produced by git-sync (provenance §8.1). The history
|
||||
* version is created on the PUSH path — when an incoming git body is written back
|
||||
* into the Docmost doc — not by the pull itself. Like {@link AiAgentBadge} it is
|
||||
* ADDITIVE — shown next to the human author, never replacing them — but a git-sync
|
||||
* edit is NOT an agent edit and has no chat to deep-link into, so it is a small,
|
||||
* neutral, non-clickable label.
|
||||
*/
|
||||
export function GitSyncBadge({ authorName }: GitSyncBadgeProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const tooltip = t("Synced from Git on behalf of {{name}}", {
|
||||
name: authorName ?? "",
|
||||
});
|
||||
|
||||
return (
|
||||
<Tooltip label={tooltip} withArrow>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="gray"
|
||||
radius="sm"
|
||||
leftSection={<IconGitMerge size={12} stroke={2} />}
|
||||
>
|
||||
{t("Git sync")}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -26,6 +26,7 @@ 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,
|
||||
@@ -37,6 +38,7 @@ 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;
|
||||
@@ -103,6 +105,7 @@ export function FullEditor({
|
||||
<MemoizedFixedToolbar />
|
||||
)}
|
||||
<MemoizedDeletedPageBanner slugId={slugId} />
|
||||
<MemoizedTemporaryNoteBanner slugId={slugId} />
|
||||
<MemoizedTitleEditor
|
||||
pageId={pageId}
|
||||
slugId={slugId}
|
||||
|
||||
@@ -1,7 +1,40 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { toggleTemplate } from "@/features/page-embed/services/page-embed-api";
|
||||
import type { ToggleTemplateResponse } from "@/features/page-embed/types/page-embed.types";
|
||||
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),
|
||||
});
|
||||
}
|
||||
|
||||
export function useToggleTemplateMutation() {
|
||||
return useMutation<
|
||||
@@ -18,3 +51,20 @@ 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",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import api from "@/lib/api-client";
|
||||
import type {
|
||||
PageTemplateLookup,
|
||||
ToggleTemplateResponse,
|
||||
ToggleTemporaryResponse,
|
||||
} from "../types/page-embed.types";
|
||||
|
||||
export async function lookupTemplate(params: {
|
||||
@@ -18,3 +19,11 @@ 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;
|
||||
}
|
||||
|
||||
@@ -14,3 +14,9 @@ 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;
|
||||
};
|
||||
|
||||
@@ -1,227 +0,0 @@
|
||||
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
|
||||
import { render, screen, cleanup, within } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
|
||||
// Mantine Tooltip mounts its label lazily on hover via Floating UI, which is
|
||||
// flaky under jsdom. Replace ONLY the Tooltip with a thin wrapper that renders
|
||||
// the label inline (keeping Badge/Switch/etc. real), so the provenance label —
|
||||
// the contract we care about — is deterministically queryable.
|
||||
vi.mock("@mantine/core", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@mantine/core")>("@mantine/core");
|
||||
const Tooltip = ({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
}) => (
|
||||
<>
|
||||
{children}
|
||||
<span data-testid="tooltip-label">{label}</span>
|
||||
</>
|
||||
);
|
||||
Tooltip.Group = ({ children }: { children?: React.ReactNode }) => (
|
||||
<>{children}</>
|
||||
);
|
||||
return { ...actual, Tooltip };
|
||||
});
|
||||
|
||||
// jsdom lacks matchMedia, which MantineProvider's color-scheme hook needs.
|
||||
beforeAll(() => {
|
||||
if (!window.matchMedia) {
|
||||
window.matchMedia = (query: string) =>
|
||||
({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
}) as unknown as MediaQueryList;
|
||||
}
|
||||
});
|
||||
|
||||
// --- Mocks for the heavy / networked module graph ---------------------------
|
||||
// HistoryItem pulls in i18n, jotai atoms (ai-chat / history), a config-backed
|
||||
// avatar and a time formatter. The provenance-badge contract is the unit under
|
||||
// test, so we stub everything else down to inert, deterministic renders and
|
||||
// keep the real Mantine Badge/Tooltip so role/label queries are meaningful.
|
||||
|
||||
// i18n: interpolate {{name}} so the git-sync tooltip carries the author name,
|
||||
// letting us assert provenance attribution without a real i18n backend.
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, vars?: Record<string, unknown>) =>
|
||||
vars && typeof vars.name !== "undefined"
|
||||
? key.replace("{{name}}", String(vars.name))
|
||||
: key,
|
||||
}),
|
||||
}));
|
||||
|
||||
// jotai setters: the badges call useSetAtom; return inert setters so a click on
|
||||
// the (deep-linkable) AiAgentBadge would fire these — proving the git-sync badge
|
||||
// does NOT wire any of them.
|
||||
const setAiChatWindowOpen = vi.fn();
|
||||
const setActiveChatId = vi.fn();
|
||||
const setDraft = vi.fn();
|
||||
const setHistoryModalOpen = vi.fn();
|
||||
vi.mock("jotai", async () => {
|
||||
const actual = await vi.importActual<typeof import("jotai")>("jotai");
|
||||
return {
|
||||
...actual,
|
||||
useSetAtom: (atom: unknown) => {
|
||||
switch (atom) {
|
||||
case aiChatWindowOpenAtom:
|
||||
return setAiChatWindowOpen;
|
||||
case activeAiChatIdAtom:
|
||||
return setActiveChatId;
|
||||
case aiChatDraftAtom:
|
||||
return setDraft;
|
||||
case historyAtoms:
|
||||
return setHistoryModalOpen;
|
||||
default:
|
||||
return vi.fn();
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Atoms are imported only as identity tokens for the useSetAtom switch above.
|
||||
vi.mock("@/features/ai-chat/atoms/ai-chat-atom.ts", () => ({
|
||||
activeAiChatIdAtom: { __tag: "activeAiChatIdAtom" },
|
||||
aiChatWindowOpenAtom: { __tag: "aiChatWindowOpenAtom" },
|
||||
aiChatDraftAtom: { __tag: "aiChatDraftAtom" },
|
||||
}));
|
||||
vi.mock("@/features/page-history/atoms/history-atoms.ts", () => ({
|
||||
historyAtoms: { __tag: "historyAtoms" },
|
||||
}));
|
||||
|
||||
// Avatar reaches into config (getAvatarUrl) — stub to a plain element.
|
||||
vi.mock("@/components/ui/custom-avatar.tsx", () => ({
|
||||
CustomAvatar: ({ name }: { name?: string }) => (
|
||||
<span data-testid="avatar">{name}</span>
|
||||
),
|
||||
}));
|
||||
|
||||
// Deterministic, locale-free date string.
|
||||
vi.mock("@/lib/time", () => ({
|
||||
formattedDate: () => "2026-06-21",
|
||||
}));
|
||||
|
||||
import HistoryItem from "./history-item";
|
||||
import {
|
||||
activeAiChatIdAtom,
|
||||
aiChatWindowOpenAtom,
|
||||
aiChatDraftAtom,
|
||||
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
|
||||
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
||||
import type { IPageHistory } from "@/features/page-history/types/page.types";
|
||||
|
||||
function makeItem(overrides: Partial<IPageHistory> = {}): IPageHistory {
|
||||
return {
|
||||
id: "h1",
|
||||
pageId: "p1",
|
||||
title: "Title",
|
||||
slug: "slug",
|
||||
icon: "",
|
||||
coverPhoto: "",
|
||||
version: 1,
|
||||
lastUpdatedById: "u1",
|
||||
workspaceId: "w1",
|
||||
createdAt: "2026-06-21T00:00:00.000Z",
|
||||
updatedAt: "2026-06-21T00:00:00.000Z",
|
||||
lastUpdatedBy: { id: "u1", name: "Alice", avatarUrl: "" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderItem(item: IPageHistory) {
|
||||
return render(
|
||||
<MantineProvider>
|
||||
<HistoryItem
|
||||
historyItem={item}
|
||||
index={0}
|
||||
onSelect={vi.fn()}
|
||||
isActive={false}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("HistoryItem git-sync provenance badge", () => {
|
||||
// Test 1: the git-sync badge renders ONLY for lastUpdatedSource === 'git-sync'.
|
||||
it("renders the Git sync badge only when lastUpdatedSource is 'git-sync'", () => {
|
||||
renderItem(makeItem({ lastUpdatedSource: "git-sync" }));
|
||||
expect(screen.getByText("Git sync")).toBeTruthy();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["agent", "agent"],
|
||||
["user", "user"],
|
||||
["undefined", undefined],
|
||||
])(
|
||||
"does NOT render the Git sync badge when lastUpdatedSource is %s",
|
||||
(_label, source) => {
|
||||
renderItem(makeItem({ lastUpdatedSource: source }));
|
||||
expect(screen.queryByText("Git sync")).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
// Test 2: provenance attribution + the git-sync badge is NOT interactive.
|
||||
it("attributes the git-sync provenance to the correct author and is not clickable", () => {
|
||||
renderItem(
|
||||
makeItem({
|
||||
lastUpdatedSource: "git-sync",
|
||||
lastUpdatedBy: { id: "u2", name: "Bob", avatarUrl: "" },
|
||||
}),
|
||||
);
|
||||
|
||||
const badge = screen.getByText("Git sync");
|
||||
|
||||
// Provenance attribution: the tooltip label carries the author name (the
|
||||
// git-sync badge passes authorName -> "Synced from Git on behalf of {{name}}").
|
||||
expect(screen.getByText("Synced from Git on behalf of Bob")).toBeTruthy();
|
||||
|
||||
// The git-sync badge must NOT behave like AiAgentBadge: the badge element
|
||||
// itself is not a button, carries no role=button and no tabIndex, and
|
||||
// clicking it must not trigger any ai-chat deep-link. (The surrounding
|
||||
// history-row IS an UnstyledButton — that is the row's own select affordance,
|
||||
// not the badge — so we scope these checks to the badge element.)
|
||||
const badgeRoot = (badge.closest("[class*='mantine-Badge-root']") ??
|
||||
badge) as HTMLElement;
|
||||
expect(badgeRoot.getAttribute("role")).not.toBe("button");
|
||||
expect(badgeRoot.getAttribute("tabindex")).toBeNull();
|
||||
expect(badgeRoot.tagName.toLowerCase()).not.toBe("button");
|
||||
// No interactive descendant button lives inside the badge itself.
|
||||
expect(within(badgeRoot).queryByRole("button")).toBeNull();
|
||||
|
||||
badgeRoot.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(setActiveChatId).not.toHaveBeenCalled();
|
||||
expect(setAiChatWindowOpen).not.toHaveBeenCalled();
|
||||
expect(setDraft).not.toHaveBeenCalled();
|
||||
expect(setHistoryModalOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Sanity contrast: the agent badge (the copy-paste source) IS interactive when
|
||||
// it carries an aiChatId — proving the not-clickable assertion above is real.
|
||||
it("contrast: the AI-agent badge is a deep-link button when it has an aiChatId", () => {
|
||||
renderItem(
|
||||
makeItem({
|
||||
lastUpdatedSource: "agent",
|
||||
lastUpdatedAiChatId: "chat-1",
|
||||
}),
|
||||
);
|
||||
const agentBadge = screen.getByText("AI-agent");
|
||||
const root = agentBadge.closest("[role='button']");
|
||||
expect(root).not.toBeNull();
|
||||
within(root as HTMLElement).getByText("AI-agent");
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Text, Group, UnstyledButton, Avatar, Tooltip } from "@mantine/core";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { AiAgentBadge } from "@/components/ui/ai-agent-badge.tsx";
|
||||
import { GitSyncBadge } from "@/components/ui/git-sync-badge.tsx";
|
||||
import { formattedDate } from "@/lib/time";
|
||||
import classes from "./css/history.module.css";
|
||||
import clsx from "clsx";
|
||||
@@ -42,7 +41,6 @@ const HistoryItem = memo(function HistoryItem({
|
||||
const contributors = historyItem.contributors;
|
||||
const hasContributors = contributors && contributors.length > 0;
|
||||
const isAgentEdit = historyItem.lastUpdatedSource === "agent";
|
||||
const isGitSyncEdit = historyItem.lastUpdatedSource === "git-sync";
|
||||
|
||||
return (
|
||||
<UnstyledButton
|
||||
@@ -110,10 +108,6 @@ const HistoryItem = memo(function HistoryItem({
|
||||
onActivate={() => setHistoryModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isGitSyncEdit && (
|
||||
<GitSyncBadge authorName={historyItem.lastUpdatedBy?.name} />
|
||||
)}
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ActionIcon, Button, Group, Menu, Text, ThemeIcon, Tooltip } from "@mant
|
||||
import {
|
||||
IconArrowRight,
|
||||
IconArrowsHorizontal,
|
||||
IconClockHour4,
|
||||
IconDots,
|
||||
IconEye,
|
||||
IconEyeOff,
|
||||
@@ -24,6 +25,10 @@ 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";
|
||||
@@ -160,6 +165,29 @@ 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 =
|
||||
@@ -309,6 +337,12 @@ 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} />}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { useDisclosure } from "@mantine/hooks";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import {
|
||||
IconArrowRight,
|
||||
IconClockHour4,
|
||||
IconCopy,
|
||||
IconDotsVertical,
|
||||
IconFileExport,
|
||||
@@ -30,7 +31,10 @@ import {
|
||||
useRemoveFavoriteMutation,
|
||||
} from "@/features/favorite/queries/favorite-query";
|
||||
|
||||
import { useToggleTemplateMutation } from "@/features/page-embed/queries/page-embed-query";
|
||||
import {
|
||||
useToggleTemplateMutation,
|
||||
useToggleTemporaryMutation,
|
||||
} 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";
|
||||
@@ -65,6 +69,8 @@ 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;
|
||||
@@ -84,6 +90,29 @@ 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);
|
||||
@@ -248,6 +277,17 @@ 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"
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ActionIcon, rem, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
IconChevronDown,
|
||||
IconChevronRight,
|
||||
IconClockHour4,
|
||||
IconFileDescription,
|
||||
IconPlus,
|
||||
IconPointFilled,
|
||||
@@ -191,6 +192,28 @@ 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} />
|
||||
|
||||
|
||||
@@ -22,7 +22,10 @@ import { getSpaceUrl } from "@/lib/config.ts";
|
||||
|
||||
export type UseTreeMutation = {
|
||||
handleMove: (sourceId: string, op: DropOp) => Promise<void>;
|
||||
handleCreate: (parentId: string | null) => Promise<void>;
|
||||
handleCreate: (
|
||||
parentId: string | null,
|
||||
opts?: { temporary?: boolean },
|
||||
) => Promise<void>;
|
||||
handleRename: (id: string, name: string) => Promise<void>;
|
||||
handleDelete: (id: string) => Promise<void>;
|
||||
};
|
||||
@@ -119,9 +122,15 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
|
||||
);
|
||||
|
||||
const handleCreate = useCallback(
|
||||
async (parentId: string | null) => {
|
||||
const payload: { spaceId: string; parentPageId?: string } = { spaceId };
|
||||
async (parentId: string | null, opts?: { temporary?: boolean }) => {
|
||||
const payload: {
|
||||
spaceId: string;
|
||||
parentPageId?: string;
|
||||
temporary?: boolean;
|
||||
} = { 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 {
|
||||
@@ -138,6 +147,8 @@ 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: [],
|
||||
};
|
||||
|
||||
|
||||
@@ -9,5 +9,7 @@ export type SpaceTreeNode = {
|
||||
hasChildren: boolean;
|
||||
canEdit?: boolean;
|
||||
isTemplate?: boolean;
|
||||
// Death-timer deadline. null/absent => permanent; ISO string => temporary note.
|
||||
temporaryExpiresAt?: string | null;
|
||||
children: SpaceTreeNode[];
|
||||
};
|
||||
|
||||
@@ -26,6 +26,7 @@ export function buildTree(pages: IPage[]): SpaceTreeNode[] {
|
||||
parentPageId: page.parentPageId,
|
||||
canEdit: page.canEdit ?? page.permissions?.canEdit,
|
||||
isTemplate: page.isTemplate,
|
||||
temporaryExpiresAt: page.temporaryExpiresAt,
|
||||
children: [],
|
||||
};
|
||||
});
|
||||
|
||||
@@ -13,6 +13,10 @@ 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;
|
||||
|
||||
@@ -1,240 +0,0 @@
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeAll,
|
||||
afterEach,
|
||||
} from "vitest";
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
|
||||
// --- Mocks for the heavy / networked module graph ---------------------------
|
||||
// EditSpaceForm wires the "Enable Git sync" Switch to a TanStack-Query mutation
|
||||
// (useUpdateSpaceMutation). We mock ONLY that hook so the test fully controls
|
||||
// mutateAsync (resolve / reject) and isPending, and stub i18n. The real Mantine
|
||||
// Switch is rendered so the checkbox role / disabled state is meaningful.
|
||||
|
||||
// i18n: identity translator — labels stay as their English keys for queries.
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
// Mutation hook: a controllable mutateAsync plus a togglable isPending.
|
||||
const mutateAsync = vi.fn();
|
||||
let isPending = false;
|
||||
vi.mock("@/features/space/queries/space-query.ts", () => ({
|
||||
useUpdateSpaceMutation: () => ({
|
||||
mutateAsync,
|
||||
get isPending() {
|
||||
return isPending;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// jsdom lacks matchMedia, which MantineProvider's color-scheme hook needs.
|
||||
beforeAll(() => {
|
||||
if (!window.matchMedia) {
|
||||
window.matchMedia = (query: string) =>
|
||||
({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
}) as unknown as MediaQueryList;
|
||||
}
|
||||
});
|
||||
|
||||
import { EditSpaceForm } from "./edit-space-form";
|
||||
import type { ISpace } from "@/features/space/types/space.types.ts";
|
||||
|
||||
function makeSpace(overrides: Partial<ISpace> = {}): ISpace {
|
||||
return {
|
||||
id: "space-1",
|
||||
name: "Engineering",
|
||||
description: "",
|
||||
slug: "eng",
|
||||
hostname: "host",
|
||||
creatorId: "u1",
|
||||
createdAt: new Date("2026-01-01"),
|
||||
updatedAt: new Date("2026-01-01"),
|
||||
...overrides,
|
||||
} as ISpace;
|
||||
}
|
||||
|
||||
function renderForm(props: { space: ISpace; readOnly?: boolean }) {
|
||||
return render(
|
||||
<MantineProvider>
|
||||
<EditSpaceForm space={props.space} readOnly={props.readOnly} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
// The form now renders TWO switches (git-sync enable + auto-merge-conflicts) in
|
||||
// that DOM order. Mantine renders each as an <input type="checkbox"
|
||||
// role="switch"> but does NOT expose its label as the accessible name, so we
|
||||
// disambiguate by DOM order (index 0 = enable, 1 = auto-merge) and assert the
|
||||
// human-readable label text is present alongside.
|
||||
function getToggle(): HTMLInputElement {
|
||||
screen.getByText("Enable Git sync");
|
||||
return screen.getAllByRole("switch")[0] as HTMLInputElement;
|
||||
}
|
||||
|
||||
function getAutoMergeToggle(): HTMLInputElement {
|
||||
screen.getByText("Auto-merge conflicts on push");
|
||||
return screen.getAllByRole("switch")[1] as HTMLInputElement;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
mutateAsync.mockReset();
|
||||
isPending = false;
|
||||
});
|
||||
|
||||
describe("EditSpaceForm git-sync toggle", () => {
|
||||
// Test 3: initial checked state derives from settings.gitSync.enabled ?? false.
|
||||
it("derives initial checked state from space.settings.gitSync.enabled (true -> checked)", () => {
|
||||
renderForm({
|
||||
space: makeSpace({ settings: { gitSync: { enabled: true } } }),
|
||||
});
|
||||
expect(getToggle().checked).toBe(true);
|
||||
});
|
||||
|
||||
it("defaults to unchecked when gitSync settings are missing", () => {
|
||||
renderForm({ space: makeSpace() });
|
||||
expect(getToggle().checked).toBe(false);
|
||||
});
|
||||
|
||||
// Test 4: toggling fires the mutation with { spaceId, gitSyncEnabled } and
|
||||
// optimistically flips the switch.
|
||||
it("fires the mutation with the correct payload and optimistically flips on", async () => {
|
||||
mutateAsync.mockResolvedValue(undefined);
|
||||
renderForm({ space: makeSpace() });
|
||||
|
||||
const toggle = getToggle();
|
||||
expect(toggle.checked).toBe(false);
|
||||
|
||||
fireEvent.click(toggle);
|
||||
|
||||
// Optimistic update: the switch reflects the new state immediately.
|
||||
expect(toggle.checked).toBe(true);
|
||||
|
||||
expect(mutateAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mutateAsync).toHaveBeenCalledWith({
|
||||
spaceId: "space-1",
|
||||
gitSyncEnabled: true,
|
||||
});
|
||||
|
||||
// Resolution leaves the toggle on.
|
||||
await waitFor(() => expect(toggle.checked).toBe(true));
|
||||
});
|
||||
|
||||
// Test 5: rollback on mutation error — the most valuable test.
|
||||
it("rolls back the toggle to its prior state when the mutation rejects", async () => {
|
||||
mutateAsync.mockRejectedValue(new Error("network"));
|
||||
renderForm({
|
||||
space: makeSpace({ settings: { gitSync: { enabled: false } } }),
|
||||
});
|
||||
|
||||
const toggle = getToggle();
|
||||
expect(toggle.checked).toBe(false);
|
||||
|
||||
fireEvent.click(toggle);
|
||||
|
||||
// Optimistically flips on before the rejection lands.
|
||||
expect(toggle.checked).toBe(true);
|
||||
expect(mutateAsync).toHaveBeenCalledWith({
|
||||
spaceId: "space-1",
|
||||
gitSyncEnabled: true,
|
||||
});
|
||||
|
||||
// After the rejected promise settles, the component reverts to OFF so the
|
||||
// user is not misled into believing sync is enabled.
|
||||
await waitFor(() => expect(toggle.checked).toBe(false));
|
||||
});
|
||||
|
||||
// Test 6: disabled when readOnly and when the mutation is pending.
|
||||
it("disables the toggle when readOnly", () => {
|
||||
renderForm({ space: makeSpace(), readOnly: true });
|
||||
expect(getToggle().disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("disables the toggle while the mutation is pending", () => {
|
||||
isPending = true;
|
||||
renderForm({ space: makeSpace() });
|
||||
expect(getToggle().disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("EditSpaceForm auto-merge-conflicts toggle", () => {
|
||||
it("derives initial checked state from space.settings.gitSync.autoMergeConflicts (true -> checked)", () => {
|
||||
renderForm({
|
||||
space: makeSpace({
|
||||
settings: { gitSync: { autoMergeConflicts: true } },
|
||||
}),
|
||||
});
|
||||
expect(getAutoMergeToggle().checked).toBe(true);
|
||||
});
|
||||
|
||||
it("defaults to unchecked when autoMergeConflicts is missing (SAFE default)", () => {
|
||||
renderForm({ space: makeSpace() });
|
||||
expect(getAutoMergeToggle().checked).toBe(false);
|
||||
});
|
||||
|
||||
it("fires the mutation with { spaceId, autoMergeConflicts } and optimistically flips on", async () => {
|
||||
mutateAsync.mockResolvedValue(undefined);
|
||||
renderForm({ space: makeSpace() });
|
||||
|
||||
const toggle = getAutoMergeToggle();
|
||||
expect(toggle.checked).toBe(false);
|
||||
|
||||
fireEvent.click(toggle);
|
||||
|
||||
// Optimistic update.
|
||||
expect(toggle.checked).toBe(true);
|
||||
expect(mutateAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mutateAsync).toHaveBeenCalledWith({
|
||||
spaceId: "space-1",
|
||||
autoMergeConflicts: true,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(toggle.checked).toBe(true));
|
||||
});
|
||||
|
||||
it("rolls back to its prior state when the mutation rejects", async () => {
|
||||
mutateAsync.mockRejectedValue(new Error("network"));
|
||||
renderForm({
|
||||
space: makeSpace({
|
||||
settings: { gitSync: { autoMergeConflicts: false } },
|
||||
}),
|
||||
});
|
||||
|
||||
const toggle = getAutoMergeToggle();
|
||||
expect(toggle.checked).toBe(false);
|
||||
|
||||
fireEvent.click(toggle);
|
||||
|
||||
expect(toggle.checked).toBe(true);
|
||||
expect(mutateAsync).toHaveBeenCalledWith({
|
||||
spaceId: "space-1",
|
||||
autoMergeConflicts: true,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(toggle.checked).toBe(false));
|
||||
});
|
||||
|
||||
it("disables the toggle when readOnly", () => {
|
||||
renderForm({ space: makeSpace(), readOnly: true });
|
||||
expect(getAutoMergeToggle().disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,5 @@
|
||||
import {
|
||||
Group,
|
||||
Box,
|
||||
Button,
|
||||
TextInput,
|
||||
Stack,
|
||||
Textarea,
|
||||
Divider,
|
||||
Switch,
|
||||
} from "@mantine/core";
|
||||
import React, { useState } from "react";
|
||||
import { Group, Box, Button, TextInput, Stack, Textarea } from "@mantine/core";
|
||||
import React from "react";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { zod4Resolver } from "mantine-form-zod-resolver";
|
||||
import { z } from "zod/v4";
|
||||
@@ -38,44 +29,6 @@ export function EditSpaceForm({ space, readOnly }: EditSpaceFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const updateSpaceMutation = useUpdateSpaceMutation();
|
||||
|
||||
const [gitSyncEnabled, setGitSyncEnabled] = useState<boolean>(
|
||||
space?.settings?.gitSync?.enabled ?? false,
|
||||
);
|
||||
|
||||
const [autoMergeConflicts, setAutoMergeConflicts] = useState<boolean>(
|
||||
space?.settings?.gitSync?.autoMergeConflicts ?? false,
|
||||
);
|
||||
|
||||
const handleGitSyncToggle = async (value: boolean) => {
|
||||
const previous = gitSyncEnabled;
|
||||
setGitSyncEnabled(value); // optimistic update
|
||||
try {
|
||||
await updateSpaceMutation.mutateAsync({
|
||||
spaceId: space.id,
|
||||
gitSyncEnabled: value,
|
||||
});
|
||||
} catch (err) {
|
||||
setGitSyncEnabled(previous); // revert on failure
|
||||
// The mutation surfaces a toast via onError; still log the raw error so it
|
||||
// is not silently swallowed (AGENTS.md).
|
||||
console.error("Failed to toggle git-sync for space", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAutoMergeConflictsToggle = async (value: boolean) => {
|
||||
const previous = autoMergeConflicts;
|
||||
setAutoMergeConflicts(value); // optimistic update
|
||||
try {
|
||||
await updateSpaceMutation.mutateAsync({
|
||||
spaceId: space.id,
|
||||
autoMergeConflicts: value,
|
||||
});
|
||||
} catch (err) {
|
||||
setAutoMergeConflicts(previous); // revert on failure
|
||||
console.error("Failed to toggle git-sync auto-merge-conflicts", err);
|
||||
}
|
||||
};
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
validate: zod4Resolver(formSchema),
|
||||
initialValues: {
|
||||
@@ -151,31 +104,6 @@ export function EditSpaceForm({ space, readOnly }: EditSpaceFormProps) {
|
||||
</Group>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<Divider my="lg" />
|
||||
|
||||
<Switch
|
||||
label={t("Enable Git sync")}
|
||||
description={t("Sync this space's pages to a Git repository.")}
|
||||
checked={gitSyncEnabled}
|
||||
disabled={readOnly || updateSpaceMutation.isPending}
|
||||
onChange={(event) =>
|
||||
handleGitSyncToggle(event.currentTarget.checked)
|
||||
}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
mt="md"
|
||||
label={t("Auto-merge conflicts on push")}
|
||||
description={t(
|
||||
"When off (recommended), a page whose content still has unresolved Git conflict markers is skipped on push until you resolve the conflict in Git. When on, the markers are stripped and both sides' content is pushed.",
|
||||
)}
|
||||
checked={autoMergeConflicts}
|
||||
disabled={readOnly || updateSpaceMutation.isPending}
|
||||
onChange={(event) =>
|
||||
handleAutoMergeConflictsToggle(event.currentTarget.checked)
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
IconEye,
|
||||
IconEyeOff,
|
||||
IconFileExport,
|
||||
IconHourglass,
|
||||
IconPlus,
|
||||
IconSettings,
|
||||
IconStar,
|
||||
@@ -71,6 +72,10 @@ export function SpaceSidebar() {
|
||||
handleCreate(null);
|
||||
}
|
||||
|
||||
function handleCreateTemporaryPage() {
|
||||
handleCreate(null, { temporary: true });
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={classes.navbar}>
|
||||
@@ -111,16 +116,39 @@ export function SpaceSidebar() {
|
||||
SpaceCaslAction.Manage,
|
||||
SpaceCaslSubject.Page,
|
||||
) && (
|
||||
<Tooltip label={t("Create page")} withArrow position="right">
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size={18}
|
||||
onClick={handleCreatePage}
|
||||
aria-label={t("Create page")}
|
||||
<>
|
||||
<Tooltip
|
||||
label={t("Create page")}
|
||||
withArrow
|
||||
position="right"
|
||||
>
|
||||
<IconPlus />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size={18}
|
||||
onClick={handleCreatePage}
|
||||
aria-label={t("Create page")}
|
||||
>
|
||||
<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>
|
||||
|
||||
@@ -13,15 +13,9 @@ export interface ISpaceCommentsSettings {
|
||||
allowViewerComments?: boolean;
|
||||
}
|
||||
|
||||
export interface ISpaceGitSyncSettings {
|
||||
enabled?: boolean;
|
||||
autoMergeConflicts?: boolean;
|
||||
}
|
||||
|
||||
export interface ISpaceSettings {
|
||||
sharing?: ISpaceSharingSettings;
|
||||
comments?: ISpaceCommentsSettings;
|
||||
gitSync?: ISpaceGitSyncSettings;
|
||||
}
|
||||
|
||||
export interface ISpace {
|
||||
@@ -41,8 +35,6 @@ export interface ISpace {
|
||||
// for updates
|
||||
disablePublicSharing?: boolean;
|
||||
allowViewerComments?: boolean;
|
||||
gitSyncEnabled?: boolean;
|
||||
autoMergeConflicts?: boolean;
|
||||
}
|
||||
|
||||
interface IMembership {
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -28,6 +28,8 @@ 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;
|
||||
|
||||
@@ -3,6 +3,7 @@ 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";
|
||||
@@ -19,6 +20,7 @@ export default function WorkspaceSettings() {
|
||||
<WorkspaceNameForm />
|
||||
<HtmlEmbedSettings />
|
||||
<TrackerSettings />
|
||||
<TemporaryNoteSettings />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"migration:reset": "tsx src/database/migrate.ts down-to NO_MIGRATIONS",
|
||||
"migration:codegen": "kysely-codegen --dialect=postgres --camel-case --env-file=../../.env --out-file=./src/database/types/db.d.ts",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"pretest": "pnpm --filter @docmost/editor-ext build && pnpm --filter @docmost/git-sync build && pnpm --filter @docmost/mcp build",
|
||||
"pretest": "pnpm --filter @docmost/editor-ext build",
|
||||
"test": "jest",
|
||||
"test:int": "jest --config test/jest-integration.json",
|
||||
"test:watch": "jest --watch",
|
||||
@@ -41,7 +41,6 @@
|
||||
"@aws-sdk/s3-request-presigner": "3.1050.0",
|
||||
"@azure/storage-blob": "12.31.0",
|
||||
"@clickhouse/client": "^1.18.2",
|
||||
"@docmost/git-sync": "workspace:*",
|
||||
"@docmost/mcp": "workspace:*",
|
||||
"@docmost/pdf-inspector": "1.9.6",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
@@ -189,12 +188,7 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"^.+\\.(t|j)sx?$": [
|
||||
"ts-jest",
|
||||
{
|
||||
"isolatedModules": true
|
||||
}
|
||||
]
|
||||
"^.+\\.(t|j)sx?$": "ts-jest"
|
||||
},
|
||||
"transformIgnorePatterns": [
|
||||
"/node_modules/(?!(\\.pnpm/)?(nanoid|uuid|image-dimensions|marked|happy-dom|lib0)(@|/))"
|
||||
@@ -204,17 +198,11 @@
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node",
|
||||
"setupFiles": [
|
||||
"<rootDir>/../test/jest.setup.ts"
|
||||
],
|
||||
"moduleNameMapper": {
|
||||
"^@docmost/db/(.*)$": "<rootDir>/database/$1",
|
||||
"^@docmost/transactional/(.*)$": "<rootDir>/integrations/transactional/$1",
|
||||
"^@docmost/ee/(.*)$": "<rootDir>/ee/$1",
|
||||
"^src/(.*)$": "<rootDir>/$1",
|
||||
"^@docmost/git-sync$": "<rootDir>/../../../packages/git-sync/src/index.ts",
|
||||
"^@docmost/git-sync/(.*)$": "<rootDir>/../../../packages/git-sync/src/$1",
|
||||
"^(\\.{1,2}/.*)\\.js$": "$1"
|
||||
"^src/(.*)$": "<rootDir>/$1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ import { ClsModule } from 'nestjs-cls';
|
||||
import { NoopAuditModule } from './integrations/audit/audit.module';
|
||||
import { ThrottleModule } from './integrations/throttle/throttle.module';
|
||||
import { McpModule } from './integrations/mcp/mcp.module';
|
||||
import { GitSyncModule } from './integrations/git-sync/git-sync.module';
|
||||
import { AiModule } from './integrations/ai/ai.module';
|
||||
import { AiChatModule } from './core/ai-chat/ai-chat.module';
|
||||
|
||||
@@ -90,7 +89,6 @@ try {
|
||||
TelemetryModule,
|
||||
ThrottleModule,
|
||||
McpModule,
|
||||
GitSyncModule,
|
||||
AiModule,
|
||||
AiChatModule,
|
||||
...enterpriseModules,
|
||||
|
||||
@@ -149,45 +149,6 @@ export class CollaborationGateway {
|
||||
return this.hocuspocus.openDirectConnection(documentName, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a git-originated body into a page, applying the merge on the instance
|
||||
* that OWNS the live Y.Doc so a connected editor CONVERGES on the change.
|
||||
*
|
||||
* git-sync must NOT use openDirectConnection directly for this: that opens the
|
||||
* document on whichever instance/process runs git-sync (the API/worker). When
|
||||
* an editor is connected to a DIFFERENT collab instance/process, that is a
|
||||
* SEPARATE, detached Y.Doc — the merge lands in the detached doc and the DB,
|
||||
* but the live editor never receives the Yjs update; its next debounced
|
||||
* autosave then overwrites the DB with its stale state and SILENTLY REVERTS
|
||||
* the git change (the data-loss bug). Routing through the custom-event channel
|
||||
* runs the merge on the owning instance's shared Document, whose update is
|
||||
* broadcast to every connection (handleUpdate), so the editor's CRDT converges
|
||||
* on the merged result.
|
||||
*
|
||||
* Without redis there is a single instance, so the write runs locally — which
|
||||
* is already the owning (and only) instance the editor is connected to.
|
||||
*/
|
||||
async writePageBody(
|
||||
documentName: string,
|
||||
payload: {
|
||||
prosemirrorJson: unknown;
|
||||
baseProsemirrorJson?: unknown;
|
||||
userId: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
if (this.redisSync) {
|
||||
await this.handleYjsEvent(
|
||||
'gitSyncWriteBody',
|
||||
documentName,
|
||||
payload as any,
|
||||
);
|
||||
return;
|
||||
}
|
||||
await this.collabEventsService
|
||||
.getHandlers(this.hocuspocus)
|
||||
.gitSyncWriteBody(documentName, payload as any);
|
||||
}
|
||||
|
||||
/*
|
||||
*Can be used before calling openDirectConnection directly
|
||||
*/
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
// Exercises the REAL `gitSyncWriteBody` collab handler (the owner-routed body
|
||||
// write the data-loss fix introduces). The handler imports the editor graph via
|
||||
// collaboration.util / yjs.util (tiptapExtensions -> editor-ext -> react-dom,
|
||||
// unloadable under jest's node env, same coupling noted in
|
||||
// gitmost-datasource.service.spec.ts), so we stub those + the transformer. The
|
||||
// stubbed toYdoc builds paragraph blocks straight from the ProseMirror JSON so
|
||||
// we can assert convergence on real text.
|
||||
jest.mock('./collaboration.util', () => ({
|
||||
tiptapExtensions: [],
|
||||
getPageId: (name: string) => name.replace(/^page\./, ''),
|
||||
prosemirrorNodeToYElement: jest.fn(),
|
||||
}));
|
||||
jest.mock('./yjs.util', () => ({
|
||||
setYjsMark: jest.fn(),
|
||||
updateYjsMarkAttribute: jest.fn(),
|
||||
}));
|
||||
jest.mock('@hocuspocus/transformer', () => {
|
||||
const Yjs = require('yjs');
|
||||
return {
|
||||
TiptapTransformer: {
|
||||
toYdoc: (json: any) => {
|
||||
if (json?.__throw) throw new Error('boom: malformed doc');
|
||||
const d = new Yjs.Doc();
|
||||
const frag = d.getXmlFragment('default');
|
||||
const blocks = (json?.content ?? []).map((node: any) => {
|
||||
const el = new Yjs.XmlElement(node.type || 'paragraph');
|
||||
const text = (node.content ?? [])
|
||||
.map((t: any) => t.text ?? '')
|
||||
.join('');
|
||||
const t = new Yjs.XmlText();
|
||||
if (text) t.insert(0, text);
|
||||
el.insert(0, [t]);
|
||||
return el;
|
||||
});
|
||||
if (blocks.length) frag.insert(0, blocks);
|
||||
return d;
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import * as Y from 'yjs';
|
||||
import { CollaborationHandler } from './collaboration.handler';
|
||||
|
||||
const pmDoc = (...paras: string[]) => ({
|
||||
type: 'doc',
|
||||
content: paras.map((text) => ({
|
||||
type: 'paragraph',
|
||||
content: text ? [{ type: 'text', text }] : [],
|
||||
})),
|
||||
});
|
||||
|
||||
const texts = (frag: Y.XmlFragment): string[] =>
|
||||
frag.toArray().map((el) =>
|
||||
(el as Y.XmlElement)
|
||||
.toArray()
|
||||
.map((c) => (c as Y.XmlText).toString())
|
||||
.join(''),
|
||||
);
|
||||
|
||||
// Build a fake Hocuspocus whose openDirectConnection yields a DirectConnection
|
||||
// over a REAL shared Document, with a connected "editor" doc that receives the
|
||||
// shared doc's updates (modelling Document.handleUpdate's broadcast on the
|
||||
// OWNING instance). Initial content carries live block ids; the editor starts
|
||||
// fully synced with the shared doc.
|
||||
function fakeHocuspocus(initial: { text: string; id: string }[]) {
|
||||
const shared = new Y.Doc();
|
||||
const frag = shared.getXmlFragment('default');
|
||||
shared.transact(() => {
|
||||
frag.insert(
|
||||
0,
|
||||
initial.map((s) => {
|
||||
const el = new Y.XmlElement('paragraph');
|
||||
el.setAttribute('id', s.id);
|
||||
const t = new Y.XmlText();
|
||||
if (s.text) t.insert(0, s.text);
|
||||
el.insert(0, [t]);
|
||||
return el;
|
||||
}),
|
||||
);
|
||||
});
|
||||
const editor = new Y.Doc();
|
||||
Y.applyUpdate(editor, Y.encodeStateAsUpdate(shared));
|
||||
// Broadcast relay: server-originated updates flow to the connected editor.
|
||||
shared.on('update', (u: Uint8Array, origin: any) => {
|
||||
if (origin !== 'editor') Y.applyUpdate(editor, u, 'server');
|
||||
});
|
||||
|
||||
const openDirectConnection = jest.fn(async () => ({
|
||||
// DirectConnection.transact runs the fn directly against the Document (no
|
||||
// wrapping Y transaction), exactly like @hocuspocus/server.
|
||||
transact: async (fn: (doc: Y.Doc) => void) => fn(shared),
|
||||
disconnect: jest.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
return { hocuspocus: { openDirectConnection } as any, shared, editor };
|
||||
}
|
||||
|
||||
describe('CollaborationHandler.gitSyncWriteBody (owner-routed body write)', () => {
|
||||
it('converges a connected editor on the git change (no silent revert)', async () => {
|
||||
const { hocuspocus, shared, editor } = fakeHocuspocus([
|
||||
{ text: 'alpha', id: 'p1' },
|
||||
{ text: 'beta', id: 'p2' },
|
||||
]);
|
||||
const handler = new CollaborationHandler();
|
||||
const handlers = handler.getHandlers(hocuspocus);
|
||||
|
||||
// git changed block 1 beta -> beta2; base is the pre-change content.
|
||||
await handlers.gitSyncWriteBody('page.x', {
|
||||
prosemirrorJson: pmDoc('alpha', 'beta2'),
|
||||
baseProsemirrorJson: pmDoc('alpha', 'beta'),
|
||||
userId: 'svc-user',
|
||||
});
|
||||
|
||||
// The shared (owning-instance) doc holds the merge...
|
||||
expect(texts(shared.getXmlFragment('default'))).toEqual(['alpha', 'beta2']);
|
||||
// ...and the connected editor CONVERGED via the broadcast (the bug would
|
||||
// leave it on 'beta' and revert the page on its next autosave).
|
||||
expect(texts(editor.getXmlFragment('default'))).toEqual(['alpha', 'beta2']);
|
||||
});
|
||||
|
||||
it('preserves a concurrent edit to a DIFFERENT block (3-way, finding #2)', async () => {
|
||||
const { hocuspocus, shared, editor } = fakeHocuspocus([
|
||||
{ text: 'alpha', id: 'p1' },
|
||||
{ text: 'beta', id: 'p2' },
|
||||
]);
|
||||
// The editor is actively editing block 0 while the push arrives.
|
||||
const eFrag = editor.getXmlFragment('default');
|
||||
editor.transact(
|
||||
() => (eFrag.get(0) as Y.XmlElement).get(0) instanceof Y.XmlText &&
|
||||
((eFrag.get(0) as Y.XmlElement).get(0) as Y.XmlText).insert(5, ' EDIT'),
|
||||
'editor',
|
||||
);
|
||||
Y.applyUpdate(shared, Y.encodeStateAsUpdate(editor), 'editor');
|
||||
|
||||
const handler = new CollaborationHandler();
|
||||
await handler.getHandlers(hocuspocus).gitSyncWriteBody('page.x', {
|
||||
prosemirrorJson: pmDoc('alpha', 'beta2'),
|
||||
baseProsemirrorJson: pmDoc('alpha', 'beta'),
|
||||
userId: 'svc-user',
|
||||
});
|
||||
|
||||
// Human's block-0 edit AND git's block-1 change both survive on the editor.
|
||||
expect(texts(editor.getXmlFragment('default'))).toEqual([
|
||||
'alpha EDIT',
|
||||
'beta2',
|
||||
]);
|
||||
});
|
||||
|
||||
it('crash-safe: a transform failure never opens the connection or mutates the live doc', async () => {
|
||||
const { hocuspocus, shared } = fakeHocuspocus([{ text: 'alpha', id: 'p1' }]);
|
||||
const before = texts(shared.getXmlFragment('default'));
|
||||
const handler = new CollaborationHandler();
|
||||
|
||||
await expect(
|
||||
handler.getHandlers(hocuspocus).gitSyncWriteBody('page.x', {
|
||||
prosemirrorJson: { __throw: true } as any,
|
||||
userId: 'svc-user',
|
||||
}),
|
||||
).rejects.toThrow('boom');
|
||||
|
||||
// The incoming doc is built BEFORE opening the connection, so the throw
|
||||
// happens first: the live doc is untouched and no connection was opened.
|
||||
expect(hocuspocus.openDirectConnection).not.toHaveBeenCalled();
|
||||
expect(texts(shared.getXmlFragment('default'))).toEqual(before);
|
||||
});
|
||||
|
||||
it('falls back to a 2-way merge when no base is supplied', async () => {
|
||||
const { hocuspocus, shared, editor } = fakeHocuspocus([
|
||||
{ text: 'alpha', id: 'p1' },
|
||||
]);
|
||||
const handler = new CollaborationHandler();
|
||||
|
||||
await handler.getHandlers(hocuspocus).gitSyncWriteBody('page.x', {
|
||||
prosemirrorJson: pmDoc('alpha', 'gamma'),
|
||||
userId: 'svc-user',
|
||||
});
|
||||
|
||||
expect(texts(shared.getXmlFragment('default'))).toEqual(['alpha', 'gamma']);
|
||||
expect(texts(editor.getXmlFragment('default'))).toEqual(['alpha', 'gamma']);
|
||||
});
|
||||
});
|
||||
@@ -8,10 +8,6 @@ import {
|
||||
import { setYjsMark, updateYjsMarkAttribute, YjsSelection } from './yjs.util';
|
||||
import * as Y from 'yjs';
|
||||
import { User } from '@docmost/db/types/entity.types';
|
||||
import {
|
||||
mergeXmlFragments,
|
||||
mergeXmlFragments3Way,
|
||||
} from '../integrations/git-sync/services/yjs-body-merge';
|
||||
|
||||
export type CollabEventHandlers = ReturnType<
|
||||
CollaborationHandler['getHandlers']
|
||||
@@ -116,69 +112,6 @@ export class CollaborationHandler {
|
||||
},
|
||||
);
|
||||
},
|
||||
/**
|
||||
* Git-sync body write, applied as a block-level MERGE into the LIVE doc on
|
||||
* the instance that OWNS it (routed here via the custom-event channel —
|
||||
* see CollaborationGateway.writePageBody). Running on the owning instance
|
||||
* is what makes a connected editor CONVERGE: the merge mutates the shared
|
||||
* Document, whose update is broadcast to every connection, so the editor's
|
||||
* CRDT applies the git change instead of silently reverting it on its next
|
||||
* autosave (the data-loss bug this fixes).
|
||||
*
|
||||
* With a `baseProsemirrorJson` (the last-synced common ancestor) it does a
|
||||
* THREE-WAY merge — a block only the human changed is kept, a block only
|
||||
* git changed is taken (conflicts -> git). Without a base it falls back to
|
||||
* the 2-way merge.
|
||||
*/
|
||||
gitSyncWriteBody: async (
|
||||
documentName: string,
|
||||
payload: {
|
||||
prosemirrorJson: any;
|
||||
baseProsemirrorJson?: any;
|
||||
userId: string;
|
||||
},
|
||||
) => {
|
||||
const { prosemirrorJson, baseProsemirrorJson, userId } = payload;
|
||||
|
||||
// Build the incoming (and base) Yjs docs BEFORE opening the connection /
|
||||
// touching the live doc. If a transform throws (a malformed/unsupported
|
||||
// doc) we must NOT have mutated the live body — otherwise a conversion
|
||||
// failure could leave the page empty (crash-safe conversion).
|
||||
const targetDoc = TiptapTransformer.toYdoc(
|
||||
prosemirrorJson,
|
||||
'default',
|
||||
tiptapExtensions,
|
||||
);
|
||||
const baseDoc =
|
||||
baseProsemirrorJson != null
|
||||
? TiptapTransformer.toYdoc(
|
||||
baseProsemirrorJson,
|
||||
'default',
|
||||
tiptapExtensions,
|
||||
)
|
||||
: null;
|
||||
|
||||
// actor:'git-sync' + the service user flow into PersistenceExtension
|
||||
// (lastUpdatedSource='git-sync', lastUpdatedById=userId).
|
||||
await this.withYdocConnection(
|
||||
hocuspocus,
|
||||
documentName,
|
||||
{ actor: 'git-sync', user: { id: userId } },
|
||||
(doc) => {
|
||||
const liveFrag = doc.getXmlFragment('default');
|
||||
const targetFrag = targetDoc.getXmlFragment('default');
|
||||
if (baseDoc) {
|
||||
mergeXmlFragments3Way(
|
||||
liveFrag,
|
||||
targetFrag,
|
||||
baseDoc.getXmlFragment('default'),
|
||||
);
|
||||
} else {
|
||||
mergeXmlFragments(liveFrag, targetFrag);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
// Stub collaboration.util so importing the extension does not drag in the
|
||||
// editor-ext -> @tiptap/react -> react-dom graph (unloadable under jest's node
|
||||
// env, same coupling the gitmost-datasource / mcp specs document). The
|
||||
// extension only calls getPageId, jsonToText and isEmptyParagraphDoc from it on
|
||||
// the store path; tiptapExtensions is unused by onStoreDocument.
|
||||
jest.mock('../collaboration.util', () => ({
|
||||
tiptapExtensions: [],
|
||||
getPageId: (name: string) => name.replace(/^page\./, ''),
|
||||
jsonToText: () => 'text',
|
||||
isEmptyParagraphDoc: () => false,
|
||||
// The post-write mention extraction walks the doc via jsonToNode().descendants;
|
||||
// return a node-like stub with no descendants so no mentions are produced
|
||||
// (mention handling is out of scope here — we only assert provenance).
|
||||
jsonToNode: () => ({ descendants: () => undefined }),
|
||||
}));
|
||||
|
||||
// Control the Yjs<->JSON bridge: fromYdoc returns the "incoming" doc the writer
|
||||
// is storing. We keep it distinct from the page's persisted content so the
|
||||
// no-op guard (isDeepStrictEqual) never short-circuits the write.
|
||||
const INCOMING_JSON = { type: 'doc', content: [{ type: 'paragraph' }, { t: 1 }] };
|
||||
jest.mock('@hocuspocus/transformer', () => ({
|
||||
TiptapTransformer: {
|
||||
fromYdoc: jest.fn(() => INCOMING_JSON),
|
||||
toYdoc: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Run the executeTx callback inline with a passthrough trx.
|
||||
jest.mock('@docmost/db/utils', () => ({
|
||||
executeTx: jest.fn(async (_db: any, cb: any) => cb({} as any)),
|
||||
}));
|
||||
|
||||
import * as Y from 'yjs';
|
||||
import { PersistenceExtension } from './persistence.extension';
|
||||
import {
|
||||
onChangePayload,
|
||||
onStoreDocumentPayload,
|
||||
} from '@hocuspocus/server';
|
||||
|
||||
/**
|
||||
* Provenance-precedence coverage for PersistenceExtension.onStoreDocument
|
||||
* (test-strategy Module 4 / item #2): the contract `agent > git-sync > user`,
|
||||
* plus the negative that a git-sync store does NOT pin a boundary history
|
||||
* snapshot. We drive the precedence through the real public method (onChange to
|
||||
* arm the sticky agent marker, then onStoreDocument), mocking the repos / db /
|
||||
* Yjs bridge so no real database or collab server is needed. The store's
|
||||
* persisted `lastUpdatedSource` and the saveHistory call are the observable
|
||||
* outputs.
|
||||
*/
|
||||
describe('PersistenceExtension.onStoreDocument — provenance precedence (#2)', () => {
|
||||
const DOCUMENT_NAME = 'page.page-1';
|
||||
const PAGE_ID = 'page-1';
|
||||
|
||||
// `page.content` differs from INCOMING_JSON so the write is never skipped.
|
||||
const persistedPage = (overrides?: { lastUpdatedSource?: string }) => ({
|
||||
id: PAGE_ID,
|
||||
slugId: 'slug-1',
|
||||
spaceId: 'space-1',
|
||||
workspaceId: 'ws-1',
|
||||
creatorId: 'creator-1',
|
||||
contributorIds: ['creator-1'],
|
||||
content: { type: 'doc', content: [{ type: 'paragraph', content: [] }] },
|
||||
lastUpdatedSource: overrides?.lastUpdatedSource ?? 'user',
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
const build = (pageOverrides?: { lastUpdatedSource?: string }) => {
|
||||
const pageRepo = {
|
||||
findById: jest.fn().mockResolvedValue(persistedPage(pageOverrides)),
|
||||
updatePage: jest.fn().mockResolvedValue({ numUpdatedRows: 1n }),
|
||||
};
|
||||
const pageHistoryRepo = {
|
||||
// No prior snapshot -> humanBaselineMissing is true, so the ONLY thing
|
||||
// gating the boundary snapshot in these tests is the source precedence.
|
||||
findPageLastHistory: jest.fn().mockResolvedValue(null),
|
||||
saveHistory: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const aiQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||
const historyQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||
const notificationQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||
const collabHistory = {
|
||||
addContributors: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const transclusionService = {
|
||||
syncPageTransclusions: jest.fn().mockResolvedValue(undefined),
|
||||
syncPageReferences: jest.fn().mockResolvedValue(undefined),
|
||||
syncPageTemplateReferences: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const ext = new PersistenceExtension(
|
||||
pageRepo as any,
|
||||
pageHistoryRepo as any,
|
||||
{} as any, // db
|
||||
aiQueue as any,
|
||||
historyQueue as any,
|
||||
notificationQueue as any,
|
||||
collabHistory as any,
|
||||
transclusionService as any,
|
||||
);
|
||||
|
||||
return { ext, pageRepo, pageHistoryRepo, historyQueue };
|
||||
};
|
||||
|
||||
// A real Y.Doc is required for Y.encodeStateAsUpdate(document); broadcastStateless
|
||||
// is a no-op spy. The fromYdoc bridge is mocked, so the doc's contents are
|
||||
// irrelevant to the JSON path.
|
||||
const makeStorePayload = (context: any): onStoreDocumentPayload =>
|
||||
({
|
||||
documentName: DOCUMENT_NAME,
|
||||
document: Object.assign(new Y.Doc(), {
|
||||
broadcastStateless: jest.fn(),
|
||||
}),
|
||||
context,
|
||||
}) as any;
|
||||
|
||||
const makeChangePayload = (actor: string): onChangePayload =>
|
||||
({
|
||||
documentName: DOCUMENT_NAME,
|
||||
context: { user: { id: 'user-1' }, actor },
|
||||
}) as any;
|
||||
|
||||
const sourceOf = (pageRepo: { updatePage: jest.Mock }) =>
|
||||
pageRepo.updatePage.mock.calls[0][0].lastUpdatedSource;
|
||||
|
||||
it("tags 'user' for a plain write (no agent touch, no git-sync actor)", async () => {
|
||||
const { ext, pageRepo } = build();
|
||||
|
||||
await ext.onStoreDocument(
|
||||
makeStorePayload({ user: { id: 'user-1' }, actor: 'user' }),
|
||||
);
|
||||
|
||||
expect(sourceOf(pageRepo)).toBe('user');
|
||||
});
|
||||
|
||||
it("tags 'git-sync' when the writer's actor is 'git-sync' and no agent touched the window", async () => {
|
||||
const { ext, pageRepo } = build();
|
||||
|
||||
await ext.onStoreDocument(
|
||||
makeStorePayload({ user: { id: 'svc-user' }, actor: 'git-sync' }),
|
||||
);
|
||||
|
||||
expect(sourceOf(pageRepo)).toBe('git-sync');
|
||||
});
|
||||
|
||||
it("keeps 'git-sync' for an explicit git-sync store even with a sticky agent marker (#14 loop-guard)", async () => {
|
||||
const { ext, pageRepo } = build();
|
||||
|
||||
// An agent edit landed earlier in the coalescing window (sticky marker),
|
||||
// then a git-sync writer performs the store. Red-team finding #14: an
|
||||
// EXPLICIT current-write actor is authoritative for THIS write, so the
|
||||
// store must stay 'git-sync' — otherwise the PageChangeListener loop-guard
|
||||
// (keyed on lastUpdatedSource === 'git-sync') fails to recognize git-sync's
|
||||
// own write and re-exports it. Explicit 'agent' still wins (see below); the
|
||||
// sticky marker only promotes a plain human writer to 'agent'.
|
||||
await ext.onChange(makeChangePayload('agent'));
|
||||
await ext.onStoreDocument(
|
||||
makeStorePayload({ user: { id: 'svc-user' }, actor: 'git-sync' }),
|
||||
);
|
||||
|
||||
expect(sourceOf(pageRepo)).toBe('git-sync');
|
||||
});
|
||||
|
||||
it("tags 'agent' when the storing writer itself is the agent (no prior onChange)", async () => {
|
||||
const { ext, pageRepo } = build();
|
||||
|
||||
await ext.onStoreDocument(
|
||||
makeStorePayload({ user: { id: 'agent-user' }, actor: 'agent' }),
|
||||
);
|
||||
|
||||
expect(sourceOf(pageRepo)).toBe('agent');
|
||||
});
|
||||
|
||||
// --- negative: a git-sync store must NOT pin a boundary history snapshot ----
|
||||
// The boundary-snapshot branch only fires when the resolved source is 'agent'
|
||||
// AND the prior persisted source is not 'agent'. A git-sync store resolves to
|
||||
// 'git-sync', so saveHistory must NOT be called.
|
||||
it('does NOT write a boundary history snapshot for a git-sync store', async () => {
|
||||
const { ext, pageHistoryRepo } = build({ lastUpdatedSource: 'user' });
|
||||
|
||||
await ext.onStoreDocument(
|
||||
makeStorePayload({ user: { id: 'svc-user' }, actor: 'git-sync' }),
|
||||
);
|
||||
|
||||
expect(pageHistoryRepo.saveHistory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('DOES pin a boundary snapshot for an agent store over a prior human state (control)', async () => {
|
||||
// Confirms the negative above is meaningful: under the SAME mocks, an agent
|
||||
// store over a 'user' baseline DOES trigger the boundary snapshot.
|
||||
const { ext, pageHistoryRepo } = build({ lastUpdatedSource: 'user' });
|
||||
|
||||
await ext.onStoreDocument(
|
||||
makeStorePayload({ user: { id: 'agent-user' }, actor: 'agent' }),
|
||||
);
|
||||
|
||||
expect(pageHistoryRepo.saveHistory).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does NOT pin a boundary snapshot for a plain user store', async () => {
|
||||
const { ext, pageHistoryRepo } = build({ lastUpdatedSource: 'user' });
|
||||
|
||||
await ext.onStoreDocument(
|
||||
makeStorePayload({ user: { id: 'user-1' }, actor: 'user' }),
|
||||
);
|
||||
|
||||
expect(pageHistoryRepo.saveHistory).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -52,17 +52,7 @@ export function resolveSource(
|
||||
stickyTouched: boolean,
|
||||
contextActor?: string,
|
||||
): ProvenanceSource {
|
||||
// An EXPLICIT current-write actor is authoritative for THIS write and wins
|
||||
// over the sticky-agent fallback. Order: explicit 'agent' > explicit
|
||||
// 'git-sync' > sticky agent marker > plain human 'user'. The git-sync case
|
||||
// must NOT be masked by the sticky marker, or the PageChangeListener
|
||||
// loop-guard (which keys on lastUpdatedSource === 'git-sync') would re-export
|
||||
// git-sync's own writes (#14). Explicit agent still wins so a window that
|
||||
// mixed an agent edit stays tagged 'agent'.
|
||||
if (contextActor === 'agent') return 'agent';
|
||||
if (contextActor === 'git-sync') return 'git-sync';
|
||||
if (stickyTouched) return 'agent';
|
||||
return 'user';
|
||||
return stickyTouched || contextActor === 'agent' ? 'agent' : 'user';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -186,11 +176,6 @@ export class PersistenceExtension implements Extension {
|
||||
// Sticky agent marker: 'agent' if any agent edit landed in this window, OR
|
||||
// if the current writer is the agent (covers a store with no prior onChange
|
||||
// agent event in the same window). §15 H2.
|
||||
// Provenance precedence: agent > git-sync > user (see resolveSource). A
|
||||
// 'git-sync' store is NOT given an immediate history snapshot — it is
|
||||
// debounced like a human edit (a git-sync write is a block-level merge into
|
||||
// the live doc, so it reads like an incremental human edit, not a bulk
|
||||
// import that would warrant its own immediate snapshot).
|
||||
const lastUpdatedSource = resolveSource(
|
||||
this.consumeAgentTouched(documentName),
|
||||
context?.actor,
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
// Regression coverage for the custom-event request/reply protocol in the
|
||||
// RedisSyncExtension. git-sync routes its body write through a custom event
|
||||
// (`gitSyncWriteBody`) which, when the target doc is owned by a DIFFERENT collab
|
||||
// instance, runs REMOTELY inside `handleRedisMessage` on the owning instance. The
|
||||
// remote handler can THROW (markdown->ProseMirror transform on a malformed body).
|
||||
//
|
||||
// Before the fix the throw was uncaught: (1) no `customEventComplete` reply was
|
||||
// published, so the origin's awaiting promise only rejected after `customEventTTL`
|
||||
// (~30s) as a generic 'TIMEOUT', and (2) an unhandledRejection escaped the async
|
||||
// `messageBuffer` listener on the owning instance. These tests assert the throw is
|
||||
// turned into an error-carrying reply that rejects the origin PROMPTLY with the
|
||||
// real message, with the no-throw and local paths unchanged.
|
||||
|
||||
import { RedisSyncExtension } from './redis-sync.extension';
|
||||
|
||||
type Listener = (channel: Buffer, message: Buffer) => unknown;
|
||||
|
||||
// Minimal in-memory pub/sub + lock store shared across FakeRedis duplicates,
|
||||
// modelling the two-instance topology (origin + owner) over one Redis.
|
||||
class FakeRedisBus {
|
||||
instances: FakeRedis[] = [];
|
||||
locks = new Map<string, string>();
|
||||
published: { channel: string; message: Buffer }[] = [];
|
||||
|
||||
register(inst: FakeRedis) {
|
||||
this.instances.push(inst);
|
||||
}
|
||||
|
||||
publish(channel: string, message: Buffer) {
|
||||
this.published.push({ channel, message });
|
||||
for (const inst of this.instances) {
|
||||
if (!inst.subscribed.has(channel)) continue;
|
||||
for (const listener of inst.messageListeners) {
|
||||
// ioredis delivers async; `void` mirrors the production listener
|
||||
// registration (`sub.on('messageBuffer', ...)`), whose rejection would
|
||||
// surface as an unhandledRejection if the handler did not catch.
|
||||
void listener(Buffer.from(channel), message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class FakeRedis {
|
||||
subscribed = new Set<string>();
|
||||
messageListeners: Listener[] = [];
|
||||
|
||||
constructor(private bus: FakeRedisBus) {
|
||||
bus.register(this);
|
||||
}
|
||||
|
||||
duplicate() {
|
||||
return new FakeRedis(this.bus);
|
||||
}
|
||||
|
||||
subscribe(...channels: string[]) {
|
||||
for (const c of channels) this.subscribed.add(c);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
on(event: string, cb: any) {
|
||||
if (event === 'messageBuffer') this.messageListeners.push(cb as Listener);
|
||||
return this;
|
||||
}
|
||||
|
||||
publish(channel: string, message: Buffer) {
|
||||
this.bus.publish(channel, message);
|
||||
return Promise.resolve(1);
|
||||
}
|
||||
|
||||
// Models `SET key val PX ttl NX GET`: only writes when absent (NX); returns the
|
||||
// previous value (GET) so the origin observes the owner already holding the lock.
|
||||
set(key: string, val: string, ...args: any[]) {
|
||||
const hasNX = args.includes('NX');
|
||||
const hasGET = args.includes('GET');
|
||||
const old = this.bus.locks.get(key) ?? null;
|
||||
if (!hasNX || old === null) this.bus.locks.set(key, val);
|
||||
return Promise.resolve(hasGET ? old : 'OK');
|
||||
}
|
||||
|
||||
del(key: string) {
|
||||
this.bus.locks.delete(key);
|
||||
return Promise.resolve(1);
|
||||
}
|
||||
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
const pack = (m: any) => Buffer.from(JSON.stringify(m));
|
||||
const unpack = (b: Buffer) => JSON.parse(b.toString());
|
||||
|
||||
function makeExtension(
|
||||
bus: FakeRedisBus,
|
||||
serverId: string,
|
||||
customEvents: Record<string, (doc: string, payload: any) => Promise<any>>,
|
||||
) {
|
||||
const ext = new RedisSyncExtension({
|
||||
redis: new FakeRedis(bus) as any,
|
||||
pack: pack as any,
|
||||
unpack: unpack as any,
|
||||
serverId,
|
||||
customEvents: customEvents as any,
|
||||
customEventTTL: 30_000,
|
||||
});
|
||||
// Doc is NOT loaded on this instance -> handleEvent takes the remote/proxy path.
|
||||
(ext as any).instance = { documents: new Map() };
|
||||
return ext;
|
||||
}
|
||||
|
||||
describe('RedisSyncExtension custom-event error propagation', () => {
|
||||
let unhandled: unknown[];
|
||||
let onUnhandled: (e: unknown) => void;
|
||||
|
||||
beforeEach(() => {
|
||||
// Fake timers so the 30s TTL fallback timer never fires (and never dangles).
|
||||
jest.useFakeTimers();
|
||||
unhandled = [];
|
||||
onUnhandled = (e) => unhandled.push(e);
|
||||
process.on('unhandledRejection', onUnhandled);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.off('unhandledRejection', onUnhandled);
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
const flush = async () => {
|
||||
for (let i = 0; i < 10; i++) await Promise.resolve();
|
||||
};
|
||||
|
||||
it('owner publishes an error-carrying reply (no unhandledRejection) when the remote handler throws', async () => {
|
||||
const bus = new FakeRedisBus();
|
||||
const owner = makeExtension(bus, 'owner', {
|
||||
boom: async () => {
|
||||
throw new Error('kaboom');
|
||||
},
|
||||
});
|
||||
|
||||
// Drive the remote branch directly, as if the origin's customEventStart arrived.
|
||||
await (owner as any).handleRedisMessage(
|
||||
Buffer.from('collabMsg:owner'),
|
||||
pack({
|
||||
type: 'customEventStart',
|
||||
documentName: 'page.x',
|
||||
eventName: 'boom',
|
||||
payload: {},
|
||||
replyTo: 'collabMsg:origin',
|
||||
replyId: 7,
|
||||
}),
|
||||
);
|
||||
await flush();
|
||||
|
||||
const replies = bus.published
|
||||
.filter((p) => p.channel === 'collabMsg:origin')
|
||||
.map((p) => unpack(p.message));
|
||||
expect(replies).toHaveLength(1);
|
||||
expect(replies[0]).toMatchObject({
|
||||
type: 'customEventComplete',
|
||||
replyId: 7,
|
||||
error: 'kaboom',
|
||||
});
|
||||
expect(unhandled).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('origin rejects PROMPTLY with the real error (not a TTL TIMEOUT) when the remote handler throws', async () => {
|
||||
const bus = new FakeRedisBus();
|
||||
// Owner already holds the document lock.
|
||||
bus.locks.set('collabLock:page.x', 'owner');
|
||||
makeExtension(bus, 'owner', {
|
||||
boom: async () => {
|
||||
throw new Error('kaboom');
|
||||
},
|
||||
});
|
||||
const origin = makeExtension(bus, 'origin', {
|
||||
boom: async () => undefined,
|
||||
});
|
||||
|
||||
const promise = (origin as any).handleEvent('boom', 'page.x', { foo: 1 });
|
||||
// Attach a catch immediately so a rejection is never momentarily unhandled.
|
||||
const settled = promise.then(
|
||||
() => ({ ok: true as const }),
|
||||
(e: unknown) => ({ ok: false as const, error: e }),
|
||||
);
|
||||
|
||||
await flush();
|
||||
// Resolves WITHOUT advancing any timer -> the 30s TIMEOUT fallback did not fire.
|
||||
const result = await settled;
|
||||
expect(result.ok).toBe(false);
|
||||
expect((result as any).error).toBeInstanceOf(Error);
|
||||
expect(((result as any).error as Error).message).toBe('kaboom');
|
||||
expect(unhandled).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('origin resolves with the payload when the remote handler succeeds (unchanged behavior)', async () => {
|
||||
const bus = new FakeRedisBus();
|
||||
bus.locks.set('collabLock:page.x', 'owner');
|
||||
makeExtension(bus, 'owner', {
|
||||
ok: async (_doc: string, payload: any) => ({ echoed: payload }),
|
||||
});
|
||||
const origin = makeExtension(bus, 'origin', {
|
||||
ok: async () => undefined,
|
||||
});
|
||||
|
||||
const promise = (origin as any).handleEvent('ok', 'page.x', { foo: 1 });
|
||||
await flush();
|
||||
await expect(promise).resolves.toEqual({ echoed: { foo: 1 } });
|
||||
expect(unhandled).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -51,15 +51,9 @@ export class RedisSyncExtension<TCE extends CustomEvents> implements Extension {
|
||||
private instance!: Hocuspocus;
|
||||
private readonly customEvents: TCE;
|
||||
private replyIdCounter: number = 0;
|
||||
private pendingReplies: Record<
|
||||
number,
|
||||
{
|
||||
// @ts-ignore
|
||||
resolve: PromiseWithResolvers<any>['resolve'];
|
||||
// @ts-ignore
|
||||
reject: PromiseWithResolvers<any>['reject'];
|
||||
}
|
||||
> = {};
|
||||
// @ts-ignore
|
||||
private pendingReplies: Record<number, PromiseWithResolvers<any>['resolve']> =
|
||||
{};
|
||||
|
||||
constructor(configuration: Configuration<TCE>) {
|
||||
const {
|
||||
@@ -182,45 +176,25 @@ export class RedisSyncExtension<TCE extends CustomEvents> implements Extension {
|
||||
}
|
||||
if (type === 'customEventStart') {
|
||||
const { documentName, eventName, payload, replyTo, replyId } = msg;
|
||||
let reply: RSAMessageCustomEventComplete;
|
||||
try {
|
||||
const res = await this.handleEventLocally(
|
||||
eventName as Extract<keyof TCE, string>,
|
||||
documentName,
|
||||
payload,
|
||||
);
|
||||
reply = {
|
||||
type: 'customEventComplete',
|
||||
replyId,
|
||||
payload: res,
|
||||
};
|
||||
} catch (err) {
|
||||
// The remote handler threw (e.g. the markdown->ProseMirror transform in
|
||||
// gitSyncWriteBody can throw on a malformed body). Reply with the error on
|
||||
// the SAME correlation channel so the origin rejects promptly with the real
|
||||
// message instead of waiting out customEventTTL as a generic 'TIMEOUT'.
|
||||
// Catching here also keeps the throw from escaping this async messageBuffer
|
||||
// listener as an unhandledRejection on the owning instance.
|
||||
reply = {
|
||||
type: 'customEventComplete',
|
||||
replyId,
|
||||
payload: undefined,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
const res = await this.handleEventLocally(
|
||||
eventName as Extract<keyof TCE, string>,
|
||||
documentName,
|
||||
payload,
|
||||
);
|
||||
const reply: RSAMessageCustomEventComplete = {
|
||||
type: 'customEventComplete',
|
||||
replyId,
|
||||
payload: res,
|
||||
};
|
||||
this.pub.publish(`${replyTo}`, this.pack(reply));
|
||||
return;
|
||||
}
|
||||
if (type === 'customEventComplete') {
|
||||
const { replyId, payload, error } = msg;
|
||||
const pending = this.pendingReplies[replyId];
|
||||
if (!pending) return;
|
||||
const { replyId, payload } = msg;
|
||||
const resolveFn = this.pendingReplies[replyId];
|
||||
if (!resolveFn) return;
|
||||
delete this.pendingReplies[replyId];
|
||||
if (error !== undefined) {
|
||||
pending.reject(new Error(error));
|
||||
} else {
|
||||
pending.resolve(payload);
|
||||
}
|
||||
resolveFn(payload);
|
||||
return;
|
||||
}
|
||||
const { socketId } = msg;
|
||||
@@ -299,22 +273,11 @@ export class RedisSyncExtension<TCE extends CustomEvents> implements Extension {
|
||||
};
|
||||
const msg = this.pack(proxyMessage);
|
||||
this.pub.publish(`${this.msgChannel}:${proxyTo}`, msg);
|
||||
// Manual deferred (no Promise.withResolvers) so this runs on Node < 22 too.
|
||||
let resolve!: (v: unknown) => void;
|
||||
let reject!: (e: unknown) => void;
|
||||
const promise = new Promise((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
this.pendingReplies[replyId] = { resolve, reject };
|
||||
// @ts-ignore
|
||||
const { promise, resolve, reject } = Promise.withResolvers();
|
||||
this.pendingReplies[replyId] = resolve;
|
||||
setTimeout(() => {
|
||||
// Fallback for a genuinely lost reply. A handler that threw now rejects
|
||||
// promptly via the error-carrying customEventComplete above; this TIMEOUT
|
||||
// only fires when no reply ever comes back.
|
||||
if (this.pendingReplies[replyId]) {
|
||||
delete this.pendingReplies[replyId];
|
||||
reject('TIMEOUT');
|
||||
}
|
||||
reject('TIMEOUT');
|
||||
}, this.customEventTTL);
|
||||
return promise as Promise<ReturnType<TCE[TName]>>;
|
||||
}
|
||||
|
||||
@@ -72,10 +72,6 @@ export type RSAMessageCustomEventComplete = {
|
||||
type: 'customEventComplete';
|
||||
replyId: number;
|
||||
payload: unknown;
|
||||
// When the remote handler THREW, the owner sends back the error message here
|
||||
// instead of a payload, so the origin can reject its awaiting promise promptly
|
||||
// (with the real error) rather than waiting out the customEventTTL timeout.
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type RSAMessage =
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { resolveSource } from './persistence.extension';
|
||||
|
||||
// Red-team finding #14: an explicit git-sync write (no agent edit in the
|
||||
// coalescing window) must keep the 'git-sync' source so the git-sync
|
||||
// listener's loop-guard can recognize its own writes and not re-export them.
|
||||
describe('resolveSource — #14 git-sync provenance loop-guard', () => {
|
||||
it('keeps git-sync source for an explicit git-sync write (stickyTouched=true, actor=git-sync)', () => {
|
||||
expect(resolveSource(true, 'git-sync')).toBe('git-sync');
|
||||
});
|
||||
});
|
||||
@@ -1,535 +0,0 @@
|
||||
/**
|
||||
* JEST CONFIG NOTE (#119 ESM refactor): this is the one spec that needs the REAL
|
||||
* `@docmost/git-sync` converter (not a mock). The package is now ESM, which jest
|
||||
* cannot `require()` nor `import()` without --experimental-vm-modules, so the
|
||||
* server jest config `moduleNameMapper`s `@docmost/git-sync` to its TS SOURCE and
|
||||
* strips the ESM `.js` import suffixes. ts-jest then type-checks that source under
|
||||
* the server's (looser) tsconfig and trips a benign narrowing; the global
|
||||
* `isolatedModules: true` on the ts-jest transform (apps/server/package.json)
|
||||
* makes it transpile-only so this spec loads. Full type-checking of the package
|
||||
* is still enforced by its own `tsc`/vitest gates and the server `tsc --noEmit`.
|
||||
*
|
||||
* §13.1 IDEMPOTENCY GATE — the blocking gate for git-sync Phase B.
|
||||
*
|
||||
* Proves the `@docmost/git-sync` pure converter is schema-compatible
|
||||
* with the server's REAL editor-ext document schema: a representative corpus of
|
||||
* editor-ext ProseMirror documents must survive a full round trip through the
|
||||
* actual server write path without losing any node / mark / attribute.
|
||||
*
|
||||
* Pipeline per document (issue #194 §13.1):
|
||||
* 1. md = convertProseMirrorToMarkdown(content) // git-sync export
|
||||
* 2. doc = await markdownToProseMirror(md) // git-sync import
|
||||
* 3. push `doc` through the REAL editor-ext Yjs write path the server uses:
|
||||
* ydoc = TiptapTransformer.toYdoc(doc, 'default', tiptapExtensions)
|
||||
* normalized = TiptapTransformer.fromYdoc(ydoc, 'default')
|
||||
* This is exactly what PersistenceExtension does on store
|
||||
* (apps/server/src/collaboration/extensions/persistence.extension.ts:96/115)
|
||||
* with the same `tiptapExtensions` (collaboration.util.ts) and the same
|
||||
* `@hocuspocus/transformer`, so the gate exercises the real schema
|
||||
* validation that runs on a git-sync write (issue #194 §3.3).
|
||||
* 4. assert docsCanonicallyEqual(canon(original), canon(normalized)) === true
|
||||
*
|
||||
* Any node / mark / attr that editor-ext drops (because the git-sync
|
||||
* docmost-schema named it differently, or declares a different default) makes
|
||||
* the gate FAIL for that document — exactly the schema-divergence issue #194 §3.3 /
|
||||
* §13.1 warn about. Genuine, irreducible divergences are isolated into the
|
||||
* clearly-named `KNOWN DIVERGENCE` block at the bottom (never silently hidden).
|
||||
*
|
||||
* Requires the workspace packages built first:
|
||||
* pnpm --filter @docmost/editor-ext build
|
||||
* pnpm --filter @docmost/git-sync build
|
||||
*/
|
||||
import { TiptapTransformer } from '@hocuspocus/transformer';
|
||||
// Import the server's real schema FIRST so `@docmost/editor-ext` resolves to its
|
||||
// built CJS `dist` (its `main`). The ESM-only `@docmost/git-sync` package is
|
||||
// mapped to its TS SOURCE by the jest `moduleNameMapper` (the built ESM cannot
|
||||
// be `require()`d nor dynamically `import()`ed under jest's node VM), so ts-jest
|
||||
// transpiles the real converter to CJS here — exercising the actual converter
|
||||
// the server ships, not a stub.
|
||||
import { tiptapExtensions } from './collaboration.util';
|
||||
import {
|
||||
convertProseMirrorToMarkdown,
|
||||
markdownToProseMirror,
|
||||
canonicalizeContent,
|
||||
docsCanonicallyEqual,
|
||||
} from '@docmost/git-sync';
|
||||
|
||||
/**
|
||||
* Run a single editor-ext document through the full gate pipeline and return
|
||||
* the canonical original vs the canonical doc as it lands after the real Yjs
|
||||
* write path, plus the intermediate markdown for diagnostics.
|
||||
*/
|
||||
async function runGate(original: any): Promise<{
|
||||
md: string;
|
||||
imported: any;
|
||||
normalized: any;
|
||||
canonOriginal: any;
|
||||
canonNormalized: any;
|
||||
}> {
|
||||
// 1) editor-ext JSON -> markdown (git-sync export).
|
||||
const md = convertProseMirrorToMarkdown(original);
|
||||
|
||||
// 2) markdown -> ProseMirror JSON (git-sync import, docmost-schema).
|
||||
const imported = await markdownToProseMirror(md);
|
||||
|
||||
// 3) push through the REAL editor-ext schema via the server's Yjs write path.
|
||||
// toYdoc validates `imported` against tiptapExtensions (throws on an
|
||||
// unknown node, drops unknown attrs); fromYdoc reads it back as the
|
||||
// normalized editor-ext JSON the server would persist.
|
||||
const ydoc = TiptapTransformer.toYdoc(imported, 'default', tiptapExtensions);
|
||||
const normalized = TiptapTransformer.fromYdoc(ydoc, 'default');
|
||||
|
||||
return {
|
||||
md,
|
||||
imported,
|
||||
normalized,
|
||||
canonOriginal: canonicalizeContent(original),
|
||||
canonNormalized: canonicalizeContent(normalized),
|
||||
};
|
||||
}
|
||||
|
||||
const doc = (...content: any[]) => ({ type: 'doc', content });
|
||||
const text = (t: string, marks?: any[]) =>
|
||||
marks ? { type: 'text', text: t, marks } : { type: 'text', text: t };
|
||||
const para = (...content: any[]) => ({ type: 'paragraph', content });
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Corpus: editor-ext ProseMirror documents covering the common node/mark types.
|
||||
// Node / mark / attr names and DEFAULTS are taken from the real schema —
|
||||
// editor-ext (packages/editor-ext/src) + the server's tiptapExtensions
|
||||
// (collaboration.util.ts) — NOT guessed. Where editor-ext materializes a
|
||||
// non-null default on import (e.g. image.align="center", callout.type, list
|
||||
// start) the fixture pre-authors that materialized value so the round trip is
|
||||
// already at its fixpoint (matches how the engine normalizes-on-write, SPEC §11).
|
||||
// ---------------------------------------------------------------------------
|
||||
const CORPUS: Record<string, any> = {
|
||||
'paragraphs + headings (h1-h3)': doc(
|
||||
{ type: 'heading', attrs: { level: 1 }, content: [text('Heading one')] },
|
||||
{ type: 'heading', attrs: { level: 2 }, content: [text('Heading two')] },
|
||||
{ type: 'heading', attrs: { level: 3 }, content: [text('Heading three')] },
|
||||
para(text('A plain paragraph of text.')),
|
||||
para(text('Second paragraph.')),
|
||||
),
|
||||
|
||||
'inline marks (bold/italic/strike/code)': doc(
|
||||
para(
|
||||
text('normal '),
|
||||
text('bold', [{ type: 'bold' }]),
|
||||
text(' '),
|
||||
text('italic', [{ type: 'italic' }]),
|
||||
text(' '),
|
||||
text('struck', [{ type: 'strike' }]),
|
||||
text(' '),
|
||||
text('code', [{ type: 'code' }]),
|
||||
),
|
||||
),
|
||||
|
||||
'links': doc(
|
||||
para(
|
||||
text('see '),
|
||||
text('the site', [
|
||||
{ type: 'link', attrs: { href: 'https://example.com' } },
|
||||
]),
|
||||
text(' for more'),
|
||||
),
|
||||
),
|
||||
|
||||
'bullet list': doc({
|
||||
type: 'bulletList',
|
||||
content: [
|
||||
{ type: 'listItem', content: [para(text('first'))] },
|
||||
{ type: 'listItem', content: [para(text('second'))] },
|
||||
{ type: 'listItem', content: [para(text('third'))] },
|
||||
],
|
||||
}),
|
||||
|
||||
'ordered list': doc({
|
||||
type: 'orderedList',
|
||||
attrs: { start: 1 },
|
||||
content: [
|
||||
{ type: 'listItem', content: [para(text('one'))] },
|
||||
{ type: 'listItem', content: [para(text('two'))] },
|
||||
],
|
||||
}),
|
||||
|
||||
'task list (checkbox)': doc({
|
||||
type: 'taskList',
|
||||
content: [
|
||||
{
|
||||
type: 'taskItem',
|
||||
attrs: { checked: true },
|
||||
content: [para(text('done item'))],
|
||||
},
|
||||
{
|
||||
type: 'taskItem',
|
||||
attrs: { checked: false },
|
||||
content: [para(text('todo item'))],
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
||||
'blockquote': doc({
|
||||
type: 'blockquote',
|
||||
content: [para(text('a quoted line')), para(text('second quoted line'))],
|
||||
}),
|
||||
|
||||
'callout (info)': doc({
|
||||
type: 'callout',
|
||||
attrs: { type: 'info' },
|
||||
content: [para(text('an informational callout'))],
|
||||
}),
|
||||
|
||||
'callout (warning)': doc({
|
||||
type: 'callout',
|
||||
attrs: { type: 'warning' },
|
||||
content: [para(text('a warning callout'))],
|
||||
}),
|
||||
|
||||
'code block (with language)': doc({
|
||||
type: 'codeBlock',
|
||||
attrs: { language: 'typescript' },
|
||||
// A fenced code block's body is stored with a trailing newline (the form a
|
||||
// markdown ``` fence round-trips to: marked normalizes the code text to end
|
||||
// in "\n"). Authoring the fixture at that fixpoint mirrors how the engine
|
||||
// normalizes-on-write (SPEC §11): codeBlock + `language` round-trip exactly.
|
||||
content: [text('const a: number = 1;\nconsole.log(a);\n')],
|
||||
}),
|
||||
|
||||
'horizontal rule': doc(
|
||||
para(text('before')),
|
||||
{ type: 'horizontalRule' },
|
||||
para(text('after')),
|
||||
),
|
||||
|
||||
'table (header row + cells)': doc({
|
||||
type: 'table',
|
||||
content: [
|
||||
{
|
||||
type: 'tableRow',
|
||||
content: [
|
||||
{
|
||||
type: 'tableHeader',
|
||||
attrs: { colspan: 1, rowspan: 1, colwidth: null },
|
||||
content: [para(text('Name'))],
|
||||
},
|
||||
{
|
||||
type: 'tableHeader',
|
||||
attrs: { colspan: 1, rowspan: 1, colwidth: null },
|
||||
content: [para(text('Value'))],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'tableRow',
|
||||
content: [
|
||||
{
|
||||
type: 'tableCell',
|
||||
attrs: { colspan: 1, rowspan: 1, colwidth: null },
|
||||
content: [para(text('alpha'))],
|
||||
},
|
||||
{
|
||||
type: 'tableCell',
|
||||
attrs: { colspan: 1, rowspan: 1, colwidth: null },
|
||||
content: [para(text('1'))],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
||||
// --- editor-ext nodes/marks beyond the original corpus (item #7) ----------
|
||||
// Each of these was verified to round-trip CLEANLY through the real gate
|
||||
// (export -> markdown -> import -> editor-ext Yjs write path). Fixtures are
|
||||
// pre-authored at the engine's normalize-on-write fixpoint (SPEC §11), e.g.
|
||||
// details carries the materialized `open:false`, and color marks use the
|
||||
// `rgb(...)` form the HTML re-parser normalizes to.
|
||||
|
||||
'mention (user)': doc(
|
||||
para(
|
||||
text('hi '),
|
||||
{
|
||||
type: 'mention',
|
||||
attrs: {
|
||||
id: 'user-123',
|
||||
label: 'Alice',
|
||||
entityType: 'user',
|
||||
entityId: 'user-123',
|
||||
creatorId: 'creator-1',
|
||||
},
|
||||
},
|
||||
text(' there'),
|
||||
),
|
||||
),
|
||||
|
||||
'inline math': doc(
|
||||
para(
|
||||
text('inline '),
|
||||
{ type: 'mathInline', attrs: { text: 'x^2' } },
|
||||
text(' math'),
|
||||
),
|
||||
),
|
||||
|
||||
'block math': doc({ type: 'mathBlock', attrs: { text: 'x^2 + y^2 = z^2' } }),
|
||||
|
||||
'details (collapsible)': doc({
|
||||
type: 'details',
|
||||
// `open:false` is the value editor-ext materializes on import; pre-authoring
|
||||
// it puts the fixture at its round-trip fixpoint.
|
||||
attrs: { open: false },
|
||||
content: [
|
||||
{ type: 'detailsSummary', content: [text('Summary line')] },
|
||||
{ type: 'detailsContent', content: [para(text('hidden body'))] },
|
||||
],
|
||||
}),
|
||||
|
||||
'highlight (mark, no color)': doc(
|
||||
para(
|
||||
text('a '),
|
||||
text('highlighted', [{ type: 'highlight' }]),
|
||||
text(' word'),
|
||||
),
|
||||
),
|
||||
|
||||
'highlight (mark, with color)': doc(
|
||||
para(
|
||||
text('a '),
|
||||
text('red', [{ type: 'highlight', attrs: { color: 'rgb(255, 0, 0)' } }]),
|
||||
text(' word'),
|
||||
),
|
||||
),
|
||||
|
||||
'subscript': doc(
|
||||
para(text('H'), text('2', [{ type: 'subscript' }]), text('O')),
|
||||
),
|
||||
|
||||
'superscript': doc(
|
||||
para(text('E=mc'), text('2', [{ type: 'superscript' }])),
|
||||
),
|
||||
|
||||
'text color (textStyle)': doc(
|
||||
// The HTML re-parser normalizes CSS colors to the `rgb(...)` form, so the
|
||||
// fixture pre-authors that form; a `#hex` color would round-trip to the
|
||||
// equivalent rgb() and is therefore a value-normalization divergence (see
|
||||
// the KNOWN DIVERGENCE block below).
|
||||
para(text('green', [{ type: 'textStyle', attrs: { color: 'rgb(0, 255, 0)' } }])),
|
||||
),
|
||||
|
||||
'nested / mixed document': doc(
|
||||
{ type: 'heading', attrs: { level: 1 }, content: [text('Mixed')] },
|
||||
para(
|
||||
text('intro with '),
|
||||
text('bold', [{ type: 'bold' }]),
|
||||
text(' and a '),
|
||||
text('link', [{ type: 'link', attrs: { href: 'https://example.com' } }]),
|
||||
text('.'),
|
||||
),
|
||||
{
|
||||
type: 'bulletList',
|
||||
content: [
|
||||
{
|
||||
type: 'listItem',
|
||||
content: [
|
||||
para(text('item with '), text('code', [{ type: 'code' }])),
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'listItem',
|
||||
content: [
|
||||
para(text('item with sublist')),
|
||||
{
|
||||
type: 'bulletList',
|
||||
content: [
|
||||
{ type: 'listItem', content: [para(text('nested a'))] },
|
||||
{ type: 'listItem', content: [para(text('nested b'))] },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'callout',
|
||||
attrs: { type: 'success' },
|
||||
content: [
|
||||
para(text('callout body')),
|
||||
{ type: 'codeBlock', attrs: { language: 'bash' }, content: [text('echo hi\n')] },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'blockquote',
|
||||
content: [para(text('quote at the end'))],
|
||||
},
|
||||
),
|
||||
|
||||
// Atom embeds that carry no inline text: they must round-trip via their
|
||||
// schema-matching HTML (data-type div), NOT a literal that re-imports as plain
|
||||
// text. `subpages` used to export as the literal "{{SUBPAGES}}" and came back
|
||||
// as visible text on the page (red-team round-trip data loss) — this locks it.
|
||||
// editor-ext materializes the `recursive: false` default on import, so the
|
||||
// fixture pre-authors it to sit at the round-trip fixpoint (matches the other
|
||||
// default-materializing fixtures above).
|
||||
'subpages embed': doc({ type: 'subpages', attrs: { recursive: false } }),
|
||||
};
|
||||
|
||||
describe('git-sync converter §13.1 idempotency gate (editor-ext schema)', () => {
|
||||
for (const [name, original] of Object.entries(CORPUS)) {
|
||||
it(`round-trips losslessly: ${name}`, async () => {
|
||||
const { md, canonOriginal, canonNormalized } = await runGate(original);
|
||||
|
||||
const equal = docsCanonicallyEqual(original, canonNormalized);
|
||||
if (!equal) {
|
||||
// Surface a readable diff so a real divergence is actionable.
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
`\n[GATE FAIL] ${name}\n--- markdown ---\n${md}\n` +
|
||||
`--- canonical original ---\n${JSON.stringify(canonOriginal, null, 2)}\n` +
|
||||
`--- canonical round-tripped ---\n${JSON.stringify(canonNormalized, null, 2)}\n`,
|
||||
);
|
||||
}
|
||||
expect(equal).toBe(true);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// KNOWN DIVERGENCE — images (isolated so it does NOT silently weaken the gate).
|
||||
//
|
||||
// This is NOT a schema-name divergence: the `image` NODE itself round-trips
|
||||
// through editor-ext fine (it survives toYdoc under the real tiptapExtensions).
|
||||
// The loss is intrinsic to MARKDOWN, the on-disk transport format git-sync uses:
|
||||
//
|
||||
// 1. `convertProseMirrorToMarkdown` emits a standard `` image
|
||||
// (markdown-converter.ts case "image"). Standard markdown image syntax has
|
||||
// no way to express `width` / `height` / `align`, so those attrs are
|
||||
// DROPPED on export and cannot be recovered on import.
|
||||
// 2. A block-level image is hoisted out of its line by the HTML re-parser,
|
||||
// leaving a leading EMPTY paragraph (the same block-image-hoist limitation
|
||||
// documented in packages/git-sync/test/fixtures/known-limitations).
|
||||
//
|
||||
// The gate documents the EXACT lossy shape below. If the converter is ever
|
||||
// taught to preserve image dimensions (e.g. by emitting an HTML <img> with
|
||||
// data-* attrs, as it already does for video/diagrams), these assertions flip
|
||||
// and the image fixture should be promoted into the green CORPUS above.
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('git-sync converter §13.1 image dimensions preserved (was KNOWN DIVERGENCE)', () => {
|
||||
const imageDoc = doc({
|
||||
type: 'image',
|
||||
attrs: {
|
||||
src: 'https://example.com/pic.png',
|
||||
width: 640,
|
||||
height: 480,
|
||||
align: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
it('preserves width/height/align by exporting an HTML <img> (PR #119 round-trip fix)', async () => {
|
||||
const { md, canonNormalized } = await runGate(imageDoc);
|
||||
|
||||
// A top-level image carrying layout attrs is now exported as a schema-
|
||||
// matching HTML <img> (the same path video/diagrams already use), so the
|
||||
// dimensions and alignment survive the round trip instead of collapsing to
|
||||
// bare ``.
|
||||
expect(md.trim()).toBe(
|
||||
'<img src="https://example.com/pic.png" width="640" height="480" align="center">',
|
||||
);
|
||||
|
||||
// The round-tripped image keeps src + the layout attrs. width/height are
|
||||
// re-imported as strings (matching the video/audio/pdf string convention),
|
||||
// so assert the values rather than the JS type.
|
||||
const imgAttrs = (canonNormalized as any).content[0].attrs;
|
||||
expect((canonNormalized as any).content[0].type).toBe('image');
|
||||
expect(imgAttrs.src).toBe('https://example.com/pic.png');
|
||||
expect(imgAttrs.align).toBe('center');
|
||||
expect(String(imgAttrs.width)).toBe('640');
|
||||
expect(String(imgAttrs.height)).toBe('480');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// KNOWN DIVERGENCE — text alignment (item #7; isolated, not silently dropped).
|
||||
//
|
||||
// editor-ext registers TextAlign for heading+paragraph, and the SERVER schema
|
||||
// fully supports it — the loss is intrinsic to the MARKDOWN transport:
|
||||
//
|
||||
// • A paragraph's `textAlign` is EXPORTED as `<div align="...">text</div>`
|
||||
// (markdown-converter case "paragraph"), but on import the converter's
|
||||
// docmost-schema declares `textAlign` WITHOUT a parseHTML mapping, so the
|
||||
// `align` attribute is never recovered -> it imports as `textAlign:null`
|
||||
// and canonicalizes away. A heading's alignment is not even exported.
|
||||
// • Therefore any non-default alignment is dropped on a full round trip.
|
||||
//
|
||||
// If the converter is ever taught to parse `align`/`text-align` back onto the
|
||||
// block, this assertion flips and an aligned-paragraph fixture should be
|
||||
// promoted into the green CORPUS above.
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('git-sync converter §13.1 KNOWN DIVERGENCE (text alignment dropped)', () => {
|
||||
it('drops a paragraph textAlign on the markdown round trip', async () => {
|
||||
const alignedDoc = doc({
|
||||
type: 'paragraph',
|
||||
attrs: { textAlign: 'center' },
|
||||
content: [text('centered')],
|
||||
});
|
||||
|
||||
const { canonNormalized } = await runGate(alignedDoc);
|
||||
|
||||
// The round-tripped paragraph carries no alignment.
|
||||
expect(canonNormalized).toEqual({
|
||||
type: 'doc',
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'centered' }] }],
|
||||
});
|
||||
expect(docsCanonicallyEqual(alignedDoc, canonNormalized)).toBe(false);
|
||||
});
|
||||
|
||||
it('drops a heading textAlign (headings do not export alignment at all)', async () => {
|
||||
const alignedHeading = doc({
|
||||
type: 'heading',
|
||||
attrs: { level: 2, textAlign: 'center' },
|
||||
content: [text('centered heading')],
|
||||
});
|
||||
|
||||
const { md, canonNormalized } = await runGate(alignedHeading);
|
||||
|
||||
// Export is a plain markdown heading — no alignment syntax.
|
||||
expect(md.trim()).toBe('## centered heading');
|
||||
expect(docsCanonicallyEqual(alignedHeading, canonNormalized)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// KNOWN DIVERGENCE — textStyle color is VALUE-NORMALIZED, not lost (item #7).
|
||||
//
|
||||
// The textStyle/color mark itself round-trips (the green CORPUS has the rgb()
|
||||
// form). But a `#hex` color is normalized to the equivalent `rgb(...)` string
|
||||
// by the HTML re-parser on import, and canonicalize.ts does NOT normalize color
|
||||
// formats — so a `#hex` original is not STRING-identical to its round trip even
|
||||
// though the color is semantically preserved. Locked here so the boundary is
|
||||
// explicit: author color fixtures in rgb() form to stay in the green corpus.
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('git-sync converter §13.1 KNOWN DIVERGENCE (textStyle color #hex -> rgb)', () => {
|
||||
it('normalizes a #hex text color to rgb() (semantically preserved, string-divergent)', async () => {
|
||||
const hexDoc = doc(
|
||||
para(text('green', [{ type: 'textStyle', attrs: { color: '#00ff00' } }])),
|
||||
);
|
||||
|
||||
const { canonNormalized } = await runGate(hexDoc);
|
||||
|
||||
// Color survives, but as the normalized rgb() string.
|
||||
expect(canonNormalized).toEqual({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'green',
|
||||
marks: [{ type: 'textStyle', attrs: { color: 'rgb(0, 255, 0)' } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
// Not string-identical to the #hex original.
|
||||
expect(docsCanonicallyEqual(hexDoc, canonNormalized)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -9,8 +9,6 @@ import { ProvenanceSource } from '../../core/auth/dto/jwt-payload';
|
||||
* cannot fake an 'agent' marker.
|
||||
*/
|
||||
export interface AuthProvenanceData {
|
||||
// ProvenanceSource includes 'git-sync' — set by the in-process git-sync data
|
||||
// plane (issue #194 §8.1) when it drives PageService writes; never from a request token.
|
||||
actor: ProvenanceSource;
|
||||
aiChatId: string | null;
|
||||
}
|
||||
@@ -62,14 +60,6 @@ export function agentSourceFields<S extends string, C extends string>(
|
||||
sourceKey: S,
|
||||
chatKey: C,
|
||||
): Partial<Record<S, ProvenanceSource> & Record<C, string | null>> {
|
||||
// git-sync data-plane write (issue #194 §8.1): stamp the source 'git-sync' with NO
|
||||
// aiChatId (it has no internal ai_chats row). Mirrors the agent branch; each
|
||||
// write has a single actor, so precedence is irrelevant here.
|
||||
if (provenance?.actor === 'git-sync') {
|
||||
return { [sourceKey]: 'git-sync' } as Partial<
|
||||
Record<S, ProvenanceSource> & Record<C, string | null>
|
||||
>;
|
||||
}
|
||||
if (provenance?.actor !== 'agent') return {};
|
||||
return {
|
||||
[sourceKey]: 'agent',
|
||||
|
||||
@@ -3,12 +3,8 @@
|
||||
* from the SIGNED token claim (never a request body), so 'agent' is unspoofable.
|
||||
* Single source of truth so a typo like 'agnet' can't slip through as a bare
|
||||
* string (#143 review). Distinct from `ActorType` (auth principal kind).
|
||||
*
|
||||
* 'git-sync' marks writes made by the git-sync data plane (issue #194 §8.1). It NEVER
|
||||
* travels in a user-facing token; it is set in-process on the collab connection
|
||||
* context by the native datasource, so it cannot be spoofed from a request.
|
||||
*/
|
||||
export type ProvenanceSource = 'user' | 'agent' | 'git-sync';
|
||||
export type ProvenanceSource = 'user' | 'agent';
|
||||
|
||||
export enum JwtType {
|
||||
ACCESS = 'access',
|
||||
@@ -30,8 +26,7 @@ export type JwtPayload = {
|
||||
// normal user token (treated as 'user'); set only when the internal agent
|
||||
// mints a provenance access token so REST writes (create/rename/move page,
|
||||
// comment create/resolve) record a non-spoofable 'agent' marker (§6.5 / §15
|
||||
// C3 / §14 N2). (git-sync writes use the in-process actor, not a token — see
|
||||
// the ProvenanceSource note.)
|
||||
// C3 / §14 N2).
|
||||
actor?: ProvenanceSource;
|
||||
// Nullable: an external MCP agent has no internal ai_chats row, so it carries
|
||||
// an 'agent' actor with a null aiChatId.
|
||||
@@ -44,8 +39,7 @@ export type JwtCollabPayload = {
|
||||
type: 'collab';
|
||||
// Optional agent-edit provenance, signed into the collab token. Absent for
|
||||
// the human collab path (treated as 'user'); set only when the internal agent
|
||||
// mints a provenance collab token (§6.6 / §15 C2). 'git-sync' (in ProvenanceSource)
|
||||
// is accepted for type-compatibility with the in-process git-sync write path.
|
||||
// mints a provenance collab token (§6.6 / §15 C2).
|
||||
actor?: ProvenanceSource;
|
||||
// Nullable: an external MCP agent has no internal ai_chats row, so it carries
|
||||
// an 'agent' actor with a null aiChatId.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
// 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;
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsOptional,
|
||||
IsString,
|
||||
@@ -32,4 +33,10 @@ 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;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ 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';
|
||||
@@ -16,6 +17,7 @@ import { LabelModule } from '../label/label.module';
|
||||
PageService,
|
||||
PageHistoryService,
|
||||
TrashCleanupService,
|
||||
TemporaryNoteCleanupService,
|
||||
BacklinkService,
|
||||
],
|
||||
exports: [PageService, PageHistoryService],
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { PageService } from './page.service';
|
||||
import { MovePageDto } from '../dto/move-page.dto';
|
||||
import { CreatePageDto } from '../dto/create-page.dto';
|
||||
import { UpdatePageDto } from '../dto/update-page.dto';
|
||||
import { Page, User } from '@docmost/db/types/entity.types';
|
||||
import { AuthProvenanceData } from '../../../common/decorators/auth-provenance.decorator';
|
||||
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
|
||||
@@ -424,294 +422,78 @@ describe('PageService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('git-sync provenance stamping (#1)', () => {
|
||||
const GIT_SYNC: AuthProvenanceData = { actor: 'git-sync', aiChatId: null };
|
||||
const USER_PROVENANCE: AuthProvenanceData = { actor: 'user', aiChatId: null };
|
||||
|
||||
describe('create()', () => {
|
||||
// Build a service whose insertPage/generalQueue are observable and whose
|
||||
// nextPagePosition (a DB query) is stubbed, so create() reaches insertPage
|
||||
// without a real database.
|
||||
const makeService = () => {
|
||||
const insertedPage = { id: 'page-1', slugId: 'slug-1' };
|
||||
const pageRepo = {
|
||||
insertPage: jest.fn().mockResolvedValue(insertedPage),
|
||||
};
|
||||
// add() is fire-and-forget (the service .catch()es it); resolve so no
|
||||
// unhandled rejection leaks.
|
||||
const generalQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||
|
||||
const svc = new PageService(
|
||||
pageRepo as any, // pageRepo
|
||||
{} as any, // pagePermissionRepo
|
||||
{} as any, // attachmentRepo
|
||||
{} as any, // db
|
||||
{} as any, // storageService
|
||||
{} as any, // attachmentQueue
|
||||
{} as any, // aiQueue
|
||||
generalQueue as any, // generalQueue
|
||||
{} as any, // eventEmitter
|
||||
{} as any, // collaborationGateway
|
||||
{} as any, // watcherService
|
||||
{} as any, // transclusionService
|
||||
);
|
||||
|
||||
// nextPagePosition runs a kysely query; stub it so create() never hits
|
||||
// the db. No DTO content is provided, so parseProsemirrorContent is
|
||||
// skipped entirely (content/textContent/ydoc stay undefined).
|
||||
jest.spyOn(svc, 'nextPagePosition').mockResolvedValue('a0');
|
||||
|
||||
return { svc, pageRepo };
|
||||
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 createDto: CreatePageDto = {
|
||||
title: 'New page',
|
||||
spaceId: 'space-1',
|
||||
} as any;
|
||||
const makeGeneralQueue = () =>
|
||||
({ add: jest.fn().mockReturnValue({ catch: jest.fn() }) }) as any;
|
||||
|
||||
it("stamps lastUpdatedSource:'git-sync' on the insertPage payload", async () => {
|
||||
const { svc, pageRepo } = makeService();
|
||||
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 };
|
||||
};
|
||||
|
||||
await svc.create('user-1', 'ws-1', createDto, GIT_SYNC);
|
||||
afterEach(() => jest.useRealTimers());
|
||||
|
||||
expect(pageRepo.insertPage).toHaveBeenCalledTimes(1);
|
||||
expect(pageRepo.insertPage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ lastUpdatedSource: 'git-sync' }),
|
||||
);
|
||||
// git-sync carries no aiChatId (unlike the agent branch).
|
||||
const payload = pageRepo.insertPage.mock.calls[0][0];
|
||||
expect(payload.lastUpdatedAiChatId).toBeUndefined();
|
||||
// The human stays the responsible author.
|
||||
expect(payload.creatorId).toBe('user-1');
|
||||
expect(payload.lastUpdatedById).toBe('user-1');
|
||||
});
|
||||
|
||||
it('leaves the source column unset for a plain user create', async () => {
|
||||
const { svc, pageRepo } = makeService();
|
||||
|
||||
await svc.create('user-1', 'ws-1', createDto, USER_PROVENANCE);
|
||||
|
||||
const payload = pageRepo.insertPage.mock.calls[0][0];
|
||||
expect(payload.lastUpdatedSource).toBeUndefined();
|
||||
});
|
||||
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),
|
||||
);
|
||||
});
|
||||
|
||||
describe('update() (rename)', () => {
|
||||
const makeService = () => {
|
||||
const pageRepo = {
|
||||
updatePage: jest.fn().mockResolvedValue({ numUpdatedRows: 1n }),
|
||||
// update() re-reads the row at the end to return the refreshed page.
|
||||
findById: jest.fn().mockResolvedValue({ id: 'page-1' }),
|
||||
};
|
||||
const generalQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||
const aiQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||
|
||||
const svc = new PageService(
|
||||
pageRepo as any, // pageRepo
|
||||
{} as any, // pagePermissionRepo
|
||||
{} as any, // attachmentRepo
|
||||
{} as any, // db
|
||||
{} as any, // storageService
|
||||
{} as any, // attachmentQueue
|
||||
aiQueue as any, // aiQueue
|
||||
generalQueue as any, // generalQueue
|
||||
{} as any, // eventEmitter
|
||||
{} as any, // collaborationGateway
|
||||
{} as any, // watcherService
|
||||
{} as any, // transclusionService
|
||||
);
|
||||
|
||||
return { svc, pageRepo };
|
||||
};
|
||||
|
||||
const page: Page = {
|
||||
id: 'page-1',
|
||||
slugId: 'slug-1',
|
||||
spaceId: 'space-1',
|
||||
workspaceId: 'ws-1',
|
||||
title: 'Old title',
|
||||
icon: null,
|
||||
parentPageId: null,
|
||||
contributorIds: [],
|
||||
} as any;
|
||||
|
||||
const user: User = { id: 'user-1' } as any;
|
||||
|
||||
it("stamps lastUpdatedSource:'git-sync' on the updatePage payload", async () => {
|
||||
const { svc, pageRepo } = makeService();
|
||||
const dto: UpdatePageDto = { title: 'New title' } as any;
|
||||
|
||||
await svc.update(page, dto, user, GIT_SYNC);
|
||||
|
||||
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
|
||||
const payload = pageRepo.updatePage.mock.calls[0][0];
|
||||
expect(payload.lastUpdatedSource).toBe('git-sync');
|
||||
expect(payload.lastUpdatedAiChatId).toBeUndefined();
|
||||
// The acting user stays the responsible author.
|
||||
expect(payload.lastUpdatedById).toBe('user-1');
|
||||
});
|
||||
|
||||
it('leaves the source column unset for a plain user rename', async () => {
|
||||
const { svc, pageRepo } = makeService();
|
||||
const dto: UpdatePageDto = { title: 'New title' } as any;
|
||||
|
||||
await svc.update(page, dto, user, USER_PROVENANCE);
|
||||
|
||||
const payload = pageRepo.updatePage.mock.calls[0][0];
|
||||
expect(payload.lastUpdatedSource).toBeUndefined();
|
||||
});
|
||||
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),
|
||||
);
|
||||
});
|
||||
|
||||
describe('movePage()', () => {
|
||||
const SPACE_ID = 'space-1';
|
||||
const VALID_POSITION = 'a0';
|
||||
|
||||
const makeService = () => {
|
||||
const pageRepo = {
|
||||
findById: jest.fn().mockResolvedValue({
|
||||
id: 'dest-parent',
|
||||
deletedAt: null,
|
||||
spaceId: SPACE_ID,
|
||||
}),
|
||||
updatePage: jest.fn().mockResolvedValue({ numUpdatedRows: 1n }),
|
||||
};
|
||||
const eventEmitter = { emit: jest.fn() };
|
||||
|
||||
// movePage now runs the cycle-check + UPDATE inside executeTx(this.db),
|
||||
// i.e. this.db.transaction().execute(fn => fn(trx)). A permissive
|
||||
// chainable Proxy stands in for the Kysely trx so the per-space
|
||||
// advisory-lock `sql``.execute(trx)` resolves and updatePage runs.
|
||||
const trxStub: any = new Proxy(function () {}, {
|
||||
get: (_t, p) =>
|
||||
p === 'then'
|
||||
? undefined
|
||||
: p === 'execute' || p === 'executeTakeFirst'
|
||||
? () => Promise.resolve([])
|
||||
: () => trxStub,
|
||||
});
|
||||
const db = {
|
||||
transaction: () => ({ execute: (fn: any) => fn(trxStub) }),
|
||||
};
|
||||
|
||||
const svc = new PageService(
|
||||
pageRepo as any, // pageRepo
|
||||
{} as any, // pagePermissionRepo
|
||||
{} as any, // attachmentRepo
|
||||
db as any, // db
|
||||
{} as any, // storageService
|
||||
{} as any, // attachmentQueue
|
||||
{} as any, // aiQueue
|
||||
{} as any, // generalQueue
|
||||
eventEmitter as any, // eventEmitter
|
||||
{} as any, // collaborationGateway
|
||||
{} as any, // watcherService
|
||||
{} as any, // transclusionService
|
||||
);
|
||||
|
||||
// No cycle: the destination's ancestor chain does not contain the moved
|
||||
// page, so movePage reaches updatePage.
|
||||
jest
|
||||
.spyOn(svc, 'getPageBreadCrumbs')
|
||||
.mockResolvedValue([{ id: 'dest-parent' }, { id: 'root' }] as any);
|
||||
|
||||
return { svc, pageRepo };
|
||||
};
|
||||
|
||||
const movedPage: Page = {
|
||||
id: 'page-1',
|
||||
parentPageId: 'old-parent',
|
||||
spaceId: SPACE_ID,
|
||||
workspaceId: 'ws-1',
|
||||
slugId: 'slug-1',
|
||||
title: 'Page 1',
|
||||
icon: null,
|
||||
} as any;
|
||||
|
||||
const dto: MovePageDto = {
|
||||
pageId: 'page-1',
|
||||
position: VALID_POSITION,
|
||||
parentPageId: 'dest-parent',
|
||||
};
|
||||
|
||||
it("stamps lastUpdatedSource:'git-sync' on the updatePage payload", async () => {
|
||||
const { svc, pageRepo } = makeService();
|
||||
|
||||
await svc.movePage(dto, movedPage, GIT_SYNC);
|
||||
|
||||
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
|
||||
const payload = pageRepo.updatePage.mock.calls[0][0];
|
||||
expect(payload.lastUpdatedSource).toBe('git-sync');
|
||||
expect(payload.lastUpdatedAiChatId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('leaves the source column unset for a plain user move', async () => {
|
||||
const { svc, pageRepo } = makeService();
|
||||
|
||||
await svc.movePage(dto, movedPage, USER_PROVENANCE);
|
||||
|
||||
const payload = pageRepo.updatePage.mock.calls[0][0];
|
||||
expect(payload.lastUpdatedSource).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('removePage()', () => {
|
||||
// removePage forwards a `source` 4th arg to pageRepo.removePage: 'git-sync'
|
||||
// for a git-sync-driven soft-delete (so the change-listener loop-guard skips
|
||||
// its own write), undefined otherwise.
|
||||
const makeService = () => {
|
||||
const pageRepo = {
|
||||
removePage: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const svc = new PageService(
|
||||
pageRepo as any, // pageRepo
|
||||
{} as any, // pagePermissionRepo
|
||||
{} as any, // attachmentRepo
|
||||
{} as any, // db
|
||||
{} as any, // storageService
|
||||
{} as any, // attachmentQueue
|
||||
{} as any, // aiQueue
|
||||
{} as any, // generalQueue
|
||||
{} as any, // eventEmitter
|
||||
{} as any, // collaborationGateway
|
||||
{} as any, // watcherService
|
||||
{} as any, // transclusionService
|
||||
);
|
||||
|
||||
return { svc, pageRepo };
|
||||
};
|
||||
|
||||
it("forwards 'git-sync' as the source for a git-sync soft-delete", async () => {
|
||||
const { svc, pageRepo } = makeService();
|
||||
|
||||
await svc.removePage('page-1', 'user-1', 'ws-1', GIT_SYNC);
|
||||
|
||||
expect(pageRepo.removePage).toHaveBeenCalledTimes(1);
|
||||
const [pageId, userId, workspaceId, source] =
|
||||
pageRepo.removePage.mock.calls[0];
|
||||
expect(pageId).toBe('page-1');
|
||||
expect(userId).toBe('user-1');
|
||||
expect(workspaceId).toBe('ws-1');
|
||||
expect(source).toBe('git-sync');
|
||||
});
|
||||
|
||||
it('forwards undefined as the source for a plain user delete', async () => {
|
||||
const { svc, pageRepo } = makeService();
|
||||
|
||||
await svc.removePage('page-1', 'user-1', 'ws-1', USER_PROVENANCE);
|
||||
|
||||
const [, , , source] = pageRepo.removePage.mock.calls[0];
|
||||
expect(source).toBeUndefined();
|
||||
});
|
||||
|
||||
it('forwards undefined as the source when no provenance is given', async () => {
|
||||
const { svc, pageRepo } = makeService();
|
||||
|
||||
await svc.removePage('page-1', 'user-1', 'ws-1');
|
||||
|
||||
const [, , , source] = pageRepo.removePage.mock.calls[0];
|
||||
expect(source).toBeUndefined();
|
||||
});
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,6 +61,7 @@ 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
|
||||
@@ -140,6 +141,20 @@ 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;
|
||||
@@ -172,6 +187,7 @@ 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,
|
||||
@@ -356,6 +372,7 @@ export class PageService {
|
||||
'spaceId',
|
||||
'creatorId',
|
||||
'isTemplate',
|
||||
'temporaryExpiresAt',
|
||||
'deletedAt',
|
||||
])
|
||||
.select((eb) => this.pageRepo.withHasChildren(eb))
|
||||
@@ -1257,18 +1274,8 @@ export class PageService {
|
||||
pageId: string,
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
// Optional provenance. A git-sync-driven soft-delete stamps
|
||||
// `lastUpdatedSource = 'git-sync'` so the change-listener loop-guard skips
|
||||
// its own write (mirrors the create/update/move provenance branches above).
|
||||
provenance?: AuthProvenanceData,
|
||||
): Promise<void> {
|
||||
const isGitSync = provenance?.actor === 'git-sync';
|
||||
await this.pageRepo.removePage(
|
||||
pageId,
|
||||
userId,
|
||||
workspaceId,
|
||||
isGitSync ? 'git-sync' : undefined,
|
||||
);
|
||||
await this.pageRepo.removePage(pageId, userId, workspaceId);
|
||||
}
|
||||
|
||||
private async parseProsemirrorContent(
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
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;
|
||||
}
|
||||
@@ -16,8 +16,12 @@ 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')
|
||||
@@ -26,6 +30,7 @@ export class PageTemplateController {
|
||||
private readonly transclusionService: TransclusionService,
|
||||
private readonly pageRepo: PageRepo,
|
||||
private readonly pageAccessService: PageAccessService,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -82,4 +87,54 @@ 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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ 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;
|
||||
@@ -40,6 +41,8 @@ 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)
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -15,12 +15,4 @@ export class UpdateSpaceDto extends PartialType(CreateSpaceDto) {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
allowViewerComments: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
gitSyncEnabled?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
autoMergeConflicts?: boolean;
|
||||
}
|
||||
|
||||
@@ -22,199 +22,4 @@ describe('SpaceService', () => {
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('updateSpace gitSyncEnabled', () => {
|
||||
const workspaceId = 'ws-1';
|
||||
const spaceId = 'space-1';
|
||||
|
||||
// executeTx runs the callback immediately with a passthrough trx so the
|
||||
// repo calls happen inline; mirrors how the sibling sharing/comments flags
|
||||
// are persisted.
|
||||
const buildService = (settingsBefore: Record<string, any>) => {
|
||||
const spaceRepo = {
|
||||
findById: jest.fn().mockResolvedValue({
|
||||
id: spaceId,
|
||||
name: 'Space',
|
||||
slug: 'space',
|
||||
description: '',
|
||||
settings: settingsBefore,
|
||||
}),
|
||||
updateGitSyncSettings: jest.fn().mockResolvedValue({}),
|
||||
updateSharingSettings: jest.fn().mockResolvedValue({}),
|
||||
updateCommentSettings: jest.fn().mockResolvedValue({}),
|
||||
updateSpace: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ id: spaceId, name: 'Space', slug: 'space' }),
|
||||
slugExists: jest.fn().mockResolvedValue(false),
|
||||
};
|
||||
const auditService = { log: jest.fn() };
|
||||
|
||||
const svc = new SpaceService(
|
||||
spaceRepo as any,
|
||||
{} as any, // spaceMemberService
|
||||
{} as any, // shareRepo
|
||||
{} as any, // workspaceRepo
|
||||
{} as any, // licenseCheckService
|
||||
{} as any, // db
|
||||
{} as any, // attachmentQueue
|
||||
auditService as any,
|
||||
);
|
||||
|
||||
// executeTx is invoked via the imported helper; patch it on the module.
|
||||
jest
|
||||
.spyOn(require('@docmost/db/utils'), 'executeTx')
|
||||
.mockImplementation(async (_db: any, cb: any) => cb({} as any));
|
||||
|
||||
return { svc, spaceRepo, auditService };
|
||||
};
|
||||
|
||||
it('persists gitSyncEnabled via updateGitSyncSettings(enabled)', async () => {
|
||||
const { svc, spaceRepo } = buildService({});
|
||||
|
||||
await svc.updateSpace(
|
||||
{ spaceId, gitSyncEnabled: true } as any,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
expect(spaceRepo.updateGitSyncSettings).toHaveBeenCalledWith(
|
||||
spaceId,
|
||||
workspaceId,
|
||||
'enabled',
|
||||
true,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not call updateGitSyncSettings when flag is undefined', async () => {
|
||||
const { svc, spaceRepo } = buildService({});
|
||||
|
||||
await svc.updateSpace({ spaceId } as any, workspaceId);
|
||||
|
||||
expect(spaceRepo.updateGitSyncSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// --- audit delta on the git-sync toggle (test-strategy Module 4 / item #5)
|
||||
// updateSpace builds a before/after delta only when a flag's value actually
|
||||
// changes, and only logs an audit event when that delta is non-empty. These
|
||||
// assert that contract specifically for gitSyncEnabled.
|
||||
it('writes a SPACE_UPDATED audit delta on a REAL gitSyncEnabled change (false -> true)', async () => {
|
||||
// Prior persisted state: gitSync.enabled = false; the request flips it on.
|
||||
const { svc, auditService } = buildService({ gitSync: { enabled: false } });
|
||||
|
||||
await svc.updateSpace(
|
||||
{ spaceId, gitSyncEnabled: true } as any,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
expect(auditService.log).toHaveBeenCalledTimes(1);
|
||||
expect(auditService.log).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
resourceId: spaceId,
|
||||
spaceId,
|
||||
changes: {
|
||||
before: expect.objectContaining({ gitSyncEnabled: false }),
|
||||
after: expect.objectContaining({ gitSyncEnabled: true }),
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('also records the delta when no prior gitSync settings exist (undefined -> true defaults prev to false)', async () => {
|
||||
// No gitSync key at all: prev resolves to the `?? false` default, so
|
||||
// enabling it is still a real change and is audited.
|
||||
const { svc, auditService } = buildService({});
|
||||
|
||||
await svc.updateSpace(
|
||||
{ spaceId, gitSyncEnabled: true } as any,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
expect(auditService.log).toHaveBeenCalledTimes(1);
|
||||
const call = auditService.log.mock.calls[0][0];
|
||||
expect(call.changes.before.gitSyncEnabled).toBe(false);
|
||||
expect(call.changes.after.gitSyncEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('does NOT write an audit delta on a no-op gitSyncEnabled (same value true -> true)', async () => {
|
||||
// Prior persisted state already true; the request sets the same value.
|
||||
// updateGitSyncSettings still runs (idempotent persist), but nothing is
|
||||
// added to the before/after delta, so no audit event is emitted.
|
||||
const { svc, spaceRepo, auditService } = buildService({
|
||||
gitSync: { enabled: true },
|
||||
});
|
||||
|
||||
await svc.updateSpace(
|
||||
{ spaceId, gitSyncEnabled: true } as any,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
expect(spaceRepo.updateGitSyncSettings).toHaveBeenCalledTimes(1);
|
||||
expect(auditService.log).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// --- autoMergeConflicts: a SECOND key in the SAME `gitSync` jsonb object,
|
||||
// persisted the same way as `enabled` (the repo's jsonb-merge keeps siblings).
|
||||
it('persists autoMergeConflicts via updateGitSyncSettings(autoMergeConflicts)', async () => {
|
||||
const { svc, spaceRepo } = buildService({});
|
||||
|
||||
await svc.updateSpace(
|
||||
{ spaceId, autoMergeConflicts: true } as any,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
expect(spaceRepo.updateGitSyncSettings).toHaveBeenCalledWith(
|
||||
spaceId,
|
||||
workspaceId,
|
||||
'autoMergeConflicts',
|
||||
true,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not call updateGitSyncSettings when autoMergeConflicts is undefined', async () => {
|
||||
const { svc, spaceRepo } = buildService({});
|
||||
|
||||
await svc.updateSpace({ spaceId } as any, workspaceId);
|
||||
|
||||
expect(spaceRepo.updateGitSyncSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('writes a SPACE_UPDATED audit delta on a REAL autoMergeConflicts change (false -> true)', async () => {
|
||||
// Prior persisted state: gitSync.autoMergeConflicts = false; flip it on.
|
||||
const { svc, auditService } = buildService({
|
||||
gitSync: { autoMergeConflicts: false },
|
||||
});
|
||||
|
||||
await svc.updateSpace(
|
||||
{ spaceId, autoMergeConflicts: true } as any,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
expect(auditService.log).toHaveBeenCalledTimes(1);
|
||||
expect(auditService.log).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
resourceId: spaceId,
|
||||
spaceId,
|
||||
changes: {
|
||||
before: expect.objectContaining({ autoMergeConflicts: false }),
|
||||
after: expect.objectContaining({ autoMergeConflicts: true }),
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('does NOT write an audit delta on a no-op autoMergeConflicts (same value true -> true)', async () => {
|
||||
const { svc, spaceRepo, auditService } = buildService({
|
||||
gitSync: { autoMergeConflicts: true },
|
||||
});
|
||||
|
||||
await svc.updateSpace(
|
||||
{ spaceId, autoMergeConflicts: true } as any,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
expect(spaceRepo.updateGitSyncSettings).toHaveBeenCalledTimes(1);
|
||||
expect(auditService.log).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -213,41 +213,6 @@ export class SpaceService {
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof updateSpaceDto.gitSyncEnabled !== 'undefined') {
|
||||
const prev = settingsBefore?.gitSync?.enabled ?? false;
|
||||
if (prev !== updateSpaceDto.gitSyncEnabled) {
|
||||
before.gitSyncEnabled = prev;
|
||||
after.gitSyncEnabled = updateSpaceDto.gitSyncEnabled;
|
||||
}
|
||||
|
||||
await this.spaceRepo.updateGitSyncSettings(
|
||||
updateSpaceDto.spaceId,
|
||||
workspaceId,
|
||||
'enabled',
|
||||
updateSpaceDto.gitSyncEnabled,
|
||||
trx,
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof updateSpaceDto.autoMergeConflicts !== 'undefined') {
|
||||
const prev = settingsBefore?.gitSync?.autoMergeConflicts ?? false;
|
||||
if (prev !== updateSpaceDto.autoMergeConflicts) {
|
||||
before.autoMergeConflicts = prev;
|
||||
after.autoMergeConflicts = updateSpaceDto.autoMergeConflicts;
|
||||
}
|
||||
|
||||
// Merges into the SAME `gitSync` jsonb object as `enabled` (the repo's
|
||||
// jsonb-merge preserves sibling keys), so toggling one never clobbers the
|
||||
// other.
|
||||
await this.spaceRepo.updateGitSyncSettings(
|
||||
updateSpaceDto.spaceId,
|
||||
workspaceId,
|
||||
'autoMergeConflicts',
|
||||
updateSpaceDto.autoMergeConflicts,
|
||||
trx,
|
||||
);
|
||||
}
|
||||
|
||||
updatedSpace = await this.spaceRepo.updateSpace(
|
||||
{
|
||||
name: updateSpaceDto.name,
|
||||
|
||||
@@ -84,6 +84,13 @@ 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;
|
||||
|
||||
@@ -330,6 +330,7 @@ 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' ||
|
||||
@@ -337,7 +338,13 @@ export class WorkspaceService {
|
||||
) {
|
||||
const ws = await this.db
|
||||
.selectFrom('workspaces')
|
||||
.select(['id', 'licenseKey', 'plan', 'trashRetentionDays'])
|
||||
.select([
|
||||
'id',
|
||||
'licenseKey',
|
||||
'plan',
|
||||
'trashRetentionDays',
|
||||
'temporaryNoteHours',
|
||||
])
|
||||
.where('id', '=', workspaceId)
|
||||
.executeTakeFirst();
|
||||
|
||||
@@ -378,6 +385,14 @@ 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) {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,157 +0,0 @@
|
||||
import {
|
||||
Kysely,
|
||||
CamelCasePlugin,
|
||||
DummyDriver,
|
||||
PostgresAdapter,
|
||||
PostgresIntrospector,
|
||||
PostgresQueryCompiler,
|
||||
CompiledQuery,
|
||||
} from 'kysely';
|
||||
import { PageRepo } from './page.repo';
|
||||
import type { KyselyDB } from '../../types/kysely.types';
|
||||
|
||||
/**
|
||||
* SQL-builder unit test for the git-sync provenance stamp on PageRepo's
|
||||
* soft-delete / restore paths (PR #119 review). Both `removePage` and
|
||||
* `restorePage` take an optional `lastUpdatedSource` arg and conditionally fold
|
||||
* it into the recursive-subtree `UPDATE pages SET ...` via
|
||||
* `...(lastUpdatedSource ? { lastUpdatedSource } : {})`. The change-listener
|
||||
* loop-guard reads `last_updated_source = 'git-sync'` to recognize git-sync's own
|
||||
* writes and skip the echo cycle; this test guards that the stamp is present when
|
||||
* the arg is supplied and ABSENT when it is omitted (an ordinary user delete must
|
||||
* not clobber the column).
|
||||
*
|
||||
* Harness: the same compile-only Kysely/DummyDriver pattern as
|
||||
* space.repo.spec.ts, plus the production `CamelCasePlugin` (so the compiled SQL
|
||||
* carries the real snake_case column names, e.g. `last_updated_source`) and a
|
||||
* thin driver that returns ONE fixed row for every query. The fixed row is what
|
||||
* lets the repo's guard reads (root snapshot / recursive descendants / restore
|
||||
* target) resolve non-empty so execution reaches the subtree UPDATE we assert on
|
||||
* — a bare DummyDriver returns no rows and both methods short-circuit before the
|
||||
* update. We never hit a real database; we capture each compiled statement via
|
||||
* Kysely's `log` hook and inspect the `update "pages" set ...` SQL.
|
||||
*/
|
||||
describe('PageRepo — git-sync provenance on soft-delete / restore SQL', () => {
|
||||
// A single row shaped to satisfy every column the repo reads off its guard
|
||||
// queries. `parentPageId: null` keeps restorePage on the simple path (no
|
||||
// parent-detach UPDATE), so the only `update "pages"` statement is the one we
|
||||
// assert on.
|
||||
const FIXED_ROW = {
|
||||
id: 'p1',
|
||||
slugId: 's1',
|
||||
title: 'Doc',
|
||||
icon: null,
|
||||
position: 'a0',
|
||||
spaceId: 'space-1',
|
||||
parentPageId: null,
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
class FixedRowDriver extends DummyDriver {
|
||||
async acquireConnection(): Promise<any> {
|
||||
return {
|
||||
async executeQuery() {
|
||||
return { rows: [{ ...FIXED_ROW }] };
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
async *streamQuery() {},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface Captured {
|
||||
sql: string;
|
||||
parameters: readonly unknown[];
|
||||
}
|
||||
|
||||
// Compile-only Kysely on the Postgres dialect (CamelCasePlugin for real column
|
||||
// names) whose `log` hook records every executed statement's compiled SQL.
|
||||
function makeRepoCapturingSql() {
|
||||
const captured: Captured[] = [];
|
||||
const db = new Kysely<any>({
|
||||
dialect: {
|
||||
createAdapter: () => new PostgresAdapter(),
|
||||
createDriver: () => new FixedRowDriver(),
|
||||
createIntrospector: (d) => new PostgresIntrospector(d),
|
||||
createQueryCompiler: () => new PostgresQueryCompiler(),
|
||||
},
|
||||
plugins: [new CamelCasePlugin()],
|
||||
log: (event) => {
|
||||
if (event.level === 'query') {
|
||||
const q = event.query as CompiledQuery;
|
||||
captured.push({ sql: q.sql, parameters: q.parameters });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const repo = new PageRepo(
|
||||
db as unknown as KyselyDB,
|
||||
{} as any,
|
||||
{ emit: jest.fn() } as any,
|
||||
);
|
||||
// Find the single subtree UPDATE on pages (collapse whitespace for matching).
|
||||
const getUpdatePagesSql = (): Captured | undefined =>
|
||||
captured
|
||||
.map((c) => ({ ...c, sql: c.sql.replace(/\s+/g, ' ') }))
|
||||
.find((c) => /update "pages" set/i.test(c.sql));
|
||||
return { repo, getUpdatePagesSql };
|
||||
}
|
||||
|
||||
describe('removePage', () => {
|
||||
it("stamps last_updated_source = 'git-sync' on the subtree soft-delete when the provenance arg is supplied", async () => {
|
||||
const { repo, getUpdatePagesSql } = makeRepoCapturingSql();
|
||||
|
||||
await repo.removePage('p1', 'user-1', 'ws-1', 'git-sync');
|
||||
|
||||
const update = getUpdatePagesSql();
|
||||
expect(update).toBeDefined();
|
||||
// The provenance column is in the UPDATE's SET clause...
|
||||
expect(update!.sql).toContain('"last_updated_source" =');
|
||||
// ...with the 'git-sync' marker as the bound value.
|
||||
expect(update!.parameters).toContain('git-sync');
|
||||
// Sanity: it is still the soft-delete UPDATE (sets deleted_at too).
|
||||
expect(update!.sql).toContain('"deleted_at" =');
|
||||
});
|
||||
|
||||
it('OMITS last_updated_source from the soft-delete when the provenance arg is undefined', async () => {
|
||||
const { repo, getUpdatePagesSql } = makeRepoCapturingSql();
|
||||
|
||||
await repo.removePage('p1', 'user-1', 'ws-1');
|
||||
|
||||
const update = getUpdatePagesSql();
|
||||
expect(update).toBeDefined();
|
||||
// Ordinary user delete: the column must NOT be touched (keeps prior value).
|
||||
expect(update!.sql).not.toContain('last_updated_source');
|
||||
expect(update!.parameters).not.toContain('git-sync');
|
||||
// It is still the soft-delete UPDATE.
|
||||
expect(update!.sql).toContain('"deleted_at" =');
|
||||
});
|
||||
});
|
||||
|
||||
describe('restorePage', () => {
|
||||
it("stamps last_updated_source = 'git-sync' on the subtree restore when the provenance arg is supplied", async () => {
|
||||
const { repo, getUpdatePagesSql } = makeRepoCapturingSql();
|
||||
|
||||
await repo.restorePage('p1', 'ws-1', 'git-sync');
|
||||
|
||||
const update = getUpdatePagesSql();
|
||||
expect(update).toBeDefined();
|
||||
expect(update!.sql).toContain('"last_updated_source" =');
|
||||
expect(update!.parameters).toContain('git-sync');
|
||||
// Sanity: it is the restore UPDATE (clears deleted_at).
|
||||
expect(update!.sql).toContain('"deleted_at" =');
|
||||
});
|
||||
|
||||
it('OMITS last_updated_source from the restore when the provenance arg is undefined', async () => {
|
||||
const { repo, getUpdatePagesSql } = makeRepoCapturingSql();
|
||||
|
||||
await repo.restorePage('p1', 'ws-1');
|
||||
|
||||
const update = getUpdatePagesSql();
|
||||
expect(update).toBeDefined();
|
||||
expect(update!.sql).not.toContain('last_updated_source');
|
||||
expect(update!.parameters).not.toContain('git-sync');
|
||||
expect(update!.sql).toContain('"deleted_at" =');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -51,6 +51,7 @@ export class PageRepo {
|
||||
'workspaceId',
|
||||
'isLocked',
|
||||
'isTemplate',
|
||||
'temporaryExpiresAt',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'deletedAt',
|
||||
@@ -297,11 +298,6 @@ export class PageRepo {
|
||||
pageId: string,
|
||||
deletedById: string,
|
||||
workspaceId: string,
|
||||
// Optional provenance marker. When the soft-delete is driven by an automated
|
||||
// data plane (e.g. git-sync), stamp `lastUpdatedSource` so the change-listener
|
||||
// loop-guard recognizes it as its own write and does not schedule an echo
|
||||
// cycle. Omitted for ordinary user deletes (column keeps its prior value).
|
||||
lastUpdatedSource?: string,
|
||||
): Promise<void> {
|
||||
const currentDate = new Date();
|
||||
|
||||
@@ -352,7 +348,6 @@ export class PageRepo {
|
||||
.set({
|
||||
deletedById: deletedById,
|
||||
deletedAt: currentDate,
|
||||
...(lastUpdatedSource ? { lastUpdatedSource } : {}),
|
||||
})
|
||||
.where('id', 'in', pageIds)
|
||||
.where('deletedAt', 'is', null)
|
||||
@@ -383,14 +378,7 @@ export class PageRepo {
|
||||
}
|
||||
}
|
||||
|
||||
async restorePage(
|
||||
pageId: string,
|
||||
workspaceId: string,
|
||||
// See removePage: stamp `lastUpdatedSource` for automated (git-sync) restores
|
||||
// so the change-listener loop-guard skips the echo cycle. Omitted for
|
||||
// ordinary user restores.
|
||||
lastUpdatedSource?: string,
|
||||
): Promise<void> {
|
||||
async restorePage(pageId: string, workspaceId: string): Promise<void> {
|
||||
// First, check if the page being restored has a deleted parent
|
||||
const pageToRestore = await this.db
|
||||
.selectFrom('pages')
|
||||
@@ -438,11 +426,10 @@ export class PageRepo {
|
||||
// Restore all pages, but only detach the root page if its parent is deleted
|
||||
await this.db
|
||||
.updateTable('pages')
|
||||
.set({
|
||||
deletedById: null,
|
||||
deletedAt: null,
|
||||
...(lastUpdatedSource ? { lastUpdatedSource } : {}),
|
||||
})
|
||||
// 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 })
|
||||
.where('id', 'in', pageIds)
|
||||
.execute();
|
||||
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
import {
|
||||
Kysely,
|
||||
DummyDriver,
|
||||
PostgresAdapter,
|
||||
PostgresIntrospector,
|
||||
PostgresQueryCompiler,
|
||||
CompiledQuery,
|
||||
} from 'kysely';
|
||||
import { SpaceRepo } from './space.repo';
|
||||
import type { KyselyDB } from '../../types/kysely.types';
|
||||
|
||||
/**
|
||||
* SQL-builder unit test for the jsonb-merge invariant of
|
||||
* SpaceRepo.updateGitSyncSettings (review comment #694 / test-strategy item #6).
|
||||
*
|
||||
* The merge is RAW SQL, so a behavioural test would need a live Postgres — which
|
||||
* is intentionally out of scope here (the reviewer's own §13.3 was deferred for
|
||||
* the same reason). Instead we follow the existing repo-spec convention
|
||||
* (ai-agent-roles.repo.spec.ts) of NOT executing: we compile the query with a
|
||||
* DummyDriver Postgres dialect and assert the generated SQL preserves sibling
|
||||
* keys. The structural invariant the SQL must encode:
|
||||
*
|
||||
* settings := COALESCE(settings, '{}') || jsonb_build_object('gitSync', ...)
|
||||
* gitSync := COALESCE(settings->'gitSync', '{}') || jsonb_build_object(key, value)
|
||||
*
|
||||
* The OUTER `||` merges into the existing top-level `settings`, so a sibling
|
||||
* top-level key (e.g. `sharing`) is preserved. The INNER COALESCE merges into
|
||||
* the existing `gitSync` object, so a sibling key inside gitSync (e.g. `other`)
|
||||
* is preserved. A naive `set settings = jsonb_build_object('gitSync', ...)`
|
||||
* would clobber both — this test guards exactly that regression.
|
||||
*/
|
||||
describe('SpaceRepo.updateGitSyncSettings — jsonb merge SQL', () => {
|
||||
// A real Kysely on the Postgres dialect, but with a DummyDriver: it compiles
|
||||
// queries to real Postgres SQL without ever opening a connection.
|
||||
function makeCompileOnlyDb() {
|
||||
return new Kysely<any>({
|
||||
dialect: {
|
||||
createAdapter: () => new PostgresAdapter(),
|
||||
createDriver: () => new DummyDriver(),
|
||||
createIntrospector: (db) => new PostgresIntrospector(db),
|
||||
createQueryCompiler: () => new PostgresQueryCompiler(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Build the repo over the compile-only db. The repo terminates the query with
|
||||
// `.executeTakeFirst()`, so we wrap every kysely builder in a Proxy: when the
|
||||
// repo finally calls `executeTakeFirst`, we `.compile()` that same builder
|
||||
// ourselves to capture the exact SQL it was about to run, then delegate.
|
||||
function makeRepoCapturingSql() {
|
||||
const db = makeCompileOnlyDb();
|
||||
let captured: CompiledQuery | undefined;
|
||||
|
||||
// kysely builders are immutable — each .set()/.where()/.returningAll()
|
||||
// returns a NEW builder — so re-wrap any chainable result.
|
||||
const wrap = (b: any): any =>
|
||||
new Proxy(b, {
|
||||
get(target, prop, receiver) {
|
||||
const value = Reflect.get(target, prop, receiver);
|
||||
if (typeof value !== 'function') return value;
|
||||
return (...callArgs: unknown[]) => {
|
||||
// Capture the SQL at the terminal execute call.
|
||||
if (
|
||||
(prop === 'executeTakeFirst' || prop === 'execute') &&
|
||||
typeof target.compile === 'function'
|
||||
) {
|
||||
captured = target.compile();
|
||||
}
|
||||
const result = value.apply(target, callArgs);
|
||||
if (
|
||||
result &&
|
||||
typeof result === 'object' &&
|
||||
typeof (result as any).compile === 'function'
|
||||
) {
|
||||
return wrap(result);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const originalUpdateTable = db.updateTable.bind(db);
|
||||
jest
|
||||
.spyOn(db, 'updateTable')
|
||||
.mockImplementation((...args: Parameters<typeof originalUpdateTable>) =>
|
||||
wrap(originalUpdateTable(...args)),
|
||||
);
|
||||
|
||||
const repo = new SpaceRepo(db as unknown as KyselyDB, {} as any);
|
||||
return { repo, getCaptured: () => captured };
|
||||
}
|
||||
|
||||
it("compiles a jsonb merge that preserves sibling top-level and gitSync keys", async () => {
|
||||
const { repo, getCaptured } = makeRepoCapturingSql();
|
||||
|
||||
// DummyDriver yields no rows; executeTakeFirst resolves to undefined. The
|
||||
// SQL is fully compiled by then, which is all we assert.
|
||||
await repo.updateGitSyncSettings('space-1', 'ws-1', 'enabled', true);
|
||||
|
||||
const compiled = getCaptured();
|
||||
expect(compiled).toBeDefined();
|
||||
// The raw SQL template carries newlines/indentation; collapse whitespace so
|
||||
// the structural assertions are not coupled to source formatting.
|
||||
const sql = compiled!.sql.replace(/\s+/g, ' ');
|
||||
|
||||
// OUTER merge into the existing settings object -> sibling top-level keys
|
||||
// (e.g. `sharing`) survive (NOT a bare jsonb_build_object assignment).
|
||||
expect(sql).toContain(`set "settings" = COALESCE(settings, '{}'::jsonb) ||`);
|
||||
// INNER merge into the existing gitSync object -> sibling gitSync keys
|
||||
// (e.g. `other`) survive.
|
||||
expect(sql).toContain(
|
||||
`jsonb_build_object('gitSync', COALESCE(settings->'gitSync', '{}'::jsonb) ||`,
|
||||
);
|
||||
// The pref key is set via jsonb_build_object on the inner object.
|
||||
expect(sql).toContain(`jsonb_build_object('enabled',`);
|
||||
// Scoped to the row + workspace.
|
||||
expect(sql).toContain(`where "id" =`);
|
||||
expect(sql).toContain(`and "workspaceId" =`);
|
||||
|
||||
// Sanity: this is NOT a clobbering assignment (no top-level
|
||||
// `set "settings" = jsonb_build_object(` without the COALESCE/merge).
|
||||
expect(sql).not.toContain(`set "settings" = jsonb_build_object(`);
|
||||
|
||||
// The pref VALUE is inlined via sql.lit (matches the repo's sql.lit usage);
|
||||
// updatedAt + id + workspaceId are the only bound parameters (the jsonb
|
||||
// merge text is all literal). updatedAt is a Date, so assert id/workspaceId.
|
||||
expect(compiled!.parameters).toContain('space-1');
|
||||
expect(compiled!.parameters).toContain('ws-1');
|
||||
});
|
||||
|
||||
it('inlines the prefKey/prefValue literally (sql.raw key, sql.lit value)', async () => {
|
||||
const { repo, getCaptured } = makeRepoCapturingSql();
|
||||
|
||||
await repo.updateGitSyncSettings('space-1', 'ws-1', 'enabled', false);
|
||||
|
||||
const sql = getCaptured()!.sql.replace(/\s+/g, ' ');
|
||||
// key via sql.raw + value via sql.lit -> both appear literally in the
|
||||
// inner build object (no bound parameter for either).
|
||||
expect(sql).toContain(`jsonb_build_object('enabled', false)`);
|
||||
});
|
||||
});
|
||||
@@ -111,28 +111,6 @@ export class SpaceRepo {
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async updateGitSyncSettings(
|
||||
spaceId: string,
|
||||
workspaceId: string,
|
||||
prefKey: string,
|
||||
prefValue: string | boolean,
|
||||
trx?: KyselyTransaction,
|
||||
) {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
return db
|
||||
.updateTable('spaces')
|
||||
.set({
|
||||
settings: sql`COALESCE(settings, '{}'::jsonb)
|
||||
|| jsonb_build_object('gitSync', COALESCE(settings->'gitSync', '{}'::jsonb)
|
||||
|| jsonb_build_object('${sql.raw(prefKey)}', ${sql.lit(prefValue)}))`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where('id', '=', spaceId)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.returningAll()
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async updateCommentSettings(
|
||||
spaceId: string,
|
||||
workspaceId: string,
|
||||
|
||||
@@ -58,6 +58,7 @@ export class WorkspaceRepo {
|
||||
'plan',
|
||||
'enforceMfa',
|
||||
'trashRetentionDays',
|
||||
'temporaryNoteHours',
|
||||
'isScimEnabled',
|
||||
];
|
||||
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
||||
|
||||
2
apps/server/src/database/types/db.d.ts
vendored
2
apps/server/src/database/types/db.d.ts
vendored
@@ -297,6 +297,7 @@ export interface Pages {
|
||||
position: string | null;
|
||||
slugId: string;
|
||||
spaceId: string;
|
||||
temporaryExpiresAt: Timestamp | null;
|
||||
textContent: string | null;
|
||||
title: string | null;
|
||||
tsv: string | null;
|
||||
@@ -419,6 +420,7 @@ export interface WorkspaceInvitations {
|
||||
export interface Workspaces {
|
||||
auditRetentionDays: Generated<number>;
|
||||
trashRetentionDays: Generated<number>;
|
||||
temporaryNoteHours: Generated<number>;
|
||||
billingEmail: string | null;
|
||||
createdAt: Generated<Timestamp>;
|
||||
customDomain: string | null;
|
||||
|
||||
@@ -14,112 +14,4 @@ describe('EnvironmentService', () => {
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('getGitSyncPollIntervalMs', () => {
|
||||
const withEnv = (value?: string) =>
|
||||
new EnvironmentService({
|
||||
get: (_key: string, fallback?: string) => value ?? fallback,
|
||||
} as any);
|
||||
|
||||
it('defaults to 15000 when unset', () => {
|
||||
expect(withEnv().getGitSyncPollIntervalMs()).toBe(15000);
|
||||
});
|
||||
|
||||
it('parses a valid positive int', () => {
|
||||
expect(withEnv('30000').getGitSyncPollIntervalMs()).toBe(30000);
|
||||
});
|
||||
|
||||
it('falls back to 15000 for non-positive or unparseable values', () => {
|
||||
expect(withEnv('0').getGitSyncPollIntervalMs()).toBe(15000);
|
||||
expect(withEnv('-100').getGitSyncPollIntervalMs()).toBe(15000);
|
||||
expect(withEnv('not-a-number').getGitSyncPollIntervalMs()).toBe(15000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGitSyncDebounceMs', () => {
|
||||
const withEnv = (value?: string) =>
|
||||
new EnvironmentService({
|
||||
get: (_key: string, fallback?: string) => value ?? fallback,
|
||||
} as any);
|
||||
|
||||
it('defaults to 2000 when unset', () => {
|
||||
expect(withEnv().getGitSyncDebounceMs()).toBe(2000);
|
||||
});
|
||||
|
||||
it('parses a valid positive int', () => {
|
||||
expect(withEnv('500').getGitSyncDebounceMs()).toBe(500);
|
||||
});
|
||||
|
||||
it('falls back to 2000 for non-positive or unparseable values', () => {
|
||||
expect(withEnv('0').getGitSyncDebounceMs()).toBe(2000);
|
||||
expect(withEnv('-5').getGitSyncDebounceMs()).toBe(2000);
|
||||
expect(withEnv('not-a-number').getGitSyncDebounceMs()).toBe(2000);
|
||||
});
|
||||
});
|
||||
|
||||
// getGitSyncDataDir reads two distinct keys (GIT_SYNC_DATA_DIR and DATA_DIR),
|
||||
// so this builder maps each key to a supplied value (and honours the fallback
|
||||
// the getter passes for DATA_DIR's `|| './data'`).
|
||||
describe('getGitSyncDataDir', () => {
|
||||
const withEnv = (values: Record<string, string | undefined>) =>
|
||||
new EnvironmentService({
|
||||
get: (key: string, fallback?: string) => values[key] ?? fallback,
|
||||
} as any);
|
||||
|
||||
it("defaults to './data/git-sync' when neither key is set", () => {
|
||||
expect(withEnv({}).getGitSyncDataDir()).toBe('./data/git-sync');
|
||||
});
|
||||
|
||||
it('derives from DATA_DIR with the /git-sync suffix', () => {
|
||||
expect(
|
||||
withEnv({ DATA_DIR: '/var/lib/docmost' }).getGitSyncDataDir(),
|
||||
).toBe('/var/lib/docmost/git-sync');
|
||||
});
|
||||
|
||||
it('strips trailing slashes from DATA_DIR before appending', () => {
|
||||
expect(
|
||||
withEnv({ DATA_DIR: '/var/lib/docmost///' }).getGitSyncDataDir(),
|
||||
).toBe('/var/lib/docmost/git-sync');
|
||||
});
|
||||
|
||||
it('lets an explicit GIT_SYNC_DATA_DIR override the DATA_DIR derivation', () => {
|
||||
expect(
|
||||
withEnv({
|
||||
GIT_SYNC_DATA_DIR: '/custom/vault',
|
||||
DATA_DIR: '/var/lib/docmost',
|
||||
}).getGitSyncDataDir(),
|
||||
).toBe('/custom/vault');
|
||||
});
|
||||
|
||||
it('returns the explicit override verbatim (no /git-sync suffix, no slash strip)', () => {
|
||||
expect(
|
||||
withEnv({ GIT_SYNC_DATA_DIR: '/custom/vault/' }).getGitSyncDataDir(),
|
||||
).toBe('/custom/vault/');
|
||||
});
|
||||
});
|
||||
|
||||
// isGitSyncEnabled is the `.toLowerCase() === 'true'` contract: only a
|
||||
// case-insensitive "true" enables it; everything else (unset, "false",
|
||||
// garbage) is false.
|
||||
describe('isGitSyncEnabled', () => {
|
||||
const withEnv = (value?: string) =>
|
||||
new EnvironmentService({
|
||||
get: (_key: string, fallback?: string) => value ?? fallback,
|
||||
} as any);
|
||||
|
||||
it('is true for "true" and "TRUE" (case-insensitive)', () => {
|
||||
expect(withEnv('true').isGitSyncEnabled()).toBe(true);
|
||||
expect(withEnv('TRUE').isGitSyncEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('is false when unset (defaults to "false")', () => {
|
||||
expect(withEnv().isGitSyncEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('is false for "false" and garbage values', () => {
|
||||
expect(withEnv('false').isGitSyncEnabled()).toBe(false);
|
||||
expect(withEnv('maybe').isGitSyncEnabled()).toBe(false);
|
||||
expect(withEnv('1').isGitSyncEnabled()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -320,96 +320,4 @@ export class EnvironmentService {
|
||||
.map((o) => o.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
// --- git-sync (issue #194 §7.2) -------------------------------------------------
|
||||
|
||||
/** Global master switch for the git-sync control plane (default false). */
|
||||
isGitSyncEnabled(): boolean {
|
||||
return (
|
||||
this.configService.get<string>('GIT_SYNC_ENABLED', 'false').toLowerCase() ===
|
||||
'true'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether gitmost serves the per-space vaults over smart-HTTP (the /git host).
|
||||
* When GIT_SYNC_HTTP_ENABLED is UNSET it DEFAULTS to isGitSyncEnabled() — so
|
||||
* enabling sync also enables the host unless explicitly disabled. When set, it
|
||||
* is honored verbatim ('true' -> on, anything else -> off).
|
||||
*/
|
||||
isGitSyncHttpEnabled(): boolean {
|
||||
const raw = this.configService.get<string>('GIT_SYNC_HTTP_ENABLED');
|
||||
if (raw === undefined) return this.isGitSyncEnabled();
|
||||
return raw.toLowerCase() === 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
* Root directory holding the per-space vault repos. Defaults to
|
||||
* `<DATA_DIR or ./data>/git-sync`. `DATA_DIR` is read directly (no dedicated
|
||||
* getter exists in this codebase) so the vault root tracks the data volume.
|
||||
*/
|
||||
getGitSyncDataDir(): string {
|
||||
const explicit = this.configService.get<string>('GIT_SYNC_DATA_DIR');
|
||||
if (explicit) return explicit;
|
||||
const dataDir = this.configService.get<string>('DATA_DIR') || './data';
|
||||
return `${dataDir.replace(/\/+$/, '')}/git-sync`;
|
||||
}
|
||||
|
||||
/** Optional remote template, e.g. `git@host:vault-{spaceId}.git`. */
|
||||
getGitSyncRemoteTemplate(): string | undefined {
|
||||
return this.configService.get<string>('GIT_SYNC_REMOTE_TEMPLATE');
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll-safety interval in ms (default 15000). A NaN / non-positive value falls
|
||||
* back to the default so a bad override can never disable or zero the poll loop.
|
||||
*/
|
||||
getGitSyncPollIntervalMs(): number {
|
||||
const parsed = parseInt(
|
||||
this.configService.get<string>('GIT_SYNC_POLL_INTERVAL_MS', '15000'),
|
||||
10,
|
||||
);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 15000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawned `git http-backend` watchdog timeout in ms (default 120000). Bounds a
|
||||
* single smart-HTTP request so a stalled `git-receive-pack` cannot hold the
|
||||
* per-space lock forever (the child is killed and a 500 sent on expiry). A NaN /
|
||||
* non-positive value falls back to the default so a bad override can never
|
||||
* disable the watchdog.
|
||||
*/
|
||||
getGitSyncBackendTimeoutMs(): number {
|
||||
const v = parseInt(
|
||||
this.configService.get<string>('GIT_SYNC_BACKEND_TIMEOUT_MS', '120000'),
|
||||
10,
|
||||
);
|
||||
return Number.isFinite(v) && v > 0 ? v : 120000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event debounce window in ms (default 2000). A NaN / non-positive value falls
|
||||
* back to the default so a bad override can never disable the debounce.
|
||||
*/
|
||||
getGitSyncDebounceMs(): number {
|
||||
const parsed = parseInt(
|
||||
this.configService.get<string>('GIT_SYNC_DEBOUNCE_MS', '2000'),
|
||||
10,
|
||||
);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 2000;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* The service user id git-sync writes are attributed to. Required when sync is
|
||||
* enabled (validated in environment.validation.ts); optional otherwise.
|
||||
*/
|
||||
getGitSyncServiceUserId(): string | undefined {
|
||||
return this.configService.get<string>('GIT_SYNC_SERVICE_USER_ID');
|
||||
}
|
||||
|
||||
/** Optional path to the SSH key used for git remote access. */
|
||||
getGitSyncSshKeyPath(): string | undefined {
|
||||
return this.configService.get<string>('GIT_SYNC_SSH_KEY_PATH');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validateSync } from 'class-validator';
|
||||
import { EnvironmentVariables } from './environment.validation';
|
||||
|
||||
/**
|
||||
* Validation-layer coverage for the git-sync env contract (test-strategy Module
|
||||
* 4 / item #4). We drive the decorated class with `validateSync` directly — the
|
||||
* exported `validate()` helper calls `process.exit(1)` on failure and so cannot
|
||||
* be asserted in-process. We only assert the git-sync rules, providing the
|
||||
* minimal always-required fields so unrelated validators do not add noise.
|
||||
*/
|
||||
describe('EnvironmentVariables — git-sync validation', () => {
|
||||
// A baseline config that satisfies the unconditionally-required fields
|
||||
// (DATABASE_URL, REDIS_URL, APP_SECRET) so the only errors we ever see come
|
||||
// from the git-sync rules under test.
|
||||
const baseConfig = {
|
||||
DATABASE_URL: 'postgres://user:pass@localhost:5432/docmost',
|
||||
REDIS_URL: 'redis://localhost:6379',
|
||||
APP_SECRET: 'x'.repeat(32),
|
||||
};
|
||||
|
||||
const validate = (extra: Record<string, unknown>) => {
|
||||
const instance = plainToInstance(EnvironmentVariables, {
|
||||
...baseConfig,
|
||||
...extra,
|
||||
});
|
||||
return validateSync(instance);
|
||||
};
|
||||
|
||||
const errorFor = (errors: ReturnType<typeof validateSync>, property: string) =>
|
||||
errors.find((e) => e.property === property);
|
||||
|
||||
it('flags GIT_SYNC_SERVICE_USER_ID when GIT_SYNC_ENABLED="true" and the id is absent', () => {
|
||||
const errors = validate({ GIT_SYNC_ENABLED: 'true' });
|
||||
|
||||
const err = errorFor(errors, 'GIT_SYNC_SERVICE_USER_ID');
|
||||
expect(err).toBeDefined();
|
||||
// @IsNotEmpty is the failing constraint (sync is on but no attributable
|
||||
// author was configured).
|
||||
expect(err?.constraints).toHaveProperty('isNotEmpty');
|
||||
});
|
||||
|
||||
it('accepts GIT_SYNC_ENABLED="true" once GIT_SYNC_SERVICE_USER_ID is present', () => {
|
||||
const errors = validate({
|
||||
GIT_SYNC_ENABLED: 'true',
|
||||
GIT_SYNC_SERVICE_USER_ID: 'service-user-1',
|
||||
});
|
||||
|
||||
expect(errorFor(errors, 'GIT_SYNC_SERVICE_USER_ID')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not require the service user id when git-sync is disabled (unset)', () => {
|
||||
const errors = validate({});
|
||||
|
||||
// The @ValidateIf gate (GIT_SYNC_ENABLED === "true") is not met, so the
|
||||
// required-if-enabled rule is skipped entirely.
|
||||
expect(errorFor(errors, 'GIT_SYNC_SERVICE_USER_ID')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not require the service user id when git-sync is explicitly "false"', () => {
|
||||
const errors = validate({ GIT_SYNC_ENABLED: 'false' });
|
||||
|
||||
expect(errorFor(errors, 'GIT_SYNC_SERVICE_USER_ID')).toBeUndefined();
|
||||
expect(errorFor(errors, 'GIT_SYNC_ENABLED')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects a GIT_SYNC_ENABLED value outside the {true,false} set via @IsIn', () => {
|
||||
const errors = validate({ GIT_SYNC_ENABLED: 'maybe' });
|
||||
|
||||
const err = errorFor(errors, 'GIT_SYNC_ENABLED');
|
||||
expect(err).toBeDefined();
|
||||
expect(err?.constraints).toHaveProperty('isIn');
|
||||
});
|
||||
});
|
||||
@@ -170,56 +170,6 @@ export class EnvironmentVariables {
|
||||
},
|
||||
)
|
||||
CLICKHOUSE_URL: string;
|
||||
|
||||
// --- git-sync (issue #194 §7.2) — all OPTIONAL. The master switch defaults off; a
|
||||
// required-if-enabled service user id is validated only when sync is on. ---
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['true', 'false'])
|
||||
@IsString()
|
||||
GIT_SYNC_ENABLED: string;
|
||||
|
||||
// Whether to serve the per-space vaults over smart-HTTP (the /git host).
|
||||
// When unset, defaults to GIT_SYNC_ENABLED (see isGitSyncHttpEnabled).
|
||||
@IsOptional()
|
||||
@IsIn(['true', 'false'])
|
||||
@IsString()
|
||||
GIT_SYNC_HTTP_ENABLED: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
GIT_SYNC_DATA_DIR: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
GIT_SYNC_REMOTE_TEMPLATE: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
GIT_SYNC_POLL_INTERVAL_MS: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
GIT_SYNC_DEBOUNCE_MS: string;
|
||||
|
||||
// Watchdog timeout (ms) for the spawned `git http-backend` process (default
|
||||
// 120000): a stalled receive-pack is killed so it cannot hold the per-space
|
||||
// lock forever. Optional int (validated as a string env).
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
GIT_SYNC_BACKEND_TIMEOUT_MS: string;
|
||||
|
||||
|
||||
// Required when git-sync is enabled: the service user create/move/rename/delete
|
||||
// are attributed to (issue #194 §7.2). Optional otherwise.
|
||||
@ValidateIf((obj) => obj.GIT_SYNC_ENABLED === 'true')
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
GIT_SYNC_SERVICE_USER_ID: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
GIT_SYNC_SSH_KEY_PATH: string;
|
||||
}
|
||||
|
||||
export function validate(config: Record<string, any>) {
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
/**
|
||||
* Git-sync control-plane constants.
|
||||
*
|
||||
* Event/job names are REUSED from the shared event contract (event.contants.ts)
|
||||
* so the listener subscribes to the exact names the rest of the server emits —
|
||||
* never a string literal that could drift. The Redis lock-key prefix + TTLs back
|
||||
* the single-writer leader lock (§9); the debounce default backs the per-space
|
||||
* event coalescing (§10).
|
||||
*/
|
||||
import { EventName } from '../../common/events/event.contants';
|
||||
|
||||
/**
|
||||
* The page lifecycle events the git-sync listener reacts to. A change
|
||||
* to any of these in an enabled space schedules a debounced sync cycle.
|
||||
* - PAGE_CREATED / PAGE_UPDATED / PAGE_MOVED — structural + content edits;
|
||||
* - PAGE_SOFT_DELETED / PAGE_RESTORED — Trash transitions (deletes are soft);
|
||||
* - PAGE_MOVED_TO_SPACE — cross-space move (cross-repo).
|
||||
*
|
||||
* NOTE: body edits arrive via PAGE_UPDATED (emitted from persistence.extension),
|
||||
* NOT via EventName.PAGE_CONTENT_UPDATED — that name is a BullMQ queue-job name,
|
||||
* not an EventEmitter2 event, so @OnEvent would never fire for it.
|
||||
*/
|
||||
export const GIT_SYNC_PAGE_EVENTS = [
|
||||
EventName.PAGE_CREATED,
|
||||
EventName.PAGE_UPDATED,
|
||||
EventName.PAGE_MOVED,
|
||||
EventName.PAGE_MOVED_TO_SPACE,
|
||||
EventName.PAGE_SOFT_DELETED,
|
||||
EventName.PAGE_RESTORED,
|
||||
] as const;
|
||||
|
||||
/** Redis key prefix for the per-space leader lock. */
|
||||
export const GIT_SYNC_LOCK_PREFIX = 'git-sync:lock:';
|
||||
|
||||
/**
|
||||
* Leader-lock TTL (ms). Must exceed the maximum expected cycle duration so the
|
||||
* lock is not lost mid-cycle; on a crash it expires on its own. The
|
||||
* in-process mutex (orchestrator) prevents overlapping cycles on one instance,
|
||||
* and the Redis lock prevents two instances racing the same space.
|
||||
*/
|
||||
export const GIT_SYNC_LOCK_TTL_MS = 5 * 60 * 1000;
|
||||
@@ -1,115 +0,0 @@
|
||||
// Unit tests for the ops/testing controller. The orchestrator, env,
|
||||
// and the workspace-ability factory are hand-built mocks. We assert the admin
|
||||
// guard (non-admin -> ForbiddenException, no orchestrator call), that trigger
|
||||
// uses the workspace from request context (never the body), and that status
|
||||
// returns the env-derived object.
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import {
|
||||
WorkspaceCaslAction,
|
||||
WorkspaceCaslSubject,
|
||||
} from '../../core/casl/interfaces/workspace-ability.type';
|
||||
import { GitSyncController } from './git-sync.controller';
|
||||
|
||||
type AnyMock = jest.Mock;
|
||||
|
||||
interface Built {
|
||||
controller: GitSyncController;
|
||||
orchestrator: { runOnce: AnyMock };
|
||||
env: Record<string, AnyMock>;
|
||||
workspaceAbility: { createForUser: AnyMock };
|
||||
ability: { cannot: AnyMock };
|
||||
}
|
||||
|
||||
function build(opts: { cannot?: boolean } = {}): Built {
|
||||
const { cannot = false } = opts;
|
||||
const ability = { cannot: jest.fn(() => cannot) };
|
||||
const workspaceAbility = { createForUser: jest.fn(() => ability) };
|
||||
|
||||
const orchestrator = {
|
||||
runOnce: jest.fn(async () => ({ spaceId: 'space-1', ran: true })),
|
||||
};
|
||||
const env: Record<string, AnyMock> = {
|
||||
isGitSyncEnabled: jest.fn(() => true),
|
||||
getGitSyncDataDir: jest.fn(() => '/vaults'),
|
||||
getGitSyncPollIntervalMs: jest.fn(() => 15000),
|
||||
getGitSyncDebounceMs: jest.fn(() => 2000),
|
||||
getGitSyncServiceUserId: jest.fn(() => 'svc-user'),
|
||||
};
|
||||
|
||||
const controller = new GitSyncController(
|
||||
orchestrator as any,
|
||||
env as any,
|
||||
workspaceAbility as any,
|
||||
);
|
||||
return { controller, orchestrator, env, workspaceAbility, ability };
|
||||
}
|
||||
|
||||
const USER = { id: 'user-1' } as any;
|
||||
const WORKSPACE = { id: 'ctx-ws' } as any;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('GitSyncController', () => {
|
||||
describe('trigger', () => {
|
||||
it('blocks a non-admin: throws ForbiddenException and never calls runOnce', async () => {
|
||||
const { controller, orchestrator, ability } = build({ cannot: true });
|
||||
|
||||
await expect(
|
||||
controller.trigger({ spaceId: 'space-1' } as any, USER, WORKSPACE),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
|
||||
expect(ability.cannot).toHaveBeenCalledWith(
|
||||
WorkspaceCaslAction.Manage,
|
||||
WorkspaceCaslSubject.Settings,
|
||||
);
|
||||
expect(orchestrator.runOnce).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('admin: calls runOnce(dto.spaceId, workspace.id) using the workspace from context', async () => {
|
||||
const { controller, orchestrator } = build({ cannot: false });
|
||||
|
||||
// The body carries an attacker-controlled workspaceId that must be ignored.
|
||||
const res = await controller.trigger(
|
||||
{ spaceId: 'space-1', workspaceId: 'evil-ws' } as any,
|
||||
USER,
|
||||
WORKSPACE,
|
||||
);
|
||||
|
||||
expect(orchestrator.runOnce).toHaveBeenCalledWith('space-1', 'ctx-ws');
|
||||
expect(res).toEqual({ spaceId: 'space-1', ran: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('status', () => {
|
||||
it('blocks a non-admin: throws ForbiddenException and never reads env', async () => {
|
||||
const { controller, env, ability } = build({ cannot: true });
|
||||
|
||||
await expect(controller.status(USER, WORKSPACE)).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
|
||||
expect(ability.cannot).toHaveBeenCalledWith(
|
||||
WorkspaceCaslAction.Manage,
|
||||
WorkspaceCaslSubject.Settings,
|
||||
);
|
||||
// The admin guard short-circuits before the env-derived status is built.
|
||||
expect(env.isGitSyncEnabled).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('admin: returns the env-derived status object', async () => {
|
||||
const { controller } = build({ cannot: false });
|
||||
|
||||
const res = await controller.status(USER, WORKSPACE);
|
||||
|
||||
expect(res).toEqual({
|
||||
enabled: true,
|
||||
dataDir: '/vaults',
|
||||
pollIntervalMs: 15000,
|
||||
debounceMs: 2000,
|
||||
serviceUserConfigured: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,97 +0,0 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Post,
|
||||
Get,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
||||
import { User, Workspace } from '@docmost/db/types/entity.types';
|
||||
import WorkspaceAbilityFactory from '../../core/casl/abilities/workspace-ability.factory';
|
||||
import {
|
||||
WorkspaceCaslAction,
|
||||
WorkspaceCaslSubject,
|
||||
} from '../../core/casl/interfaces/workspace-ability.type';
|
||||
import { EnvironmentService } from '../environment/environment.service';
|
||||
import { IsUUID } from 'class-validator';
|
||||
import {
|
||||
GitSyncOrchestrator,
|
||||
GitSyncRunStatus,
|
||||
} from './services/git-sync.orchestrator';
|
||||
|
||||
/** Body for the manual one-shot trigger. */
|
||||
class TriggerGitSyncDto {
|
||||
// The global ValidationPipe runs with whitelist:true, which STRIPS any field
|
||||
// lacking a validation decorator — without this @IsUUID the spaceId would be
|
||||
// dropped and arrive as undefined.
|
||||
@IsUUID()
|
||||
spaceId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ops/testing endpoints for the git-sync control plane. Admin-guarded
|
||||
* (workspace Manage/Settings, mirroring WorkspaceController) so only workspace
|
||||
* admins can force a cycle. Mounted under the global `/api` prefix:
|
||||
* - POST /api/git-sync/trigger { spaceId } — run one cycle now (await result),
|
||||
* - GET /api/git-sync/status — report whether sync is enabled + config.
|
||||
*/
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('git-sync')
|
||||
export class GitSyncController {
|
||||
constructor(
|
||||
private readonly orchestrator: GitSyncOrchestrator,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
private readonly workspaceAbility: WorkspaceAbilityFactory,
|
||||
) {}
|
||||
|
||||
/** Throw unless the caller is a workspace admin (Manage Settings). */
|
||||
private assertAdmin(user: User, workspace: Workspace): void {
|
||||
const ability = this.workspaceAbility.createForUser(user, workspace);
|
||||
if (
|
||||
ability.cannot(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Settings)
|
||||
) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('trigger')
|
||||
async trigger(
|
||||
@Body() dto: TriggerGitSyncDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
): Promise<GitSyncRunStatus> {
|
||||
this.assertAdmin(user, workspace);
|
||||
// Use the workspace from the request context (never client-supplied).
|
||||
return this.orchestrator.runOnce(dto.spaceId, workspace.id);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Get('status')
|
||||
async status(
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
): Promise<{
|
||||
enabled: boolean;
|
||||
dataDir: string;
|
||||
pollIntervalMs: number;
|
||||
debounceMs: number;
|
||||
serviceUserConfigured: boolean;
|
||||
}> {
|
||||
this.assertAdmin(user, workspace);
|
||||
return {
|
||||
enabled: this.environmentService.isGitSyncEnabled(),
|
||||
dataDir: this.environmentService.getGitSyncDataDir(),
|
||||
pollIntervalMs: this.environmentService.getGitSyncPollIntervalMs(),
|
||||
debounceMs: this.environmentService.getGitSyncDebounceMs(),
|
||||
serviceUserConfigured: Boolean(
|
||||
this.environmentService.getGitSyncServiceUserId(),
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import type {
|
||||
VaultGit as VaultGitClass,
|
||||
vaultGitEnv as vaultGitEnvFn,
|
||||
runCycle as runCycleFn,
|
||||
parseDocmostMarkdown as parseDocmostMarkdownFn,
|
||||
markdownToProseMirror as markdownToProseMirrorFn,
|
||||
} from '@docmost/git-sync';
|
||||
|
||||
/**
|
||||
* Runtime value-export surface of the ESM-only `@docmost/git-sync` package that
|
||||
* the server consumes. Types are imported with `import type` (erased at compile,
|
||||
* no runtime require); only the VALUE exports below need the dynamic-load
|
||||
* treatment so a CJS `require()` of the ESM package never happens.
|
||||
*/
|
||||
interface GitSyncModule {
|
||||
VaultGit: typeof VaultGitClass;
|
||||
vaultGitEnv: typeof vaultGitEnvFn;
|
||||
runCycle: typeof runCycleFn;
|
||||
parseDocmostMarkdown: typeof parseDocmostMarkdownFn;
|
||||
markdownToProseMirror: typeof markdownToProseMirrorFn;
|
||||
}
|
||||
|
||||
// TS with module:commonjs downlevels a literal `import()` to `require()`, which
|
||||
// cannot load the ESM-only `@docmost/git-sync` package. Indirect through
|
||||
// Function so the real dynamic `import()` survives compilation and can load ESM
|
||||
// from CommonJS at runtime (same trick as
|
||||
// apps/server/src/core/ai-chat/tools/docmost-client.loader.ts and
|
||||
// integrations/mcp/mcp.service.ts).
|
||||
const esmImport = new Function(
|
||||
'specifier',
|
||||
'return import(specifier)',
|
||||
) as (specifier: string) => Promise<unknown>;
|
||||
|
||||
// Memoize the in-flight/loaded module so the dynamic import runs at most once.
|
||||
let modulePromise: Promise<GitSyncModule> | null = null;
|
||||
|
||||
/**
|
||||
* Lazily load the ESM-only `@docmost/git-sync` package (cached). Resolves the
|
||||
* package entry to an absolute path, then imports it as a `file://` URL so the
|
||||
* package "exports" map is honoured without bare-specifier resolution-base
|
||||
* fragility.
|
||||
*/
|
||||
export async function loadGitSync(): Promise<GitSyncModule> {
|
||||
if (!modulePromise) {
|
||||
modulePromise = (async () => {
|
||||
const entry = require.resolve('@docmost/git-sync');
|
||||
const mod = (await esmImport(
|
||||
pathToFileURL(entry).href,
|
||||
)) as GitSyncModule;
|
||||
return mod;
|
||||
})().catch((err) => {
|
||||
// Do not cache a rejected import — allow the next call to retry.
|
||||
modulePromise = null;
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
return modulePromise;
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { DatabaseModule } from '@docmost/db/database.module';
|
||||
import { EnvironmentModule } from '../environment/environment.module';
|
||||
import { CollaborationModule } from '../../collaboration/collaboration.module';
|
||||
import { PageModule } from '../../core/page/page.module';
|
||||
import { AuthModule } from '../../core/auth/auth.module';
|
||||
import { GitmostDataSourceService } from './services/gitmost-datasource.service';
|
||||
import { GitSyncOrchestrator } from './services/git-sync.orchestrator';
|
||||
import { SpaceLockService } from './services/space-lock.service';
|
||||
import { VaultRegistryService } from './services/vault-registry.service';
|
||||
import { PageChangeListener } from './listeners/page-change.listener';
|
||||
import { GitSyncController } from './git-sync.controller';
|
||||
import { GitHttpBackendService } from './http/git-http-backend.service';
|
||||
import { GitHttpService } from './http/git-http.service';
|
||||
|
||||
/**
|
||||
* The git-sync control plane. Wires the native datasource, the
|
||||
* orchestrator (poll + leader-lock), the per-space vault registry, the
|
||||
* event-driven listener, and the admin trigger controller.
|
||||
*
|
||||
* Imports:
|
||||
* - DatabaseModule (global) — PageRepo / SpaceRepo / KyselyDB for the
|
||||
* datasource + orchestrator queries;
|
||||
* - EnvironmentModule (global) — EnvironmentService config;
|
||||
* - CollaborationModule — exports CollaborationGateway for native body writes;
|
||||
* - PageModule — exports PageService for structural mutations;
|
||||
* - ScheduleModule (NOT forRoot) — so SchedulerRegistry is injectable (the
|
||||
* orchestrator registers a DYNAMIC poll interval in onModuleInit). forRoot()
|
||||
* is already registered globally by TelemetryModule; importing the plain
|
||||
* module here avoids a duplicate scheduler registration.
|
||||
*
|
||||
* RedisService is provided by the global RedisModule (app.module) and CASL's
|
||||
* WorkspaceAbilityFactory by the global CaslModule — both resolve without an
|
||||
* explicit import here.
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
DatabaseModule,
|
||||
EnvironmentModule,
|
||||
CollaborationModule,
|
||||
PageModule,
|
||||
// AuthModule exports AuthService (verifyUserCredentials for /git HTTP Basic).
|
||||
AuthModule,
|
||||
ScheduleModule,
|
||||
],
|
||||
controllers: [GitSyncController],
|
||||
providers: [
|
||||
GitmostDataSourceService,
|
||||
GitSyncOrchestrator,
|
||||
SpaceLockService,
|
||||
VaultRegistryService,
|
||||
PageChangeListener,
|
||||
// /git smart-HTTP host (the raw Fastify route in main.ts resolves these).
|
||||
GitHttpBackendService,
|
||||
GitHttpService,
|
||||
],
|
||||
// Exported so the raw Fastify route registered in main.ts can resolve the
|
||||
// handler from the Nest container (app.get(GitHttpService)).
|
||||
exports: [GitHttpService],
|
||||
})
|
||||
export class GitSyncModule {}
|
||||
@@ -1,375 +0,0 @@
|
||||
// Unit tests for the pure CGI-response helpers used by GitHttpBackendService.
|
||||
// The header/body split MUST treat the body as binary (Buffer) and never
|
||||
// stringify it; the Status: header sets the HTTP status (default 200).
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
// Mock the spawn boundary so run() never launches a real `git http-backend`; the
|
||||
// fake child lets us drive every stdout/stderr/error/close branch by hand.
|
||||
jest.mock('node:child_process', () => ({ spawn: jest.fn() }));
|
||||
// vaultGitEnv just builds the CGI env overlay; stub it to a passthrough so the
|
||||
// service runs without the real engine. The service loads it at runtime via the
|
||||
// `loadGitSync()` bridge (the ESM `@docmost/git-sync` package cannot be
|
||||
// `require()`d under jest), so we mock that loader rather than the package.
|
||||
jest.mock('../git-sync.loader', () => ({
|
||||
loadGitSync: jest.fn(async () => ({
|
||||
vaultGitEnv: (overlay: Record<string, string>) => overlay,
|
||||
})),
|
||||
}));
|
||||
|
||||
import {
|
||||
parseCgiResponse,
|
||||
splitCgiBuffer,
|
||||
buildGitBackendCgiEnv,
|
||||
GitHttpBackendService,
|
||||
} from './git-http-backend.service';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import type { GitHttpBackendRequest } from './git-http-backend.service';
|
||||
|
||||
const spawnMock = spawn as unknown as jest.Mock;
|
||||
|
||||
/** A fake `git http-backend` child: EventEmitter + stdout/stderr/stdin streams. */
|
||||
function fakeChild() {
|
||||
const child = new EventEmitter() as any;
|
||||
child.stdout = new EventEmitter();
|
||||
child.stderr = new EventEmitter();
|
||||
// stdin is written/ended/piped to; capture the calls, swallow nothing.
|
||||
child.stdin = Object.assign(new EventEmitter(), {
|
||||
end: jest.fn(),
|
||||
write: jest.fn(),
|
||||
});
|
||||
// The watchdog kills the child on timeout; capture the signal.
|
||||
child.kill = jest.fn();
|
||||
return child;
|
||||
}
|
||||
|
||||
/** A fake raw Node ServerResponse capturing status/headers/body/end. */
|
||||
function fakeRes() {
|
||||
const res: any = {
|
||||
headersSent: false,
|
||||
writableEnded: false,
|
||||
statusCode: 200,
|
||||
_headers: {} as Record<string, string>,
|
||||
_written: [] as Buffer[],
|
||||
setHeader: jest.fn((name: string, value: string) => {
|
||||
res._headers[name] = value;
|
||||
}),
|
||||
write: jest.fn((chunk: Buffer) => {
|
||||
res._written.push(chunk);
|
||||
return true;
|
||||
}),
|
||||
end: jest.fn((chunk?: Buffer | string) => {
|
||||
if (chunk !== undefined) res._written.push(chunk as Buffer);
|
||||
res.writableEnded = true;
|
||||
}),
|
||||
};
|
||||
return res;
|
||||
}
|
||||
|
||||
/** A fake raw Node IncomingMessage (GET => no body piped). */
|
||||
function fakeReq() {
|
||||
const req = new EventEmitter() as any;
|
||||
req.pipe = jest.fn();
|
||||
return req;
|
||||
}
|
||||
|
||||
const baseRequest: GitHttpBackendRequest = {
|
||||
spaceId: 'space-1',
|
||||
subpath: 'info/refs',
|
||||
method: 'GET',
|
||||
queryString: 'service=git-upload-pack',
|
||||
contentType: '',
|
||||
remoteUser: 'alice@example.com',
|
||||
};
|
||||
|
||||
function buildService(backendTimeoutMs = 120000) {
|
||||
const env = {
|
||||
getGitSyncDataDir: jest.fn(() => '/vaults'),
|
||||
// The watchdog timeout for the spawned git http-backend. Tests inject a tiny
|
||||
// value (or use fake timers) to drive the timeout branch.
|
||||
getGitSyncBackendTimeoutMs: jest.fn(() => backendTimeoutMs),
|
||||
};
|
||||
return new GitHttpBackendService(env as any);
|
||||
}
|
||||
|
||||
// `run()` now awaits the async `loadGitSync()` bridge before it spawns the
|
||||
// child, so the spawn (and its stream-handler wiring) happens one microtask
|
||||
// after `run()` is called. These tests drive the fake child synchronously, so
|
||||
// flush the microtask queue first to let `run()` reach the spawn.
|
||||
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
describe('GitHttpBackendService.run', () => {
|
||||
beforeEach(() => {
|
||||
spawnMock.mockReset();
|
||||
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
});
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
it('(a) responds 500 when the child errors before any headers were written', async () => {
|
||||
const child = fakeChild();
|
||||
spawnMock.mockReturnValue(child);
|
||||
const service = buildService();
|
||||
const res = fakeRes();
|
||||
|
||||
const p = service.run(baseRequest, fakeReq(), res);
|
||||
await flush();
|
||||
// Emit a child 'error' before any stdout -> 500, headers not already sent.
|
||||
child.emit('error', new Error('ENOENT spawn git'));
|
||||
await p;
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res._headers['Content-Type']).toBe('text/plain');
|
||||
expect(res.end).toHaveBeenCalledWith('Internal server error');
|
||||
});
|
||||
|
||||
it('(a) responds 500 when the child closes before a complete CGI header block', async () => {
|
||||
const child = fakeChild();
|
||||
spawnMock.mockReturnValue(child);
|
||||
const service = buildService();
|
||||
const res = fakeRes();
|
||||
|
||||
const p = service.run(baseRequest, fakeReq(), res);
|
||||
await flush();
|
||||
// stderr diagnostics, then a close with no valid CGI output -> 500.
|
||||
child.stderr.emit('data', Buffer.from('fatal: boom'));
|
||||
child.emit('close', 128);
|
||||
await p;
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.end).toHaveBeenCalledWith('Internal server error');
|
||||
});
|
||||
|
||||
it('(b) parses the CGI header block, sets status/headers, writes the body', async () => {
|
||||
const child = fakeChild();
|
||||
spawnMock.mockReturnValue(child);
|
||||
const service = buildService();
|
||||
const res = fakeRes();
|
||||
|
||||
const p = service.run(baseRequest, fakeReq(), res);
|
||||
await flush();
|
||||
// A full CGI response: status line + header + blank line + body.
|
||||
child.stdout.emit(
|
||||
'data',
|
||||
Buffer.from(
|
||||
'Status: 200 OK\r\nContent-Type: application/x-git-upload-pack-advertisement\r\n\r\nPACKBODY',
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
child.emit('close', 0);
|
||||
await p;
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res._headers['Content-Type']).toBe(
|
||||
'application/x-git-upload-pack-advertisement',
|
||||
);
|
||||
expect(Buffer.concat(res._written.map((c) => Buffer.from(c))).toString()).toContain(
|
||||
'PACKBODY',
|
||||
);
|
||||
expect(res.writableEnded).toBe(true);
|
||||
});
|
||||
|
||||
it('(c) swallows a stdout stream error (EPIPE) without throwing or 500ing', async () => {
|
||||
const child = fakeChild();
|
||||
spawnMock.mockReturnValue(child);
|
||||
const service = buildService();
|
||||
const res = fakeRes();
|
||||
const warnSpy = jest.spyOn(Logger.prototype, 'warn');
|
||||
|
||||
const p = service.run(baseRequest, fakeReq(), res);
|
||||
await flush();
|
||||
// The stdout 'error' handler must absorb this — no unhandled throw, no 500.
|
||||
expect(() => child.stdout.emit('error', new Error('EPIPE'))).not.toThrow();
|
||||
expect(() => child.stderr.emit('error', new Error('EPIPE'))).not.toThrow();
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
expect(res.statusCode).not.toBe(500);
|
||||
|
||||
// Let run() settle so the promise does not dangle.
|
||||
child.emit('close', 0);
|
||||
await p;
|
||||
});
|
||||
|
||||
it('(d) timeout: a child that never closes is killed and a 500 is sent', async () => {
|
||||
// The child never emits stdout/close (a stalled git-receive-pack). With a
|
||||
// tiny injected watchdog timeout the run() promise must still resolve: the
|
||||
// child is killed and a clean 500 is sent (no headers were sent yet).
|
||||
const child = fakeChild();
|
||||
spawnMock.mockReturnValue(child);
|
||||
const service = buildService(5); // 5ms watchdog
|
||||
const res = fakeRes();
|
||||
const warnSpy = jest.spyOn(Logger.prototype, 'warn');
|
||||
|
||||
// run() resolves only via the watchdog firing (no close/error emitted).
|
||||
await service.run(baseRequest, fakeReq(), res);
|
||||
|
||||
expect(child.kill).toHaveBeenCalledWith('SIGTERM');
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.end).toHaveBeenCalledWith('Internal server error');
|
||||
});
|
||||
|
||||
it('(d) timeout watchdog is cleared on a normal close (no kill, no 500)', async () => {
|
||||
// A normal request that completes well within the watchdog window must NOT be
|
||||
// killed and must NOT trip the timeout 500 — the timer is cleared on close.
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const child = fakeChild();
|
||||
spawnMock.mockReturnValue(child);
|
||||
const service = buildService(120000);
|
||||
const res = fakeRes();
|
||||
|
||||
const p = service.run(baseRequest, fakeReq(), res);
|
||||
// loadGitSync resolves on a real microtask; advance it under fake timers.
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
child.stdout.emit(
|
||||
'data',
|
||||
Buffer.from('Status: 200 OK\r\nContent-Type: text/plain\r\n\r\nOK', 'utf8'),
|
||||
);
|
||||
child.emit('close', 0);
|
||||
await p;
|
||||
|
||||
// The watchdog never fired even if we advance past its window.
|
||||
jest.advanceTimersByTime(200000);
|
||||
expect(child.kill).not.toHaveBeenCalled();
|
||||
expect(res.statusCode).toBe(200);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('spawn throwing synchronously -> 500 (spawn-failed)', async () => {
|
||||
spawnMock.mockImplementation(() => {
|
||||
throw new Error('spawn EACCES');
|
||||
});
|
||||
const service = buildService();
|
||||
const res = fakeRes();
|
||||
|
||||
await service.run(baseRequest, fakeReq(), res);
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.end).toHaveBeenCalledWith('Internal server error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildGitBackendCgiEnv', () => {
|
||||
const base = {
|
||||
spaceId: 'space-1',
|
||||
subpath: 'info/refs',
|
||||
method: 'GET',
|
||||
queryString: 'service=git-upload-pack',
|
||||
contentType: '',
|
||||
remoteUser: 'alice@example.com',
|
||||
};
|
||||
|
||||
it('points PATH_INFO at the NON-bare repo dir (no .git suffix)', () => {
|
||||
// Regression guard: the vault lives at <root>/<spaceId> (a working repo), so
|
||||
// PATH_INFO must be /<spaceId>/<subpath>. A `.git` suffix made git
|
||||
// http-backend resolve <root>/<spaceId>.git and 404 every fetch/push.
|
||||
const env = buildGitBackendCgiEnv(base, '/vaults');
|
||||
expect(env.PATH_INFO).toBe('/space-1/info/refs');
|
||||
expect(env.PATH_INFO).not.toContain('.git');
|
||||
expect(env.GIT_PROJECT_ROOT).toBe('/vaults');
|
||||
});
|
||||
|
||||
it('forwards method/query/content-type/remote-user and exports all repos', () => {
|
||||
const env = buildGitBackendCgiEnv(
|
||||
{ ...base, method: 'POST', subpath: 'git-receive-pack', contentType: 'application/x-git-receive-pack-request', queryString: '' },
|
||||
'/vaults',
|
||||
);
|
||||
expect(env.REQUEST_METHOD).toBe('POST');
|
||||
expect(env.PATH_INFO).toBe('/space-1/git-receive-pack');
|
||||
expect(env.CONTENT_TYPE).toBe('application/x-git-receive-pack-request');
|
||||
expect(env.REMOTE_USER).toBe('alice@example.com');
|
||||
expect(env.GIT_HTTP_EXPORT_ALL).toBe('1');
|
||||
});
|
||||
|
||||
it('sets GIT_PROTOCOL only when the client sent the header', () => {
|
||||
expect(buildGitBackendCgiEnv(base, '/vaults').GIT_PROTOCOL).toBeUndefined();
|
||||
expect(
|
||||
buildGitBackendCgiEnv({ ...base, gitProtocol: 'version=2' }, '/vaults')
|
||||
.GIT_PROTOCOL,
|
||||
).toBe('version=2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseCgiResponse', () => {
|
||||
it('defaults to status 200 with no Status header', () => {
|
||||
const r = parseCgiResponse('Content-Type: application/x-git-upload-pack-result');
|
||||
expect(r.statusCode).toBe(200);
|
||||
expect(r.headers).toEqual([
|
||||
['Content-Type', 'application/x-git-upload-pack-result'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('honors a Status header and does not forward it', () => {
|
||||
const r = parseCgiResponse('Status: 404 Not Found\nContent-Type: text/plain');
|
||||
expect(r.statusCode).toBe(404);
|
||||
expect(r.headers).toEqual([['Content-Type', 'text/plain']]);
|
||||
});
|
||||
|
||||
it('parses multiple headers and trims whitespace', () => {
|
||||
const r = parseCgiResponse(
|
||||
'Status: 403 Forbidden\r\nContent-Type: text/plain \r\nX-Foo: bar ',
|
||||
);
|
||||
expect(r.statusCode).toBe(403);
|
||||
expect(r.headers).toEqual([
|
||||
['Content-Type', 'text/plain'],
|
||||
['X-Foo', 'bar'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores malformed (colon-less) lines defensively', () => {
|
||||
const r = parseCgiResponse('Content-Type: text/plain\ngarbage-line\nX-A: b');
|
||||
expect(r.statusCode).toBe(200);
|
||||
expect(r.headers).toEqual([
|
||||
['Content-Type', 'text/plain'],
|
||||
['X-A', 'b'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores an out-of-range Status code and keeps the default', () => {
|
||||
const r = parseCgiResponse('Status: not-a-number\nContent-Type: text/plain');
|
||||
expect(r.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('treats the Status header case-insensitively', () => {
|
||||
const r = parseCgiResponse('status: 500 Boom');
|
||||
expect(r.statusCode).toBe(500);
|
||||
expect(r.headers).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('splitCgiBuffer', () => {
|
||||
it('splits on a CRLF blank line and keeps the body as bytes', () => {
|
||||
const buf = Buffer.concat([
|
||||
Buffer.from('Status: 200 OK\r\nContent-Type: text/plain\r\n\r\n', 'utf8'),
|
||||
Buffer.from([0x00, 0x01, 0x02, 0xff]),
|
||||
]);
|
||||
const split = splitCgiBuffer(buf);
|
||||
expect(split).not.toBeNull();
|
||||
expect(split!.headerText).toBe('Status: 200 OK\r\nContent-Type: text/plain');
|
||||
expect(Array.from(split!.body)).toEqual([0x00, 0x01, 0x02, 0xff]);
|
||||
});
|
||||
|
||||
it('splits on a bare LF blank line', () => {
|
||||
const buf = Buffer.from('Content-Type: text/plain\n\nhello', 'utf8');
|
||||
const split = splitCgiBuffer(buf);
|
||||
expect(split).not.toBeNull();
|
||||
expect(split!.headerText).toBe('Content-Type: text/plain');
|
||||
expect(split!.body.toString('utf8')).toBe('hello');
|
||||
});
|
||||
|
||||
it('returns an empty body when nothing follows the separator', () => {
|
||||
const buf = Buffer.from('Content-Type: text/plain\r\n\r\n', 'utf8');
|
||||
const split = splitCgiBuffer(buf);
|
||||
expect(split).not.toBeNull();
|
||||
expect(split!.body.length).toBe(0);
|
||||
});
|
||||
|
||||
it('returns null when there is no blank-line separator yet', () => {
|
||||
const buf = Buffer.from('Content-Type: text/plain\r\nincomplete', 'utf8');
|
||||
expect(splitCgiBuffer(buf)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,335 +0,0 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { spawn } from 'node:child_process';
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import { loadGitSync } from '../git-sync.loader';
|
||||
import { EnvironmentService } from '../../environment/environment.service';
|
||||
|
||||
/** The parsed first part of a CGI response: the HTTP status + header pairs. */
|
||||
export interface ParsedCgiResponse {
|
||||
statusCode: number;
|
||||
/** Lower-cased? No — keep header names verbatim as git http-backend emits. */
|
||||
headers: Array<[string, string]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the CGI header block emitted by `git http-backend` into an HTTP status
|
||||
* and a list of header pairs. The input is ONLY the header text (everything up
|
||||
* to, but not including, the blank-line separator) — the binary body is split
|
||||
* off by the caller on the raw Buffer (never stringified).
|
||||
*
|
||||
* CGI semantics (RFC 3875 §6): a `Status: <code> <reason>` header sets the HTTP
|
||||
* status (default 200 when absent). Every other header is forwarded verbatim.
|
||||
* Header lines are `Name: value`; a line without a ':' is ignored defensively.
|
||||
*
|
||||
* Pure + framework-free so it is unit-testable in isolation.
|
||||
*/
|
||||
export function parseCgiResponse(headerBlock: string): ParsedCgiResponse {
|
||||
let statusCode = 200;
|
||||
const headers: Array<[string, string]> = [];
|
||||
|
||||
// Header lines may be separated by CRLF or LF; split on either.
|
||||
const lines = headerBlock.split(/\r?\n/);
|
||||
for (const line of lines) {
|
||||
if (line.length === 0) continue;
|
||||
const sep = line.indexOf(':');
|
||||
if (sep === -1) continue; // not a header line — ignore defensively
|
||||
const name = line.slice(0, sep).trim();
|
||||
const value = line.slice(sep + 1).trim();
|
||||
if (name.toLowerCase() === 'status') {
|
||||
// `Status: 404 Not Found` — the leading integer is the HTTP status code.
|
||||
const code = parseInt(value, 10);
|
||||
if (Number.isFinite(code) && code >= 100 && code <= 599) {
|
||||
statusCode = code;
|
||||
}
|
||||
continue; // never forward the CGI Status header itself
|
||||
}
|
||||
headers.push([name, value]);
|
||||
}
|
||||
|
||||
return { statusCode, headers };
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a raw CGI response buffer at the first blank-line boundary
|
||||
* (`\r\n\r\n` or `\n\n`). Returns the header text and the remaining body bytes.
|
||||
* Returns null when no blank-line separator is present (a malformed response).
|
||||
*
|
||||
* Pure (operates on Buffers, never stringifies the body) so it is testable.
|
||||
*/
|
||||
export function splitCgiBuffer(
|
||||
buf: Buffer,
|
||||
): { headerText: string; body: Buffer } | null {
|
||||
// Prefer the CRLF separator; fall back to bare LF.
|
||||
let idx = buf.indexOf('\r\n\r\n');
|
||||
let sepLen = 4;
|
||||
if (idx === -1) {
|
||||
idx = buf.indexOf('\n\n');
|
||||
sepLen = 2;
|
||||
}
|
||||
if (idx === -1) return null;
|
||||
const headerText = buf.subarray(0, idx).toString('utf8');
|
||||
const body = buf.subarray(idx + sepLen);
|
||||
return { headerText, body };
|
||||
}
|
||||
|
||||
/** A parsed git smart-HTTP request, resolved by the controller/handler. */
|
||||
export interface GitHttpBackendRequest {
|
||||
/** The space id (the on-disk vault dir name == GIT_PROJECT_ROOT child). */
|
||||
spaceId: string;
|
||||
/** The subpath after `<spaceId>.git/`, e.g. `info/refs` or `git-receive-pack`. */
|
||||
subpath: string;
|
||||
/** REQUEST_METHOD — `GET` or `POST`. */
|
||||
method: string;
|
||||
/** Raw query string WITHOUT the leading '?', e.g. `service=git-receive-pack`. */
|
||||
queryString: string;
|
||||
/** Content-Type header value (may be empty for GET). */
|
||||
contentType: string;
|
||||
/** The Git-Protocol request header value, or undefined when absent. */
|
||||
gitProtocol?: string;
|
||||
/** Authenticated user email — used as REMOTE_USER (reflog identity). */
|
||||
remoteUser: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridges an HTTP git smart-protocol request to `git http-backend` (the CGI that
|
||||
* implements the entire smart-HTTP protocol: info/refs, upload-pack,
|
||||
* receive-pack, protocol v2, dumb fallback). We do NOT reimplement pkt-line.
|
||||
*
|
||||
* The Fastify reply is hijacked by the caller; this service streams the request
|
||||
* body to the child's stdin and writes the child's CGI response (status +
|
||||
* headers parsed from the leading header block, then the raw binary body) to the
|
||||
* Node response. Errors before any output produce a 500. Credentials are never
|
||||
* logged.
|
||||
*/
|
||||
/**
|
||||
* Build the `git http-backend` CGI environment overlay for one request (the
|
||||
* variables layered on top of `vaultGitEnv`'s cwd-isolated base). Pure so the
|
||||
* PATH_INFO / REMOTE_USER / conditional GIT_PROTOCOL wiring is unit-testable
|
||||
* without spawning git.
|
||||
*
|
||||
* PATH_INFO is the repo-relative CGI path. The vault is a NON-BARE working repo
|
||||
* on disk at `<dataDir>/<spaceId>` (the engine needs a working tree), so the
|
||||
* repo directory git http-backend must resolve is `<spaceId>` — NOT
|
||||
* `<spaceId>.git`. The URL carries the conventional `.git` suffix (stripped by
|
||||
* parseGitPath into `spaceId`); re-appending it here pointed the CGI at a
|
||||
* non-existent `<dataDir>/<spaceId>.git` and every fetch/push 404'd.
|
||||
*/
|
||||
export function buildGitBackendCgiEnv(
|
||||
parsed: GitHttpBackendRequest,
|
||||
projectRoot: string,
|
||||
): Record<string, string> {
|
||||
const cgiEnv: Record<string, string> = {
|
||||
GIT_PROJECT_ROOT: projectRoot,
|
||||
GIT_HTTP_EXPORT_ALL: '1', // authz is done by us; no git-daemon-export-ok file
|
||||
PATH_INFO: `/${parsed.spaceId}/${parsed.subpath}`,
|
||||
REQUEST_METHOD: parsed.method,
|
||||
QUERY_STRING: parsed.queryString,
|
||||
CONTENT_TYPE: parsed.contentType,
|
||||
REMOTE_USER: parsed.remoteUser,
|
||||
};
|
||||
// GIT_PROTOCOL is only set when the client sent the Git-Protocol header.
|
||||
if (parsed.gitProtocol) {
|
||||
cgiEnv.GIT_PROTOCOL = parsed.gitProtocol;
|
||||
}
|
||||
return cgiEnv;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class GitHttpBackendService {
|
||||
private readonly logger = new Logger(GitHttpBackendService.name);
|
||||
|
||||
constructor(private readonly environmentService: EnvironmentService) {}
|
||||
|
||||
/**
|
||||
* Spawn `git http-backend` for one request and bridge it to the raw Node
|
||||
* request/response. Resolves when the response has been fully written (the
|
||||
* child exited and its output was flushed), or after a 500 was sent on an
|
||||
* early failure. Never rejects — push ingestion relies on this resolving so
|
||||
* the lock-held cycle body can run afterwards.
|
||||
*/
|
||||
async run(
|
||||
parsed: GitHttpBackendRequest,
|
||||
rawReq: IncomingMessage,
|
||||
rawRes: ServerResponse,
|
||||
): Promise<void> {
|
||||
const { vaultGitEnv } = await loadGitSync();
|
||||
const projectRoot = this.environmentService.getGitSyncDataDir();
|
||||
// Build the CGI env from the engine's cwd-isolated base (strips GIT_DIR /
|
||||
// GIT_WORK_TREE), then layer the http-backend CGI variables. PATH is
|
||||
// preserved (vaultGitEnv already copies process.env, so PATH carries
|
||||
// through).
|
||||
const env = vaultGitEnv(buildGitBackendCgiEnv(parsed, projectRoot));
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
let settled = false;
|
||||
const done = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve();
|
||||
};
|
||||
|
||||
let child: ReturnType<typeof spawn>;
|
||||
try {
|
||||
child = spawn('git', ['http-backend'], { env });
|
||||
} catch (err) {
|
||||
this.send500(rawRes, 'spawn-failed', err);
|
||||
return done();
|
||||
}
|
||||
|
||||
// Watchdog: a client that opens git-receive-pack and stalls keeps the
|
||||
// child alive forever, so run() never resolves and (because this runs
|
||||
// inside withSpaceLock) the per-space lock is held + heartbeat-refreshed
|
||||
// indefinitely. Bound the request: on expiry kill the child, send a clean
|
||||
// 500 if nothing was sent yet, and settle the promise. The log carries no
|
||||
// client echo / credentials / body. `.unref()` so the timer never keeps the
|
||||
// event loop alive; ALWAYS cleared in the close/error handlers below.
|
||||
const timer = setTimeout(() => {
|
||||
this.logger.warn(
|
||||
`git http-backend timed out after ` +
|
||||
`${this.environmentService.getGitSyncBackendTimeoutMs()}ms; killing child`,
|
||||
);
|
||||
try {
|
||||
child.kill('SIGTERM');
|
||||
// Escalate to SIGKILL shortly after in case SIGTERM is ignored.
|
||||
const sigkill = setTimeout(() => {
|
||||
try {
|
||||
child.kill('SIGKILL');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, 2000);
|
||||
sigkill.unref?.();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (!headerParsed && !rawRes.headersSent) {
|
||||
this.send500(rawRes, 'timeout');
|
||||
} else {
|
||||
try {
|
||||
rawRes.end();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
done();
|
||||
}, this.environmentService.getGitSyncBackendTimeoutMs());
|
||||
timer.unref?.();
|
||||
|
||||
// Accumulate stdout until we have the full CGI header block, then write the
|
||||
// parsed status/headers and start streaming the remaining body bytes.
|
||||
let headerParsed = false;
|
||||
let pending: Buffer = Buffer.alloc(0);
|
||||
|
||||
const flushHeadersAndBody = (chunk: Buffer): void => {
|
||||
pending = Buffer.concat([pending, chunk]);
|
||||
const split = splitCgiBuffer(pending);
|
||||
if (!split) return; // header block not complete yet
|
||||
headerParsed = true;
|
||||
const { statusCode, headers } = parseCgiResponse(split.headerText);
|
||||
rawRes.statusCode = statusCode;
|
||||
for (const [name, value] of headers) {
|
||||
rawRes.setHeader(name, value);
|
||||
}
|
||||
if (split.body.length > 0) rawRes.write(split.body);
|
||||
pending = Buffer.alloc(0);
|
||||
};
|
||||
|
||||
child.stdout?.on('data', (chunk: Buffer) => {
|
||||
if (headerParsed) {
|
||||
rawRes.write(chunk);
|
||||
} else {
|
||||
flushHeadersAndBody(chunk);
|
||||
}
|
||||
});
|
||||
// A stream 'error' (e.g. EPIPE when the client aborts mid-response) is an
|
||||
// EventEmitter 'error' with no listener -> Node rethrows it as an uncaught
|
||||
// exception and crashes the process. Swallow + log it (never echo to the
|
||||
// client); child.on('close')/'error' below drives the actual cleanup.
|
||||
child.stdout?.on('error', (err) => {
|
||||
this.logger.warn(`git http-backend stdout stream error: ${err.message}`);
|
||||
});
|
||||
|
||||
let stderr = '';
|
||||
child.stderr?.on('data', (chunk: Buffer) => {
|
||||
// Capture for diagnostics; never echo to the client. http-backend writes
|
||||
// CGI errors here. We do NOT log the request body or any credentials.
|
||||
if (stderr.length < 8192) stderr += chunk.toString('utf8');
|
||||
});
|
||||
child.stderr?.on('error', (err) => {
|
||||
this.logger.warn(`git http-backend stderr stream error: ${err.message}`);
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
clearTimeout(timer);
|
||||
if (!headerParsed && !rawRes.headersSent) {
|
||||
this.send500(rawRes, 'child-error', err);
|
||||
} else {
|
||||
// Output already started — we can only terminate the stream.
|
||||
try {
|
||||
rawRes.end();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
done();
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
clearTimeout(timer);
|
||||
if (!headerParsed && !rawRes.headersSent) {
|
||||
// The child exited before emitting a complete CGI header block.
|
||||
this.logger.error(
|
||||
`git http-backend produced no valid response (exit ${code}) for ` +
|
||||
`space; stderr: ${stderr.trim().slice(0, 500)}`,
|
||||
);
|
||||
this.send500(rawRes, 'no-output');
|
||||
} else {
|
||||
try {
|
||||
rawRes.end();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
done();
|
||||
});
|
||||
|
||||
// Pipe the request body to the child's stdin. For GET there is no body, so
|
||||
// end stdin immediately. We pipe `rawReq` (the raw Node stream) directly so
|
||||
// large pushes are streamed, not buffered.
|
||||
if (parsed.method === 'POST') {
|
||||
rawReq.pipe(child.stdin!);
|
||||
rawReq.on('error', () => {
|
||||
try {
|
||||
child.stdin?.end();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
} else {
|
||||
child.stdin?.end();
|
||||
}
|
||||
// Swallow EPIPE etc. on the child's stdin so a client disconnect does not
|
||||
// crash the process.
|
||||
child.stdin?.on('error', () => {
|
||||
/* ignore broken-pipe on stdin */
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Send a clean 500 without leaking credentials or the request body. */
|
||||
private send500(rawRes: ServerResponse, reason: string, err?: unknown): void {
|
||||
const message = err instanceof Error ? err.message : undefined;
|
||||
this.logger.error(
|
||||
`git http-backend failed (${reason})${message ? `: ${message}` : ''}`,
|
||||
);
|
||||
try {
|
||||
if (!rawRes.headersSent) {
|
||||
rawRes.statusCode = 500;
|
||||
rawRes.setHeader('Content-Type', 'text/plain');
|
||||
}
|
||||
rawRes.end('Internal server error');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
// Unit tests for the pure /git smart-HTTP helpers: URL parsing, service->kind
|
||||
// mapping (read vs write), and the gating/auth decision precedence.
|
||||
import {
|
||||
decideGitHttpGate,
|
||||
parseGitPath,
|
||||
resolveServiceKind,
|
||||
} from './git-http.helpers';
|
||||
|
||||
describe('parseGitPath', () => {
|
||||
it('parses spaceId + subpath, stripping the trailing .git', () => {
|
||||
expect(parseGitPath('abc123.git/info/refs')).toEqual({
|
||||
spaceId: 'abc123',
|
||||
subpath: 'info/refs',
|
||||
});
|
||||
});
|
||||
|
||||
it('tolerates a leading slash', () => {
|
||||
expect(parseGitPath('/abc.git/git-receive-pack')).toEqual({
|
||||
spaceId: 'abc',
|
||||
subpath: 'git-receive-pack',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an empty subpath for the bare repo root', () => {
|
||||
expect(parseGitPath('abc.git')).toEqual({ spaceId: 'abc', subpath: '' });
|
||||
});
|
||||
|
||||
it('returns null when the first segment lacks .git', () => {
|
||||
expect(parseGitPath('abc/info/refs')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null on an empty space id', () => {
|
||||
expect(parseGitPath('.git/info/refs')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects path traversal', () => {
|
||||
expect(parseGitPath('abc.git/../../etc/passwd')).toBeNull();
|
||||
expect(parseGitPath('..git/x')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects percent-encoded dot/slash traversal in the subpath (case-insensitive)', () => {
|
||||
expect(parseGitPath('abc.git/%2e%2e%2fetc/passwd')).toBeNull();
|
||||
expect(parseGitPath('abc.git/%2E%2E/secret')).toBeNull();
|
||||
expect(parseGitPath('abc.git/objects/%2fabsolute')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveServiceKind', () => {
|
||||
it('GET info/refs?service=git-upload-pack -> read', () => {
|
||||
expect(
|
||||
resolveServiceKind({
|
||||
method: 'GET',
|
||||
subpath: 'info/refs',
|
||||
service: 'git-upload-pack',
|
||||
}),
|
||||
).toBe('read');
|
||||
});
|
||||
|
||||
it('GET info/refs?service=git-receive-pack -> write', () => {
|
||||
expect(
|
||||
resolveServiceKind({
|
||||
method: 'GET',
|
||||
subpath: 'info/refs',
|
||||
service: 'git-receive-pack',
|
||||
}),
|
||||
).toBe('write');
|
||||
});
|
||||
|
||||
it('POST git-upload-pack -> read', () => {
|
||||
expect(
|
||||
resolveServiceKind({ method: 'POST', subpath: 'git-upload-pack' }),
|
||||
).toBe('read');
|
||||
});
|
||||
|
||||
it('POST git-receive-pack -> write', () => {
|
||||
expect(
|
||||
resolveServiceKind({ method: 'POST', subpath: 'git-receive-pack' }),
|
||||
).toBe('write');
|
||||
});
|
||||
|
||||
it('a dumb-protocol GET (HEAD / objects) -> read', () => {
|
||||
expect(resolveServiceKind({ method: 'GET', subpath: 'HEAD' })).toBe('read');
|
||||
expect(
|
||||
resolveServiceKind({ method: 'GET', subpath: 'objects/12/abcdef' }),
|
||||
).toBe('read');
|
||||
});
|
||||
|
||||
it('info/refs with no/unknown service -> read (dumb discovery)', () => {
|
||||
expect(resolveServiceKind({ method: 'GET', subpath: 'info/refs' })).toBe(
|
||||
'read',
|
||||
);
|
||||
});
|
||||
|
||||
it('an unknown POST endpoint -> null', () => {
|
||||
expect(resolveServiceKind({ method: 'POST', subpath: 'whatever' })).toBeNull();
|
||||
});
|
||||
|
||||
it('an unsupported method -> null', () => {
|
||||
expect(
|
||||
resolveServiceKind({ method: 'DELETE', subpath: 'git-receive-pack' }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('decideGitHttpGate', () => {
|
||||
const base = {
|
||||
hasCredentials: true,
|
||||
credentialsValid: true,
|
||||
serviceKind: 'read' as const,
|
||||
gitSyncEnabled: true,
|
||||
gitHttpEnabled: true,
|
||||
spaceExists: true,
|
||||
spaceGitSyncEnabled: true,
|
||||
permissionGranted: true,
|
||||
};
|
||||
|
||||
it('proceeds on the happy path', () => {
|
||||
expect(decideGitHttpGate(base)).toEqual({ kind: 'proceed' });
|
||||
});
|
||||
|
||||
it('401 when credentials are missing (even for a valid space)', () => {
|
||||
expect(
|
||||
decideGitHttpGate({ ...base, hasCredentials: false }),
|
||||
).toEqual({ kind: 'unauthorized' });
|
||||
});
|
||||
|
||||
it('401 when credentials are present but invalid', () => {
|
||||
expect(
|
||||
decideGitHttpGate({ ...base, credentialsValid: false }),
|
||||
).toEqual({ kind: 'unauthorized' });
|
||||
});
|
||||
|
||||
it('400 on an unparseable service kind', () => {
|
||||
expect(decideGitHttpGate({ ...base, serviceKind: null })).toEqual({
|
||||
kind: 'bad-request',
|
||||
});
|
||||
});
|
||||
|
||||
it('404 when the space is not git-sync-enabled (never reveals existence)', () => {
|
||||
expect(
|
||||
decideGitHttpGate({ ...base, spaceGitSyncEnabled: false }),
|
||||
).toEqual({ kind: 'not-found' });
|
||||
});
|
||||
|
||||
it('404 when the space does not exist', () => {
|
||||
expect(decideGitHttpGate({ ...base, spaceExists: false })).toEqual({
|
||||
kind: 'not-found',
|
||||
});
|
||||
});
|
||||
|
||||
it('404 when git-sync is globally disabled', () => {
|
||||
expect(decideGitHttpGate({ ...base, gitSyncEnabled: false })).toEqual({
|
||||
kind: 'not-found',
|
||||
});
|
||||
});
|
||||
|
||||
it('404 when the git-http host is disabled', () => {
|
||||
expect(decideGitHttpGate({ ...base, gitHttpEnabled: false })).toEqual({
|
||||
kind: 'not-found',
|
||||
});
|
||||
});
|
||||
|
||||
it('403 when authenticated but lacking the required permission (reader on write)', () => {
|
||||
expect(
|
||||
decideGitHttpGate({
|
||||
...base,
|
||||
serviceKind: 'write',
|
||||
permissionGranted: false,
|
||||
}),
|
||||
).toEqual({ kind: 'forbidden' });
|
||||
});
|
||||
|
||||
it('still 401 (not 404) for missing creds against a disabled space', () => {
|
||||
// Anonymous probe must always get 401 first, regardless of space state.
|
||||
expect(
|
||||
decideGitHttpGate({
|
||||
...base,
|
||||
hasCredentials: false,
|
||||
spaceGitSyncEnabled: false,
|
||||
}),
|
||||
).toEqual({ kind: 'unauthorized' });
|
||||
});
|
||||
});
|
||||
@@ -1,147 +0,0 @@
|
||||
// Pure, framework-free helpers for the /git smart-HTTP host. They carry no Nest
|
||||
// / DI / concrete-service imports so the request parsing and the auth/authz
|
||||
// gating DECISION can be unit-tested in isolation, and nothing here ever logs a
|
||||
// password or the Authorization header.
|
||||
|
||||
/** The git operation a request maps to: a read (fetch/clone) or a write (push). */
|
||||
export type GitHttpServiceKind = 'read' | 'write';
|
||||
|
||||
/** A parsed `/git/<spaceId>.git/<subpath>` URL. */
|
||||
export interface ParsedGitPath {
|
||||
spaceId: string;
|
||||
/** The subpath after `<spaceId>.git/` (no leading slash), e.g. `info/refs`. */
|
||||
subpath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the `<rest>` of a `/git/<rest>` URL path (no query string) into the
|
||||
* space id and the repo-relative subpath. The space id is the first path
|
||||
* segment with its trailing `.git` stripped. Returns null when the shape does
|
||||
* not match (missing `.git`, empty space id, traversal attempt).
|
||||
*
|
||||
* `rest` MUST already be URL-path-decoded of its query string by the caller
|
||||
* (pass the pathname only). We reject `..` segments defensively even though
|
||||
* http-backend resolves PATH_INFO against GIT_PROJECT_ROOT.
|
||||
*/
|
||||
export function parseGitPath(rest: string): ParsedGitPath | null {
|
||||
// Strip a leading slash, then take the first segment as `<spaceId>.git`.
|
||||
const clean = rest.replace(/^\/+/, '');
|
||||
const slash = clean.indexOf('/');
|
||||
const first = slash === -1 ? clean : clean.slice(0, slash);
|
||||
const subpath = slash === -1 ? '' : clean.slice(slash + 1);
|
||||
|
||||
if (!first.endsWith('.git')) return null;
|
||||
const spaceId = first.slice(0, -'.git'.length);
|
||||
if (!spaceId) return null;
|
||||
|
||||
// Reject path traversal / degenerate ids in either component.
|
||||
if (
|
||||
spaceId === '.' ||
|
||||
spaceId.includes('..') ||
|
||||
spaceId.includes('/') ||
|
||||
subpath.split('/').some((seg) => seg === '..')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Defense-in-depth: reject percent-encoded dot/slash traversal (`%2e`, `%2f`,
|
||||
// case-insensitive) in the subpath BEFORE it is used to build PATH_INFO — a
|
||||
// decoder downstream could otherwise turn `%2e%2e%2f` back into `../`.
|
||||
if (/%2e|%2f/i.test(subpath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { spaceId, subpath };
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a parsed git request (method + subpath + query) to the required operation
|
||||
* kind. The smart-HTTP shapes:
|
||||
* - GET info/refs?service=git-upload-pack -> read (fetch)
|
||||
* - GET info/refs?service=git-receive-pack -> write (push)
|
||||
* - POST git-upload-pack -> read (fetch)
|
||||
* - POST git-receive-pack -> write (push)
|
||||
* - any other dumb-protocol GET (HEAD, objects/…) -> read
|
||||
* Returns null for an unsupported shape (e.g. a POST that is neither pack
|
||||
* endpoint) so the caller can 403/404 rather than guess.
|
||||
*/
|
||||
export function resolveServiceKind(input: {
|
||||
method: string;
|
||||
subpath: string;
|
||||
service?: string;
|
||||
}): GitHttpServiceKind | null {
|
||||
const method = input.method.toUpperCase();
|
||||
const subpath = input.subpath;
|
||||
|
||||
if (method === 'GET') {
|
||||
if (subpath === 'info/refs') {
|
||||
if (input.service === 'git-receive-pack') return 'write';
|
||||
if (input.service === 'git-upload-pack') return 'read';
|
||||
// info/refs without a known service: dumb-protocol discovery — read.
|
||||
return 'read';
|
||||
}
|
||||
// Dumb-protocol object/ref fetches (HEAD, objects/…) are reads.
|
||||
return 'read';
|
||||
}
|
||||
|
||||
if (method === 'POST') {
|
||||
if (subpath === 'git-receive-pack') return 'write';
|
||||
if (subpath === 'git-upload-pack') return 'read';
|
||||
return null; // unknown POST endpoint
|
||||
}
|
||||
|
||||
return null; // unsupported method
|
||||
}
|
||||
|
||||
/** The outcome of the gating/auth decision the request handler must enforce. */
|
||||
export type GitHttpGateDecision =
|
||||
| { kind: 'unauthorized' } // 401 + WWW-Authenticate (missing/invalid creds)
|
||||
| { kind: 'not-found' } // 404 (space hidden / sync or http disabled)
|
||||
| { kind: 'forbidden' } // 403 (authenticated but lacks the permission)
|
||||
| { kind: 'bad-request' } // 400 (unparseable git request shape)
|
||||
| { kind: 'proceed' }; // run http-backend
|
||||
|
||||
/**
|
||||
* Pure gating decision, mirroring the handler precedence so it can be unit
|
||||
* tested without the DB / CASL graph. Inputs are the already-resolved booleans
|
||||
* the handler computes from EnvironmentService / SpaceRepo / SpaceAbilityFactory.
|
||||
*
|
||||
* Precedence (matches the spec):
|
||||
* 1. no/invalid Basic credentials -> 401 (regardless of space).
|
||||
* 2. credentials present but invalid -> 401.
|
||||
* 3. unparseable git request shape -> 400.
|
||||
* 4. git-sync globally disabled, or git-http disabled, or the space is missing
|
||||
* / not git-sync-enabled -> 404 (never reveal existence).
|
||||
* 5. authenticated but lacking the required perm -> 403.
|
||||
* 6. otherwise -> proceed.
|
||||
*
|
||||
* Note (4) is checked AFTER (1)/(2): an anonymous probe always gets 401 first;
|
||||
* an authenticated user hitting a hidden/disabled space gets 404 (not 403).
|
||||
*/
|
||||
export function decideGitHttpGate(input: {
|
||||
hasCredentials: boolean;
|
||||
credentialsValid: boolean;
|
||||
serviceKind: GitHttpServiceKind | null;
|
||||
gitSyncEnabled: boolean;
|
||||
gitHttpEnabled: boolean;
|
||||
spaceExists: boolean;
|
||||
spaceGitSyncEnabled: boolean;
|
||||
permissionGranted: boolean;
|
||||
}): GitHttpGateDecision {
|
||||
if (!input.hasCredentials) return { kind: 'unauthorized' };
|
||||
if (!input.credentialsValid) return { kind: 'unauthorized' };
|
||||
if (input.serviceKind === null) return { kind: 'bad-request' };
|
||||
|
||||
if (
|
||||
!input.gitSyncEnabled ||
|
||||
!input.gitHttpEnabled ||
|
||||
!input.spaceExists ||
|
||||
!input.spaceGitSyncEnabled
|
||||
) {
|
||||
return { kind: 'not-found' };
|
||||
}
|
||||
|
||||
if (!input.permissionGranted) return { kind: 'forbidden' };
|
||||
|
||||
return { kind: 'proceed' };
|
||||
}
|
||||
@@ -1,463 +0,0 @@
|
||||
// Unit tests for GitHttpService — the /git smart-HTTP handler. Everything it
|
||||
// depends on (backend, auth, repos, ability factory, env, orchestrator) is
|
||||
// mocked so we exercise ONLY the handler wiring: workspace resolution (which is
|
||||
// done HERE, not by DomainMiddleware — see FIX 1), the auth/gating precedence,
|
||||
// the read-vs-write dispatch, and that a fetch does NOT take the lock.
|
||||
//
|
||||
// These tests deliberately NEVER set `req.raw.workspaceId`: the workspace must
|
||||
// come from WorkspaceRepo. If the handler regressed to reading
|
||||
// `req.raw.workspaceId`, the happy-path fetch test below would fail (the repo
|
||||
// would not be consulted and the request would 401).
|
||||
import { Logger, UnauthorizedException } from '@nestjs/common';
|
||||
import {
|
||||
SpaceCaslAction,
|
||||
SpaceCaslSubject,
|
||||
} from '../../../core/casl/interfaces/space-ability.type';
|
||||
import { GitHttpService } from './git-http.service';
|
||||
import { GitSyncLockHeldError } from '../services/git-sync.orchestrator';
|
||||
|
||||
type AnyMock = jest.Mock;
|
||||
|
||||
interface BuildOptions {
|
||||
selfHosted?: boolean;
|
||||
gitSyncEnabled?: boolean;
|
||||
gitHttpEnabled?: boolean;
|
||||
/** What workspaceRepo.findFirst() returns (self-hosted resolution). */
|
||||
workspace?: { id: string } | null;
|
||||
/** What spaceRepo.findById() returns. */
|
||||
space?: { id: string; settings?: unknown } | null;
|
||||
/** Result of authService.verifyUserCredentials: a user, or throw 401. */
|
||||
user?: { id: string; email: string } | null;
|
||||
/** Whether the created ability grants the requested action. */
|
||||
abilityCan?: boolean;
|
||||
}
|
||||
|
||||
interface Built {
|
||||
service: GitHttpService;
|
||||
env: Record<string, AnyMock>;
|
||||
authService: { verifyUserCredentials: AnyMock };
|
||||
spaceRepo: { findById: AnyMock };
|
||||
workspaceRepo: { findFirst: AnyMock; findByHostname: AnyMock };
|
||||
abilityFactory: { createForUser: AnyMock };
|
||||
abilityCan: AnyMock;
|
||||
vaultRegistry: { ensureServable: AnyMock };
|
||||
orchestrator: { ingestExternalPush: AnyMock };
|
||||
backend: { run: AnyMock };
|
||||
}
|
||||
|
||||
function build(opts: BuildOptions = {}): Built {
|
||||
const {
|
||||
selfHosted = true,
|
||||
gitSyncEnabled = true,
|
||||
gitHttpEnabled = true,
|
||||
workspace = { id: 'ws-1' },
|
||||
space = { id: 'space-1', settings: { gitSync: { enabled: true } } },
|
||||
user = { id: 'user-1', email: 'dev@example.com' },
|
||||
abilityCan = true,
|
||||
} = opts;
|
||||
|
||||
const env: Record<string, AnyMock> = {
|
||||
isSelfHosted: jest.fn(() => selfHosted),
|
||||
isCloud: jest.fn(() => !selfHosted),
|
||||
isGitSyncEnabled: jest.fn(() => gitSyncEnabled),
|
||||
isGitSyncHttpEnabled: jest.fn(() => gitHttpEnabled),
|
||||
};
|
||||
|
||||
const authService = {
|
||||
verifyUserCredentials: jest.fn(async () => {
|
||||
if (!user) throw new UnauthorizedException();
|
||||
return user;
|
||||
}),
|
||||
};
|
||||
|
||||
const spaceRepo = { findById: jest.fn(async () => space) };
|
||||
|
||||
const workspaceRepo = {
|
||||
findFirst: jest.fn(async () => workspace),
|
||||
findByHostname: jest.fn(async () => workspace),
|
||||
};
|
||||
|
||||
const abilityCanMock = jest.fn(() => abilityCan);
|
||||
const abilityFactory = {
|
||||
createForUser: jest.fn(async () => ({ can: abilityCanMock })),
|
||||
};
|
||||
|
||||
const vaultRegistry = { ensureServable: jest.fn(async () => undefined) };
|
||||
const orchestrator = { ingestExternalPush: jest.fn(async () => undefined) };
|
||||
const backend = { run: jest.fn(async () => undefined) };
|
||||
|
||||
const service = new GitHttpService(
|
||||
env as any,
|
||||
authService as any,
|
||||
spaceRepo as any,
|
||||
workspaceRepo as any,
|
||||
abilityFactory as any,
|
||||
vaultRegistry as any,
|
||||
orchestrator as any,
|
||||
backend as any,
|
||||
);
|
||||
|
||||
return {
|
||||
service,
|
||||
env,
|
||||
authService,
|
||||
spaceRepo,
|
||||
workspaceRepo,
|
||||
abilityFactory,
|
||||
abilityCan: abilityCanMock,
|
||||
vaultRegistry,
|
||||
orchestrator,
|
||||
backend,
|
||||
};
|
||||
}
|
||||
|
||||
/** A fake Fastify reply capturing the terminal status/headers/body. */
|
||||
function fakeReply() {
|
||||
const state: {
|
||||
statusCode?: number;
|
||||
headers: Record<string, string>;
|
||||
body?: unknown;
|
||||
hijacked: boolean;
|
||||
sent: boolean;
|
||||
} = { headers: {}, hijacked: false, sent: false };
|
||||
|
||||
const reply: any = {
|
||||
header(name: string, value: string) {
|
||||
state.headers[name] = value;
|
||||
return reply;
|
||||
},
|
||||
status(code: number) {
|
||||
state.statusCode = code;
|
||||
return reply;
|
||||
},
|
||||
send(body: unknown) {
|
||||
state.body = body;
|
||||
state.sent = true;
|
||||
return reply;
|
||||
},
|
||||
hijack() {
|
||||
state.hijacked = true;
|
||||
},
|
||||
get sent() {
|
||||
return state.sent;
|
||||
},
|
||||
// The raw Node response — only touched on the streaming/error paths.
|
||||
raw: {
|
||||
headersSent: false,
|
||||
writableEnded: false,
|
||||
statusCode: 200,
|
||||
setHeader: jest.fn(),
|
||||
end: jest.fn(),
|
||||
},
|
||||
};
|
||||
return { reply, state };
|
||||
}
|
||||
|
||||
/** A fake Fastify request for a /git smart-HTTP call. */
|
||||
function fakeRequest(opts: {
|
||||
url: string;
|
||||
method?: string;
|
||||
authorization?: string;
|
||||
host?: string;
|
||||
}) {
|
||||
const { url, method = 'GET', authorization, host = 'docs.example.com' } = opts;
|
||||
const headers: Record<string, string> = { host };
|
||||
if (authorization) headers['authorization'] = authorization;
|
||||
// query is parsed by Fastify; mirror the `service` param when present.
|
||||
const qIdx = url.indexOf('?');
|
||||
const query: Record<string, string> = {};
|
||||
if (qIdx !== -1) {
|
||||
for (const pair of url.slice(qIdx + 1).split('&')) {
|
||||
const [k, v] = pair.split('=');
|
||||
if (k) query[k] = v ?? '';
|
||||
}
|
||||
}
|
||||
return {
|
||||
url,
|
||||
method,
|
||||
headers,
|
||||
query,
|
||||
// raw is intentionally WITHOUT workspaceId — the handler must resolve it
|
||||
// itself via WorkspaceRepo (a regression to req.raw.workspaceId would 401).
|
||||
raw: {},
|
||||
} as any;
|
||||
}
|
||||
|
||||
function basic(email: string, password: string): string {
|
||||
return 'Basic ' + Buffer.from(`${email}:${password}`).toString('base64');
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// Silence the handler's logger.warn/error in negative-path tests.
|
||||
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
describe('GitHttpService.handle', () => {
|
||||
it('fetch with valid creds resolves the workspace via the repo and dispatches WITHOUT the lock', async () => {
|
||||
const built = build({ selfHosted: true });
|
||||
const { reply, state } = fakeReply();
|
||||
const req = fakeRequest({
|
||||
url: '/git/space-1.git/info/refs?service=git-upload-pack',
|
||||
method: 'GET',
|
||||
authorization: basic('dev@example.com', 'pw'),
|
||||
});
|
||||
|
||||
await built.service.handle(req, reply);
|
||||
|
||||
// The workspace came from WorkspaceRepo, NOT req.raw.workspaceId.
|
||||
expect(built.workspaceRepo.findFirst).toHaveBeenCalledTimes(1);
|
||||
expect(built.authService.verifyUserCredentials).toHaveBeenCalledWith(
|
||||
{ email: 'dev@example.com', password: 'pw' },
|
||||
'ws-1',
|
||||
);
|
||||
expect(built.spaceRepo.findById).toHaveBeenCalledWith('space-1', 'ws-1');
|
||||
// Read ability was evaluated.
|
||||
expect(built.abilityCan).toHaveBeenCalledWith(
|
||||
SpaceCaslAction.Read,
|
||||
SpaceCaslSubject.Page,
|
||||
);
|
||||
// It proceeded: vault prepared, reply hijacked, backend ran directly.
|
||||
expect(built.vaultRegistry.ensureServable).toHaveBeenCalledWith('space-1');
|
||||
expect(state.hijacked).toBe(true);
|
||||
expect(built.backend.run).toHaveBeenCalledTimes(1);
|
||||
// A fetch must NOT take the push lock.
|
||||
expect(built.orchestrator.ingestExternalPush).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('cloud deployment resolves the workspace by the host subdomain', async () => {
|
||||
const built = build({ selfHosted: false });
|
||||
const { reply } = fakeReply();
|
||||
const req = fakeRequest({
|
||||
url: '/git/space-1.git/info/refs?service=git-upload-pack',
|
||||
method: 'GET',
|
||||
authorization: basic('dev@example.com', 'pw'),
|
||||
host: 'acme.example.com',
|
||||
});
|
||||
|
||||
await built.service.handle(req, reply);
|
||||
|
||||
expect(built.workspaceRepo.findByHostname).toHaveBeenCalledWith('acme');
|
||||
expect(built.workspaceRepo.findFirst).not.toHaveBeenCalled();
|
||||
expect(built.backend.run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('missing Basic credentials -> 401 with WWW-Authenticate', async () => {
|
||||
const built = build();
|
||||
const { reply, state } = fakeReply();
|
||||
const req = fakeRequest({
|
||||
url: '/git/space-1.git/info/refs?service=git-upload-pack',
|
||||
method: 'GET',
|
||||
// no Authorization header
|
||||
});
|
||||
|
||||
await built.service.handle(req, reply);
|
||||
|
||||
expect(state.statusCode).toBe(401);
|
||||
expect(state.headers['WWW-Authenticate']).toBe('Basic realm="gitmost"');
|
||||
expect(built.backend.run).not.toHaveBeenCalled();
|
||||
expect(built.authService.verifyUserCredentials).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('invalid Basic credentials -> 401 with WWW-Authenticate', async () => {
|
||||
const built = build({ user: null }); // verifyUserCredentials throws 401
|
||||
const { reply, state } = fakeReply();
|
||||
const req = fakeRequest({
|
||||
url: '/git/space-1.git/info/refs?service=git-upload-pack',
|
||||
method: 'GET',
|
||||
authorization: basic('dev@example.com', 'wrong'),
|
||||
});
|
||||
|
||||
await built.service.handle(req, reply);
|
||||
|
||||
expect(state.statusCode).toBe(401);
|
||||
expect(state.headers['WWW-Authenticate']).toBe('Basic realm="gitmost"');
|
||||
expect(built.backend.run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a write by a Read-only user -> 403 (reader cannot push)', async () => {
|
||||
const built = build({ abilityCan: false });
|
||||
const { reply, state } = fakeReply();
|
||||
const req = fakeRequest({
|
||||
url: '/git/space-1.git/git-receive-pack',
|
||||
method: 'POST',
|
||||
authorization: basic('dev@example.com', 'pw'),
|
||||
});
|
||||
|
||||
await built.service.handle(req, reply);
|
||||
|
||||
// The Manage ability was checked for a write and denied.
|
||||
expect(built.abilityCan).toHaveBeenCalledWith(
|
||||
SpaceCaslAction.Manage,
|
||||
SpaceCaslSubject.Page,
|
||||
);
|
||||
expect(state.statusCode).toBe(403);
|
||||
expect(built.orchestrator.ingestExternalPush).not.toHaveBeenCalled();
|
||||
expect(built.backend.run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a space that is not git-sync-enabled -> 404 (existence never revealed)', async () => {
|
||||
const built = build({
|
||||
space: { id: 'space-1', settings: { gitSync: { enabled: false } } },
|
||||
});
|
||||
const { reply, state } = fakeReply();
|
||||
const req = fakeRequest({
|
||||
url: '/git/space-1.git/info/refs?service=git-upload-pack',
|
||||
method: 'GET',
|
||||
authorization: basic('dev@example.com', 'pw'),
|
||||
});
|
||||
|
||||
await built.service.handle(req, reply);
|
||||
|
||||
expect(state.statusCode).toBe(404);
|
||||
// CASL is never even evaluated for a non-candidate space.
|
||||
expect(built.abilityFactory.createForUser).not.toHaveBeenCalled();
|
||||
expect(built.backend.run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('git-sync globally disabled -> 404 even with valid creds', async () => {
|
||||
const built = build({ gitSyncEnabled: false });
|
||||
const { reply, state } = fakeReply();
|
||||
const req = fakeRequest({
|
||||
url: '/git/space-1.git/info/refs?service=git-upload-pack',
|
||||
method: 'GET',
|
||||
authorization: basic('dev@example.com', 'pw'),
|
||||
});
|
||||
|
||||
await built.service.handle(req, reply);
|
||||
|
||||
expect(state.statusCode).toBe(404);
|
||||
expect(built.backend.run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a valid write proceeds through the orchestrator (push takes the lock)', async () => {
|
||||
const built = build({ abilityCan: true });
|
||||
const { reply, state } = fakeReply();
|
||||
const req = fakeRequest({
|
||||
url: '/git/space-1.git/git-receive-pack',
|
||||
method: 'POST',
|
||||
authorization: basic('dev@example.com', 'pw'),
|
||||
});
|
||||
|
||||
await built.service.handle(req, reply);
|
||||
|
||||
expect(built.abilityCan).toHaveBeenCalledWith(
|
||||
SpaceCaslAction.Manage,
|
||||
SpaceCaslSubject.Page,
|
||||
);
|
||||
expect(state.hijacked).toBe(true);
|
||||
expect(built.orchestrator.ingestExternalPush).toHaveBeenCalledTimes(1);
|
||||
const [spaceId, workspaceId] =
|
||||
built.orchestrator.ingestExternalPush.mock.calls[0];
|
||||
expect(spaceId).toBe('space-1');
|
||||
expect(workspaceId).toBe('ws-1');
|
||||
});
|
||||
|
||||
it('GET info/refs?service=git-receive-pack streams the backend WITHOUT a cycle/lock (so the follow-up POST never 503-collides)', async () => {
|
||||
// A push is a TWO-request exchange: GET info/refs?service=git-receive-pack
|
||||
// (ref advertisement) then POST git-receive-pack (the pack). The info/refs
|
||||
// request is write-AUTHORIZED (push perms needed to see those refs) but is
|
||||
// READ-ONLY — it must NOT run ingestExternalPush (a Docmost cycle under the
|
||||
// per-space lock), or the immediately-following POST collides with the still-
|
||||
// running cycle and deterministically 503s. It must just stream the backend.
|
||||
const built = build({ abilityCan: true });
|
||||
const { reply } = fakeReply();
|
||||
const req = fakeRequest({
|
||||
url: '/git/space-1.git/info/refs?service=git-receive-pack',
|
||||
method: 'GET',
|
||||
authorization: basic('dev@example.com', 'pw'),
|
||||
});
|
||||
|
||||
await built.service.handle(req, reply);
|
||||
|
||||
// Authorized as a write (Manage), but executed as a plain stream.
|
||||
expect(built.abilityCan).toHaveBeenCalledWith(
|
||||
SpaceCaslAction.Manage,
|
||||
SpaceCaslSubject.Page,
|
||||
);
|
||||
expect(built.orchestrator.ingestExternalPush).not.toHaveBeenCalled();
|
||||
expect(built.backend.run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('a push that loses the lock -> 503 with Retry-After and a busy body (headers not written twice)', async () => {
|
||||
const built = build({ abilityCan: true });
|
||||
// The lock could not be acquired: the receive-pack closure never ran, so the
|
||||
// response is still unwritten and the handler must answer 503 itself.
|
||||
built.orchestrator.ingestExternalPush.mockRejectedValue(
|
||||
new GitSyncLockHeldError('space-1'),
|
||||
);
|
||||
const { reply, state } = fakeReply();
|
||||
const req = fakeRequest({
|
||||
url: '/git/space-1.git/git-receive-pack',
|
||||
method: 'POST',
|
||||
authorization: basic('dev@example.com', 'pw'),
|
||||
});
|
||||
|
||||
await built.service.handle(req, reply);
|
||||
|
||||
// It hijacked and went through the orchestrator (write path), but the lock
|
||||
// was held so the backend never ran.
|
||||
expect(state.hijacked).toBe(true);
|
||||
expect(built.orchestrator.ingestExternalPush).toHaveBeenCalledTimes(1);
|
||||
expect(built.backend.run).not.toHaveBeenCalled();
|
||||
|
||||
// 503 + Retry-After were written on the raw response (headersSent was false).
|
||||
const raw = reply.raw as any;
|
||||
expect(raw.statusCode).toBe(503);
|
||||
expect(raw.setHeader).toHaveBeenCalledWith('Content-Type', 'text/plain');
|
||||
expect(raw.setHeader).toHaveBeenCalledWith('Retry-After', '1');
|
||||
// The body carries the busy/retry message and the response was ended once.
|
||||
expect(raw.end).toHaveBeenCalledTimes(1);
|
||||
expect(raw.end).toHaveBeenCalledWith('git-sync busy, retry');
|
||||
// Exactly the two headers above were set — no double write of headers.
|
||||
expect(raw.setHeader).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does NOT rewrite the 503 status/headers when the response is already sent', async () => {
|
||||
const built = build({ abilityCan: true });
|
||||
built.orchestrator.ingestExternalPush.mockRejectedValue(
|
||||
new GitSyncLockHeldError('space-1'),
|
||||
);
|
||||
const { reply } = fakeReply();
|
||||
// Simulate the (defensive) case where headers were already flushed: the
|
||||
// handler must skip statusCode/setHeader and only end() the socket.
|
||||
const raw = reply.raw as any;
|
||||
raw.headersSent = true;
|
||||
const req = fakeRequest({
|
||||
url: '/git/space-1.git/git-receive-pack',
|
||||
method: 'POST',
|
||||
authorization: basic('dev@example.com', 'pw'),
|
||||
});
|
||||
|
||||
await built.service.handle(req, reply);
|
||||
|
||||
// No header writes when headersSent is already true (no "headers already
|
||||
// sent" double-write path), but the body/end still runs.
|
||||
expect(raw.setHeader).not.toHaveBeenCalled();
|
||||
expect(raw.statusCode).toBe(200); // untouched default from the fake
|
||||
expect(raw.end).toHaveBeenCalledTimes(1);
|
||||
expect(raw.end).toHaveBeenCalledWith('git-sync busy, retry');
|
||||
});
|
||||
|
||||
it('an unresolvable workspace -> 401 (credentials cannot be validated without one)', async () => {
|
||||
const built = build({ workspace: null });
|
||||
const { reply, state } = fakeReply();
|
||||
const req = fakeRequest({
|
||||
url: '/git/space-1.git/info/refs?service=git-upload-pack',
|
||||
method: 'GET',
|
||||
authorization: basic('dev@example.com', 'pw'),
|
||||
});
|
||||
|
||||
await built.service.handle(req, reply);
|
||||
|
||||
// Without a workspace we cannot run verifyUserCredentials, so credentials
|
||||
// are not validated -> 401 (the 401-before-404 ordering is preserved: an
|
||||
// unauthenticated request never reaches the space-existence 404).
|
||||
expect(built.workspaceRepo.findFirst).toHaveBeenCalledTimes(1);
|
||||
expect(built.authService.verifyUserCredentials).not.toHaveBeenCalled();
|
||||
expect(state.statusCode).toBe(401);
|
||||
expect(state.headers['WWW-Authenticate']).toBe('Basic realm="gitmost"');
|
||||
expect(built.backend.run).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,328 +0,0 @@
|
||||
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { AuthService } from '../../../core/auth/services/auth.service';
|
||||
import SpaceAbilityFactory from '../../../core/casl/abilities/space-ability.factory';
|
||||
import {
|
||||
SpaceCaslAction,
|
||||
SpaceCaslSubject,
|
||||
} from '../../../core/casl/interfaces/space-ability.type';
|
||||
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
|
||||
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
|
||||
import { User } from '@docmost/db/types/entity.types';
|
||||
import { parseBasicAuth } from '../../mcp/mcp-auth.helpers';
|
||||
import { EnvironmentService } from '../../environment/environment.service';
|
||||
import { VaultRegistryService } from '../services/vault-registry.service';
|
||||
import {
|
||||
GitSyncLockHeldError,
|
||||
GitSyncOrchestrator,
|
||||
} from '../services/git-sync.orchestrator';
|
||||
import { GitHttpBackendService } from './git-http-backend.service';
|
||||
import {
|
||||
decideGitHttpGate,
|
||||
parseGitPath,
|
||||
resolveServiceKind,
|
||||
GitHttpServiceKind,
|
||||
} from './git-http.helpers';
|
||||
|
||||
const WWW_AUTHENTICATE = 'Basic realm="gitmost"';
|
||||
|
||||
/**
|
||||
* The /git smart-HTTP host. Wires request parsing, the reused auth primitives
|
||||
* (HTTP Basic -> AuthService.verifyUserCredentials), per-space gating
|
||||
* (EnvironmentService flags + space.settings.gitSync.enabled), CASL authz
|
||||
* (SpaceAbilityFactory), and dispatch to `git http-backend`:
|
||||
* - fetch (read) -> ensureServable then stream http-backend directly (no lock).
|
||||
* - push (write) -> ensureServable then orchestrator.ingestExternalPush, which
|
||||
* runs the receive-pack under the space lock and then a Docmost cycle.
|
||||
*
|
||||
* Mounted at the ROOT (`/git/...`) by a raw Fastify route in main.ts (the global
|
||||
* `/api` prefix does not apply). Never logs the password or Authorization header.
|
||||
*/
|
||||
@Injectable()
|
||||
export class GitHttpService {
|
||||
private readonly logger = new Logger(GitHttpService.name);
|
||||
|
||||
constructor(
|
||||
private readonly environmentService: EnvironmentService,
|
||||
private readonly authService: AuthService,
|
||||
private readonly spaceRepo: SpaceRepo,
|
||||
private readonly workspaceRepo: WorkspaceRepo,
|
||||
private readonly spaceAbilityFactory: SpaceAbilityFactory,
|
||||
private readonly vaultRegistry: VaultRegistryService,
|
||||
private readonly orchestrator: GitSyncOrchestrator,
|
||||
private readonly backend: GitHttpBackendService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Resolve the workspace for a /git request the SAME way DomainMiddleware does,
|
||||
* because Nest middleware does NOT run for this raw root-mounted route (it is
|
||||
* registered under the global '/api' router), so `req.raw.workspaceId` is never
|
||||
* populated here. We replicate DomainMiddleware / McpService:
|
||||
* - self-hosted (single workspace) -> workspaceRepo.findFirst();
|
||||
* - cloud (multi-tenant) -> resolve by the host-header subdomain.
|
||||
* Returns null when no workspace resolves; the gate then 404s (after the
|
||||
* 401-before-404 credential check encoded in decideGitHttpGate).
|
||||
*/
|
||||
private async resolveWorkspaceId(req: FastifyRequest): Promise<string | null> {
|
||||
try {
|
||||
if (this.environmentService.isSelfHosted()) {
|
||||
const workspace = await this.workspaceRepo.findFirst();
|
||||
return workspace?.id ?? null;
|
||||
}
|
||||
if (this.environmentService.isCloud()) {
|
||||
const host = this.headerValue(req.headers['host']);
|
||||
const subdomain = host ? host.split('.')[0] : '';
|
||||
if (!subdomain) return null;
|
||||
const workspace = await this.workspaceRepo.findByHostname(subdomain);
|
||||
return workspace?.id ?? null;
|
||||
}
|
||||
} catch (err) {
|
||||
// A DB error resolving the workspace must not leak details; treat as
|
||||
// unresolvable (the gate will 404, unless creds are missing -> 401 first).
|
||||
this.logger.warn(
|
||||
`git-http: workspace resolution error: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle one `/git/<spaceId>.git/<subpath>` request. `rest` is the path AFTER
|
||||
* the `/git/` prefix (no query string). The Fastify reply is hijacked before
|
||||
* any streaming so the binary CGI body is written directly to the raw socket.
|
||||
*/
|
||||
async handle(req: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||
const rawReq = req.raw;
|
||||
const rawRes = reply.raw;
|
||||
|
||||
// --- parse the URL into spaceId + subpath -------------------------------
|
||||
const rest = this.extractRest(req.url);
|
||||
const parsedPath = rest === null ? null : parseGitPath(rest);
|
||||
|
||||
// --- resolve the requested git service kind (read vs write) -------------
|
||||
const service =
|
||||
typeof req.query === 'object' && req.query !== null
|
||||
? (req.query as Record<string, string | undefined>).service
|
||||
: undefined;
|
||||
const serviceKind: GitHttpServiceKind | null = parsedPath
|
||||
? resolveServiceKind({
|
||||
method: req.method,
|
||||
subpath: parsedPath.subpath,
|
||||
service,
|
||||
})
|
||||
: null;
|
||||
|
||||
// --- authenticate (HTTP Basic) ------------------------------------------
|
||||
const authHeader = req.headers['authorization'];
|
||||
const basic = parseBasicAuth(
|
||||
Array.isArray(authHeader) ? authHeader[0] : authHeader,
|
||||
);
|
||||
// Resolve the workspace ourselves — DomainMiddleware does NOT run for this
|
||||
// raw root route, so `req.raw.workspaceId` is never set (see resolver doc).
|
||||
const workspaceId: string | null = await this.resolveWorkspaceId(req);
|
||||
|
||||
let user: User | undefined;
|
||||
let credentialsValid = false;
|
||||
if (basic && workspaceId) {
|
||||
try {
|
||||
user = await this.authService.verifyUserCredentials(
|
||||
{ email: basic.email, password: basic.password },
|
||||
workspaceId,
|
||||
);
|
||||
credentialsValid = true;
|
||||
} catch (err) {
|
||||
if (!(err instanceof UnauthorizedException)) {
|
||||
// A non-credential failure (e.g. DB error): treat as invalid creds for
|
||||
// the gate (a 401), and log without leaking the password/header.
|
||||
this.logger.warn(
|
||||
`git-http: credential check error: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
credentialsValid = false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- resolve the space + per-space gating + CASL ------------------------
|
||||
let spaceExists = false;
|
||||
let spaceGitSyncEnabled = false;
|
||||
let spaceId: string | undefined;
|
||||
let permissionGranted = false;
|
||||
if (credentialsValid && user && workspaceId && parsedPath && serviceKind) {
|
||||
const space = await this.spaceRepo.findById(
|
||||
parsedPath.spaceId,
|
||||
workspaceId,
|
||||
);
|
||||
if (space) {
|
||||
spaceExists = true;
|
||||
spaceId = space.id;
|
||||
spaceGitSyncEnabled =
|
||||
(space.settings as any)?.gitSync?.enabled === true;
|
||||
|
||||
// Only evaluate CASL when the space is actually a sync candidate — an
|
||||
// unrelated space stays a 404 (existence is never revealed).
|
||||
if (spaceGitSyncEnabled) {
|
||||
try {
|
||||
const ability = await this.spaceAbilityFactory.createForUser(
|
||||
user,
|
||||
space.id,
|
||||
);
|
||||
const action =
|
||||
serviceKind === 'write'
|
||||
? SpaceCaslAction.Manage
|
||||
: SpaceCaslAction.Read;
|
||||
permissionGranted = ability.can(action, SpaceCaslSubject.Page);
|
||||
} catch {
|
||||
// createForUser throws NotFoundException when the user has no role in
|
||||
// the space — that is simply "no permission" here.
|
||||
permissionGranted = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- the gate decision (pure) -------------------------------------------
|
||||
const decision = decideGitHttpGate({
|
||||
hasCredentials: Boolean(basic),
|
||||
credentialsValid,
|
||||
serviceKind,
|
||||
gitSyncEnabled: this.environmentService.isGitSyncEnabled(),
|
||||
gitHttpEnabled: this.environmentService.isGitSyncHttpEnabled(),
|
||||
spaceExists,
|
||||
spaceGitSyncEnabled,
|
||||
permissionGranted,
|
||||
});
|
||||
|
||||
if (decision.kind === 'unauthorized') {
|
||||
reply
|
||||
.header('WWW-Authenticate', WWW_AUTHENTICATE)
|
||||
.status(401)
|
||||
.send('Authentication required');
|
||||
return;
|
||||
}
|
||||
if (decision.kind === 'bad-request') {
|
||||
reply.status(400).send('Bad request');
|
||||
return;
|
||||
}
|
||||
if (decision.kind === 'not-found') {
|
||||
reply.status(404).send('Not found');
|
||||
return;
|
||||
}
|
||||
if (decision.kind === 'forbidden') {
|
||||
reply.status(403).send('Forbidden');
|
||||
return;
|
||||
}
|
||||
|
||||
// decision.kind === 'proceed' — guaranteed below (narrowing for TS).
|
||||
if (!parsedPath || !serviceKind || !spaceId || !user || !workspaceId) {
|
||||
// Defensive: 'proceed' implies these are set, but keep TS + runtime safe.
|
||||
reply.status(500).send('Internal server error');
|
||||
return;
|
||||
}
|
||||
|
||||
// --- dispatch to git http-backend ---------------------------------------
|
||||
const backendRequest = {
|
||||
spaceId,
|
||||
subpath: parsedPath.subpath,
|
||||
method: req.method,
|
||||
queryString: this.extractQueryString(req.url),
|
||||
contentType: this.headerValue(req.headers['content-type']) ?? '',
|
||||
gitProtocol: this.headerValue(req.headers['git-protocol']),
|
||||
remoteUser: user.email,
|
||||
};
|
||||
|
||||
try {
|
||||
// Idempotently make the vault servable (repo + receive/upload config).
|
||||
await this.vaultRegistry.ensureServable(spaceId);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`git-http: failed to prepare vault for space ${spaceId}: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
if (!reply.sent) reply.status(500).send('Internal server error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Hijack the reply so the backend can stream the raw (possibly binary) CGI
|
||||
// response directly to the socket (mirrors the MCP transport pattern).
|
||||
reply.hijack();
|
||||
|
||||
// Only the ACTUAL pack-receiving write (POST git-receive-pack) runs under the
|
||||
// space lock + a Docmost cycle. Everything else streams the http-backend
|
||||
// directly with NO lock and NO cycle: a fetch/clone (read), AND the
|
||||
// write-AUTHORIZED but READ-ONLY ref advertisement
|
||||
// (GET info/refs?service=git-receive-pack). Running a cycle on info/refs is
|
||||
// both wasteful and HARMFUL — it holds the per-space lock, so the push's
|
||||
// immediately-following POST git-receive-pack collides with it and 503s
|
||||
// (a deterministic push failure). Authz already happened above via the gate.
|
||||
const isReceivePack =
|
||||
req.method === 'POST' && parsedPath.subpath === 'git-receive-pack';
|
||||
if (serviceKind === 'read' || !isReceivePack) {
|
||||
await this.backend.run(backendRequest, rawReq, rawRes);
|
||||
return;
|
||||
}
|
||||
|
||||
// Push: run the receive-pack under the space lock, then a Docmost cycle.
|
||||
try {
|
||||
await this.orchestrator.ingestExternalPush(spaceId, workspaceId, () =>
|
||||
this.backend.run(backendRequest, rawReq, rawRes),
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof GitSyncLockHeldError) {
|
||||
// The lock could not be acquired and the receive-pack never ran, so the
|
||||
// response is still unwritten — answer 503 so git retries.
|
||||
if (!rawRes.headersSent) {
|
||||
rawRes.statusCode = 503;
|
||||
rawRes.setHeader('Content-Type', 'text/plain');
|
||||
rawRes.setHeader('Retry-After', '1');
|
||||
}
|
||||
try {
|
||||
rawRes.end('git-sync busy, retry');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Any other error: the receive-pack closure handles its own response, so
|
||||
// we only log here and make sure the socket is closed.
|
||||
this.logger.error(
|
||||
`git-http: push ingestion error for space ${spaceId}: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
try {
|
||||
if (!rawRes.writableEnded) rawRes.end();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Normalise a possibly-array header value to its first string. */
|
||||
private headerValue(value: string | string[] | undefined): string | undefined {
|
||||
if (Array.isArray(value)) return value[0];
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the part of the URL AFTER `/git/` and BEFORE the query string.
|
||||
* Returns null when the URL is not under `/git/`.
|
||||
*/
|
||||
private extractRest(url: string): string | null {
|
||||
const qIdx = url.indexOf('?');
|
||||
const pathname = qIdx === -1 ? url : url.slice(0, qIdx);
|
||||
const prefix = '/git/';
|
||||
if (!pathname.startsWith(prefix)) return null;
|
||||
return pathname.slice(prefix.length);
|
||||
}
|
||||
|
||||
/** The raw query string without the leading '?', or '' when none. */
|
||||
private extractQueryString(url: string): string {
|
||||
const qIdx = url.indexOf('?');
|
||||
return qIdx === -1 ? '' : url.slice(qIdx + 1);
|
||||
}
|
||||
}
|
||||
@@ -1,252 +0,0 @@
|
||||
// Unit tests for the event-driven git-sync trigger. The orchestrator
|
||||
// and page repo are hand-built mocks; the debounce coalescing is exercised with
|
||||
// jest fake timers. We assert the gate, the loop-guard (anti-echo), the
|
||||
// missing-page short-circuit, the heterogeneous event-shape id resolution, the
|
||||
// debounce collapse, and that errors are swallowed + logged.
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { PageChangeListener } from './page-change.listener';
|
||||
|
||||
type AnyMock = jest.Mock;
|
||||
|
||||
interface Built {
|
||||
listener: PageChangeListener;
|
||||
env: { isGitSyncEnabled: AnyMock; getGitSyncDebounceMs: AnyMock };
|
||||
orchestrator: { runOnce: AnyMock };
|
||||
pageRepo: { findById: AnyMock };
|
||||
}
|
||||
|
||||
function build(opts: { enabled?: boolean; debounceMs?: number } = {}): Built {
|
||||
const { enabled = true, debounceMs = 2000 } = opts;
|
||||
const env = {
|
||||
isGitSyncEnabled: jest.fn(() => enabled),
|
||||
getGitSyncDebounceMs: jest.fn(() => debounceMs),
|
||||
};
|
||||
const orchestrator = { runOnce: jest.fn(async () => undefined) };
|
||||
const pageRepo = { findById: jest.fn() };
|
||||
|
||||
const listener = new PageChangeListener(
|
||||
env as any,
|
||||
orchestrator as any,
|
||||
pageRepo as any,
|
||||
);
|
||||
return { listener, env, orchestrator, pageRepo };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('PageChangeListener', () => {
|
||||
describe('gate', () => {
|
||||
it('does nothing when git-sync is disabled (no findById, no schedule)', async () => {
|
||||
const { listener, orchestrator, pageRepo } = build({ enabled: false });
|
||||
await listener.handlePageEvent({ pageId: 'p1', workspaceId: 'ws-1' });
|
||||
expect(pageRepo.findById).not.toHaveBeenCalled();
|
||||
expect(orchestrator.runOnce).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('loop-guard (anti-echo)', () => {
|
||||
it("does NOT schedule a cycle when the page row's source is 'git-sync'", async () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const { listener, orchestrator, pageRepo } = build();
|
||||
pageRepo.findById.mockResolvedValue({
|
||||
id: 'p1',
|
||||
spaceId: 'space-1',
|
||||
workspaceId: 'ws-1',
|
||||
lastUpdatedSource: 'git-sync',
|
||||
});
|
||||
await listener.handlePageEvent({ pageId: 'p1', workspaceId: 'ws-1' });
|
||||
jest.runOnlyPendingTimers();
|
||||
expect(orchestrator.runOnce).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('schedules exactly one cycle for a normal (non-git-sync) source', async () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const { listener, orchestrator, pageRepo } = build();
|
||||
pageRepo.findById.mockResolvedValue({
|
||||
id: 'p1',
|
||||
spaceId: 'space-1',
|
||||
workspaceId: 'ws-1',
|
||||
lastUpdatedSource: 'user',
|
||||
});
|
||||
await listener.handlePageEvent({ pageId: 'p1', workspaceId: 'ws-1' });
|
||||
jest.runOnlyPendingTimers();
|
||||
expect(orchestrator.runOnce).toHaveBeenCalledTimes(1);
|
||||
expect(orchestrator.runOnce).toHaveBeenCalledWith('space-1', 'ws-1');
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('missing page', () => {
|
||||
it('does not schedule when findById returns null/undefined', async () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const { listener, orchestrator, pageRepo } = build();
|
||||
pageRepo.findById.mockResolvedValue(undefined);
|
||||
await listener.handlePageEvent({ pageId: 'p1', workspaceId: 'ws-1' });
|
||||
jest.runOnlyPendingTimers();
|
||||
expect(orchestrator.runOnce).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('spaceId/workspaceId resolution', () => {
|
||||
// The page row used to fill in any ids the event omits.
|
||||
const pageRow = {
|
||||
id: 'p1',
|
||||
spaceId: 'row-space',
|
||||
workspaceId: 'row-ws',
|
||||
lastUpdatedSource: 'user',
|
||||
};
|
||||
|
||||
async function resolve(event: Record<string, unknown>) {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const { listener, orchestrator, pageRepo } = build();
|
||||
pageRepo.findById.mockResolvedValue(pageRow);
|
||||
await listener.handlePageEvent(event as any);
|
||||
jest.runOnlyPendingTimers();
|
||||
return { orchestrator, pageRepo };
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
}
|
||||
|
||||
it("resolves pageId + event.spaceId + event.workspaceId", async () => {
|
||||
const { orchestrator, pageRepo } = await resolve({
|
||||
pageId: 'p1',
|
||||
spaceId: 'evt-space',
|
||||
workspaceId: 'evt-ws',
|
||||
});
|
||||
expect(pageRepo.findById).toHaveBeenCalledWith('p1', { includeContent: false });
|
||||
expect(orchestrator.runOnce).toHaveBeenCalledWith('evt-space', 'evt-ws');
|
||||
});
|
||||
|
||||
it('resolves pageId from pageIds[0]', async () => {
|
||||
const { orchestrator, pageRepo } = await resolve({
|
||||
pageIds: ['p1', 'p2'],
|
||||
spaceId: 'evt-space',
|
||||
workspaceId: 'evt-ws',
|
||||
});
|
||||
expect(pageRepo.findById).toHaveBeenCalledWith('p1', { includeContent: false });
|
||||
expect(orchestrator.runOnce).toHaveBeenCalledWith('evt-space', 'evt-ws');
|
||||
});
|
||||
|
||||
it('resolves pageId + spaceId from pages[]', async () => {
|
||||
const { orchestrator } = await resolve({
|
||||
pages: [{ id: 'p1', spaceId: 'pages-space' }],
|
||||
workspaceId: 'evt-ws',
|
||||
});
|
||||
expect(orchestrator.runOnce).toHaveBeenCalledWith('pages-space', 'evt-ws');
|
||||
});
|
||||
|
||||
it('resolves pageId + spaceId from node', async () => {
|
||||
const { orchestrator } = await resolve({
|
||||
node: { id: 'p1', spaceId: 'node-space' },
|
||||
workspaceId: 'evt-ws',
|
||||
});
|
||||
expect(orchestrator.runOnce).toHaveBeenCalledWith('node-space', 'evt-ws');
|
||||
});
|
||||
|
||||
it('falls back to the fetched page row when the event omits spaceId/workspaceId', async () => {
|
||||
const { orchestrator } = await resolve({ pageId: 'p1' });
|
||||
// No spaceId/workspaceId on the event -> use the page row's values.
|
||||
expect(orchestrator.runOnce).toHaveBeenCalledWith('row-space', 'row-ws');
|
||||
});
|
||||
});
|
||||
|
||||
describe('debounce coalescing', () => {
|
||||
it('collapses a burst of N events for one space into exactly one runOnce', async () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const { listener, orchestrator, pageRepo } = build({ debounceMs: 500 });
|
||||
pageRepo.findById.mockResolvedValue({
|
||||
id: 'p1',
|
||||
spaceId: 'space-1',
|
||||
workspaceId: 'ws-1',
|
||||
lastUpdatedSource: 'user',
|
||||
});
|
||||
|
||||
// Fire a burst of 5 events; await each so its findById promise settles
|
||||
// and schedule() runs before the next event resets the timer.
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await listener.handlePageEvent({ pageId: 'p1', workspaceId: 'ws-1' });
|
||||
}
|
||||
|
||||
// Nothing fired yet (still within the debounce window).
|
||||
expect(orchestrator.runOnce).not.toHaveBeenCalled();
|
||||
|
||||
// Advance past the debounce window: the coalesced cycle fires once.
|
||||
jest.advanceTimersByTime(500);
|
||||
expect(orchestrator.runOnce).toHaveBeenCalledTimes(1);
|
||||
expect(orchestrator.runOnce).toHaveBeenCalledWith('space-1', 'ws-1');
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('onModuleDestroy', () => {
|
||||
it('clears every pending debounce timer and empties the map', async () => {
|
||||
jest.useFakeTimers();
|
||||
const clearSpy = jest.spyOn(global, 'clearTimeout');
|
||||
try {
|
||||
const { listener, orchestrator, pageRepo } = build({ debounceMs: 500 });
|
||||
pageRepo.findById.mockResolvedValue({
|
||||
id: 'p1',
|
||||
spaceId: 'space-1',
|
||||
workspaceId: 'ws-1',
|
||||
lastUpdatedSource: 'user',
|
||||
});
|
||||
|
||||
// Schedule a pending cycle, then tear the module down before it fires.
|
||||
await listener.handlePageEvent({ pageId: 'p1', workspaceId: 'ws-1' });
|
||||
clearSpy.mockClear(); // ignore any clears done by schedule() itself
|
||||
|
||||
listener.onModuleDestroy();
|
||||
|
||||
// The pending timer was cleared and the map drained, so advancing past
|
||||
// the debounce window fires NO cycle.
|
||||
expect(clearSpy).toHaveBeenCalledTimes(1);
|
||||
expect((listener as any).debounce.size).toBe(0);
|
||||
jest.advanceTimersByTime(500);
|
||||
expect(orchestrator.runOnce).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
clearSpy.mockRestore();
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('error swallowing', () => {
|
||||
it('does not throw and logs a warning when findById throws', async () => {
|
||||
const warnSpy = jest
|
||||
.spyOn(Logger.prototype, 'warn')
|
||||
.mockImplementation(() => undefined);
|
||||
try {
|
||||
const { listener, orchestrator, pageRepo } = build();
|
||||
pageRepo.findById.mockRejectedValue(new Error('db down'));
|
||||
|
||||
await expect(
|
||||
listener.handlePageEvent({ pageId: 'p1', workspaceId: 'ws-1' }),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
expect(String(warnSpy.mock.calls[0][0])).toContain('db down');
|
||||
expect(orchestrator.runOnce).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
warnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,156 +0,0 @@
|
||||
import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { EnvironmentService } from '../../environment/environment.service';
|
||||
import { GitSyncOrchestrator } from '../services/git-sync.orchestrator';
|
||||
import { GIT_SYNC_PAGE_EVENTS } from '../git-sync.constants';
|
||||
|
||||
/**
|
||||
* Shape of the page domain events the listener consumes. Different emit sites
|
||||
* carry different optional fields (page.repo `PageEvent`, `PageMovedEvent`,
|
||||
* etc.), so this is the intersection we read: a `pageIds` list / single `pageId`,
|
||||
* the `workspaceId`, and an OPTIONAL `spaceId` (present only on some events). When
|
||||
* `spaceId` is absent we resolve it from the page row.
|
||||
*/
|
||||
interface PageEventLike {
|
||||
pageIds?: string[];
|
||||
pageId?: string;
|
||||
workspaceId?: string;
|
||||
spaceId?: string;
|
||||
pages?: { id: string; spaceId: string }[];
|
||||
node?: { id: string; spaceId: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Event-driven trigger for the git-sync control plane. Subscribes to
|
||||
* the page lifecycle events and, for an enabled space, schedules a DEBOUNCED
|
||||
* `orchestrator.runOnce(spaceId, workspaceId)` — coalescing a burst of edits into
|
||||
* a single cycle per space.
|
||||
*
|
||||
* Loop-guard (best-effort): an event whose page row already reads
|
||||
* `lastUpdatedSource === 'git-sync'` is the orchestrator's OWN write, so we skip
|
||||
* it to avoid a write -> event -> sync echo. The guard ALWAYS runs (the page row
|
||||
* is fetched for every event, structural ones included). This is the cheap first
|
||||
* guard; the full bodyHash + updatedAt loop-guard (consuming the push side's
|
||||
* `PushedPageRecord`) is a later hardening step — noted, not built
|
||||
* here. The poll-safety interval still converges anything this guard drops.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PageChangeListener implements OnModuleDestroy {
|
||||
private readonly logger = new Logger(PageChangeListener.name);
|
||||
// spaceId -> pending debounce timer. The cycle closes over its own
|
||||
// workspaceId, so the timer handle is all the map needs to track.
|
||||
private readonly debounce = new Map<string, NodeJS.Timeout>();
|
||||
|
||||
constructor(
|
||||
private readonly environmentService: EnvironmentService,
|
||||
private readonly orchestrator: GitSyncOrchestrator,
|
||||
private readonly pageRepo: PageRepo,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* One handler bound to ALL git-sync page events (the array form of `@OnEvent`).
|
||||
* Fetches the page row once to apply the loop-guard (unconditionally) and to
|
||||
* resolve the page's space + workspace, then schedules the debounced cycle.
|
||||
*/
|
||||
@OnEvent(GIT_SYNC_PAGE_EVENTS as unknown as string[])
|
||||
async handlePageEvent(event: PageEventLike): Promise<void> {
|
||||
if (!this.environmentService.isGitSyncEnabled()) return;
|
||||
|
||||
try {
|
||||
const pageId = this.firstPageId(event);
|
||||
if (!pageId) return;
|
||||
|
||||
// The loop-guard MUST always run — even structural events that already
|
||||
// carry spaceId+workspaceId could be the orchestrator's OWN write (it stamps
|
||||
// lastUpdatedSource='git-sync' on create/update/move/rename + body writes).
|
||||
// So ALWAYS fetch the page row: it gives us the loop-guard source AND fills
|
||||
// in any missing space/workspace in a single read. A missing page
|
||||
// (hard-deleted) is ignored.
|
||||
const page = await this.pageRepo.findById(pageId, {
|
||||
includeContent: false,
|
||||
});
|
||||
if (!page) return;
|
||||
|
||||
// Loop-guard: skip our own writes to avoid a write -> event -> sync echo
|
||||
// (best-effort). Applies unconditionally now.
|
||||
if (page.lastUpdatedSource === 'git-sync') return;
|
||||
|
||||
// Prefer ids carried on the event; fall back to the row we already fetched.
|
||||
const spaceId = this.eventSpaceId(event, pageId) ?? page.spaceId;
|
||||
const workspaceId = event.workspaceId ?? page.workspaceId;
|
||||
|
||||
if (!spaceId || !workspaceId) return;
|
||||
this.schedule(spaceId, workspaceId);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`git-sync: failed to handle page event: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Pull the first affected pageId out of the heterogeneous event shapes. */
|
||||
private firstPageId(event: PageEventLike): string | undefined {
|
||||
return (
|
||||
event.pageId ??
|
||||
event.pageIds?.[0] ??
|
||||
event.pages?.[0]?.id ??
|
||||
event.node?.id
|
||||
);
|
||||
}
|
||||
|
||||
/** A spaceId carried directly on the event, for the given pageId if scoped. */
|
||||
private eventSpaceId(
|
||||
event: PageEventLike,
|
||||
pageId: string,
|
||||
): string | undefined {
|
||||
if (event.spaceId) return event.spaceId;
|
||||
const fromPages = event.pages?.find((p) => p.id === pageId)?.spaceId;
|
||||
if (fromPages) return fromPages;
|
||||
if (event.node?.id === pageId) return event.node.spaceId;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* On shutdown, clear every pending debounce timer so a not-yet-fired cycle does
|
||||
* not run against a tearing-down module. The timers are already `.unref()`'d (so
|
||||
* they never block process exit), but clearing them also drops the dangling
|
||||
* references and prevents a late `runOnce` from firing post-destroy.
|
||||
*/
|
||||
onModuleDestroy(): void {
|
||||
for (const timer of this.debounce.values()) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
this.debounce.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounce per space: a new event resets the timer so a burst collapses into a
|
||||
* single cycle. On fire, `runOnce` is enqueued (it internally serializes via the
|
||||
* in-process mutex + Redis lock, so a still-running cycle is simply skipped and
|
||||
* the next event reschedules).
|
||||
*/
|
||||
private schedule(spaceId: string, workspaceId: string): void {
|
||||
const existing = this.debounce.get(spaceId);
|
||||
if (existing) clearTimeout(existing);
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
this.debounce.delete(spaceId);
|
||||
void this.orchestrator
|
||||
.runOnce(spaceId, workspaceId)
|
||||
.catch((err) =>
|
||||
this.logger.error(
|
||||
`git-sync: debounced cycle for space ${spaceId} failed: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
),
|
||||
);
|
||||
}, this.environmentService.getGitSyncDebounceMs());
|
||||
|
||||
// Do not keep the event loop alive solely for a pending sync.
|
||||
timer.unref?.();
|
||||
this.debounce.set(spaceId, timer);
|
||||
}
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import { mergeXmlFragments3Way } from './yjs-body-merge';
|
||||
|
||||
/**
|
||||
* Convergence repro for the git-ingest "silent revert" data-loss bug.
|
||||
*
|
||||
* ROOT CAUSE (confirmed): the merge logic itself is correct, but the git-ingest
|
||||
* write was applied via `openDirectConnection` on whichever instance/process
|
||||
* runs git-sync (the api/worker). When an editor is connected to a DIFFERENT
|
||||
* collab instance/process, that opens a SEPARATE, detached Y.Doc. The merge
|
||||
* lands in that detached doc (and the DB), but the live editor's Y.Doc never
|
||||
* receives the Yjs update — so its next debounced autosave overwrites the DB
|
||||
* with its STALE state and silently reverts the git change.
|
||||
*
|
||||
* These tests reproduce the invariant deterministically at the Yjs level (two
|
||||
* Y.Docs exchanging updates), because the real failure is DISTRIBUTED — it only
|
||||
* manifests when the write and the editor live on different instances, which a
|
||||
* single in-process Hocuspocus cannot reproduce (in one process the direct
|
||||
* connection already shares the editor's doc). HONEST SCOPE: this models the two
|
||||
* outcomes; full cross-instance convergence is not (and cannot be) proven in a
|
||||
* unit test without a live multi-instance Hocuspocus + redis.
|
||||
*
|
||||
* PATH B (the BUG): the git update is NOT delivered to the editor's doc — the
|
||||
* editor's later autosave reverts the change. Asserts the LOSS.
|
||||
* PATH A (the FIX): the git update IS delivered to the editor's doc as a Yjs
|
||||
* update — which is exactly what running the merge on the OWNING instance's
|
||||
* shared Document does (its update is broadcast to every connection). The
|
||||
* editor's CRDT converges and a later autosave preserves the git change.
|
||||
*
|
||||
* The fix routes git-sync's body write through CollaborationGateway.writePageBody
|
||||
* (the custom-event channel) so it executes on the owning instance — turning
|
||||
* PATH B into PATH A.
|
||||
*/
|
||||
|
||||
type Spec = { text: string; id?: string };
|
||||
|
||||
// Build a Y.XmlFragment('default'). `id` is set only when provided, mirroring
|
||||
// the live doc (block UniqueIDs present) vs a git-parsed body (ids absent).
|
||||
function buildFragment(doc: Y.Doc, specs: Spec[]): Y.XmlFragment {
|
||||
const frag = doc.getXmlFragment('default');
|
||||
const blocks = specs.map((s) => {
|
||||
const el = new Y.XmlElement('paragraph');
|
||||
if (s.id) el.setAttribute('id', s.id);
|
||||
const t = new Y.XmlText();
|
||||
if (s.text) t.insert(0, s.text);
|
||||
el.insert(0, [t]);
|
||||
return el;
|
||||
});
|
||||
if (blocks.length) frag.insert(0, blocks);
|
||||
return frag;
|
||||
}
|
||||
|
||||
const texts = (frag: Y.XmlFragment): string[] =>
|
||||
frag.toArray().map((el) =>
|
||||
(el as Y.XmlElement)
|
||||
.toArray()
|
||||
.map((c) => (c as Y.XmlText).toString())
|
||||
.join(''),
|
||||
);
|
||||
|
||||
// Append '!' to the end of the given block's text — a tiny human edit that
|
||||
// stands in for a connected editor's autosave-triggering keystroke.
|
||||
function humanEdit(doc: Y.Doc, blockIndex: number, mark = '!'): void {
|
||||
const frag = doc.getXmlFragment('default');
|
||||
const el = frag.get(blockIndex) as Y.XmlElement;
|
||||
const t = el.get(0) as Y.XmlText;
|
||||
doc.transact(() => t.insert(t.length, mark));
|
||||
}
|
||||
|
||||
describe('git-ingest convergence with an open editor', () => {
|
||||
// Shared setup: the page is persisted with two blocks (live ids), and BOTH the
|
||||
// server-side ingest doc (S) and the connected editor's doc (C) load that same
|
||||
// state — they start fully synced, exactly like two instances that each loaded
|
||||
// the page from the DB.
|
||||
function setup() {
|
||||
const db = new Y.Doc();
|
||||
buildFragment(db, [
|
||||
{ text: 'alpha', id: 'p1' },
|
||||
{ text: 'beta', id: 'p2' },
|
||||
]);
|
||||
const state0 = Y.encodeStateAsUpdate(db);
|
||||
|
||||
const server = new Y.Doc(); // where the git merge is applied
|
||||
Y.applyUpdate(server, state0);
|
||||
const editor = new Y.Doc(); // the browser's live in-memory doc
|
||||
Y.applyUpdate(editor, state0);
|
||||
|
||||
// base (last-synced, from git markdown — no ids) == the pre-change content.
|
||||
const baseDoc = new Y.Doc();
|
||||
const baseFrag = buildFragment(baseDoc, [{ text: 'alpha' }, { text: 'beta' }]);
|
||||
return { state0, server, editor, baseFrag };
|
||||
}
|
||||
|
||||
// git changed the SECOND block alpha/beta -> beta2; the editor is idle on it.
|
||||
function applyGitMerge(server: Y.Doc, baseFrag: Y.XmlFragment): Uint8Array {
|
||||
const targetDoc = new Y.Doc();
|
||||
const targetFrag = buildFragment(targetDoc, [
|
||||
{ text: 'alpha' },
|
||||
{ text: 'beta2' },
|
||||
]);
|
||||
let captured: Uint8Array | null = null;
|
||||
const onUpdate = (u: Uint8Array) => {
|
||||
// Accumulate (the merge emits one update per op when unwrapped); here a
|
||||
// single transact yields one update covering the whole merge.
|
||||
captured = captured ? Y.mergeUpdates([captured, u]) : u;
|
||||
};
|
||||
server.on('update', onUpdate);
|
||||
server.transact(() =>
|
||||
mergeXmlFragments3Way(
|
||||
server.getXmlFragment('default'),
|
||||
targetFrag,
|
||||
baseFrag,
|
||||
),
|
||||
);
|
||||
server.off('update', onUpdate);
|
||||
return captured!;
|
||||
}
|
||||
|
||||
it('PATH B (the BUG): undelivered git update is reverted by the editor autosave — DATA LOSS', () => {
|
||||
const { server, editor, baseFrag } = setup();
|
||||
|
||||
// git merge lands on the server doc only.
|
||||
applyGitMerge(server, baseFrag);
|
||||
expect(texts(server.getXmlFragment('default'))).toEqual(['alpha', 'beta2']);
|
||||
|
||||
// The editor NEVER receives the update (detached doc on another instance).
|
||||
// It makes an unrelated edit on block 0 and autosaves its full state.
|
||||
humanEdit(editor, 0);
|
||||
const persisted = new Y.Doc();
|
||||
Y.applyUpdate(persisted, Y.encodeStateAsUpdate(editor));
|
||||
|
||||
// git's 'beta2' is gone — the page reverted to 'beta'. This is the bug.
|
||||
expect(texts(persisted.getXmlFragment('default'))).toEqual([
|
||||
'alpha!',
|
||||
'beta',
|
||||
]);
|
||||
});
|
||||
|
||||
it('PATH A (the FIX): delivering the git update to the editor converges — git change SURVIVES', () => {
|
||||
const { server, editor, baseFrag } = setup();
|
||||
|
||||
// git merge on the server doc, capturing the broadcastable Yjs update.
|
||||
const gitUpdate = applyGitMerge(server, baseFrag);
|
||||
|
||||
// Running on the OWNING instance broadcasts the update to the connected
|
||||
// editor (Document.handleUpdate). Model that: the editor applies it.
|
||||
Y.applyUpdate(editor, gitUpdate);
|
||||
expect(texts(editor.getXmlFragment('default'))).toEqual(['alpha', 'beta2']);
|
||||
|
||||
// The editor now autosaves (unrelated edit on block 0). Its full state still
|
||||
// carries git's change — no revert.
|
||||
humanEdit(editor, 0);
|
||||
const persisted = new Y.Doc();
|
||||
Y.applyUpdate(persisted, Y.encodeStateAsUpdate(editor));
|
||||
expect(texts(persisted.getXmlFragment('default'))).toEqual([
|
||||
'alpha!',
|
||||
'beta2',
|
||||
]);
|
||||
});
|
||||
|
||||
it('PATH A — concurrent edits to DIFFERENT paragraphs both survive (finding #2)', () => {
|
||||
const { server, editor, baseFrag } = setup();
|
||||
|
||||
// The editor is actively editing block 0 (concurrent with the push).
|
||||
humanEdit(editor, 0, ' EDIT');
|
||||
|
||||
// git changes block 1; merge on the server, broadcast to the editor.
|
||||
const gitUpdate = applyGitMerge(server, baseFrag);
|
||||
Y.applyUpdate(editor, gitUpdate);
|
||||
|
||||
// Both sides preserved: the human's block-0 edit AND git's block-1 change.
|
||||
const persisted = new Y.Doc();
|
||||
Y.applyUpdate(persisted, Y.encodeStateAsUpdate(editor));
|
||||
expect(texts(persisted.getXmlFragment('default'))).toEqual([
|
||||
'alpha EDIT',
|
||||
'beta2',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,524 +0,0 @@
|
||||
// Unit tests for the git-sync control plane. The engine's `runCycle`
|
||||
// (which owns the PULL->PUSH branch choreography) is mocked so we exercise ONLY
|
||||
// the orchestrator's wiring: gating, the Redis leader lock + in-process mutex
|
||||
// (via SpaceLockService),
|
||||
// the remote-template substitution in the settings it hands the engine, the
|
||||
// external-push ingest, and the idempotent interval lifecycle. The cycle
|
||||
// mechanics themselves are covered by the engine's own cycle round-trip spec.
|
||||
//
|
||||
// The engine mock must be declared before importing the orchestrator so the
|
||||
// runtime `loadGitSync()` bridge resolves to the mocked `runCycle` (the ESM
|
||||
// `@docmost/git-sync` package cannot be `require()`d under jest). The `mock`
|
||||
// prefix lets the hoisted factory reference it.
|
||||
const mockRunCycle = jest.fn();
|
||||
|
||||
jest.mock('../git-sync.loader', () => ({
|
||||
loadGitSync: jest.fn(async () => ({
|
||||
runCycle: mockRunCycle,
|
||||
})),
|
||||
}));
|
||||
|
||||
import { Logger } from '@nestjs/common';
|
||||
import {
|
||||
Kysely,
|
||||
DummyDriver,
|
||||
PostgresAdapter,
|
||||
PostgresIntrospector,
|
||||
PostgresQueryCompiler,
|
||||
CompiledQuery,
|
||||
} from 'kysely';
|
||||
import {
|
||||
GitSyncOrchestrator,
|
||||
GitSyncLockHeldError,
|
||||
} from './git-sync.orchestrator';
|
||||
import { SpaceLockService } from './space-lock.service';
|
||||
|
||||
type AnyMock = jest.Mock;
|
||||
|
||||
const runCycleMock = mockRunCycle as unknown as AnyMock;
|
||||
|
||||
/** The default happy-path cycle result the engine returns. */
|
||||
const OK_CYCLE = {
|
||||
ran: true,
|
||||
pull: { written: 0, deleted: 0, conflict: false },
|
||||
push: { mode: 'apply', failures: 0 },
|
||||
};
|
||||
|
||||
interface BuildOptions {
|
||||
/** Env tunables (only the load-bearing ones are surfaced as overrides). */
|
||||
enabled?: boolean;
|
||||
serviceUserId?: string | undefined;
|
||||
remoteTemplate?: string | undefined;
|
||||
dataDir?: string;
|
||||
pollIntervalMs?: number;
|
||||
debounceMs?: number;
|
||||
/** A hook applied to the fake vault so a test can override its behaviour. */
|
||||
vaultOverrides?: Record<string, unknown>;
|
||||
/**
|
||||
* The row `buildSettings` reads for the per-space `autoMergeConflicts` flag
|
||||
* (`executeTakeFirst`). Default: the SAFE off value. Pass `undefined` to model
|
||||
* a missing row (no space / no settings).
|
||||
*/
|
||||
settingsRow?: { autoMergeConflicts: boolean } | undefined;
|
||||
}
|
||||
|
||||
interface Built {
|
||||
orchestrator: GitSyncOrchestrator;
|
||||
env: Record<string, AnyMock>;
|
||||
dataSource: { bind: AnyMock };
|
||||
client: Record<string, AnyMock>;
|
||||
vaultRegistry: { getVault: AnyMock; vaultPath: AnyMock };
|
||||
vault: Record<string, AnyMock>;
|
||||
scheduler: Record<string, AnyMock>;
|
||||
redis: { set: AnyMock; eval: AnyMock };
|
||||
redisService: { getOrThrow: AnyMock };
|
||||
db: unknown;
|
||||
}
|
||||
|
||||
function build(opts: BuildOptions = {}): Built {
|
||||
const {
|
||||
enabled = true,
|
||||
remoteTemplate = undefined,
|
||||
dataDir = '/vaults',
|
||||
pollIntervalMs = 15000,
|
||||
debounceMs = 2000,
|
||||
vaultOverrides = {},
|
||||
} = opts;
|
||||
// Distinguish "key omitted" (default off row) from "key present but undefined"
|
||||
// (a deliberately MISSING settings row).
|
||||
const settingsRow =
|
||||
'settingsRow' in opts ? opts.settingsRow : { autoMergeConflicts: false };
|
||||
// Distinguish "key omitted" (default to a valid id) from "key present but
|
||||
// undefined" (the no-service-user test deliberately sets it undefined).
|
||||
const serviceUserId = 'serviceUserId' in opts ? opts.serviceUserId : 'svc-user';
|
||||
|
||||
const env: Record<string, AnyMock> = {
|
||||
isGitSyncEnabled: jest.fn(() => enabled),
|
||||
getGitSyncServiceUserId: jest.fn(() => serviceUserId),
|
||||
getGitSyncRemoteTemplate: jest.fn(() => remoteTemplate),
|
||||
getGitSyncDataDir: jest.fn(() => dataDir),
|
||||
getGitSyncPollIntervalMs: jest.fn(() => pollIntervalMs),
|
||||
getGitSyncDebounceMs: jest.fn(() => debounceMs),
|
||||
};
|
||||
|
||||
// The read-side / write-side client the datasource hands back.
|
||||
const client: Record<string, AnyMock> = {
|
||||
listSpaceTree: jest.fn(async () => ({ pages: [], complete: true })),
|
||||
deletePage: jest.fn(async () => undefined),
|
||||
createPage: jest.fn(async () => undefined),
|
||||
updatePageBody: jest.fn(async () => undefined),
|
||||
};
|
||||
const dataSource = { bind: jest.fn(() => client) };
|
||||
|
||||
// The fake VaultGit: every method the orchestrator calls is a jest.fn.
|
||||
const vault: Record<string, AnyMock> = {
|
||||
assertGitAvailable: jest.fn(async () => undefined),
|
||||
ensureRepo: jest.fn(async () => undefined),
|
||||
isMergeInProgress: jest.fn(async () => false),
|
||||
ensureBranch: jest.fn(async () => undefined),
|
||||
checkout: jest.fn(async () => undefined),
|
||||
listTrackedFiles: jest.fn(async () => []),
|
||||
...(vaultOverrides as Record<string, AnyMock>),
|
||||
};
|
||||
const vaultRegistry = {
|
||||
getVault: jest.fn(async () => vault),
|
||||
vaultPath: jest.fn((spaceId: string) => `${dataDir}/${spaceId}`),
|
||||
};
|
||||
|
||||
const scheduler: Record<string, AnyMock> = {
|
||||
addInterval: jest.fn(),
|
||||
deleteInterval: jest.fn(),
|
||||
};
|
||||
|
||||
const redis = {
|
||||
// Default: lock acquired. Tests override per-case.
|
||||
set: jest.fn(async () => 'OK'),
|
||||
eval: jest.fn(async () => 1),
|
||||
};
|
||||
const redisService = { getOrThrow: jest.fn(() => redis) };
|
||||
|
||||
// Chainable Kysely stub. `buildSettings` reads the space's
|
||||
// `gitSync.autoMergeConflicts` flag via
|
||||
// `selectFrom('spaces').select(...).where('id','=',id).executeTakeFirst()`;
|
||||
// default it to the SAFE off value. `enabledSpaces` uses `.execute()`.
|
||||
const db = (() => {
|
||||
const builder: any = {
|
||||
select: () => builder,
|
||||
where: () => builder,
|
||||
executeTakeFirst: async () => settingsRow,
|
||||
execute: async () => [],
|
||||
};
|
||||
return { selectFrom: () => builder };
|
||||
})();
|
||||
|
||||
// The REAL SpaceLockService, constructed against the mock redis above, so all
|
||||
// existing lock assertions (lock-held, in-progress, leader lock, release CAS,
|
||||
// heartbeat) still exercise the same `redis.set`/`redis.eval` mock unchanged.
|
||||
const spaceLock = new SpaceLockService(redisService as any);
|
||||
|
||||
const orchestrator = new GitSyncOrchestrator(
|
||||
env as any,
|
||||
dataSource as any,
|
||||
vaultRegistry as any,
|
||||
scheduler as any,
|
||||
spaceLock as any,
|
||||
db as any,
|
||||
);
|
||||
|
||||
return {
|
||||
orchestrator,
|
||||
env,
|
||||
dataSource,
|
||||
client,
|
||||
vaultRegistry,
|
||||
vault,
|
||||
scheduler,
|
||||
redis,
|
||||
redisService,
|
||||
db,
|
||||
};
|
||||
}
|
||||
|
||||
/** The engine runs a clean cycle by default. */
|
||||
function primeEngineHappyPath(): void {
|
||||
runCycleMock.mockResolvedValue(OK_CYCLE);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
primeEngineHappyPath();
|
||||
});
|
||||
|
||||
describe('GitSyncOrchestrator', () => {
|
||||
describe('runOnce gating', () => {
|
||||
it("short-circuits with skipped:'disabled' when git-sync is disabled", async () => {
|
||||
const { orchestrator, redis, vaultRegistry } = build({ enabled: false });
|
||||
const res = await orchestrator.runOnce('space-1', 'ws-1');
|
||||
expect(res).toEqual({ spaceId: 'space-1', ran: false, skipped: 'disabled' });
|
||||
// No lock, no vault work performed.
|
||||
expect(redis.set).not.toHaveBeenCalled();
|
||||
expect(vaultRegistry.getVault).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns skipped:'no-service-user' when the service user id is falsy", async () => {
|
||||
const { orchestrator, redis } = build({ serviceUserId: undefined });
|
||||
const res = await orchestrator.runOnce('space-1', 'ws-1');
|
||||
expect(res).toEqual({
|
||||
spaceId: 'space-1',
|
||||
ran: false,
|
||||
skipped: 'no-service-user',
|
||||
});
|
||||
expect(redis.set).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('in-process mutex', () => {
|
||||
it("a second runOnce while the first is in-flight returns skipped:'in-progress'", async () => {
|
||||
const built = build();
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
// Hang the first cycle inside driveCycle by stalling getVault.
|
||||
built.vaultRegistry.getVault.mockImplementationOnce(async () => {
|
||||
await gate;
|
||||
return built.vault;
|
||||
});
|
||||
|
||||
const first = built.orchestrator.runOnce('space-1', 'ws-1');
|
||||
// Let the first call enter the running set + acquire the lock.
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
const second = await built.orchestrator.runOnce('space-1', 'ws-1');
|
||||
expect(second).toEqual({
|
||||
spaceId: 'space-1',
|
||||
ran: false,
|
||||
skipped: 'in-progress',
|
||||
});
|
||||
|
||||
release();
|
||||
await first;
|
||||
});
|
||||
});
|
||||
|
||||
describe('redis leader lock', () => {
|
||||
it("returns skipped:'lock-held' and cleans up the mutex when the lock is not acquired", async () => {
|
||||
const built = build();
|
||||
// First acquire fails (not 'OK'); a later acquire succeeds.
|
||||
built.redis.set
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValue('OK');
|
||||
|
||||
const res = await built.orchestrator.runOnce('space-1', 'ws-1');
|
||||
expect(res).toEqual({
|
||||
spaceId: 'space-1',
|
||||
ran: false,
|
||||
skipped: 'lock-held',
|
||||
});
|
||||
// The mutex must be clear: a subsequent call can acquire + run.
|
||||
const res2 = await built.orchestrator.runOnce('space-1', 'ws-1');
|
||||
expect(res2.ran).toBe(true);
|
||||
expect(res2.skipped).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('poisoned-space protection', () => {
|
||||
it('releases the lock and clears the mutex when the cycle throws, returning { error }', async () => {
|
||||
const built = build();
|
||||
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
runCycleMock.mockRejectedValueOnce(new Error('boom'));
|
||||
|
||||
const res = await built.orchestrator.runOnce('space-1', 'ws-1');
|
||||
expect(res.ran).toBe(false);
|
||||
expect(res.error).toBe('boom');
|
||||
// CAS release was invoked (eval) and the space is no longer "running":
|
||||
expect(built.redis.eval).toHaveBeenCalledTimes(1);
|
||||
|
||||
// A subsequent call can re-acquire (mutex cleared after the throw).
|
||||
runCycleMock.mockResolvedValue(OK_CYCLE);
|
||||
const res2 = await built.orchestrator.runOnce('space-1', 'ws-1');
|
||||
expect(res2.ran).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cycle wiring', () => {
|
||||
it('drives runCycle with the space vault, the bound client, and settings', async () => {
|
||||
const built = build();
|
||||
await built.orchestrator.runOnce('space-1', 'ws-1');
|
||||
|
||||
expect(runCycleMock).toHaveBeenCalledTimes(1);
|
||||
const [deps] = runCycleMock.mock.calls[0];
|
||||
expect(deps.spaceId).toBe('space-1');
|
||||
expect(deps.vault).toBe(built.vault);
|
||||
expect(deps.client).toBe(built.client);
|
||||
expect(deps.settings.vaultPath).toBe('/vaults/space-1');
|
||||
// The bound datasource identity is the (workspace, service-user) pair.
|
||||
expect(built.dataSource.bind).toHaveBeenCalledWith({
|
||||
workspaceId: 'ws-1',
|
||||
userId: 'svc-user',
|
||||
});
|
||||
});
|
||||
|
||||
it('threads autoMergeConflicts:true from the space settings row into the engine settings', async () => {
|
||||
const built = build({ settingsRow: { autoMergeConflicts: true } });
|
||||
await built.orchestrator.runOnce('space-1', 'ws-1');
|
||||
const [deps] = runCycleMock.mock.calls[0];
|
||||
expect(deps.settings.autoMergeConflicts).toBe(true);
|
||||
});
|
||||
|
||||
it('defaults autoMergeConflicts to false when the settings row is missing', async () => {
|
||||
const built = build({ settingsRow: undefined });
|
||||
await built.orchestrator.runOnce('space-1', 'ws-1');
|
||||
const [deps] = runCycleMock.mock.calls[0];
|
||||
expect(deps.settings.autoMergeConflicts).toBe(false);
|
||||
});
|
||||
|
||||
it("surfaces the engine's skipped status (e.g. merge-in-progress) verbatim", async () => {
|
||||
const built = build();
|
||||
runCycleMock.mockResolvedValue({ ran: false, skipped: 'merge-in-progress' });
|
||||
|
||||
const res = await built.orchestrator.runOnce('space-1', 'ws-1');
|
||||
expect(res).toEqual({
|
||||
spaceId: 'space-1',
|
||||
ran: false,
|
||||
skipped: 'merge-in-progress',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ingestExternalPush', () => {
|
||||
it('streams the receive-pack FIRST, then runs the Docmost cycle', async () => {
|
||||
const order: string[] = [];
|
||||
const built = build();
|
||||
runCycleMock.mockImplementation(async () => {
|
||||
order.push('cycle');
|
||||
return OK_CYCLE;
|
||||
});
|
||||
const runReceivePack = jest.fn(async () => {
|
||||
order.push('receive-pack');
|
||||
});
|
||||
|
||||
await built.orchestrator.ingestExternalPush('space-1', 'ws-1', runReceivePack);
|
||||
|
||||
expect(runReceivePack).toHaveBeenCalledTimes(1);
|
||||
// The cycle only runs AFTER the push commits land on main.
|
||||
expect(order).toEqual(['receive-pack', 'cycle']);
|
||||
});
|
||||
|
||||
it('throws GitSyncLockHeldError and does NOT run the receive-pack when the lock is held', async () => {
|
||||
const built = build();
|
||||
built.redis.set.mockResolvedValue(null); // acquire fails → lock-held
|
||||
const runReceivePack = jest.fn(async () => undefined);
|
||||
|
||||
await expect(
|
||||
built.orchestrator.ingestExternalPush('space-1', 'ws-1', runReceivePack),
|
||||
).rejects.toBeInstanceOf(GitSyncLockHeldError);
|
||||
|
||||
// We must never write to the working tree concurrently with a cycle.
|
||||
expect(runReceivePack).not.toHaveBeenCalled();
|
||||
expect(runCycleMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('swallows a post-push cycle error (the push is durable; poll retries)', async () => {
|
||||
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
const built = build();
|
||||
// The cycle throws AFTER the receive-pack already succeeded.
|
||||
runCycleMock.mockRejectedValueOnce(new Error('cycle boom'));
|
||||
const runReceivePack = jest.fn(async () => undefined);
|
||||
|
||||
// Does NOT throw — the durable push must not be reported as failed.
|
||||
await expect(
|
||||
built.orchestrator.ingestExternalPush('space-1', 'ws-1', runReceivePack),
|
||||
).resolves.toBeUndefined();
|
||||
expect(runReceivePack).toHaveBeenCalledTimes(1);
|
||||
// Lock was still released (CAS eval) despite the cycle error.
|
||||
expect(built.redis.eval).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('runs the receive-pack but SKIPS the cycle when no service user is configured', async () => {
|
||||
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
const built = build({ serviceUserId: undefined });
|
||||
const runReceivePack = jest.fn(async () => undefined);
|
||||
|
||||
await expect(
|
||||
built.orchestrator.ingestExternalPush('space-1', 'ws-1', runReceivePack),
|
||||
).resolves.toBeUndefined();
|
||||
// The push is durable on main; the immediate cycle is skipped, not failed.
|
||||
expect(runReceivePack).toHaveBeenCalledTimes(1);
|
||||
expect(runCycleMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses (LockHeldError) and runs nothing when git-sync is globally disabled', async () => {
|
||||
const built = build({ enabled: false });
|
||||
const runReceivePack = jest.fn(async () => undefined);
|
||||
|
||||
await expect(
|
||||
built.orchestrator.ingestExternalPush('space-1', 'ws-1', runReceivePack),
|
||||
).rejects.toBeInstanceOf(GitSyncLockHeldError);
|
||||
expect(runReceivePack).not.toHaveBeenCalled();
|
||||
expect(built.redis.set).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('remote template substitution', () => {
|
||||
it('substitutes {spaceId} into the gitRemote settings handed to the engine', async () => {
|
||||
const built = build({ remoteTemplate: 'git@h:vault-{spaceId}.git' });
|
||||
await built.orchestrator.runOnce('space-42', 'ws-1');
|
||||
const [deps] = runCycleMock.mock.calls[0];
|
||||
expect(deps.settings.gitRemote).toBe('git@h:vault-space-42.git');
|
||||
});
|
||||
});
|
||||
|
||||
describe('module lifecycle', () => {
|
||||
it('registers exactly one interval on init and tears it down idempotently on destroy', () => {
|
||||
const built = build();
|
||||
jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
|
||||
|
||||
built.orchestrator.onModuleInit();
|
||||
expect(built.scheduler.addInterval).toHaveBeenCalledTimes(1);
|
||||
const [name] = built.scheduler.addInterval.mock.calls[0];
|
||||
|
||||
built.orchestrator.onModuleDestroy();
|
||||
expect(built.scheduler.deleteInterval).toHaveBeenCalledTimes(1);
|
||||
expect(built.scheduler.deleteInterval).toHaveBeenCalledWith(name);
|
||||
|
||||
// A second destroy is a no-op (guard against double-delete).
|
||||
built.orchestrator.onModuleDestroy();
|
||||
expect(built.scheduler.deleteInterval).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('registers nothing on init when git-sync is disabled', () => {
|
||||
const built = build({ enabled: false });
|
||||
built.orchestrator.onModuleInit();
|
||||
expect(built.scheduler.addInterval).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// The poll-safety backstop: each tick enumerates the STRICT opt-in spaces and
|
||||
// reconciles each one under its own lock. We drive the private `pollTick()`
|
||||
// directly and (separately) compile `enabledSpaces()` to assert its opt-in SQL.
|
||||
describe('pollTick + enabledSpaces (strict opt-in backstop)', () => {
|
||||
it('runs runOnce exactly once per enabled space, with the right (spaceId, workspaceId)', async () => {
|
||||
const built = build();
|
||||
// Isolate the tick wiring from the cycle machinery: stub the enumeration
|
||||
// and count runOnce (it never throws; here we don't exercise its body).
|
||||
const runOnce = jest
|
||||
.spyOn(built.orchestrator, 'runOnce')
|
||||
.mockResolvedValue({ spaceId: 'x', ran: true });
|
||||
jest
|
||||
.spyOn(built.orchestrator as any, 'enabledSpaces')
|
||||
.mockResolvedValue([
|
||||
{ spaceId: 'space-1', workspaceId: 'ws-1' },
|
||||
{ spaceId: 'space-2', workspaceId: 'ws-2' },
|
||||
]);
|
||||
|
||||
await (built.orchestrator as any).pollTick();
|
||||
|
||||
expect(runOnce).toHaveBeenCalledTimes(2);
|
||||
// Per-space isolation: each space is reconciled with its OWN workspace id.
|
||||
expect(runOnce).toHaveBeenNthCalledWith(1, 'space-1', 'ws-1');
|
||||
expect(runOnce).toHaveBeenNthCalledWith(2, 'space-2', 'ws-2');
|
||||
});
|
||||
|
||||
it('does NOT throw and runs nothing when the enabled-spaces query throws (try/catch backstop)', async () => {
|
||||
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
const built = build();
|
||||
const runOnce = jest.spyOn(built.orchestrator, 'runOnce');
|
||||
jest
|
||||
.spyOn(built.orchestrator as any, 'enabledSpaces')
|
||||
.mockRejectedValue(new Error('db down'));
|
||||
|
||||
// A failed enumeration must never break the interval — pollTick swallows it.
|
||||
await expect(
|
||||
(built.orchestrator as any).pollTick(),
|
||||
).resolves.toBeUndefined();
|
||||
expect(runOnce).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('early-returns (no enumeration, no runOnce) when git-sync is disabled', async () => {
|
||||
const built = build({ enabled: false });
|
||||
const enabled = jest.spyOn(built.orchestrator as any, 'enabledSpaces');
|
||||
const runOnce = jest.spyOn(built.orchestrator, 'runOnce');
|
||||
|
||||
await (built.orchestrator as any).pollTick();
|
||||
|
||||
// Gated on the master switch before any DB work.
|
||||
expect(enabled).not.toHaveBeenCalled();
|
||||
expect(runOnce).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('compiles the STRICT opt-in enumeration SQL (spaces, deletedAt is null, enabled flag)', async () => {
|
||||
// Inject a compile-only Kysely (DummyDriver) whose `log` hook captures the
|
||||
// exact SQL `enabledSpaces()` runs — no fake builder, the real query is
|
||||
// compiled. DummyDriver yields no rows; we only assert the SQL shape.
|
||||
const built = build();
|
||||
let captured: CompiledQuery | undefined;
|
||||
const compileDb = new Kysely<any>({
|
||||
dialect: {
|
||||
createAdapter: () => new PostgresAdapter(),
|
||||
createDriver: () => new DummyDriver(),
|
||||
createIntrospector: (d) => new PostgresIntrospector(d),
|
||||
createQueryCompiler: () => new PostgresQueryCompiler(),
|
||||
},
|
||||
log: (event) => {
|
||||
if (event.level === 'query') captured = event.query as CompiledQuery;
|
||||
},
|
||||
});
|
||||
// Swap the orchestrator's injected db for the compile-only instance.
|
||||
(built.orchestrator as any).db = compileDb;
|
||||
|
||||
const rows = await (built.orchestrator as any).enabledSpaces();
|
||||
// DummyDriver returns no rows -> empty opt-in list (the no-space default).
|
||||
expect(rows).toEqual([]);
|
||||
|
||||
expect(captured).toBeDefined();
|
||||
const sql = captured!.sql.replace(/\s+/g, ' ');
|
||||
expect(sql).toContain('from "spaces"');
|
||||
// deletedAt-is-null guard (live spaces only).
|
||||
expect(sql).toContain('"deletedAt" is null');
|
||||
// STRICT per-space opt-in: the raw jsonb flag predicate, verbatim.
|
||||
expect(sql).toContain(`settings->'gitSync'->>'enabled' = 'true'`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,371 +0,0 @@
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
OnModuleDestroy,
|
||||
OnModuleInit,
|
||||
} from '@nestjs/common';
|
||||
import { SchedulerRegistry } from '@nestjs/schedule';
|
||||
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import { sql } from 'kysely';
|
||||
import type { Settings } from '@docmost/git-sync';
|
||||
import { loadGitSync } from '../git-sync.loader';
|
||||
import { EnvironmentService } from '../../environment/environment.service';
|
||||
import { GitmostDataSourceService } from './gitmost-datasource.service';
|
||||
import { VaultRegistryService } from './vault-registry.service';
|
||||
import { SpaceLockService } from './space-lock.service';
|
||||
|
||||
/** A space the poll loop should reconcile: its id + the workspace it lives in. */
|
||||
interface EnabledSpace {
|
||||
spaceId: string;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown by `ingestExternalPush` when the per-space lock cannot be acquired (a
|
||||
* poll cycle is mid-flight on this or another replica). The /git HTTP handler
|
||||
* maps it to a 503 so the git client retries rather than racing a cycle's
|
||||
* working-tree checkout/merge.
|
||||
*/
|
||||
export class GitSyncLockHeldError extends Error {
|
||||
constructor(public readonly spaceId: string) {
|
||||
super(`git-sync: space ${spaceId} is busy (lock held); retry the push`);
|
||||
this.name = 'GitSyncLockHeldError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Small status summary returned by `runOnce` (for the admin trigger + logs). */
|
||||
export interface GitSyncRunStatus {
|
||||
spaceId: string;
|
||||
ran: boolean;
|
||||
/** Why the cycle did not run (lock held elsewhere, busy, disabled, error). */
|
||||
skipped?:
|
||||
| 'lock-held'
|
||||
| 'in-progress'
|
||||
| 'disabled'
|
||||
| 'no-service-user'
|
||||
| 'merge-in-progress';
|
||||
pull?: { written: number; deleted: number; conflict: boolean };
|
||||
push?: { mode: string; failures: number };
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The git-sync control plane. Drives the vendored engine in
|
||||
* process: under a Redis leader lock (single-writer across replicas) plus an
|
||||
* in-process per-space mutex (no overlapping cycles on one instance), it runs a
|
||||
* PULL (Docmost -> vault) then a PUSH (vault -> Docmost) for a space.
|
||||
*
|
||||
* Enumeration of enabled spaces: STRICT opt-in. Only spaces whose
|
||||
* per-space flag `space.settings.gitSync.enabled === true` (written by the Phase-C
|
||||
* UI) are reconciled. There is intentionally NO all-spaces fallback: when no space
|
||||
* carries the flag, git-sync does NOTHING (an empty list) — flagging every space
|
||||
* the moment GIT_SYNC_ENABLED flips on is a safety hazard (it could mass-sync large
|
||||
* spaces). The whole loop is still gated on the GIT_SYNC_ENABLED master switch
|
||||
* first; per-space opt-in is now REQUIRED on top of it.
|
||||
*/
|
||||
@Injectable()
|
||||
export class GitSyncOrchestrator implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(GitSyncOrchestrator.name);
|
||||
/** The registered poll-interval name, or null when none is registered. */
|
||||
private pollIntervalName: string | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly environmentService: EnvironmentService,
|
||||
private readonly dataSource: GitmostDataSourceService,
|
||||
private readonly vaultRegistry: VaultRegistryService,
|
||||
private readonly schedulerRegistry: SchedulerRegistry,
|
||||
private readonly spaceLock: SpaceLockService,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
) {}
|
||||
|
||||
// --- enabled-space enumeration --------------------------------
|
||||
|
||||
/**
|
||||
* Enumerate the spaces the poll loop should reconcile. STRICT opt-in: ONLY
|
||||
* spaces carrying the Phase-C per-space flag (`settings->'gitSync'->>'enabled'
|
||||
* = 'true'`, written by the Phase-C UI) are returned. There is intentionally NO
|
||||
* fallback to "all live spaces" — when no space is flagged this returns an empty
|
||||
* list and git-sync does nothing (correct opt-in behavior). The GIT_SYNC_ENABLED
|
||||
* master switch gates whether the loop runs at all; this flag gates which spaces.
|
||||
*/
|
||||
private async enabledSpaces(): Promise<EnabledSpace[]> {
|
||||
return this.db
|
||||
.selectFrom('spaces')
|
||||
.select(['id as spaceId', 'workspaceId'])
|
||||
.where('deletedAt', 'is', null)
|
||||
.where(sql<boolean>`settings->'gitSync'->>'enabled' = 'true'`)
|
||||
.execute();
|
||||
}
|
||||
|
||||
// --- one sync cycle for a space -------------------------------
|
||||
|
||||
/**
|
||||
* Build the engine `Settings` for a space. The engine's REST-era fields
|
||||
* (docmostApiUrl/email/password) are unused on the native path — the
|
||||
* datasource writes in-process — so they are placeholders; only `vaultPath`,
|
||||
* `gitRemote`, and the tunables are load-bearing.
|
||||
*/
|
||||
private async buildSettings(spaceId: string): Promise<Settings> {
|
||||
const remoteTemplate = this.environmentService.getGitSyncRemoteTemplate();
|
||||
const gitRemote = remoteTemplate
|
||||
? remoteTemplate.replace(/\{spaceId\}/g, spaceId)
|
||||
: undefined;
|
||||
// Per-space PUSH policy for still-conflicted page bodies (SPEC §9): read the
|
||||
// `gitSync.autoMergeConflicts` flag from the space's jsonb settings. STRICT
|
||||
// opt-in like `enabled` — anything other than the literal 'true' (absent, null,
|
||||
// 'false') resolves to the SAFE default (skip a conflicted page, do not push).
|
||||
const row = await this.db
|
||||
.selectFrom('spaces')
|
||||
.select(
|
||||
sql<boolean>`settings->'gitSync'->>'autoMergeConflicts' = 'true'`.as(
|
||||
'autoMergeConflicts',
|
||||
),
|
||||
)
|
||||
.where('id', '=', spaceId)
|
||||
.executeTakeFirst();
|
||||
return {
|
||||
docmostApiUrl: 'http://native.local',
|
||||
docmostEmail: 'native@local',
|
||||
docmostPassword: 'native',
|
||||
docmostSpaceId: spaceId,
|
||||
vaultPath: this.vaultRegistry.vaultPath(spaceId),
|
||||
gitRemote,
|
||||
pollIntervalMs: this.environmentService.getGitSyncPollIntervalMs(),
|
||||
debounceMs: this.environmentService.getGitSyncDebounceMs(),
|
||||
logLevel: 'info',
|
||||
autoMergeConflicts: row?.autoMergeConflicts ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one full PULL + PUSH cycle for a space, under the Redis leader lock and
|
||||
* the in-process mutex. Never throws — per-space errors are caught, logged, and
|
||||
* returned in the status so a poll interval is never broken by one bad space.
|
||||
*/
|
||||
async runOnce(
|
||||
spaceId: string,
|
||||
workspaceId: string,
|
||||
): Promise<GitSyncRunStatus> {
|
||||
if (!this.environmentService.isGitSyncEnabled()) {
|
||||
return { spaceId, ran: false, skipped: 'disabled' };
|
||||
}
|
||||
const serviceUserId = this.environmentService.getGitSyncServiceUserId();
|
||||
if (!serviceUserId) {
|
||||
this.logger.error(
|
||||
'git-sync: GIT_SYNC_SERVICE_USER_ID is required when GIT_SYNC_ENABLED — skipping',
|
||||
);
|
||||
return { spaceId, ran: false, skipped: 'no-service-user' };
|
||||
}
|
||||
|
||||
// Run the full cycle under the per-space lock. withSpaceLock owns the
|
||||
// in-process mutex (no overlapping cycles on this instance) AND the Redis
|
||||
// leader lock (single writer across replicas), and returns a skip sentinel
|
||||
// when it could not enter — surfaced here as the existing skipped:'in-progress'
|
||||
// / 'lock-held' status so runOnce's observable behavior is unchanged.
|
||||
try {
|
||||
const result = await this.spaceLock.withSpaceLock(spaceId, (signal) =>
|
||||
this.driveCycle(spaceId, workspaceId, serviceUserId, signal),
|
||||
);
|
||||
if ('skipped' in result && !('spaceId' in result)) {
|
||||
return { spaceId, ran: false, skipped: result.skipped };
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger.error(`git-sync: cycle failed for space ${spaceId}: ${message}`);
|
||||
return { spaceId, ran: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ingest a push that arrived over smart-HTTP (the /git host). Under the SAME
|
||||
* per-space lock the poll cycle uses, it:
|
||||
* 1. runs `runReceivePack()` — the closure that spawns `git http-backend` for
|
||||
* the receive-pack request and finishes streaming the HTTP response to the
|
||||
* client. The client's push result is determined here.
|
||||
* 2. THEN — still holding the lock — runs the full Docmost cycle (the same
|
||||
* `driveCycle` body `runOnce` uses) so the freshly received commits on
|
||||
* `main` flow back into Docmost pages.
|
||||
*
|
||||
* If the cycle body in step 2 throws, it is LOGGED but NOT rethrown: the push
|
||||
* already succeeded and the commits are durable on `main`, so the poll-interval
|
||||
* backstop will reconcile them on the next tick. The receive-pack itself is the
|
||||
* load-bearing step.
|
||||
*
|
||||
* Lock contention: if the lock cannot be acquired (a poll cycle is mid-flight),
|
||||
* this throws a `GitSyncLockHeldError`. The HTTP handler converts that to a 503
|
||||
* so git surfaces a retryable error to the user (chosen over blocking the
|
||||
* request behind a potentially long cycle). The receive-pack is NOT run when
|
||||
* the lock is held — we never write to the working tree concurrently with a
|
||||
* cycle.
|
||||
*/
|
||||
async ingestExternalPush(
|
||||
spaceId: string,
|
||||
workspaceId: string,
|
||||
runReceivePack: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
if (!this.environmentService.isGitSyncEnabled()) {
|
||||
// The HTTP gate already checks this, but be defensive: never run a cycle
|
||||
// when sync is globally off.
|
||||
throw new GitSyncLockHeldError(spaceId);
|
||||
}
|
||||
const serviceUserId = this.environmentService.getGitSyncServiceUserId();
|
||||
|
||||
const result = await this.spaceLock.withSpaceLock(spaceId, async (signal) => {
|
||||
// 1) Stream the receive-pack to the client (durable commits land on main).
|
||||
await runReceivePack();
|
||||
|
||||
// 2) Reconcile the new commits into Docmost. A service user is required to
|
||||
// attribute the writes; without one we cannot run the cycle — the commits
|
||||
// are still durable and the poll backstop will pick them up once configured.
|
||||
if (!serviceUserId) {
|
||||
this.logger.error(
|
||||
'git-sync: GIT_SYNC_SERVICE_USER_ID is required to ingest an external ' +
|
||||
'push — the push is durable on main; skipping the immediate cycle.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.driveCycle(spaceId, workspaceId, serviceUserId, signal);
|
||||
} catch (err) {
|
||||
// Do NOT rethrow: the push succeeded and the commits are durable on main;
|
||||
// the poll-interval backstop retries the cycle. Log for visibility.
|
||||
this.logger.error(
|
||||
`git-sync: post-push cycle failed for space ${spaceId} (push is ` +
|
||||
`durable; poll will retry): ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
});
|
||||
|
||||
// The lock was held (in-progress or another replica) — surface to the caller
|
||||
// so the HTTP handler can answer 503 and let git retry.
|
||||
if (typeof result === 'object' && result !== null && 'skipped' in result) {
|
||||
throw new GitSyncLockHeldError(spaceId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive ONE reconcile cycle for a space. The PULL->PUSH branch choreography
|
||||
* lives in the engine's `runCycle` (so it can never drift from the engine it
|
||||
* ships with); the orchestrator owns only the lock (its caller) and the
|
||||
* service binding. There is no delete cap — deletes apply unconditionally (they
|
||||
* are soft/reversible) and every cycle logs what it deleted via `log`.
|
||||
*/
|
||||
private async driveCycle(
|
||||
spaceId: string,
|
||||
workspaceId: string,
|
||||
serviceUserId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<GitSyncRunStatus> {
|
||||
const { runCycle } = await loadGitSync();
|
||||
const settings = await this.buildSettings(spaceId);
|
||||
const vault = await this.vaultRegistry.getVault(spaceId);
|
||||
const client = this.dataSource.bind({ workspaceId, userId: serviceUserId });
|
||||
|
||||
const result = await runCycle({
|
||||
// Cooperative-abort signal from the per-space lock: if a heartbeat refresh
|
||||
// cannot confirm the lock, the cycle bails before its next destructive
|
||||
// write phase instead of writing blind after a possible lock loss.
|
||||
signal,
|
||||
spaceId,
|
||||
client,
|
||||
vault,
|
||||
settings,
|
||||
// ABSOLUTE-path fs primitives the engine cycle injects (it stays IO-free).
|
||||
fs: {
|
||||
readFile: (absPath) => readFile(absPath, 'utf8'),
|
||||
writeFile: (absPath, text) => writeFile(absPath, text, 'utf8'),
|
||||
mkdir: (absDir) => mkdir(absDir, { recursive: true }).then(() => undefined),
|
||||
rm: (absPath) => rm(absPath, { force: true }),
|
||||
},
|
||||
// Every cycle logs its full push plan + per-action lines + completion
|
||||
// counts (created/updated/deleted/skipped/failures) through this `log`, so
|
||||
// what was deleted (and what was not) is always recorded. There is no
|
||||
// delete cap: deletes are soft (Trash, reversible), so a blocking limit
|
||||
// only got in the way of legitimate deletes; engine correctness (covered by
|
||||
// the reconcile/layout tests) is what prevents phantom deletions.
|
||||
log: (line: string) => this.logger.log(`git-sync[${spaceId}] ${line}`),
|
||||
});
|
||||
|
||||
return { spaceId, ...result };
|
||||
}
|
||||
|
||||
// --- poll-safety interval -------------------------------------
|
||||
|
||||
/** Registered interval name (shared by registration + teardown). */
|
||||
private static readonly POLL_INTERVAL_NAME = 'git-sync-poll';
|
||||
|
||||
/**
|
||||
* Register the poll-safety interval DYNAMICALLY so it honors the configured
|
||||
* GIT_SYNC_POLL_INTERVAL_MS (a static `@Interval` decorator could only hardcode
|
||||
* a value at class-eval time, before config is readable — diverging from what
|
||||
* `/status` reports). When git-sync is disabled we register nothing.
|
||||
*
|
||||
* ScheduleModule: forRoot() is registered ONCE globally by TelemetryModule;
|
||||
* GitSyncModule imports the plain ScheduleModule so SchedulerRegistry is
|
||||
* injectable without a duplicate forRoot.
|
||||
*/
|
||||
onModuleInit(): void {
|
||||
if (!this.environmentService.isGitSyncEnabled()) return;
|
||||
|
||||
const ms = this.environmentService.getGitSyncPollIntervalMs();
|
||||
const handle = setInterval(() => {
|
||||
void this.pollTick();
|
||||
}, ms);
|
||||
// Do not keep the event loop alive solely for the poll timer.
|
||||
handle.unref?.();
|
||||
this.schedulerRegistry.addInterval(
|
||||
GitSyncOrchestrator.POLL_INTERVAL_NAME,
|
||||
handle,
|
||||
);
|
||||
this.pollIntervalName = GitSyncOrchestrator.POLL_INTERVAL_NAME;
|
||||
this.logger.log(`git-sync: poll interval registered (${ms}ms).`);
|
||||
}
|
||||
|
||||
/** Tear down the dynamic interval on shutdown (guard against double-delete). */
|
||||
onModuleDestroy(): void {
|
||||
if (!this.pollIntervalName) return;
|
||||
try {
|
||||
// deleteInterval clears the timer and removes it from the registry.
|
||||
this.schedulerRegistry.deleteInterval(this.pollIntervalName);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`git-sync: failed to delete poll interval: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
} finally {
|
||||
this.pollIntervalName = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One poll tick: catches events missed by the listener and reconciles after
|
||||
* downtime. Gated on GIT_SYNC_ENABLED (defensive — the interval is only
|
||||
* registered when enabled). Each enabled space runs under its own lock
|
||||
* (overlaps skipped). Never throws (runOnce swallows per-space errors).
|
||||
*/
|
||||
private async pollTick(): Promise<void> {
|
||||
if (!this.environmentService.isGitSyncEnabled()) return;
|
||||
let spaces: EnabledSpace[];
|
||||
try {
|
||||
spaces = await this.enabledSpaces();
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`git-sync: failed to enumerate enabled spaces: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
for (const { spaceId, workspaceId } of spaces) {
|
||||
// runOnce never throws; a per-space error is logged and returned in status.
|
||||
await this.runOnce(spaceId, workspaceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,477 +0,0 @@
|
||||
// Stub the collab util so importing the service does not drag in the
|
||||
// editor-ext -> @tiptap/react -> react-dom graph (unloadable under jest's node
|
||||
// env, same coupling noted in mcp.service.spec.ts). The captured transact
|
||||
// callback is never executed in these unit tests, so the stub extensions array
|
||||
// is sufficient; the real collab write path is exercised by integration tests.
|
||||
jest.mock('../../../collaboration/collaboration.util', () => ({
|
||||
tiptapExtensions: [],
|
||||
getPageId: (name: string) => name.replace(/^page\./, ''),
|
||||
}));
|
||||
// writeBody now builds the replacement Yjs state eagerly (before clearing the
|
||||
// live doc), so TiptapTransformer.toYdoc runs in these unit tests. Real Tiptap
|
||||
// extensions are stubbed to [] above (they drag in the React graph), which can't
|
||||
// build a schema — so stub the transformer to return a small non-empty Y.Doc.
|
||||
// The real conversion is exercised by the @docmost/git-sync converter tests and
|
||||
// the integration tests.
|
||||
jest.mock('@hocuspocus/transformer', () => {
|
||||
const Yjs = require('yjs');
|
||||
return {
|
||||
TiptapTransformer: {
|
||||
toYdoc: jest.fn(() => {
|
||||
const d = new Yjs.Doc();
|
||||
d.getXmlFragment('default').insert(0, [new Yjs.XmlElement('paragraph')]);
|
||||
return d;
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
// PageService is only ever a mocked dependency here; stub the editor-ext entry
|
||||
// it imports so loading its module does not pull in the React graph either.
|
||||
jest.mock('@docmost/editor-ext', () => ({
|
||||
markdownToHtml: jest.fn(),
|
||||
}));
|
||||
// The service loads `parseDocmostMarkdown` / `markdownToProseMirror` at runtime
|
||||
// via the `loadGitSync()` bridge (the ESM `@docmost/git-sync` package cannot be
|
||||
// `require()`d under jest). Stub the loader: the real conversion is exercised by
|
||||
// the @docmost/git-sync converter tests and the converter gate; here the mocked
|
||||
// TiptapTransformer.toYdoc ignores the converted doc anyway, so a passthrough
|
||||
// body + a minimal ProseMirror doc is sufficient.
|
||||
jest.mock('../git-sync.loader', () => ({
|
||||
loadGitSync: jest.fn(async () => ({
|
||||
parseDocmostMarkdown: (md: string) => ({ meta: {}, body: md }),
|
||||
markdownToProseMirror: async () => ({
|
||||
type: 'doc',
|
||||
content: [{ type: 'paragraph' }],
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GitmostDataSourceService } from './gitmost-datasource.service';
|
||||
|
||||
// Focused unit/contract test for the native GitSyncClient adapter.
|
||||
// No DB, no real collab server: the repos/services/gateway are mocked and we
|
||||
// assert the mapping logic + the provenance/soft-delete/position contracts.
|
||||
|
||||
type AnyMock = jest.Mock;
|
||||
|
||||
interface Mocks {
|
||||
pageRepo: {
|
||||
findById: AnyMock;
|
||||
getSpaceDescendants: AnyMock;
|
||||
restorePage: AnyMock;
|
||||
};
|
||||
spaceRepo: { findById: AnyMock };
|
||||
pageService: {
|
||||
create: AnyMock;
|
||||
update: AnyMock;
|
||||
movePage: AnyMock;
|
||||
removePage: AnyMock;
|
||||
};
|
||||
collabGateway: { writePageBody: AnyMock };
|
||||
// Minimal Kysely-ish chainable mock for the direct-query paths.
|
||||
db: any;
|
||||
}
|
||||
|
||||
function makeQueryBuilder(rows: any[]) {
|
||||
const qb: any = {};
|
||||
for (const m of ['select', 'where', 'orderBy', 'limit']) {
|
||||
qb[m] = jest.fn(() => qb);
|
||||
}
|
||||
qb.execute = jest.fn(async () => rows);
|
||||
qb.executeTakeFirst = jest.fn(async () => rows[0]);
|
||||
return qb;
|
||||
}
|
||||
|
||||
function build(rows: any[] = []): {
|
||||
service: GitmostDataSourceService;
|
||||
mocks: Mocks;
|
||||
} {
|
||||
const mocks: Mocks = {
|
||||
pageRepo: {
|
||||
findById: jest.fn(),
|
||||
getSpaceDescendants: jest.fn(),
|
||||
restorePage: jest.fn(async () => undefined),
|
||||
},
|
||||
spaceRepo: { findById: jest.fn(async () => ({ id: 'space-1' })) },
|
||||
pageService: {
|
||||
create: jest.fn(),
|
||||
update: jest.fn(async () => undefined),
|
||||
movePage: jest.fn(async () => undefined),
|
||||
removePage: jest.fn(async () => undefined),
|
||||
},
|
||||
collabGateway: {
|
||||
writePageBody: jest.fn(async () => undefined),
|
||||
},
|
||||
db: {
|
||||
selectFrom: jest.fn(() => makeQueryBuilder(rows)),
|
||||
},
|
||||
};
|
||||
|
||||
const service = new GitmostDataSourceService(
|
||||
mocks.pageRepo as any,
|
||||
mocks.spaceRepo as any,
|
||||
mocks.pageService as any,
|
||||
mocks.collabGateway as any,
|
||||
mocks.db as any,
|
||||
);
|
||||
|
||||
return { service, mocks };
|
||||
}
|
||||
|
||||
const CTX = { workspaceId: 'ws-1', userId: 'svc-user' };
|
||||
|
||||
describe('GitmostDataSourceService', () => {
|
||||
describe('listSpaceTree', () => {
|
||||
it('maps descendants to PageNode and is always complete:true', async () => {
|
||||
const { service, mocks } = build();
|
||||
mocks.spaceRepo.findById.mockResolvedValue({ id: 'space-1' });
|
||||
mocks.pageRepo.getSpaceDescendants.mockResolvedValue([
|
||||
{
|
||||
id: 'p1',
|
||||
slugId: 's1',
|
||||
title: 'Root',
|
||||
parentPageId: null,
|
||||
position: 'a0',
|
||||
},
|
||||
{
|
||||
id: 'p2',
|
||||
slugId: 's2',
|
||||
title: 'Child',
|
||||
parentPageId: 'p1',
|
||||
position: 'a1',
|
||||
},
|
||||
]);
|
||||
|
||||
const client = service.bind(CTX);
|
||||
const res = await client.listSpaceTree('space-1');
|
||||
|
||||
expect(res.complete).toBe(true);
|
||||
expect(mocks.pageRepo.getSpaceDescendants).toHaveBeenCalledWith(
|
||||
'space-1',
|
||||
{ includeContent: false },
|
||||
);
|
||||
expect(res.pages).toEqual([
|
||||
{
|
||||
id: 'p1',
|
||||
slugId: 's1',
|
||||
title: 'Root',
|
||||
parentPageId: null,
|
||||
hasChildren: true, // p2's parent is p1
|
||||
position: 'a0',
|
||||
},
|
||||
{
|
||||
id: 'p2',
|
||||
slugId: 's2',
|
||||
title: 'Child',
|
||||
parentPageId: 'p1',
|
||||
hasChildren: false,
|
||||
position: 'a1',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('throws when the space is not found', async () => {
|
||||
const { service, mocks } = build();
|
||||
mocks.spaceRepo.findById.mockResolvedValue(undefined);
|
||||
await expect(service.bind(CTX).listSpaceTree('nope')).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPageJson', () => {
|
||||
it('returns the engine page shape with ISO updatedAt + content', async () => {
|
||||
const { service, mocks } = build();
|
||||
const updatedAt = new Date('2026-06-20T10:00:00.000Z');
|
||||
mocks.pageRepo.findById.mockResolvedValue({
|
||||
id: 'p1',
|
||||
slugId: 's1',
|
||||
title: 'Doc',
|
||||
parentPageId: null,
|
||||
spaceId: 'space-1',
|
||||
updatedAt,
|
||||
content: { type: 'doc', content: [] },
|
||||
});
|
||||
|
||||
const res = await service.bind(CTX).getPageJson('p1');
|
||||
expect(mocks.pageRepo.findById).toHaveBeenCalledWith('p1', {
|
||||
includeContent: true,
|
||||
});
|
||||
expect(res).toEqual({
|
||||
id: 'p1',
|
||||
slugId: 's1',
|
||||
title: 'Doc',
|
||||
parentPageId: null,
|
||||
spaceId: 'space-1',
|
||||
updatedAt: '2026-06-20T10:00:00.000Z',
|
||||
content: { type: 'doc', content: [] },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('importPageMarkdown', () => {
|
||||
it('parses md, converts to ProseMirror, and routes the body write to the owning instance', async () => {
|
||||
const { service, mocks } = build();
|
||||
mocks.pageRepo.findById.mockResolvedValue({
|
||||
id: 'p1',
|
||||
updatedAt: new Date('2026-06-20T11:00:00.000Z'),
|
||||
});
|
||||
|
||||
const res = await service
|
||||
.bind(CTX)
|
||||
.importPageMarkdown('p1', '# Hello\n\nworld');
|
||||
|
||||
// writeBody routes through writePageBody (NOT openDirectConnection): the
|
||||
// merge must run on the instance that owns the live doc so a connected
|
||||
// editor converges instead of silently reverting the change. The service
|
||||
// user rides on the payload as the responsible author.
|
||||
expect(mocks.collabGateway.writePageBody).toHaveBeenCalledTimes(1);
|
||||
const [docName, payload] = mocks.collabGateway.writePageBody.mock.calls[0];
|
||||
expect(docName).toBe('page.p1');
|
||||
expect(payload.userId).toBe('svc-user');
|
||||
// A converted ProseMirror doc was passed; no base on a plain import.
|
||||
expect(payload.prosemirrorJson).toEqual(
|
||||
expect.objectContaining({ type: 'doc' }),
|
||||
);
|
||||
expect(payload.baseProsemirrorJson).toBeUndefined();
|
||||
|
||||
expect(res.updatedAt).toBe('2026-06-20T11:00:00.000Z');
|
||||
});
|
||||
|
||||
// The 2-way path (no base) is covered above; this exercises the THREE-WAY
|
||||
// branch that only fires when a `baseMarkdown` is supplied (review #5). The
|
||||
// merge dispatch itself now lives in the collab handler (gitSyncWriteBody);
|
||||
// here we assert the datasource forwards the base so the owning instance can
|
||||
// run the 3-way reconcile.
|
||||
describe('with a baseMarkdown (three-way merge)', () => {
|
||||
it('forwards the parsed base body so the owning instance can three-way merge', async () => {
|
||||
const { service, mocks } = build();
|
||||
mocks.pageRepo.findById.mockResolvedValue({
|
||||
id: 'p1',
|
||||
updatedAt: new Date('2026-06-20T11:00:00.000Z'),
|
||||
});
|
||||
|
||||
await service
|
||||
.bind(CTX)
|
||||
.importPageMarkdown('p1', '# Full\n\ngit', '# Base\n\nbase');
|
||||
|
||||
expect(mocks.collabGateway.writePageBody).toHaveBeenCalledTimes(1);
|
||||
const [, payload] = mocks.collabGateway.writePageBody.mock.calls[0];
|
||||
// Both the incoming body AND the last-synced base were converted and
|
||||
// forwarded — proof the 3-way common-ancestor is plumbed through.
|
||||
expect(payload.prosemirrorJson).toEqual(
|
||||
expect.objectContaining({ type: 'doc' }),
|
||||
);
|
||||
expect(payload.baseProsemirrorJson).toEqual(
|
||||
expect.objectContaining({ type: 'doc' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createPage', () => {
|
||||
it('creates the shell with git-sync provenance, writes body, returns id', async () => {
|
||||
const { service, mocks } = build();
|
||||
mocks.pageService.create.mockResolvedValue({ id: 'new-id' });
|
||||
mocks.pageRepo.findById.mockResolvedValue({
|
||||
id: 'new-id',
|
||||
updatedAt: new Date('2026-06-20T12:00:00.000Z'),
|
||||
});
|
||||
|
||||
const res = await service
|
||||
.bind(CTX)
|
||||
.createPage('Title', 'body md', 'space-1', 'parent-1');
|
||||
|
||||
expect(mocks.pageService.create).toHaveBeenCalledWith(
|
||||
'svc-user',
|
||||
'ws-1',
|
||||
{ spaceId: 'space-1', title: 'Title', parentPageId: 'parent-1' },
|
||||
{ actor: 'git-sync', aiChatId: null },
|
||||
);
|
||||
expect(mocks.collabGateway.writePageBody).toHaveBeenCalledWith(
|
||||
'page.new-id',
|
||||
expect.objectContaining({ userId: 'svc-user' }),
|
||||
);
|
||||
expect(res).toEqual({
|
||||
data: { id: 'new-id' },
|
||||
updatedAt: '2026-06-20T12:00:00.000Z',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('deletePage', () => {
|
||||
it('uses the soft-delete path (removePage), not a force delete', async () => {
|
||||
const { service, mocks } = build();
|
||||
await service.bind(CTX).deletePage('p1');
|
||||
// Passes git-sync provenance so the soft-delete stamps
|
||||
// lastUpdatedSource='git-sync' (loop-guard, PR #119 review).
|
||||
expect(mocks.pageService.removePage).toHaveBeenCalledWith(
|
||||
'p1',
|
||||
'svc-user',
|
||||
'ws-1',
|
||||
{ actor: 'git-sync', aiChatId: null },
|
||||
);
|
||||
// No forceDelete on the service surface used here.
|
||||
expect((mocks.pageService as any).forceDelete).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('movePage', () => {
|
||||
it('computes a fractional position when none is supplied', async () => {
|
||||
// db query returns a last sibling at 'a0' -> jittered key after it.
|
||||
const { service, mocks } = build([{ position: 'a0' }]);
|
||||
mocks.pageRepo.findById.mockResolvedValue({
|
||||
id: 'p1',
|
||||
spaceId: 'space-1',
|
||||
});
|
||||
|
||||
await service.bind(CTX).movePage('p1', 'parent-1');
|
||||
|
||||
expect(mocks.pageService.movePage).toHaveBeenCalledTimes(1);
|
||||
const [dto, page, provenance] = mocks.pageService.movePage.mock.calls[0];
|
||||
expect(dto.pageId).toBe('p1');
|
||||
expect(dto.parentPageId).toBe('parent-1');
|
||||
expect(typeof dto.position).toBe('string');
|
||||
expect(dto.position.length).toBeGreaterThan(0);
|
||||
expect(page).toEqual({ id: 'p1', spaceId: 'space-1' });
|
||||
expect(provenance).toEqual({ actor: 'git-sync', aiChatId: null });
|
||||
});
|
||||
|
||||
it('passes through an explicit position unchanged', async () => {
|
||||
const { service, mocks } = build();
|
||||
mocks.pageRepo.findById.mockResolvedValue({
|
||||
id: 'p1',
|
||||
spaceId: 'space-1',
|
||||
});
|
||||
|
||||
await service.bind(CTX).movePage('p1', null, 'zz');
|
||||
const [dto] = mocks.pageService.movePage.mock.calls[0];
|
||||
expect(dto.position).toBe('zz');
|
||||
// db not consulted for a supplied position.
|
||||
expect(mocks.db.selectFrom).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('renamePage', () => {
|
||||
it('updates only the title with git-sync provenance', async () => {
|
||||
const { service, mocks } = build();
|
||||
mocks.pageRepo.findById.mockResolvedValue({ id: 'p1', title: 'old' });
|
||||
|
||||
await service.bind(CTX).renamePage('p1', 'new title');
|
||||
|
||||
const [page, dto, user, provenance] =
|
||||
mocks.pageService.update.mock.calls[0];
|
||||
expect(page).toEqual({ id: 'p1', title: 'old' });
|
||||
expect(dto.title).toBe('new title');
|
||||
expect(user).toEqual({ id: 'svc-user' });
|
||||
expect(provenance).toEqual({ actor: 'git-sync', aiChatId: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('restorePage', () => {
|
||||
it('restores via the repo restore path scoped to the workspace', async () => {
|
||||
const { service, mocks } = build();
|
||||
const res = await service.bind(CTX).restorePage('p1');
|
||||
// Stamps lastUpdatedSource='git-sync' on restore (loop-guard, PR #119).
|
||||
expect(mocks.pageRepo.restorePage).toHaveBeenCalledWith(
|
||||
'p1',
|
||||
'ws-1',
|
||||
'git-sync',
|
||||
);
|
||||
expect(res).toEqual({ id: 'p1' });
|
||||
});
|
||||
});
|
||||
|
||||
// Phase-B+ continuous-sync methods: not yet called by the engine but wired into
|
||||
// the GitSyncClient seam (PR #119 review #5). Exercised via the bound client.
|
||||
describe('listRecentSince', () => {
|
||||
it('queries non-deleted pages newest-first and ISO-stringifies updatedAt', async () => {
|
||||
const rows = [
|
||||
{
|
||||
id: 'p1',
|
||||
slugId: 's1',
|
||||
title: 'A',
|
||||
parentPageId: null,
|
||||
spaceId: 'space-1',
|
||||
updatedAt: new Date('2026-06-20T10:00:00.000Z'),
|
||||
},
|
||||
];
|
||||
const { service, mocks } = build(rows);
|
||||
const qb = mocks.db.selectFrom.mock.results; // populated after the call
|
||||
|
||||
const out = (await service
|
||||
.bind(CTX)
|
||||
.listRecentSince('space-1', '2026-06-19T00:00:00.000Z', 100)) as any[];
|
||||
|
||||
// Query builder shaped against the `pages` table with the expected chain.
|
||||
expect(mocks.db.selectFrom).toHaveBeenCalledWith('pages');
|
||||
const builder = qb[0].value;
|
||||
expect(builder.select).toHaveBeenCalled();
|
||||
expect(builder.orderBy).toHaveBeenCalledWith('updatedAt', 'desc');
|
||||
// deletedAt is null + the conditional spaceId / since / cap clauses.
|
||||
const whereArgs = builder.where.mock.calls.map((c: any[]) => c[0]);
|
||||
expect(whereArgs).toContain('deletedAt');
|
||||
expect(whereArgs).toContain('spaceId');
|
||||
expect(whereArgs).toContain('updatedAt');
|
||||
expect(builder.limit).toHaveBeenCalledWith(100);
|
||||
|
||||
expect(out).toEqual([
|
||||
{
|
||||
id: 'p1',
|
||||
slugId: 's1',
|
||||
title: 'A',
|
||||
parentPageId: null,
|
||||
spaceId: 'space-1',
|
||||
updatedAt: '2026-06-20T10:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('omits the spaceId / since / cap clauses when not supplied', async () => {
|
||||
const { service, mocks } = build([]);
|
||||
|
||||
await service.bind(CTX).listRecentSince(undefined, null);
|
||||
|
||||
const builder = mocks.db.selectFrom.mock.results[0].value;
|
||||
const whereArgs = builder.where.mock.calls.map((c: any[]) => c[0]);
|
||||
// Only the deletedAt-is-null guard; no spaceId / updatedAt> clauses.
|
||||
expect(whereArgs).toEqual(['deletedAt']);
|
||||
expect(builder.limit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('listTrash', () => {
|
||||
it('queries soft-deleted pages and ISO-stringifies deletedAt (null stays null)', async () => {
|
||||
const rows = [
|
||||
{
|
||||
id: 'p1',
|
||||
slugId: 's1',
|
||||
title: 'Trashed',
|
||||
parentPageId: null,
|
||||
spaceId: 'space-1',
|
||||
deletedAt: new Date('2026-06-21T09:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
id: 'p2',
|
||||
slugId: 's2',
|
||||
title: 'NoDate',
|
||||
parentPageId: null,
|
||||
spaceId: 'space-1',
|
||||
deletedAt: null,
|
||||
},
|
||||
];
|
||||
const { service, mocks } = build(rows);
|
||||
|
||||
const out = (await service.bind(CTX).listTrash('space-1')) as any[];
|
||||
|
||||
expect(mocks.db.selectFrom).toHaveBeenCalledWith('pages');
|
||||
const builder = mocks.db.selectFrom.mock.results[0].value;
|
||||
const whereCalls = builder.where.mock.calls;
|
||||
// deletedAt is-not null (the trash predicate) + spaceId filter.
|
||||
expect(whereCalls).toContainEqual(['deletedAt', 'is not', null]);
|
||||
expect(whereCalls).toContainEqual(['spaceId', '=', 'space-1']);
|
||||
expect(builder.orderBy).toHaveBeenCalledWith('deletedAt', 'desc');
|
||||
|
||||
expect(out[0].deletedAt).toBe('2026-06-21T09:00:00.000Z');
|
||||
expect(out[1].deletedAt).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,422 +0,0 @@
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
|
||||
import type {
|
||||
GitSyncClient,
|
||||
GitSyncPageNodeLite,
|
||||
} from '@docmost/git-sync';
|
||||
import { loadGitSync } from '../git-sync.loader';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import { PageService } from '../../../core/page/services/page.service';
|
||||
import { CollaborationGateway } from '../../../collaboration/collaboration.gateway';
|
||||
import { AuthProvenanceData } from '../../../common/decorators/auth-provenance.decorator';
|
||||
|
||||
/**
|
||||
* The acting context the orchestrator binds the datasource to. The datasource is
|
||||
* NOT a fixed-identity singleton: it operates on behalf of a (workspaceId,
|
||||
* userId) pair the orchestrator supplies per space. `userId` is the
|
||||
* git-sync service user — it stays the responsible author (creatorId /
|
||||
* lastUpdatedById) while the `'git-sync'` actor marks provenance.
|
||||
*/
|
||||
export interface GitSyncBindContext {
|
||||
workspaceId: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The git-sync provenance carried into PageService writes. PageService.create/
|
||||
* update/movePage honor this provenance and stamp `lastUpdatedSource = 'git-sync'`
|
||||
* on the page row when `provenance.actor === 'git-sync'`. Body writes (writeBody,
|
||||
* §3.3) likewise stamp 'git-sync' because the collab context's `actor: 'git-sync'`
|
||||
* flows into PersistenceExtension. So ALL git-sync structural + body writes mark
|
||||
* the row's source, which the listener's loop-guard reads to skip our own writes.
|
||||
*/
|
||||
const GIT_SYNC_PROVENANCE: AuthProvenanceData = {
|
||||
actor: 'git-sync',
|
||||
aiChatId: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* Native, in-process implementation of the engine's `GitSyncClient` seam
|
||||
* Reads go through repositories (PageRepo/SpaceRepo); body writes go
|
||||
* through collab `openDirectConnection` (§3.3); structural mutations
|
||||
* (create/move/delete/rename) go through PageService.
|
||||
*
|
||||
* Shape: this is an `@Injectable()` holding the repos/services. The orchestrator
|
||||
* calls `bind({ workspaceId, userId })` to obtain a `GitSyncClient` bound to that
|
||||
* acting context. The bound object is a thin closure over `this` — no per-call
|
||||
* identity plumbing leaks into the engine.
|
||||
*/
|
||||
@Injectable()
|
||||
export class GitmostDataSourceService {
|
||||
private readonly logger = new Logger(GitmostDataSourceService.name);
|
||||
|
||||
constructor(
|
||||
private readonly pageRepo: PageRepo,
|
||||
private readonly spaceRepo: SpaceRepo,
|
||||
private readonly pageService: PageService,
|
||||
private readonly collabGateway: CollaborationGateway,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Bind the datasource to an acting (workspaceId, userId) context and return a
|
||||
* `GitSyncClient` the engine can consume directly.
|
||||
*/
|
||||
bind(ctx: GitSyncBindContext): GitSyncClient {
|
||||
return {
|
||||
listSpaceTree: (spaceId, rootPageId) =>
|
||||
this.listSpaceTree(ctx, spaceId, rootPageId),
|
||||
getPageJson: (pageId) => this.getPageJson(ctx, pageId),
|
||||
importPageMarkdown: (pageId, fullMarkdown, baseMarkdown) =>
|
||||
this.importPageMarkdown(ctx, pageId, fullMarkdown, baseMarkdown),
|
||||
createPage: (title, content, spaceId, parentPageId) =>
|
||||
this.createPage(ctx, title, content, spaceId, parentPageId),
|
||||
deletePage: (pageId) => this.deletePage(ctx, pageId),
|
||||
movePage: (pageId, parentPageId, position) =>
|
||||
this.movePage(pageId, parentPageId, position),
|
||||
renamePage: (pageId, title) => this.renamePage(ctx, pageId, title),
|
||||
listRecentSince: (spaceId, sinceIso, hardPageCap) =>
|
||||
this.listRecentSince(spaceId, sinceIso, hardPageCap),
|
||||
listTrash: (spaceId) => this.listTrash(spaceId),
|
||||
restorePage: (pageId) => this.restorePage(ctx, pageId),
|
||||
};
|
||||
}
|
||||
|
||||
// --- reads (pull) ---------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Full page tree of a space mapped to the engine's `PageNode` shape. We read
|
||||
* the DB directly, so `complete` is ALWAYS `true` — the incomplete-fetch
|
||||
* suppression (SPEC §8) never fires natively.
|
||||
*/
|
||||
private async listSpaceTree(
|
||||
ctx: GitSyncBindContext,
|
||||
spaceId: string,
|
||||
_rootPageId?: string,
|
||||
): Promise<{ pages: GitSyncPageNodeLite[]; complete: boolean }> {
|
||||
const space = await this.spaceRepo.findById(spaceId, ctx.workspaceId);
|
||||
if (!space) {
|
||||
throw new NotFoundException(`Space ${spaceId} not found`);
|
||||
}
|
||||
|
||||
const rows = await this.pageRepo.getSpaceDescendants(space.id, {
|
||||
includeContent: false,
|
||||
});
|
||||
|
||||
// `getSpaceDescendants` does not select `hasChildren`; derive it from the
|
||||
// parent links present in the same result set.
|
||||
const parentIds = new Set<string>();
|
||||
for (const row of rows) {
|
||||
if (row.parentPageId) parentIds.add(row.parentPageId);
|
||||
}
|
||||
|
||||
const pages: GitSyncPageNodeLite[] = rows.map((row) => ({
|
||||
id: row.id,
|
||||
slugId: row.slugId,
|
||||
title: row.title,
|
||||
parentPageId: row.parentPageId ?? null,
|
||||
hasChildren: parentIds.has(row.id),
|
||||
position: row.position,
|
||||
}));
|
||||
|
||||
return { pages, complete: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* One page WITH its ProseMirror body content (editor-ext schema). `updatedAt`
|
||||
* is serialized to an ISO string for the loop-guard.
|
||||
*/
|
||||
private async getPageJson(
|
||||
ctx: GitSyncBindContext,
|
||||
pageId: string,
|
||||
): Promise<{
|
||||
id: string;
|
||||
slugId: string;
|
||||
title: string;
|
||||
parentPageId: string | null;
|
||||
spaceId: string;
|
||||
updatedAt: string;
|
||||
content: unknown;
|
||||
}> {
|
||||
const page = await this.pageRepo.findById(pageId, { includeContent: true });
|
||||
if (!page) {
|
||||
throw new NotFoundException(`Page ${pageId} not found`);
|
||||
}
|
||||
|
||||
return {
|
||||
id: page.id,
|
||||
slugId: page.slugId,
|
||||
title: page.title,
|
||||
parentPageId: page.parentPageId ?? null,
|
||||
spaceId: page.spaceId,
|
||||
updatedAt: new Date(page.updatedAt).toISOString(),
|
||||
content: page.content,
|
||||
};
|
||||
}
|
||||
|
||||
// --- writes (push) --------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Merge a page's body from a self-contained markdown file: parse the meta+body
|
||||
* envelope, convert the body to ProseMirror, then merge it through collab
|
||||
* (§3.3). When `baseMarkdown` (the last-synced version of the file) is given,
|
||||
* the body write is a THREE-WAY merge against the live doc so concurrent human
|
||||
* edits survive (review #5); without it, a 2-way merge. Returns the fresh
|
||||
* page's `updatedAt` for the loop-guard.
|
||||
*/
|
||||
private async importPageMarkdown(
|
||||
ctx: GitSyncBindContext,
|
||||
pageId: string,
|
||||
fullMarkdown: string,
|
||||
baseMarkdown?: string | null,
|
||||
): Promise<{ updatedAt?: string }> {
|
||||
const { parseDocmostMarkdown, markdownToProseMirror } = await loadGitSync();
|
||||
const { body } = parseDocmostMarkdown(fullMarkdown);
|
||||
const doc = await markdownToProseMirror(body);
|
||||
|
||||
let baseDoc: unknown;
|
||||
if (baseMarkdown != null) {
|
||||
const { body: baseBody } = parseDocmostMarkdown(baseMarkdown);
|
||||
baseDoc = await markdownToProseMirror(baseBody);
|
||||
}
|
||||
|
||||
await this.writeBody(pageId, doc, ctx.userId, baseDoc);
|
||||
|
||||
const page = await this.pageRepo.findById(pageId);
|
||||
return {
|
||||
updatedAt: page ? new Date(page.updatedAt).toISOString() : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a page shell via PageService, then write its body through collab.
|
||||
* Returns the assigned id (`data.id`) + the page's `updatedAt`.
|
||||
*/
|
||||
private async createPage(
|
||||
ctx: GitSyncBindContext,
|
||||
title: string,
|
||||
content: string,
|
||||
spaceId: string,
|
||||
parentPageId?: string,
|
||||
): Promise<{ data: { id: string }; updatedAt?: string }> {
|
||||
const page = await this.pageService.create(
|
||||
ctx.userId,
|
||||
ctx.workspaceId,
|
||||
{ spaceId, title, parentPageId },
|
||||
GIT_SYNC_PROVENANCE,
|
||||
);
|
||||
|
||||
// The shell is created without body; push the markdown body through collab.
|
||||
const { parseDocmostMarkdown, markdownToProseMirror } = await loadGitSync();
|
||||
const { body } = parseDocmostMarkdown(content);
|
||||
const doc = await markdownToProseMirror(body);
|
||||
await this.writeBody(page.id, doc, ctx.userId);
|
||||
|
||||
const fresh = await this.pageRepo.findById(page.id);
|
||||
return {
|
||||
data: { id: page.id },
|
||||
updatedAt: fresh ? new Date(fresh.updatedAt).toISOString() : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-delete the page to Trash (reversible). NOT a force delete — `restorePage`
|
||||
* can bring it back.
|
||||
*/
|
||||
private async deletePage(
|
||||
ctx: GitSyncBindContext,
|
||||
pageId: string,
|
||||
): Promise<unknown> {
|
||||
await this.pageService.removePage(
|
||||
pageId,
|
||||
ctx.userId,
|
||||
ctx.workspaceId,
|
||||
GIT_SYNC_PROVENANCE,
|
||||
);
|
||||
return { id: pageId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reparent a page. Docmost-move REQUIRES a fractional-index `position`; when the
|
||||
* engine omits it, compute a key after the destination's last sibling (plan
|
||||
* §3.2 / §14.4).
|
||||
*/
|
||||
private async movePage(
|
||||
pageId: string,
|
||||
parentPageId: string | null,
|
||||
position?: string,
|
||||
): Promise<unknown> {
|
||||
const page = await this.pageRepo.findById(pageId);
|
||||
if (!page) {
|
||||
throw new NotFoundException(`Page ${pageId} not found`);
|
||||
}
|
||||
|
||||
const resolvedPosition =
|
||||
position ?? (await this.computeMovePosition(page.spaceId, parentPageId));
|
||||
|
||||
await this.pageService.movePage(
|
||||
{ pageId, parentPageId: parentPageId ?? null, position: resolvedPosition },
|
||||
page,
|
||||
GIT_SYNC_PROVENANCE,
|
||||
);
|
||||
return { id: pageId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a fractional-index position AFTER the last sibling under
|
||||
* `parentPageId` (root pages when null) in the space, ordered by `position`
|
||||
* with the "C" collation Docmost uses. Falls back to a fresh key
|
||||
* when there are no siblings.
|
||||
*/
|
||||
private async computeMovePosition(
|
||||
spaceId: string,
|
||||
parentPageId: string | null,
|
||||
): Promise<string> {
|
||||
let query = this.db
|
||||
.selectFrom('pages')
|
||||
.select(['position'])
|
||||
.where('spaceId', '=', spaceId)
|
||||
.where('deletedAt', 'is', null)
|
||||
.orderBy('position', (ob) => ob.collate('C').desc())
|
||||
.limit(1);
|
||||
|
||||
query = parentPageId
|
||||
? query.where('parentPageId', '=', parentPageId)
|
||||
: query.where('parentPageId', 'is', null);
|
||||
|
||||
const lastSibling = await query.executeTakeFirst();
|
||||
return generateJitteredKeyBetween(lastSibling?.position ?? null, null);
|
||||
}
|
||||
|
||||
/** Change a page's title only (no body touch). */
|
||||
private async renamePage(
|
||||
ctx: GitSyncBindContext,
|
||||
pageId: string,
|
||||
title: string,
|
||||
): Promise<unknown> {
|
||||
const page = await this.pageRepo.findById(pageId);
|
||||
if (!page) {
|
||||
throw new NotFoundException(`Page ${pageId} not found`);
|
||||
}
|
||||
// PageService.update takes a User; the git-sync service user is the
|
||||
// responsible author. Only the id is read off it for lastUpdatedById.
|
||||
// `pageId` satisfies the UpdatePageDto type; PageService.update reads the
|
||||
// page id off `page`, not the DTO. Only `title` is applied here.
|
||||
await this.pageService.update(
|
||||
page,
|
||||
{ pageId, title },
|
||||
{ id: ctx.userId } as any,
|
||||
GIT_SYNC_PROVENANCE,
|
||||
);
|
||||
return { id: pageId };
|
||||
}
|
||||
|
||||
// --- continuous (phase B+) ------------------------------------------------
|
||||
|
||||
/**
|
||||
* Pages in the space updated since `sinceIso` (poll-safety reconciliation,
|
||||
* SPEC §8). `spaceId` undefined widens to all spaces; `hardPageCap` bounds the
|
||||
* result. Reads the DB directly (no cursor pagination needed here).
|
||||
*/
|
||||
private async listRecentSince(
|
||||
spaceId: string | undefined,
|
||||
sinceIso: string | null,
|
||||
hardPageCap?: number,
|
||||
): Promise<unknown[]> {
|
||||
let query = this.db
|
||||
.selectFrom('pages')
|
||||
.select([
|
||||
'id',
|
||||
'slugId',
|
||||
'title',
|
||||
'parentPageId',
|
||||
'spaceId',
|
||||
'updatedAt',
|
||||
])
|
||||
.where('deletedAt', 'is', null)
|
||||
.orderBy('updatedAt', 'desc');
|
||||
|
||||
if (spaceId) query = query.where('spaceId', '=', spaceId);
|
||||
if (sinceIso) query = query.where('updatedAt', '>', new Date(sinceIso));
|
||||
if (hardPageCap) query = query.limit(hardPageCap);
|
||||
|
||||
const rows = await query.execute();
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
updatedAt: new Date(row.updatedAt).toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Soft-deleted (trashed) pages for the space (deletion detection). */
|
||||
private async listTrash(spaceId: string): Promise<unknown[]> {
|
||||
const rows = await this.db
|
||||
.selectFrom('pages')
|
||||
.select(['id', 'slugId', 'title', 'parentPageId', 'spaceId', 'deletedAt'])
|
||||
.where('spaceId', '=', spaceId)
|
||||
.where('deletedAt', 'is not', null)
|
||||
.orderBy('deletedAt', 'desc')
|
||||
.execute();
|
||||
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
deletedAt: row.deletedAt ? new Date(row.deletedAt).toISOString() : null,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Restore a soft-deleted page from Trash. */
|
||||
private async restorePage(
|
||||
ctx: GitSyncBindContext,
|
||||
pageId: string,
|
||||
): Promise<unknown> {
|
||||
// Stamp git-sync provenance so the change-listener loop-guard skips the
|
||||
// PAGE_RESTORED echo (mirrors deletePage / create / update / move).
|
||||
await this.pageRepo.restorePage(
|
||||
pageId,
|
||||
ctx.workspaceId,
|
||||
GIT_SYNC_PROVENANCE.actor,
|
||||
);
|
||||
return { id: pageId };
|
||||
}
|
||||
|
||||
// --- linchpin: native body write (§3.3) -----------------------------------
|
||||
|
||||
/**
|
||||
* In-process body write — no loopback websocket, no service-user token.
|
||||
*
|
||||
* Routes the write through `CollaborationGateway.writePageBody`, which applies
|
||||
* the block-level MERGE on the instance that OWNS the live Y.Doc (via the
|
||||
* custom-event channel) rather than opening a direct connection on this
|
||||
* (api/worker) instance. That distinction is load-bearing: when an editor is
|
||||
* connected to a different collab instance/process, a direct connection here
|
||||
* mutates a SEPARATE, detached doc the editor never sees — the editor's next
|
||||
* autosave then silently REVERTS the git change (data loss). Running on the
|
||||
* owning instance broadcasts the merge as a Yjs update so the editor converges
|
||||
* (see CollaborationGateway.writePageBody for the full rationale).
|
||||
*
|
||||
* The merge itself stays a block-level reconcile, not a full-body replace
|
||||
* (review #5): only changed blocks are touched, concurrently-edited blocks are
|
||||
* left untouched, and an unchanged resync is a 0-op write. With a `base` (the
|
||||
* last-synced version) it is a THREE-WAY merge so a block ONLY the human
|
||||
* changed is kept and a block ONLY git changed is taken (conflicts -> git);
|
||||
* without a base (e.g. createPage) it falls back to the 2-way merge. The
|
||||
* `{ actor: 'git-sync', user: { id: userId } }` context flows into
|
||||
* PersistenceExtension.onStoreDocument, which persists ydoc+content+textContent,
|
||||
* stamps `lastUpdatedSource = 'git-sync'`, and broadcasts `page.updated`.
|
||||
*/
|
||||
private async writeBody(
|
||||
pageId: string,
|
||||
prosemirrorJson: unknown,
|
||||
userId: string,
|
||||
baseProsemirrorJson?: unknown,
|
||||
): Promise<void> {
|
||||
const documentName = `page.${pageId}`;
|
||||
await this.collabGateway.writePageBody(documentName, {
|
||||
prosemirrorJson,
|
||||
baseProsemirrorJson,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
/**
|
||||
* Backward-filled LCS length table for sequences `a` and `b`: `dp[i][j]` is the
|
||||
* length of the longest common subsequence of the suffixes `a[i:]` and `b[j:]`.
|
||||
* O(n*m) time/space — fine for page block counts.
|
||||
*
|
||||
* Shared by the two-way block diff (`yjs-body-merge.diffBlocks`) and the
|
||||
* three-way merge planner (`three-way-merge.lcsPairs`) so the (identical) table
|
||||
* construction lives in ONE place; each caller does its own traceback over the
|
||||
* returned table.
|
||||
*/
|
||||
export function buildLcsTable(a: string[], b: string[]): number[][] {
|
||||
const n = a.length;
|
||||
const m = b.length;
|
||||
const dp: number[][] = Array.from({ length: n + 1 }, () =>
|
||||
new Array(m + 1).fill(0),
|
||||
);
|
||||
for (let i = n - 1; i >= 0; i--) {
|
||||
for (let j = m - 1; j >= 0; j--) {
|
||||
dp[i][j] =
|
||||
a[i] === b[j]
|
||||
? dp[i + 1][j + 1] + 1
|
||||
: Math.max(dp[i + 1][j], dp[i][j + 1]);
|
||||
}
|
||||
}
|
||||
return dp;
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
// Red-team finding #10: single-writer guarantee across replicas must survive a
|
||||
// TTL lapse with a swallowed heartbeat refresh. Two SpaceLockService instances
|
||||
// (A, B) share ONE redis store. A holds 'X' and stays in-flight; the lock key
|
||||
// then disappears (TTL expiry while refreshLock silently failed). B must NOT be
|
||||
// able to acquire 'X' and run its fn concurrently with A — that would be two
|
||||
// writers racing the same working tree. This test asserts the DESIRED
|
||||
// single-writer behavior, so it FAILS today if the lapse lets B in.
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { SpaceLockService } from './space-lock.service';
|
||||
import { GIT_SYNC_LOCK_PREFIX } from '../git-sync.constants';
|
||||
|
||||
/**
|
||||
* Minimal shared fake redis honoring exactly the two primitives the lock uses:
|
||||
* - `SET key val PX ttl NX` → 'OK' only when the key is absent (NX semantics).
|
||||
* - `eval(<get/del CAS>|<get/pexpire CAS>, 1, key, instanceId[, ttl])` →
|
||||
* compares the stored value to ARGV[1] before del/pexpire (CAS).
|
||||
* TTL expiry is not time-driven here; tests simulate it by mutating `store`.
|
||||
*/
|
||||
function makeSharedRedis() {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
store,
|
||||
async set(key: string, val: string, _px: 'PX', _ttl: number, nx: 'NX') {
|
||||
if (nx === 'NX' && store.has(key)) return null;
|
||||
store.set(key, val);
|
||||
return 'OK';
|
||||
},
|
||||
async eval(lua: string, _numKeys: number, key: string, argInstanceId: string) {
|
||||
// Only act when WE still own the key (CAS), mirroring the Lua scripts.
|
||||
if (store.get(key) !== argInstanceId) return 0;
|
||||
if (lua.includes('del')) {
|
||||
store.delete(key);
|
||||
return 1;
|
||||
}
|
||||
// pexpire CAS refresh: value matches, "extend" is a no-op in the fake.
|
||||
return 1;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildInstance(redis: ReturnType<typeof makeSharedRedis>) {
|
||||
const redisService = { getOrThrow: jest.fn(() => redis) };
|
||||
return new SpaceLockService(redisService as any);
|
||||
}
|
||||
|
||||
async function flushMicrotasks(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
describe('SpaceLockService — finding #10 single-writer across TTL lapse', () => {
|
||||
it('B must not run its fn concurrently with an in-flight A after the lock key vanishes', async () => {
|
||||
const redis = makeSharedRedis();
|
||||
const A = buildInstance(redis);
|
||||
const B = buildInstance(redis);
|
||||
|
||||
let aRunning = false;
|
||||
let releaseA!: () => void;
|
||||
const gateA = new Promise<void>((resolve) => {
|
||||
releaseA = resolve;
|
||||
});
|
||||
|
||||
// A acquires 'X' and stays in-flight awaiting the gate.
|
||||
const aResult = A.withSpaceLock('X', async () => {
|
||||
aRunning = true;
|
||||
await gateA;
|
||||
aRunning = false;
|
||||
return 'A-done';
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
// Sanity: A is in-flight and owns the redis key.
|
||||
expect(aRunning).toBe(true);
|
||||
expect(redis.store.has(GIT_SYNC_LOCK_PREFIX + 'X')).toBe(true);
|
||||
|
||||
// Simulate TTL lapse with a swallowed heartbeat refresh: the lock key
|
||||
// disappears from the shared store while A is still running.
|
||||
redis.store.delete(GIT_SYNC_LOCK_PREFIX + 'X');
|
||||
|
||||
// Now B tries to take 'X'. Desired: rejected as 'lock-held' (single writer);
|
||||
// and under no circumstance may fn2 run while A is still in flight.
|
||||
let bRanWhileARunning = false;
|
||||
const bResult = await B.withSpaceLock('X', async () => {
|
||||
bRanWhileARunning = aRunning; // captures whether A was still in-flight
|
||||
return 'B-done';
|
||||
});
|
||||
|
||||
// Single-writer assertions: B did NOT execute concurrently with A.
|
||||
expect(bRanWhileARunning).toBe(false);
|
||||
expect(bResult).toEqual({ skipped: 'lock-held' });
|
||||
|
||||
// Cleanup: let A finish.
|
||||
releaseA();
|
||||
await expect(aResult).resolves.toBe('A-done');
|
||||
});
|
||||
});
|
||||
@@ -1,20 +0,0 @@
|
||||
import { diff3Plan, type Pick } from './three-way-merge';
|
||||
|
||||
// Materialize a plan into the merged key sequence for assertion.
|
||||
function apply(plan: Pick[], live: string[], target: string[]): string[] {
|
||||
return plan.map((p) => (p.src === 'live' ? live[p.index] : target[p.index]));
|
||||
}
|
||||
|
||||
const merge = (o: string[], a: string[], b: string[]): string[] =>
|
||||
apply(diff3Plan(o, a, b), a, b);
|
||||
|
||||
describe('diff3Plan red-team #9 (human edit + adjacent git insert)', () => {
|
||||
it('keeps human block-2 edit AND applies git insert of 2.5', () => {
|
||||
// base: 1 2 3
|
||||
// live: 1 H 3 (human rewrote block 2)
|
||||
// target: 1 2 2.5 3 (git inserted 2.5 after block 2)
|
||||
expect(
|
||||
merge(['1', '2', '3'], ['1', 'H', '3'], ['1', '2', '2.5', '3']),
|
||||
).toEqual(['1', 'H', '2.5', '3']);
|
||||
});
|
||||
});
|
||||
@@ -1,271 +0,0 @@
|
||||
// Unit tests for SpaceLockService in ISOLATION. The lock is exercised against a
|
||||
// fake redis (mock `set`/`eval`) and we assert the exact ARGUMENTS passed to
|
||||
// redis — the test-coverage gap this refactor (PR #119 #2) closes: acquire uses
|
||||
// `SET ... PX <ttl> NX`, release uses a DEL-CAS Lua, and the heartbeat refresh
|
||||
// uses a PEXPIRE-CAS Lua, all keyed by the same private instanceId.
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { SpaceLockService } from './space-lock.service';
|
||||
import {
|
||||
GIT_SYNC_LOCK_PREFIX,
|
||||
GIT_SYNC_LOCK_TTL_MS,
|
||||
} from '../git-sync.constants';
|
||||
|
||||
type AnyMock = jest.Mock;
|
||||
|
||||
interface Built {
|
||||
service: SpaceLockService;
|
||||
redis: { set: AnyMock; eval: AnyMock };
|
||||
}
|
||||
|
||||
function build(): Built {
|
||||
const redis = {
|
||||
// Default: lock acquired. Tests override per-case.
|
||||
set: jest.fn(async () => 'OK'),
|
||||
eval: jest.fn(async () => 1),
|
||||
};
|
||||
const redisService = { getOrThrow: jest.fn(() => redis) };
|
||||
const service = new SpaceLockService(redisService as any);
|
||||
return { service, redis };
|
||||
}
|
||||
|
||||
/** Drain queued microtasks so awaited continuations inside the lock run. */
|
||||
async function flushMicrotasks(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('SpaceLockService', () => {
|
||||
describe('acquire (SET NX/PX)', () => {
|
||||
it('calls redis.set with (prefix+spaceId, <instanceId>, PX, ttl, NX) and reuses the instanceId on release', async () => {
|
||||
const { service, redis } = build();
|
||||
|
||||
const result = await service.withSpaceLock('space-1', async () => 'ok');
|
||||
expect(result).toBe('ok');
|
||||
|
||||
// acquire arguments
|
||||
expect(redis.set).toHaveBeenCalledTimes(1);
|
||||
const [key, instanceId, px, ttl, nx] = redis.set.mock.calls[0];
|
||||
expect(key).toBe(GIT_SYNC_LOCK_PREFIX + 'space-1');
|
||||
expect(typeof instanceId).toBe('string');
|
||||
expect(instanceId.length).toBeGreaterThan(0);
|
||||
expect(px).toBe('PX');
|
||||
expect(ttl).toBe(GIT_SYNC_LOCK_TTL_MS);
|
||||
expect(nx).toBe('NX');
|
||||
|
||||
// release (eval) reuses the SAME instanceId as ARGV[1]
|
||||
expect(redis.eval).toHaveBeenCalledTimes(1);
|
||||
const [, , relKey, relInstanceId] = redis.eval.mock.calls[0];
|
||||
expect(relKey).toBe(GIT_SYNC_LOCK_PREFIX + 'space-1');
|
||||
expect(relInstanceId).toBe(instanceId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('release (DEL-CAS Lua)', () => {
|
||||
it('returns the fn result and runs a get/del CAS-compared release in finally', async () => {
|
||||
const { service, redis } = build();
|
||||
|
||||
const result = await service.withSpaceLock('space-1', async () => 42);
|
||||
expect(result).toBe(42);
|
||||
|
||||
expect(redis.eval).toHaveBeenCalledTimes(1);
|
||||
const [lua, numKeys, key, instanceId] = redis.eval.mock.calls[0];
|
||||
expect(lua).toContain('get');
|
||||
expect(lua).toContain('del');
|
||||
expect(lua).toContain('== ARGV[1]');
|
||||
expect(numKeys).toBe(1);
|
||||
expect(key).toBe(GIT_SYNC_LOCK_PREFIX + 'space-1');
|
||||
expect(typeof instanceId).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
describe('lock held by another replica', () => {
|
||||
it("returns { skipped: 'lock-held' } without running fn or releasing when set != 'OK'", async () => {
|
||||
const { service, redis } = build();
|
||||
redis.set.mockResolvedValueOnce(null);
|
||||
const fn = jest.fn(async () => 'ran');
|
||||
|
||||
const result = await service.withSpaceLock('space-1', fn);
|
||||
|
||||
expect(result).toEqual({ skipped: 'lock-held' });
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
// No release: we never acquired it.
|
||||
expect(redis.eval).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('in-process mutex', () => {
|
||||
it("a second withSpaceLock on the same space mid-flight returns { skipped: 'in-progress' } without a second set", async () => {
|
||||
const { service, redis } = build();
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
|
||||
const first = service.withSpaceLock('space-1', async () => {
|
||||
await gate;
|
||||
return 'first';
|
||||
});
|
||||
// Let the first call acquire + enter the running set.
|
||||
await flushMicrotasks();
|
||||
|
||||
const second = await service.withSpaceLock('space-1', async () => 'second');
|
||||
expect(second).toEqual({ skipped: 'in-progress' });
|
||||
// Only the first call hit redis.set — the mutex short-circuits the second.
|
||||
expect(redis.set).toHaveBeenCalledTimes(1);
|
||||
|
||||
release();
|
||||
await expect(first).resolves.toBe('first');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fn throwing', () => {
|
||||
it('propagates the throw AND still releases (eval) in finally', async () => {
|
||||
const { service, redis } = build();
|
||||
const boom = new Error('boom');
|
||||
|
||||
await expect(
|
||||
service.withSpaceLock('space-1', async () => {
|
||||
throw boom;
|
||||
}),
|
||||
).rejects.toBe(boom);
|
||||
|
||||
// Release still ran despite the throw.
|
||||
expect(redis.eval).toHaveBeenCalledTimes(1);
|
||||
const [lua] = redis.eval.mock.calls[0];
|
||||
expect(lua).toContain('del');
|
||||
});
|
||||
});
|
||||
|
||||
describe('heartbeat refresh (PEXPIRE-CAS Lua)', () => {
|
||||
it('extends the lock via a pexpire CAS-Lua with the same instanceId while fn is in flight', async () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const { service, redis } = build();
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
|
||||
const run = service.withSpaceLock('space-1', async () => {
|
||||
await gate;
|
||||
return 'done';
|
||||
});
|
||||
// Let acquire resolve and the running.add + setInterval registration run.
|
||||
await flushMicrotasks();
|
||||
|
||||
// Capture the instanceId used on acquire so we can assert it is reused.
|
||||
const instanceId = redis.set.mock.calls[0][1];
|
||||
|
||||
// Advance past one heartbeat interval (≈ TTL/3) to fire refreshLock.
|
||||
jest.advanceTimersByTime(Math.floor(GIT_SYNC_LOCK_TTL_MS / 3));
|
||||
await flushMicrotasks();
|
||||
|
||||
// The refresh eval ran (release has not, fn still awaiting the gate).
|
||||
expect(redis.eval).toHaveBeenCalledTimes(1);
|
||||
const [lua, numKeys, key, argInstanceId, ttlArg] =
|
||||
redis.eval.mock.calls[0];
|
||||
expect(lua).toContain('pexpire');
|
||||
expect(lua).toContain('== ARGV[1]');
|
||||
expect(numKeys).toBe(1);
|
||||
expect(key).toBe(GIT_SYNC_LOCK_PREFIX + 'space-1');
|
||||
expect(argInstanceId).toBe(instanceId);
|
||||
expect(ttlArg).toBe(String(GIT_SYNC_LOCK_TTL_MS));
|
||||
|
||||
// Let fn finish; release runs in finally (second eval, the DEL-CAS).
|
||||
release();
|
||||
await flushMicrotasks();
|
||||
await expect(run).resolves.toBe('done');
|
||||
expect(redis.eval).toHaveBeenCalledTimes(2);
|
||||
expect(redis.eval.mock.calls[1][0]).toContain('del');
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// The lost-lock guard: a heartbeat refresh that cannot CONFIRM we still own the
|
||||
// lock (CAS miss, res !== 1) OR that throws (Redis error) aborts the supplied
|
||||
// controller so the in-flight protected fn stops instead of writing blind after
|
||||
// a possible lock takeover. `withSpaceLock` threads that signal into `fn`.
|
||||
describe('abort-on-lost-lock', () => {
|
||||
it('aborts the in-flight fn when the heartbeat refresh CAS-MISSES (eval -> 0)', async () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const { service, redis } = build();
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
let captured: AbortSignal | undefined;
|
||||
|
||||
const run = service.withSpaceLock('space-1', async (signal) => {
|
||||
captured = signal;
|
||||
await gate;
|
||||
return 'done';
|
||||
});
|
||||
// Let acquire resolve and the setInterval register.
|
||||
await flushMicrotasks();
|
||||
expect(captured).toBeDefined();
|
||||
expect(captured!.aborted).toBe(false);
|
||||
|
||||
// The refresh CAS-misses: the key no longer holds our instanceId.
|
||||
redis.eval.mockResolvedValue(0);
|
||||
jest.advanceTimersByTime(Math.floor(GIT_SYNC_LOCK_TTL_MS / 3));
|
||||
await flushMicrotasks();
|
||||
|
||||
// The lost lock aborted the protected fn's signal.
|
||||
expect(captured!.aborted).toBe(true);
|
||||
|
||||
release();
|
||||
await flushMicrotasks();
|
||||
await expect(run).resolves.toBe('done');
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('aborts the in-flight fn when the heartbeat refresh THROWS (Redis error)', async () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const { service, redis } = build();
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
let captured: AbortSignal | undefined;
|
||||
|
||||
const run = service.withSpaceLock('space-1', async (signal) => {
|
||||
captured = signal;
|
||||
await gate;
|
||||
return 'done';
|
||||
});
|
||||
await flushMicrotasks();
|
||||
expect(captured!.aborted).toBe(false);
|
||||
|
||||
// The refresh eval rejects (Redis down). release() in finally must still
|
||||
// resolve, so only reject the NEXT (heartbeat) call, then go back to OK.
|
||||
redis.eval.mockRejectedValueOnce(new Error('redis down'));
|
||||
jest.advanceTimersByTime(Math.floor(GIT_SYNC_LOCK_TTL_MS / 3));
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(captured!.aborted).toBe(true);
|
||||
|
||||
release();
|
||||
await flushMicrotasks();
|
||||
await expect(run).resolves.toBe('done');
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Silence the warn logger if a refresh/release path ever logs (defensive).
|
||||
beforeAll(() => {
|
||||
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
});
|
||||
@@ -1,181 +0,0 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { RedisService } from '@nestjs-labs/nestjs-ioredis';
|
||||
import type { Redis } from 'ioredis';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import {
|
||||
GIT_SYNC_LOCK_PREFIX,
|
||||
GIT_SYNC_LOCK_TTL_MS,
|
||||
} from '../git-sync.constants';
|
||||
|
||||
/**
|
||||
* The per-space lock used by the git-sync control plane: an in-process per-space
|
||||
* mutex (no overlapping cycles on one instance) PLUS a Redis leader lock
|
||||
* (single writer across replicas). Extracted from `GitSyncOrchestrator` so the
|
||||
* locking primitive is a single reusable, independently testable unit
|
||||
* (PR #119 refactor #2).
|
||||
*/
|
||||
@Injectable()
|
||||
export class SpaceLockService {
|
||||
private readonly logger = new Logger(SpaceLockService.name);
|
||||
private readonly redis: Redis;
|
||||
/** Unique per process instance — the leader-lock value (CAS on release). */
|
||||
private readonly instanceId = randomUUID();
|
||||
/** In-process per-space mutex: spaceIds with a cycle currently running. */
|
||||
private readonly running = new Set<string>();
|
||||
/**
|
||||
* Process-wide single-writer guard: spaceId -> instanceId of the live holder.
|
||||
* Unlike `running` (scoped to ONE service instance), this is shared by every
|
||||
* SpaceLockService in the process, so even if the Redis lock key lapses
|
||||
* (swallowed heartbeat / TTL expiry) a SECOND holder in the same process
|
||||
* cannot start a concurrent cycle for the same space — it is rejected
|
||||
* 'lock-held'. The cross-PROCESS race is handled by the Redis lock plus
|
||||
* abort-on-refresh-failure (and, as a follow-up, fencing tokens).
|
||||
*/
|
||||
private static readonly liveLocks = new Map<string, string>();
|
||||
|
||||
constructor(redisService: RedisService) {
|
||||
this.redis = redisService.getOrThrow();
|
||||
}
|
||||
|
||||
// --- Redis leader lock -----------------------------------------
|
||||
|
||||
/**
|
||||
* Acquire per-space leadership: `SET <key> <instanceId> PX <ttl> NX` returns
|
||||
* 'OK' only when the key did not exist. Any other reply means another replica
|
||||
* holds it.
|
||||
*/
|
||||
private async acquire(spaceId: string): Promise<boolean> {
|
||||
const ok = await this.redis.set(
|
||||
GIT_SYNC_LOCK_PREFIX + spaceId,
|
||||
this.instanceId,
|
||||
'PX',
|
||||
GIT_SYNC_LOCK_TTL_MS,
|
||||
'NX',
|
||||
);
|
||||
return ok === 'OK';
|
||||
}
|
||||
|
||||
/**
|
||||
* Release the lock with a CAS Lua so we only delete it when WE still hold it
|
||||
* (the value matches our instanceId) — never another replica's lock that took
|
||||
* over after our TTL expired.
|
||||
*/
|
||||
private async release(spaceId: string): Promise<void> {
|
||||
const lua =
|
||||
'if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end';
|
||||
try {
|
||||
await this.redis.eval(lua, 1, GIT_SYNC_LOCK_PREFIX + spaceId, this.instanceId);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`git-sync: failed to release lock for space ${spaceId}: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CAS-guarded TTL refresh: extend the lock's TTL ONLY while WE still own it
|
||||
* (the stored value matches our instanceId) — never extend another replica's
|
||||
* lock that took over after our TTL expired. Used by the heartbeat in
|
||||
* `withSpaceLock` so a long-running push (client-controlled receive-pack + the
|
||||
* Docmost cycle) cannot outlive the lock and let a concurrent cycle race the
|
||||
* working tree. Never throws (a thrown timer callback would crash the process),
|
||||
* but a refresh it cannot CONFIRM is treated as a LOST lock: it aborts the
|
||||
* supplied controller so the in-flight protected fn stops instead of writing
|
||||
* blind while another replica may already have taken over the lock.
|
||||
*/
|
||||
private async refreshLock(
|
||||
spaceId: string,
|
||||
controller?: AbortController,
|
||||
): Promise<void> {
|
||||
const lua =
|
||||
'if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("pexpire", KEYS[1], ARGV[2]) else return 0 end';
|
||||
try {
|
||||
const res = await this.redis.eval(
|
||||
lua,
|
||||
1,
|
||||
GIT_SYNC_LOCK_PREFIX + spaceId,
|
||||
this.instanceId,
|
||||
String(GIT_SYNC_LOCK_TTL_MS),
|
||||
);
|
||||
// CAS miss (res !== 1): we no longer own the key — our TTL lapsed and
|
||||
// another replica may hold it now. Abort the in-flight cycle rather than
|
||||
// swallowing the loss and racing the working tree.
|
||||
if (res !== 1) {
|
||||
this.logger.warn(
|
||||
`git-sync: lock for space ${spaceId} lost during refresh — aborting in-flight cycle`,
|
||||
);
|
||||
controller?.abort();
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`git-sync: failed to refresh lock for space ${spaceId}: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
// A refresh we cannot confirm means we may no longer hold the lock; abort.
|
||||
controller?.abort();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `fn` under the per-space lock: the in-process mutex (no overlapping
|
||||
* cycles on this instance) AND the Redis leader lock (single writer across
|
||||
* replicas). Returns `fn`'s result, or a skip sentinel when the lock could not
|
||||
* be acquired — `{ skipped: 'in-progress' }` (this instance is mid-cycle) or
|
||||
* `{ skipped: 'lock-held' }` (another replica holds the Redis lock). The mutex
|
||||
* + Redis lock are always released in a `finally`, even when `fn` throws (the
|
||||
* throw propagates to the caller). This is the single reusable wrapper shared
|
||||
* by `runOnce` (the poll/admin cycle) and `ingestExternalPush` (a push from a
|
||||
* git client over HTTP) so both serialize against each other identically.
|
||||
*/
|
||||
async withSpaceLock<T>(
|
||||
spaceId: string,
|
||||
fn: (signal: AbortSignal) => Promise<T>,
|
||||
): Promise<T | { skipped: 'lock-held' | 'in-progress' }> {
|
||||
if (this.running.has(spaceId)) {
|
||||
return { skipped: 'in-progress' };
|
||||
}
|
||||
// Cross-instance, same-process single-writer guard: another live holder (a
|
||||
// different SpaceLockService in this process) is mid-cycle for this space.
|
||||
// This survives a swallowed heartbeat / Redis TTL lapse, so a second writer
|
||||
// in the process cannot race the working tree — it is rejected 'lock-held'.
|
||||
if (SpaceLockService.liveLocks.has(spaceId)) {
|
||||
return { skipped: 'lock-held' };
|
||||
}
|
||||
// Reserve the in-process slot synchronously (before any await) so two
|
||||
// concurrent same-space calls on THIS instance cannot both pass the guard and
|
||||
// race acquire(). Redis NX is already authoritative across replicas; this just
|
||||
// closes the in-process TOCTOU window. Released in the outer finally on every
|
||||
// path (acquire-failure, fn-throw, normal completion).
|
||||
this.running.add(spaceId);
|
||||
SpaceLockService.liveLocks.set(spaceId, this.instanceId);
|
||||
try {
|
||||
if (!(await this.acquire(spaceId))) {
|
||||
return { skipped: 'lock-held' };
|
||||
}
|
||||
// Lost-lock signal: a failed/CAS-missed heartbeat refresh aborts this so the
|
||||
// protected fn can stop instead of writing blind after our lock lapsed.
|
||||
const controller = new AbortController();
|
||||
// Heartbeat: periodically (≈ TTL/3) extend the lock's TTL while `fn` runs so
|
||||
// a long push (client-controlled receive-pack + the Docmost cycle) cannot
|
||||
// outlive the fixed TTL and let a concurrent cycle race the working tree. The
|
||||
// refresh is CAS-guarded (only extends while WE own it). `.unref()` keeps the
|
||||
// timer from holding the event loop open; it is ALWAYS cleared in `finally`.
|
||||
const heartbeat = setInterval(() => {
|
||||
void this.refreshLock(spaceId, controller);
|
||||
}, Math.max(1, Math.floor(GIT_SYNC_LOCK_TTL_MS / 3)));
|
||||
heartbeat.unref?.();
|
||||
try {
|
||||
return await fn(controller.signal);
|
||||
} finally {
|
||||
clearInterval(heartbeat);
|
||||
await this.release(spaceId);
|
||||
}
|
||||
} finally {
|
||||
this.running.delete(spaceId);
|
||||
SpaceLockService.liveLocks.delete(spaceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
import { diff3Plan, type Pick } from './three-way-merge';
|
||||
|
||||
// Materialize a plan into the merged key sequence for assertion.
|
||||
function apply(plan: Pick[], live: string[], target: string[]): string[] {
|
||||
return plan.map((p) => (p.src === 'live' ? live[p.index] : target[p.index]));
|
||||
}
|
||||
|
||||
const merge = (o: string[], a: string[], b: string[]): string[] =>
|
||||
apply(diff3Plan(o, a, b), a, b);
|
||||
|
||||
describe('diff3Plan (block-level three-way merge)', () => {
|
||||
it('identical on all three sides -> unchanged (all from live)', () => {
|
||||
const plan = diff3Plan(['1', '2', '3'], ['1', '2', '3'], ['1', '2', '3']);
|
||||
expect(plan.every((p) => p.src === 'live')).toBe(true);
|
||||
expect(apply(plan, ['1', '2', '3'], ['1', '2', '3'])).toEqual(['1', '2', '3']);
|
||||
});
|
||||
|
||||
it('git changed a block the human did not -> takes git', () => {
|
||||
expect(merge(['1', '2', '3'], ['1', '2', '3'], ['1', '9', '3'])).toEqual([
|
||||
'1',
|
||||
'9',
|
||||
'3',
|
||||
]);
|
||||
});
|
||||
|
||||
it('human changed a block git did not -> KEEPS the human edit (the core 3-way win)', () => {
|
||||
expect(merge(['1', '2', '3'], ['1', 'H', '3'], ['1', '2', '3'])).toEqual([
|
||||
'1',
|
||||
'H',
|
||||
'3',
|
||||
]);
|
||||
});
|
||||
|
||||
it('human and git changed DIFFERENT blocks -> both preserved', () => {
|
||||
// human rewrote block 1, git rewrote block 3.
|
||||
expect(merge(['1', '2', '3'], ['H', '2', '3'], ['1', '2', 'G'])).toEqual([
|
||||
'H',
|
||||
'2',
|
||||
'G',
|
||||
]);
|
||||
});
|
||||
|
||||
it('human inserted a block AND git changed a different block -> both preserved', () => {
|
||||
expect(
|
||||
merge(['1', '2', '3'], ['1', '1.5', '2', '3'], ['1', '2', 'G']),
|
||||
).toEqual(['1', '1.5', '2', 'G']);
|
||||
});
|
||||
|
||||
it('both changed the SAME block -> conflict resolves to git', () => {
|
||||
expect(merge(['1', '2', '3'], ['1', 'H', '3'], ['1', 'G', '3'])).toEqual([
|
||||
'1',
|
||||
'G',
|
||||
'3',
|
||||
]);
|
||||
});
|
||||
|
||||
it('both made the SAME edit -> that edit (no duplication)', () => {
|
||||
expect(merge(['1', '2', '3'], ['1', 'X', '3'], ['1', 'X', '3'])).toEqual([
|
||||
'1',
|
||||
'X',
|
||||
'3',
|
||||
]);
|
||||
});
|
||||
|
||||
it('human deleted a block git left alone -> deletion preserved', () => {
|
||||
expect(merge(['1', '2', '3'], ['1', '3'], ['1', '2', '3'])).toEqual([
|
||||
'1',
|
||||
'3',
|
||||
]);
|
||||
});
|
||||
|
||||
it('git deleted a block the human left alone -> deletion applied', () => {
|
||||
expect(merge(['1', '2', '3'], ['1', '2', '3'], ['1', '3'])).toEqual([
|
||||
'1',
|
||||
'3',
|
||||
]);
|
||||
});
|
||||
|
||||
it('both deleted the same block -> gone (no conflict)', () => {
|
||||
expect(merge(['1', '2', '3'], ['1', '3'], ['1', '3'])).toEqual(['1', '3']);
|
||||
});
|
||||
|
||||
it('git appended a trailing block -> appended', () => {
|
||||
expect(merge(['1', '2'], ['1', '2'], ['1', '2', '3'])).toEqual([
|
||||
'1',
|
||||
'2',
|
||||
'3',
|
||||
]);
|
||||
});
|
||||
|
||||
it('human appended a trailing block git did not -> kept', () => {
|
||||
expect(merge(['1', '2'], ['1', '2', '3'], ['1', '2'])).toEqual([
|
||||
'1',
|
||||
'2',
|
||||
'3',
|
||||
]);
|
||||
});
|
||||
|
||||
it('empty base, git provides content (brand-new page body) -> git content', () => {
|
||||
expect(merge([], [], ['1', '2'])).toEqual(['1', '2']);
|
||||
});
|
||||
|
||||
it('git changed block 1, human edited block 3, far apart -> both kept', () => {
|
||||
expect(
|
||||
merge(
|
||||
['a', 'b', 'c', 'd', 'e'],
|
||||
['a', 'b', 'c', 'd', 'E'],
|
||||
['A', 'b', 'c', 'd', 'e'],
|
||||
),
|
||||
).toEqual(['A', 'b', 'c', 'd', 'E']);
|
||||
});
|
||||
});
|
||||
@@ -1,232 +0,0 @@
|
||||
/**
|
||||
* Pure block-level THREE-WAY merge planner (diff3) over arrays of opaque block
|
||||
* keys. Used by the git-sync body write to merge an incoming git body into the
|
||||
* live page using the last-synced version as the common ancestor (review #5):
|
||||
*
|
||||
* - a block only the human changed (live != base, git == base) -> keep LIVE
|
||||
* - a block only git changed (git != base, live == base) -> take GIT
|
||||
* - a block both sides changed (a real conflict) -> GIT wins
|
||||
* - inserts/deletes from either side are preserved when unambiguous
|
||||
*
|
||||
* Content-agnostic: it works on string keys and returns the merged block order as
|
||||
* picks ({ src: 'live'|'target', index }) — the caller (the Yjs applier)
|
||||
* materializes them — so the whole algorithm is unit-testable on plain arrays.
|
||||
*
|
||||
* Algorithm: anchor on base blocks present (unchanged) in BOTH live and target
|
||||
* (their LCS-with-base intersection). Between consecutive anchors lies one region
|
||||
* the human and/or git rewrote; resolve each region three-way. Stable anchor
|
||||
* blocks are emitted from LIVE so the applier keeps the existing Yjs block
|
||||
* instances (and the human's in-flight edits) in place.
|
||||
*/
|
||||
|
||||
import { buildLcsTable } from './lcs';
|
||||
|
||||
/** Matched index pairs of the longest common subsequence of `a` and `b`. */
|
||||
function lcsPairs(a: string[], b: string[]): Array<[number, number]> {
|
||||
const n = a.length;
|
||||
const m = b.length;
|
||||
const dp = buildLcsTable(a, b);
|
||||
const pairs: Array<[number, number]> = [];
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < n && j < m) {
|
||||
if (a[i] === b[j]) {
|
||||
pairs.push([i, j]);
|
||||
i++;
|
||||
j++;
|
||||
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
|
||||
i++;
|
||||
} else {
|
||||
j++;
|
||||
}
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
|
||||
/** o-index -> matched index in the other side (only for LCS-matched blocks). */
|
||||
function matchMap(pairs: Array<[number, number]>): Map<number, number> {
|
||||
const m = new Map<number, number>();
|
||||
for (const [o, x] of pairs) m.set(o, x);
|
||||
return m;
|
||||
}
|
||||
|
||||
/**
|
||||
* One change `side` made to `base` within a region: base blocks `[oStart,oEnd)`
|
||||
* were replaced by the side's blocks listed in `content` (region-local indices).
|
||||
* A pure insert has `oStart === oEnd`; a pure delete has empty `content`.
|
||||
*/
|
||||
interface Hunk {
|
||||
oStart: number;
|
||||
oEnd: number;
|
||||
content: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Diff `o` against one side as a list of non-overlapping hunks (the base spans
|
||||
* the side rewrote/inserted/deleted), derived from their LCS alignment.
|
||||
*/
|
||||
function buildHunks(o: string[], side: string[]): Hunk[] {
|
||||
const pairs = lcsPairs(o, side); // [oIdx, sideIdx] kept (unchanged) blocks
|
||||
const hunks: Hunk[] = [];
|
||||
let prevO = -1;
|
||||
let prevS = -1;
|
||||
const flush = (curO: number, curS: number): void => {
|
||||
const oStart = prevO + 1;
|
||||
const oEnd = curO;
|
||||
const content: number[] = [];
|
||||
for (let s = prevS + 1; s < curS; s++) content.push(s);
|
||||
if (oEnd > oStart || content.length > 0) hunks.push({ oStart, oEnd, content });
|
||||
};
|
||||
for (const [oIdx, sIdx] of pairs) {
|
||||
flush(oIdx, sIdx);
|
||||
prevO = oIdx;
|
||||
prevS = sIdx;
|
||||
}
|
||||
flush(o.length, side.length);
|
||||
return hunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Do two hunks (one per side) touch the same base region? Pure inserts only
|
||||
* collide when nested strictly inside the other hunk's base span (or, for two
|
||||
* inserts, at the same gap); changes sitting at a shared boundary do not.
|
||||
*/
|
||||
function hunksOverlap(a: Hunk, b: Hunk): boolean {
|
||||
const aIns = a.oStart === a.oEnd;
|
||||
const bIns = b.oStart === b.oEnd;
|
||||
if (aIns && bIns) return a.oStart === b.oStart;
|
||||
if (aIns) return b.oStart < a.oStart && a.oStart < b.oEnd;
|
||||
if (bIns) return a.oStart < b.oStart && b.oStart < a.oEnd;
|
||||
return Math.max(a.oStart, b.oStart) < Math.min(a.oEnd, b.oEnd);
|
||||
}
|
||||
|
||||
interface LocalPick {
|
||||
src: 'live' | 'target';
|
||||
local: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fine-grained three-way merge of ONE inter-anchor region. Combines the human's
|
||||
* and git's NON-overlapping hunks (e.g. a human edit to one block plus a git
|
||||
* insert/delete of OTHER blocks in the same region) so neither change is lost.
|
||||
* Returns the merged region as region-local picks, or `null` when the two sides
|
||||
* changed the SAME base block — a genuine conflict the caller resolves by the
|
||||
* original all-or-nothing rule (git wins the whole region).
|
||||
*/
|
||||
function tryMergeRegion(
|
||||
o: string[],
|
||||
a: string[],
|
||||
b: string[],
|
||||
): LocalPick[] | null {
|
||||
const aHunks = buildHunks(o, a);
|
||||
const bHunks = buildHunks(o, b);
|
||||
|
||||
// Any overlap between a human hunk and a git hunk is a real conflict; bail so
|
||||
// the caller falls back to git-wins (preserving the original behavior).
|
||||
for (const ah of aHunks) {
|
||||
for (const bh of bHunks) {
|
||||
if (hunksOverlap(ah, bh)) return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Disjoint: live index of each base block that BOTH sides kept (stable).
|
||||
const aKept = matchMap(lcsPairs(o, a)); // base index -> live index
|
||||
|
||||
const out: LocalPick[] = [];
|
||||
let pa = 0;
|
||||
let pb = 0;
|
||||
let oi = 0;
|
||||
while (oi < o.length || pa < aHunks.length || pb < bHunks.length) {
|
||||
const ah = pa < aHunks.length ? aHunks[pa] : null;
|
||||
const bh = pb < bHunks.length ? bHunks[pb] : null;
|
||||
const nextStart = Math.min(
|
||||
ah ? ah.oStart : o.length,
|
||||
bh ? bh.oStart : o.length,
|
||||
);
|
||||
|
||||
// Emit stable base blocks (kept by both) until the next hunk, from LIVE.
|
||||
while (oi < nextStart) {
|
||||
out.push({ src: 'live', local: aKept.get(oi) as number });
|
||||
oi++;
|
||||
}
|
||||
if (!ah && !bh) break;
|
||||
|
||||
// Apply the hunk at oi. When both sides act here they are disjoint, so the
|
||||
// pure-insert (oEnd === oi) is emitted before the side that consumes base oi.
|
||||
const aHere = ah !== null && ah.oStart === oi;
|
||||
const bHere = bh !== null && bh.oStart === oi;
|
||||
let useA: boolean;
|
||||
if (aHere && bHere) {
|
||||
useA = ah!.oEnd === oi; // insert side first; otherwise either order is fine
|
||||
} else {
|
||||
useA = aHere;
|
||||
}
|
||||
const h = (useA ? ah : bh) as Hunk;
|
||||
const src: 'live' | 'target' = useA ? 'live' : 'target';
|
||||
for (const idx of h.content) out.push({ src, local: idx });
|
||||
oi = h.oEnd;
|
||||
if (useA) pa++;
|
||||
else pb++;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface Pick {
|
||||
src: 'live' | 'target';
|
||||
index: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Three-way merge of base `o`, live `a`, target `b` (arrays of block keys).
|
||||
* Returns the merged block order as picks from live/target.
|
||||
*/
|
||||
export function diff3Plan(o: string[], a: string[], b: string[]): Pick[] {
|
||||
const oToA = matchMap(lcsPairs(o, a));
|
||||
const oToB = matchMap(lcsPairs(o, b));
|
||||
|
||||
const res: Pick[] = [];
|
||||
let oi = 0;
|
||||
let ai = 0;
|
||||
let bi = 0;
|
||||
|
||||
for (;;) {
|
||||
// Next anchor: a base block present (unchanged) in BOTH live and target.
|
||||
let anchor = oi;
|
||||
while (anchor < o.length && !(oToA.has(anchor) && oToB.has(anchor))) {
|
||||
anchor++;
|
||||
}
|
||||
const aEnd = anchor < o.length ? (oToA.get(anchor) as number) : a.length;
|
||||
const bEnd = anchor < o.length ? (oToB.get(anchor) as number) : b.length;
|
||||
|
||||
// Resolve the region [oi,anchor) that one or both sides rewrote/inserted.
|
||||
// Try a fine-grained three-way merge first so a human block-edit survives a
|
||||
// git insert/delete of OTHER blocks in the same region; only a genuine
|
||||
// same-block conflict (null) falls back to the original git-wins rule.
|
||||
const merged = tryMergeRegion(
|
||||
o.slice(oi, anchor),
|
||||
a.slice(ai, aEnd),
|
||||
b.slice(bi, bEnd),
|
||||
);
|
||||
if (merged) {
|
||||
for (const p of merged) {
|
||||
res.push(
|
||||
p.src === 'live'
|
||||
? { src: 'live', index: ai + p.local }
|
||||
: { src: 'target', index: bi + p.local },
|
||||
);
|
||||
}
|
||||
} else {
|
||||
for (let k = bi; k < bEnd; k++) res.push({ src: 'target', index: k });
|
||||
}
|
||||
|
||||
if (anchor >= o.length) break;
|
||||
|
||||
// Emit the stable anchor block from LIVE, then advance past it on all sides.
|
||||
res.push({ src: 'live', index: aEnd });
|
||||
ai = aEnd + 1;
|
||||
bi = bEnd + 1;
|
||||
oi = anchor + 1;
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
// Unit tests for the per-space vault path resolver + lazy VaultGit cache
|
||||
// `mkdir` and the git-sync loader are mocked so construction is cheap and
|
||||
// no real filesystem / git work happens. We assert the path normalization
|
||||
// (trailing slash) and the one-VaultGit-per-space caching contract.
|
||||
//
|
||||
// The service loads `VaultGit` (and `vaultGitEnv`) at runtime via the
|
||||
// `loadGitSync()` bridge (the ESM `@docmost/git-sync` package cannot be
|
||||
// `require()`d under jest), so we mock that loader rather than the package.
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { loadGitSync } from '../git-sync.loader';
|
||||
|
||||
jest.mock('node:fs/promises', () => ({
|
||||
mkdir: jest.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
// ensureServable shells out via `promisify(execFile)`; mock execFile with a
|
||||
// callback-style fn so promisify resolves. Each `git config <key> <value>` call
|
||||
// is recorded so the four config writes (incl. the security-critical
|
||||
// receive.denyNonFastForwards=true) can be asserted.
|
||||
jest.mock('node:child_process', () => ({
|
||||
execFile: jest.fn((_cmd: string, _args: string[], _opts: any, cb: any) =>
|
||||
cb(null, { stdout: '', stderr: '' }),
|
||||
),
|
||||
}));
|
||||
|
||||
// Cheap VaultGit stub: records the path it was constructed with; no shell-out.
|
||||
// `ensureRepo` is a resolved jest.fn so ensureServable can call it. Declared with
|
||||
// a `mock`-prefixed name so jest allows referencing it inside the hoisted
|
||||
// `jest.mock` factory below.
|
||||
const mockVaultGit = jest
|
||||
.fn()
|
||||
.mockImplementation((path: string) => ({
|
||||
path,
|
||||
ensureRepo: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
jest.mock('../git-sync.loader', () => ({
|
||||
loadGitSync: jest.fn(async () => ({
|
||||
VaultGit: mockVaultGit,
|
||||
vaultGitEnv: jest.fn(() => ({})),
|
||||
})),
|
||||
}));
|
||||
|
||||
import { VaultRegistryService } from './vault-registry.service';
|
||||
|
||||
type AnyMock = jest.Mock;
|
||||
|
||||
const mkdirMock = mkdir as unknown as AnyMock;
|
||||
const execFileMock = execFile as unknown as AnyMock;
|
||||
const VaultGitMock = mockVaultGit;
|
||||
void loadGitSync;
|
||||
|
||||
function build(dataDir: string): { service: VaultRegistryService } {
|
||||
const env = {
|
||||
getGitSyncDataDir: jest.fn(() => dataDir),
|
||||
};
|
||||
const service = new VaultRegistryService(env as any);
|
||||
return { service };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('VaultRegistryService', () => {
|
||||
describe('vaultPath', () => {
|
||||
it('normalizes a trailing slash in the data dir (no double slash)', () => {
|
||||
const { service } = build('/vaults/');
|
||||
expect(service.vaultPath('space-1')).toBe('/vaults/space-1');
|
||||
});
|
||||
|
||||
it('works without a trailing slash too', () => {
|
||||
const { service } = build('/vaults');
|
||||
expect(service.vaultPath('space-1')).toBe('/vaults/space-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getVault lazy cache', () => {
|
||||
it('returns the SAME instance on a second call (one VaultGit per space)', async () => {
|
||||
const { service } = build('/vaults');
|
||||
|
||||
const first = await service.getVault('space-1');
|
||||
const second = await service.getVault('space-1');
|
||||
|
||||
// Same cached instance, constructed exactly once.
|
||||
expect(second).toBe(first);
|
||||
expect(VaultGitMock).toHaveBeenCalledTimes(1);
|
||||
expect(VaultGitMock).toHaveBeenCalledWith('/vaults/space-1');
|
||||
// mkdir is only run on the first (cache-miss) construction.
|
||||
expect(mkdirMock).toHaveBeenCalledTimes(1);
|
||||
expect(mkdirMock).toHaveBeenCalledWith('/vaults/space-1', {
|
||||
recursive: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureServable', () => {
|
||||
it('ensures the repo then writes the four force-push-protection git configs', async () => {
|
||||
const { service } = build('/vaults');
|
||||
|
||||
const path = await service.ensureServable('space-1');
|
||||
expect(path).toBe('/vaults/space-1');
|
||||
|
||||
// ensureRepo ran first on the cached vault.
|
||||
const vault = await service.getVault('space-1');
|
||||
expect((vault as any).ensureRepo).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Collect every `git config <key> <value>` write.
|
||||
const configWrites = execFileMock.mock.calls
|
||||
.filter(([cmd, args]) => cmd === 'git' && args[0] === 'config')
|
||||
.map(([, args]) => [args[1], args[2]]);
|
||||
|
||||
expect(configWrites).toEqual([
|
||||
['receive.denyCurrentBranch', 'updateInstead'],
|
||||
// Security-critical: blocks force-push / history rewrites on main.
|
||||
['receive.denyNonFastForwards', 'true'],
|
||||
['http.receivepack', 'true'],
|
||||
['http.uploadpack', 'true'],
|
||||
]);
|
||||
|
||||
// Every config write targets THIS vault's cwd.
|
||||
for (const [cmd, args, opts] of execFileMock.mock.calls) {
|
||||
if (cmd === 'git' && args[0] === 'config') {
|
||||
expect(opts.cwd).toBe('/vaults/space-1');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects (and writes no git config) when ensureRepo rejects', async () => {
|
||||
const { service } = build('/vaults');
|
||||
const vault = await service.getVault('space-1');
|
||||
(vault as any).ensureRepo.mockRejectedValueOnce(new Error('init failed'));
|
||||
|
||||
await expect(service.ensureServable('space-1')).rejects.toThrow(
|
||||
'init failed',
|
||||
);
|
||||
|
||||
const configWrites = execFileMock.mock.calls.filter(
|
||||
([cmd, args]) => cmd === 'git' && args[0] === 'config',
|
||||
);
|
||||
expect(configWrites).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,96 +0,0 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import type { VaultGit } from '@docmost/git-sync';
|
||||
import { loadGitSync } from '../git-sync.loader';
|
||||
import { EnvironmentService } from '../../environment/environment.service';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/**
|
||||
* Resolves the on-disk vault location per space and owns the (lazily created,
|
||||
* cached) `VaultGit` instance for each one.
|
||||
*
|
||||
* Topology: one git repo per enabled space, rooted at
|
||||
* `<GIT_SYNC_DATA_DIR>/<spaceId>`. A `VaultGit` is constructed at most once per
|
||||
* space and reused across cycles — it is a thin, stateless shell-out wrapper, so
|
||||
* caching it just avoids re-resolving the path and re-running `mkdir`.
|
||||
*/
|
||||
@Injectable()
|
||||
export class VaultRegistryService {
|
||||
private readonly logger = new Logger(VaultRegistryService.name);
|
||||
private readonly vaults = new Map<string, VaultGit>();
|
||||
|
||||
constructor(private readonly environmentService: EnvironmentService) {}
|
||||
|
||||
/** Absolute vault path for a space: `<GIT_SYNC_DATA_DIR>/<spaceId>`. */
|
||||
vaultPath(spaceId: string): string {
|
||||
const root = this.environmentService.getGitSyncDataDir().replace(/\/+$/, '');
|
||||
return `${root}/${spaceId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get (or lazily construct + cache) the `VaultGit` for a space, ensuring its
|
||||
* directory exists. `VaultGit.ensureRepo()` is NOT called here — the engine's
|
||||
* pull/push paths call it (and the branch/ref setup) as their first step; this
|
||||
* only guarantees the parent dir exists so a fresh space does not ENOENT.
|
||||
*/
|
||||
async getVault(spaceId: string): Promise<VaultGit> {
|
||||
const cached = this.vaults.get(spaceId);
|
||||
if (cached) return cached;
|
||||
|
||||
const path = this.vaultPath(spaceId);
|
||||
await mkdir(path, { recursive: true });
|
||||
const { VaultGit } = await loadGitSync();
|
||||
const vault = new VaultGit(path);
|
||||
this.vaults.set(spaceId, vault);
|
||||
return vault;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a space's vault repo servable over smart-HTTP (the /git host). Ensures
|
||||
* the repo exists (engine `ensureRepo`: `git init -b main` + initial commit +
|
||||
* branches; idempotent), then sets the LOCAL git config a `git http-backend`
|
||||
* push needs:
|
||||
*
|
||||
* - receive.denyCurrentBranch=updateInstead — a push to the checked-out
|
||||
* `main` updates the working tree too (the engine's human-facing branch).
|
||||
* Requires a clean tree, which is guaranteed between cycles / under the
|
||||
* orchestrator lock that wraps an external push.
|
||||
* - receive.denyNonFastForwards=true — block force-push so a client cannot
|
||||
* rewrite the engine's history on `main`.
|
||||
* - http.receivepack=true / http.uploadpack=true — explicitly allow the
|
||||
* receive/upload services over HTTP.
|
||||
*
|
||||
* All four are set idempotently (plain `git config` overwrites the local
|
||||
* value). Returns the absolute vault path. Idempotent and safe to call before
|
||||
* every request.
|
||||
*/
|
||||
async ensureServable(spaceId: string): Promise<string> {
|
||||
const { vaultGitEnv } = await loadGitSync();
|
||||
const vault = await this.getVault(spaceId);
|
||||
const path = this.vaultPath(spaceId);
|
||||
|
||||
// ensureRepo also verifies git is available on its first git call; it does
|
||||
// `git init -b main` + an initial commit + the engine branches. Idempotent.
|
||||
await vault.ensureRepo();
|
||||
|
||||
const configs: Array<[string, string]> = [
|
||||
['receive.denyCurrentBranch', 'updateInstead'],
|
||||
['receive.denyNonFastForwards', 'true'],
|
||||
['http.receivepack', 'true'],
|
||||
['http.uploadpack', 'true'],
|
||||
];
|
||||
for (const [key, value] of configs) {
|
||||
await execFileAsync('git', ['config', key, value], {
|
||||
cwd: path,
|
||||
// Use the engine's cwd-isolated env (strips GIT_DIR / GIT_WORK_TREE) so
|
||||
// the config is written to THIS vault's local config, nothing else.
|
||||
env: vaultGitEnv(),
|
||||
});
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import { mergeXmlFragments, mergeXmlFragments3Way } from './yjs-body-merge';
|
||||
|
||||
/**
|
||||
* Regression for the HIGH-severity runaway whole-body duplication: a page body
|
||||
* was RE-APPENDED in full on every git-sync reconcile cycle, unbounded, with NO
|
||||
* client connected.
|
||||
*
|
||||
* ROOT CAUSE (confirmed in-process against the real failing page): the LIVE Yjs
|
||||
* document materializes the editor-schema default `indent: 0` on every
|
||||
* paragraph/heading (and on the paragraph inside every list item, callout, and
|
||||
* table cell), but a body re-imported from git — parsed from clean markdown —
|
||||
* carries NO indent attribute. So every live block's comparison key differed from
|
||||
* the same block coming back from git; the three-way merge could anchor on
|
||||
* NOTHING, and the trailing unit that git's export already contained (but the
|
||||
* merge could not match against the byte-identical live tail) was re-appended
|
||||
* each cycle. Each grown export then diverged from the last-pushed base by one
|
||||
* more unit — a self-sustaining loop.
|
||||
*
|
||||
* The fix normalizes the materialized default (`indent: 0`) out of the block key
|
||||
* (the schema-derived `serializeXmlNode` normalization in yjs-body-merge.ts drops
|
||||
* every attr equal to its ProseMirror-schema default; `indent: 0` is one such),
|
||||
* so a live block compares equal to its git-round-tripped twin and the resync is
|
||||
* a true no-op. The sibling `yjs-body-merge.schema-defaults.spec.ts` covers the
|
||||
* rest of the bug class (image.align, link mark internal, …).
|
||||
*
|
||||
* These tests model that EXACTLY at the Yjs level: a LIVE fragment whose blocks
|
||||
* carry `indent: 0` + block ids, versus a git-derived fragment of the SAME
|
||||
* content with neither — for a body built from BYTE-IDENTICAL units that each
|
||||
* contain a heading, a paragraph, a callout, and a table with empty cells (the
|
||||
* trigger). RED before the fix (the merge applies > 0 ops and the body grows),
|
||||
* GREEN after (0 ops, no growth).
|
||||
*/
|
||||
|
||||
type Attrs = Record<string, string | number>;
|
||||
|
||||
function el(
|
||||
name: string,
|
||||
attrs: Attrs,
|
||||
children: (Y.XmlElement | Y.XmlText)[],
|
||||
) {
|
||||
const e = new Y.XmlElement(name);
|
||||
for (const [k, v] of Object.entries(attrs)) e.setAttribute(k, v as string);
|
||||
if (children.length) e.insert(0, children);
|
||||
return e;
|
||||
}
|
||||
|
||||
function text(s: string): Y.XmlText {
|
||||
const t = new Y.XmlText();
|
||||
if (s) t.insert(0, s);
|
||||
return t;
|
||||
}
|
||||
|
||||
/**
|
||||
* One byte-identical content unit (heading / paragraph / callout / table-with-
|
||||
* empty-cells). `live` toggles the two things that exist ONLY in the live Yjs
|
||||
* doc and NOT in a git round-trip: the materialized `indent: 0` default and the
|
||||
* per-block `id`. `n` makes each unit's ids unique (as the editor would stamp)
|
||||
* while keeping the visible CONTENT byte-identical across units.
|
||||
*/
|
||||
function unit(
|
||||
live: boolean,
|
||||
n: number,
|
||||
headingText = 'Big Heading',
|
||||
): Y.XmlElement[] {
|
||||
const ind: Attrs = live ? { indent: 0 } : {};
|
||||
const id = (base: string): Attrs => (live ? { id: `${base}${n}` } : {});
|
||||
const para = (attrs: Attrs, s: string) =>
|
||||
el('paragraph', { ...attrs, ...ind }, [text(s)]);
|
||||
|
||||
const cell = (name: string) =>
|
||||
el(name, { colspan: 1, rowspan: 1 }, [para({}, '')]);
|
||||
|
||||
return [
|
||||
el('heading', { ...id('h'), level: 1, ...ind }, [text(headingText)]),
|
||||
para(id('p'), 'Para with the same words'),
|
||||
el('callout', { type: 'info' }, [para(id('c'), 'CalloutText here')]),
|
||||
el('table', {}, [
|
||||
el('tableRow', {}, [cell('tableHeader'), cell('tableHeader')]),
|
||||
el('tableRow', {}, [cell('tableCell'), cell('tableCell')]),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
function fragmentOf(units: Y.XmlElement[][]): {
|
||||
doc: Y.Doc;
|
||||
frag: Y.XmlFragment;
|
||||
} {
|
||||
const doc = new Y.Doc();
|
||||
const frag = doc.getXmlFragment('default');
|
||||
const blocks = units.flat();
|
||||
if (blocks.length) frag.insert(0, blocks);
|
||||
return { doc, frag };
|
||||
}
|
||||
|
||||
const blockCount = (frag: Y.XmlFragment): number => frag.toArray().length;
|
||||
|
||||
describe('git-sync reconcile import is idempotent (no whole-body duplication)', () => {
|
||||
const UNITS = 3;
|
||||
|
||||
it('3-way: identical content, live carries indent:0, base stale-by-one -> 0 ops, no growth', () => {
|
||||
// LIVE: the editor-stamped Yjs doc (indent:0 + ids on every block).
|
||||
const { doc: liveDoc, frag: live } = fragmentOf(
|
||||
Array.from({ length: UNITS }, (_, i) => unit(true, i)),
|
||||
);
|
||||
// INCOMING (git export -> re-import): same content, NO indent / ids.
|
||||
const { frag: incoming } = fragmentOf(
|
||||
Array.from({ length: UNITS }, (_, i) => unit(false, i)),
|
||||
);
|
||||
// BASE = last-pushed file, lagging by ONE unit (the realistic divergence
|
||||
// that drives the trailing insert-vs-insert).
|
||||
const { frag: base } = fragmentOf(
|
||||
Array.from({ length: UNITS - 1 }, (_, i) => unit(false, i)),
|
||||
);
|
||||
|
||||
const before = blockCount(live);
|
||||
let applied = -1;
|
||||
liveDoc.transact(() => {
|
||||
applied = mergeXmlFragments3Way(live, incoming, base);
|
||||
});
|
||||
|
||||
expect(applied).toBe(0);
|
||||
expect(blockCount(live)).toBe(before);
|
||||
});
|
||||
|
||||
it('3-way is a fixpoint across repeated cycles (does not grow)', () => {
|
||||
const { doc: liveDoc, frag: live } = fragmentOf(
|
||||
Array.from({ length: UNITS }, (_, i) => unit(true, i)),
|
||||
);
|
||||
const incomingUnits = () =>
|
||||
fragmentOf(Array.from({ length: UNITS }, (_, i) => unit(false, i))).frag;
|
||||
const baseUnits = () =>
|
||||
fragmentOf(Array.from({ length: UNITS - 1 }, (_, i) => unit(false, i)))
|
||||
.frag;
|
||||
|
||||
const before = blockCount(live);
|
||||
for (let cycle = 0; cycle < 5; cycle++) {
|
||||
let applied = -1;
|
||||
liveDoc.transact(() => {
|
||||
applied = mergeXmlFragments3Way(live, incomingUnits(), baseUnits());
|
||||
});
|
||||
expect(applied).toBe(0);
|
||||
expect(blockCount(live)).toBe(before);
|
||||
}
|
||||
});
|
||||
|
||||
it('2-way: identical content, live carries indent:0 -> 0 ops, no growth', () => {
|
||||
const { doc: liveDoc, frag: live } = fragmentOf(
|
||||
Array.from({ length: UNITS }, (_, i) => unit(true, i)),
|
||||
);
|
||||
const { frag: incoming } = fragmentOf(
|
||||
Array.from({ length: UNITS }, (_, i) => unit(false, i)),
|
||||
);
|
||||
|
||||
const before = blockCount(live);
|
||||
let applied = -1;
|
||||
liveDoc.transact(() => {
|
||||
applied = mergeXmlFragments(live, incoming);
|
||||
});
|
||||
|
||||
expect(applied).toBe(0);
|
||||
expect(blockCount(live)).toBe(before);
|
||||
});
|
||||
|
||||
it('does NOT regress real edits: a git change to one block still lands', () => {
|
||||
const { doc: liveDoc, frag: live } = fragmentOf(
|
||||
Array.from({ length: UNITS }, (_, i) => unit(true, i)),
|
||||
);
|
||||
const base = fragmentOf(
|
||||
Array.from({ length: UNITS }, (_, i) => unit(false, i)),
|
||||
).frag;
|
||||
// git edits the heading text of the LAST unit.
|
||||
const incoming = fragmentOf(
|
||||
Array.from({ length: UNITS }, (_, i) =>
|
||||
unit(false, i, i === UNITS - 1 ? 'EDITED Heading' : 'Big Heading'),
|
||||
),
|
||||
).frag;
|
||||
|
||||
const before = blockCount(live);
|
||||
liveDoc.transact(() => {
|
||||
mergeXmlFragments3Way(live, incoming, base);
|
||||
});
|
||||
|
||||
// The edit landed, and the body did NOT grow (one block changed in place).
|
||||
const headings = live
|
||||
.toArray()
|
||||
.filter((b) => (b as Y.XmlElement).nodeName === 'heading')
|
||||
.map((b) =>
|
||||
(b as Y.XmlElement)
|
||||
.toArray()
|
||||
.map((c) => (c as Y.XmlText).toString())
|
||||
.join(''),
|
||||
);
|
||||
expect(headings).toContain('EDITED Heading');
|
||||
expect(blockCount(live)).toBe(before);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user