feat(sync): scaffold monorepo, extract docmost-client, add Phase-0 harness + read-only pull
Lock the access-layer decision (REST only) and start implementation per SPEC. - monorepo (npm workspaces): packages/docmost-client = DocmostClient + lib/* copied 1:1 from docmost-mcp/src (backport target), plus bannered sync methods (listTrash, restorePage, listAllSpacePages, exportPageBody, listRecentSince / collectRecentSince cursor scan) - engine stays the root app per AGENTS.md (src/, test/, build/, data/, settings.ts); add roundtrip.ts (SPEC §11 idempotency harness), pull.ts (SPEC §6 read-only Docmost->FS mirror), sanitize.ts (SPEC §12 filenames, path-traversal-safe) - Dockerfile builds the workspace lib before the app; vitest gates CI - exportPageBody never touches /comments (SPEC §3); serializeDocmostMarkdownBody emits meta + body only - SPEC: resolve access-layer (REST), reflect root-engine layout + REST pagination - tests: sanitize (incl. dot-traversal), collectRecentSince (cutoff/dedup/cap), stripBlockIds, markdown round-trip byte-stability Note: raw ProseMirror round-trip is byte-stable in Markdown but not yet attribute- idempotent (SPEC §11 Задача №0, before Phase 2).
This commit is contained in:
144
test/fixtures/sample-doc.json
vendored
Normal file
144
test/fixtures/sample-doc.json
vendored
Normal file
@@ -0,0 +1,144 @@
|
||||
{
|
||||
"type": "doc",
|
||||
"content": [
|
||||
{
|
||||
"type": "heading",
|
||||
"attrs": { "level": 1, "id": "h-1" },
|
||||
"content": [{ "type": "text", "text": "Round-trip sample" }]
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"attrs": { "id": "p-1" },
|
||||
"content": [
|
||||
{ "type": "text", "text": "This paragraph has " },
|
||||
{ "type": "text", "marks": [{ "type": "bold" }], "text": "bold" },
|
||||
{ "type": "text", "text": ", " },
|
||||
{ "type": "text", "marks": [{ "type": "italic" }], "text": "italic" },
|
||||
{ "type": "text", "text": " and a " },
|
||||
{
|
||||
"type": "text",
|
||||
"marks": [{ "type": "link", "attrs": { "href": "https://example.com" } }],
|
||||
"text": "link"
|
||||
},
|
||||
{ "type": "text", "text": "." }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"attrs": { "id": "p-2" },
|
||||
"content": [
|
||||
{ "type": "text", "text": "Here is a " },
|
||||
{
|
||||
"type": "text",
|
||||
"marks": [
|
||||
{ "type": "comment", "attrs": { "commentId": "cmt-abc123", "resolved": false } }
|
||||
],
|
||||
"text": "commented span"
|
||||
},
|
||||
{ "type": "text", "text": " that must survive the round-trip." }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "bulletList",
|
||||
"attrs": { "id": "ul-1" },
|
||||
"content": [
|
||||
{
|
||||
"type": "listItem",
|
||||
"attrs": { "id": "li-1" },
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"attrs": { "id": "p-3" },
|
||||
"content": [{ "type": "text", "text": "First bullet" }]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "listItem",
|
||||
"attrs": { "id": "li-2" },
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"attrs": { "id": "p-4" },
|
||||
"content": [{ "type": "text", "text": "Second bullet" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "table",
|
||||
"attrs": { "id": "tbl-1" },
|
||||
"content": [
|
||||
{
|
||||
"type": "tableRow",
|
||||
"content": [
|
||||
{
|
||||
"type": "tableHeader",
|
||||
"attrs": { "colspan": 1, "rowspan": 1 },
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"content": [{ "type": "text", "text": "Name" }]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "tableHeader",
|
||||
"attrs": { "colspan": 1, "rowspan": 1 },
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"content": [{ "type": "text", "text": "Value" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "tableRow",
|
||||
"content": [
|
||||
{
|
||||
"type": "tableCell",
|
||||
"attrs": { "colspan": 1, "rowspan": 1 },
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"content": [{ "type": "text", "text": "alpha" }]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "tableCell",
|
||||
"attrs": { "colspan": 1, "rowspan": 1 },
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"content": [{ "type": "text", "text": "1" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "callout",
|
||||
"attrs": { "type": "info", "id": "callout-1" },
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"attrs": { "id": "p-5" },
|
||||
"content": [{ "type": "text", "text": "This is an info callout." }]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "codeBlock",
|
||||
"attrs": { "language": "js", "id": "code-1" },
|
||||
"content": [
|
||||
{ "type": "text", "text": "const a = 1;\nconsole.log(a);\n" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
178
test/recent-since.test.ts
Normal file
178
test/recent-since.test.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { collectRecentSince } from 'docmost-client';
|
||||
|
||||
/**
|
||||
* Unit tests for the pure cursor-pagination helper behind listRecentSince.
|
||||
* `fetchPage` is faked (no network); each test models a different server
|
||||
* behaviour to exercise one stop condition.
|
||||
*/
|
||||
|
||||
type Item = { id: string; updatedAt: string };
|
||||
|
||||
/**
|
||||
* Build a fake `fetchPage` from a list of pages. Each page is served in order;
|
||||
* the nextCursor of page i points at page i+1 (the last page has no cursor).
|
||||
* The handed-back cursor is asserted to match what we previously emitted so a
|
||||
* caller that mis-threads the cursor would fail loudly. Tracks the call count.
|
||||
*/
|
||||
function fakeServer(pages: Item[][]) {
|
||||
let calls = 0;
|
||||
const cursorFor = (i: number) => (i < pages.length - 1 ? `c${i}` : null);
|
||||
const fetchPage = async (cursor: string | null) => {
|
||||
// Resolve which page this cursor selects: null -> page 0, "cN" -> page N+1.
|
||||
const idx = cursor === null ? 0 : Number(cursor.slice(1)) + 1;
|
||||
calls++;
|
||||
const items = pages[idx] ?? [];
|
||||
return { items, nextCursor: cursorFor(idx) };
|
||||
};
|
||||
return {
|
||||
fetchPage,
|
||||
get calls() {
|
||||
return calls;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('collectRecentSince', () => {
|
||||
it('stops at the cutoff page and does not fetch beyond it', async () => {
|
||||
// Page 0: all newer than the cutoff. Page 1: contains the cutoff item, so
|
||||
// the walk must stop here and never request page 2.
|
||||
const server = fakeServer([
|
||||
[
|
||||
{ id: 'a', updatedAt: '2026-06-16T10:00:00Z' },
|
||||
{ id: 'b', updatedAt: '2026-06-16T09:00:00Z' },
|
||||
],
|
||||
[
|
||||
{ id: 'c', updatedAt: '2026-06-16T08:00:00Z' },
|
||||
{ id: 'd', updatedAt: '2026-06-16T05:00:00Z' }, // <= cutoff -> stop
|
||||
{ id: 'e', updatedAt: '2026-06-16T04:00:00Z' },
|
||||
],
|
||||
[{ id: 'f', updatedAt: '2026-06-16T03:00:00Z' }], // must NOT be fetched
|
||||
]);
|
||||
|
||||
const out = await collectRecentSince(
|
||||
server.fetchPage,
|
||||
'2026-06-16T05:00:00Z',
|
||||
);
|
||||
|
||||
// Only strictly-newer items, in server order; the cutoff item 'd' and
|
||||
// everything after it is excluded.
|
||||
expect(out.map((i) => i.id)).toEqual(['a', 'b', 'c']);
|
||||
// Fetched page 0 and page 1 only — stopped at the cutoff page.
|
||||
expect(server.calls).toBe(2);
|
||||
});
|
||||
|
||||
it('dedups ids that overlap across pages', async () => {
|
||||
// The cursor advances, but page boundaries overlap: 'b' appears on both
|
||||
// pages. The dedup-by-id Set must keep it exactly once.
|
||||
const server = fakeServer([
|
||||
[
|
||||
{ id: 'a', updatedAt: '2026-06-16T10:00:00Z' },
|
||||
{ id: 'b', updatedAt: '2026-06-16T09:00:00Z' },
|
||||
],
|
||||
[
|
||||
{ id: 'b', updatedAt: '2026-06-16T09:00:00Z' }, // overlap
|
||||
{ id: 'c', updatedAt: '2026-06-16T08:00:00Z' },
|
||||
],
|
||||
]);
|
||||
|
||||
const out = await collectRecentSince(
|
||||
server.fetchPage,
|
||||
'2026-06-16T01:00:00Z',
|
||||
);
|
||||
|
||||
expect(out.map((i) => i.id)).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('terminates when the server ignores the cursor (zero new items)', async () => {
|
||||
// A broken server that returns the SAME first page on every call and always
|
||||
// claims a nextCursor. Without the zero-new-items guard this loops to the
|
||||
// cap; with it, the second fetch contributes nothing and the walk stops.
|
||||
let calls = 0;
|
||||
const fetchPage = async (_cursor: string | null) => {
|
||||
calls++;
|
||||
return {
|
||||
items: [
|
||||
{ id: 'a', updatedAt: '2026-06-16T10:00:00Z' },
|
||||
{ id: 'b', updatedAt: '2026-06-16T09:00:00Z' },
|
||||
] as Item[],
|
||||
nextCursor: 'always', // server always claims another page
|
||||
};
|
||||
};
|
||||
|
||||
const out = await collectRecentSince(fetchPage, '2026-06-16T01:00:00Z');
|
||||
|
||||
// The newer items are returned exactly once (no hang, no duplicates).
|
||||
expect(out.map((i) => i.id)).toEqual(['a', 'b']);
|
||||
// First page collects, second page is all-seen -> stop. Capped well below
|
||||
// the default hardPageCap, proving the loop terminated.
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
|
||||
it('returns only the first page when sinceIso is null', async () => {
|
||||
const server = fakeServer([
|
||||
[
|
||||
{ id: 'a', updatedAt: '2026-06-16T10:00:00Z' },
|
||||
{ id: 'b', updatedAt: '2026-06-16T09:00:00Z' },
|
||||
],
|
||||
[{ id: 'c', updatedAt: '2026-06-16T08:00:00Z' }],
|
||||
]);
|
||||
|
||||
const out = await collectRecentSince(server.fetchPage, null);
|
||||
|
||||
expect(out.map((i) => i.id)).toEqual(['a', 'b']);
|
||||
// Exactly one page fetched.
|
||||
expect(server.calls).toBe(1);
|
||||
});
|
||||
|
||||
it('stops at hardPageCap and warns when results may be truncated', async () => {
|
||||
// Every page is all-newer-than-cutoff, every item is unique, and there is
|
||||
// always a nextCursor: the only thing that can stop the walk is the cap.
|
||||
let n = 0;
|
||||
const fetchPage = async (_cursor: string | null) => {
|
||||
const id = `id${n++}`;
|
||||
return {
|
||||
items: [{ id, updatedAt: '2026-06-16T10:00:00Z' }] as Item[],
|
||||
nextCursor: 'next', // never runs out
|
||||
};
|
||||
};
|
||||
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
const cap = 5;
|
||||
const out = await collectRecentSince(
|
||||
fetchPage,
|
||||
'2020-01-01T00:00:00Z',
|
||||
cap,
|
||||
);
|
||||
|
||||
// Exactly `cap` pages were collected (one unique item each).
|
||||
expect(out).toHaveLength(cap);
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
expect(String(warn.mock.calls[0][0])).toContain('hardPageCap=5');
|
||||
});
|
||||
|
||||
it('preserves server (descending) order across pages', async () => {
|
||||
const server = fakeServer([
|
||||
[
|
||||
{ id: 'a', updatedAt: '2026-06-16T10:00:00Z' },
|
||||
{ id: 'b', updatedAt: '2026-06-16T09:00:00Z' },
|
||||
],
|
||||
[
|
||||
{ id: 'c', updatedAt: '2026-06-16T08:00:00Z' },
|
||||
{ id: 'd', updatedAt: '2026-06-16T07:00:00Z' },
|
||||
],
|
||||
]);
|
||||
|
||||
const out = await collectRecentSince(
|
||||
server.fetchPage,
|
||||
'2026-06-16T01:00:00Z',
|
||||
);
|
||||
|
||||
expect(out.map((i) => i.id)).toEqual(['a', 'b', 'c', 'd']);
|
||||
});
|
||||
});
|
||||
29
test/roundtrip.test.ts
Normal file
29
test/roundtrip.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
convertProseMirrorToMarkdown,
|
||||
markdownToProseMirror,
|
||||
} from 'docmost-client';
|
||||
|
||||
// Resolve the fixture relative to this test file so the test is CWD-independent.
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const FIXTURE = join(here, 'fixtures', 'sample-doc.json');
|
||||
|
||||
describe('round-trip idempotency (SPEC §11)', () => {
|
||||
it('markdown is byte-stable across export -> import -> export', async () => {
|
||||
const doc = JSON.parse(await readFile(FIXTURE, 'utf8'));
|
||||
|
||||
// export -> import -> export
|
||||
const md1 = convertProseMirrorToMarkdown(doc);
|
||||
const doc2 = await markdownToProseMirror(md1);
|
||||
const md2 = convertProseMirrorToMarkdown(doc2);
|
||||
|
||||
// The property git actually needs: a second export reproduces the first
|
||||
// byte-for-byte. We intentionally do NOT deep-equal doc vs doc2 — the
|
||||
// converter reconstructs schema default attrs (e.g. indent:null), a known
|
||||
// SPEC §11 divergence that does not affect markdown stability.
|
||||
expect(md2).toBe(md1);
|
||||
});
|
||||
});
|
||||
96
test/sanitize.test.ts
Normal file
96
test/sanitize.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { sanitizeTitle, disambiguate } from '../src/sanitize.js';
|
||||
|
||||
describe('sanitizeTitle', () => {
|
||||
it('passes a plain title through unchanged', () => {
|
||||
expect(sanitizeTitle('Getting Started')).toBe('Getting Started');
|
||||
});
|
||||
|
||||
it('replaces every forbidden printable character with a dash', () => {
|
||||
// Forbidden set: / \ < > : " | ? *
|
||||
expect(sanitizeTitle('a/b\\c<d>e:f"g|h?i*j')).toBe('a-b-c-d-e-f-g-h-i-j');
|
||||
});
|
||||
|
||||
it('replaces ASCII control characters with a dash', () => {
|
||||
// Build the input with explicit control code points (tab=9, newline=10) to
|
||||
// avoid editor escaping pitfalls. Control chars become "-" BEFORE
|
||||
// whitespace is collapsed, so they survive as dashes (not a folded space).
|
||||
const TAB = String.fromCharCode(9);
|
||||
const NL = String.fromCharCode(10);
|
||||
expect(sanitizeTitle('a b' + TAB + 'c' + NL + 'd')).toBe('a b-c-d');
|
||||
});
|
||||
|
||||
it('collapses runs of plain whitespace to a single space and trims', () => {
|
||||
expect(sanitizeTitle(' hello world ')).toBe('hello world');
|
||||
});
|
||||
|
||||
it('caps the length at 120 characters', () => {
|
||||
const long = 'x'.repeat(200);
|
||||
const out = sanitizeTitle(long);
|
||||
expect(out.length).toBe(120);
|
||||
expect(out).toBe('x'.repeat(120));
|
||||
});
|
||||
|
||||
it('prefixes reserved Windows names with an underscore', () => {
|
||||
expect(sanitizeTitle('CON')).toBe('_CON');
|
||||
expect(sanitizeTitle('nul')).toBe('_nul');
|
||||
// The base name (before the first dot) is what matters.
|
||||
expect(sanitizeTitle('con.md')).toBe('_con.md');
|
||||
});
|
||||
|
||||
it('does not flag names that merely contain a reserved word', () => {
|
||||
expect(sanitizeTitle('console')).toBe('console');
|
||||
expect(sanitizeTitle('Control')).toBe('Control');
|
||||
});
|
||||
|
||||
it('returns "_" for empty or whitespace-only input', () => {
|
||||
expect(sanitizeTitle('')).toBe('_');
|
||||
expect(sanitizeTitle(' ')).toBe('_');
|
||||
});
|
||||
|
||||
it('handles a title that is only forbidden characters', () => {
|
||||
// Each forbidden char becomes "-", so the result is non-empty and safe.
|
||||
expect(sanitizeTitle('///')).toBe('---');
|
||||
});
|
||||
|
||||
it('neutralizes all-dot names so they cannot escape the vault', () => {
|
||||
// ".", "..", "..." (and whitespace-padded variants) are path-traversal
|
||||
// hazards as directory segments. The result must never be a pure-dot
|
||||
// segment and must contain no path separators.
|
||||
for (const input of ['.', '..', '...', ' .. ']) {
|
||||
const out = sanitizeTitle(input);
|
||||
expect(['.', '..', '...']).not.toContain(out);
|
||||
expect(/^\.+$/.test(out)).toBe(false);
|
||||
expect(out).not.toContain('/');
|
||||
expect(out).not.toContain('\\');
|
||||
}
|
||||
// The concrete prefixing behaviour (existing "_" safeguard).
|
||||
expect(sanitizeTitle('.')).toBe('_.');
|
||||
expect(sanitizeTitle('..')).toBe('_..');
|
||||
expect(sanitizeTitle('...')).toBe('_...');
|
||||
expect(sanitizeTitle(' .. ')).toBe('_..');
|
||||
});
|
||||
|
||||
it('is deterministic — the same input yields the same output', () => {
|
||||
const title = 'Some / weird : title?';
|
||||
expect(sanitizeTitle(title)).toBe(sanitizeTitle(title));
|
||||
});
|
||||
});
|
||||
|
||||
describe('disambiguate', () => {
|
||||
it('appends a stable ~slugId suffix', () => {
|
||||
expect(disambiguate('Notes', 'abc123')).toBe('Notes ~abc123');
|
||||
});
|
||||
|
||||
it('is deterministic for the same name and slugId', () => {
|
||||
expect(disambiguate('Notes', 'abc123')).toBe(
|
||||
disambiguate('Notes', 'abc123'),
|
||||
);
|
||||
});
|
||||
|
||||
it('produces distinct names for colliding siblings', () => {
|
||||
const a = disambiguate('Notes', 'slug-a');
|
||||
const b = disambiguate('Notes', 'slug-b');
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
});
|
||||
80
test/strip-block-ids.test.ts
Normal file
80
test/strip-block-ids.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { stripBlockIds } from '../src/roundtrip.js';
|
||||
|
||||
describe('stripBlockIds', () => {
|
||||
it('removes only attrs.id, recursively, keeping every other attribute', () => {
|
||||
const input = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'heading',
|
||||
attrs: { id: 'h1', level: 2 },
|
||||
content: [{ type: 'text', text: 'Title' }],
|
||||
},
|
||||
{
|
||||
type: 'callout',
|
||||
attrs: { id: 'c1', kind: 'info' },
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
attrs: { id: 'p1', indent: null },
|
||||
content: [{ type: 'text', text: 'Body' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const out = stripBlockIds(input);
|
||||
|
||||
expect(out).toEqual({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'heading',
|
||||
attrs: { level: 2 },
|
||||
content: [{ type: 'text', text: 'Title' }],
|
||||
},
|
||||
{
|
||||
type: 'callout',
|
||||
attrs: { kind: 'info' },
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
attrs: { indent: null },
|
||||
content: [{ type: 'text', text: 'Body' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
// No stray `id` survives anywhere in the tree.
|
||||
expect(JSON.stringify(out)).not.toContain('"id"');
|
||||
});
|
||||
|
||||
it('does not mutate its input (frozen object passes through unchanged)', () => {
|
||||
const inner = Object.freeze({
|
||||
type: 'paragraph',
|
||||
attrs: Object.freeze({ id: 'p1', indent: null }),
|
||||
content: Object.freeze([
|
||||
Object.freeze({ type: 'text', text: 'x' }),
|
||||
]),
|
||||
});
|
||||
const input = Object.freeze({
|
||||
type: 'doc',
|
||||
content: Object.freeze([inner]),
|
||||
});
|
||||
const before = JSON.stringify(input);
|
||||
|
||||
// Would throw on any write to a frozen node if the function mutated input.
|
||||
const out = stripBlockIds(input);
|
||||
|
||||
// Input is structurally identical after the call (no mutation).
|
||||
expect(JSON.stringify(input)).toBe(before);
|
||||
// The id is gone from the returned (new) tree.
|
||||
expect((out.content[0].attrs as Record<string, unknown>).id).toBeUndefined();
|
||||
expect((out.content[0].attrs as Record<string, unknown>).indent).toBeNull();
|
||||
// A fresh tree is returned, not the same reference.
|
||||
expect(out).not.toBe(input);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user