feat(git-sync): CommonJS build + §13.1 editor-ext idempotency gate (Phase A.2)

Make @docmost/git-sync natively consumable by the CommonJS server (and jest):
build to CommonJS (tsconfig module CommonJS, drop type:module, strip .js from
relative imports), and lazy-load the only ESM-only dep (marked) via the dynamic
Function('import()') trick (mirrors docmost-client.loader.ts) with a require()
fallback so vitest's evaluator works too. git-sync tests stay green (314 pass,
3 expected fail).

Add the §13.1 idempotency gate (apps/server .../git-sync-converter-gate.spec.ts):
13 editor-ext docs (paragraphs/headings, marks, links, bullet/ordered/task lists,
blockquote, callouts, code block, hr, table, nested mix) round-trip
content(editor-ext) -> convertProseMirrorToMarkdown -> markdownToProseMirror ->
TiptapTransformer.toYdoc/fromYdoc(tiptapExtensions) -> canonicalize and assert
docsCanonicallyEqual. All green => the vendored converter's docmost-schema is
schema-compatible with editor-ext (no node/mark/attr loss), which the plan §13.1
requires before Phase B. The one intrinsic markdown-image lossiness (width/height
/align can't ride plain ![](src)) is isolated in a KNOWN DIVERGENCE block, not
hidden. Server tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude code agent 227
2026-06-21 14:25:43 +03:00
parent 9a807e972d
commit 2e670934b8
27 changed files with 693 additions and 162 deletions

View File

@@ -1,3 +1,4 @@
"use strict";
/**
* Pure page-tree -> vault path mapping (SPEC §12).
*
@@ -9,7 +10,9 @@
* path logic is unit-testable without any I/O. The names are COSMETIC; identity
* lives in each file's meta block (pageId / slugId).
*/
import { sanitizeTitle, disambiguate } from "./sanitize.js";
Object.defineProperty(exports, "__esModule", { value: true });
exports.buildVaultLayout = buildVaultLayout;
const sanitize_1 = require("./sanitize");
/**
* Build the full vault layout for a space.
*
@@ -27,7 +30,7 @@ import { sanitizeTitle, disambiguate } from "./sanitize.js";
* cannot see — e.g. two pages whose parents are BOTH outside the input set
* both bucket at the root with `segments: []`.
*/
export function buildVaultLayout(pages) {
function buildVaultLayout(pages) {
// Index pages by id so the parent chain can be walked. Guard against
// duplicate ids in the input (first one wins).
const byId = new Map();
@@ -109,11 +112,11 @@ export function buildVaultLayout(pages) {
continue;
if (usedPaths.has(pathKey(entry))) {
// First attempt: disambiguate the stem with the sanitized slugId (or id).
entry.stem = disambiguate(entry.stem, sanitizeTitle(p.slugId ?? p.id));
entry.stem = (0, sanitize_1.disambiguate)(entry.stem, (0, sanitize_1.sanitizeTitle)(p.slugId ?? p.id));
if (usedPaths.has(pathKey(entry))) {
// Still colliding: append the (sanitized) id as a last resort. The id
// is globally unique, so this always resolves the collision.
entry.stem = disambiguate(entry.stem, sanitizeTitle(p.id));
entry.stem = (0, sanitize_1.disambiguate)(entry.stem, (0, sanitize_1.sanitizeTitle)(p.id));
}
}
usedPaths.add(pathKey(entry));
@@ -137,11 +140,11 @@ function nameForNode(node, parentKey, usedBySibling) {
used = new Set();
usedBySibling.set(parentKey, used);
}
let name = sanitizeTitle(node.title ?? "");
let name = (0, sanitize_1.sanitizeTitle)(node.title ?? "");
if (used.has(name)) {
// Sibling collision: disambiguate with the stable, sanitized slugId (fall
// back to the sanitized pageId if no slugId is present).
name = disambiguate(name, sanitizeTitle(node.slugId ?? node.id));
name = (0, sanitize_1.disambiguate)(name, (0, sanitize_1.sanitizeTitle)(node.slugId ?? node.id));
}
used.add(name);
return name;

View File

@@ -1,3 +1,6 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.bodyHash = bodyHash;
/**
* Loop-guard primitives (SPEC §10). The sync engine must never re-pull its OWN
* write as if it were a remote edit: after a push, the next poll will see the
@@ -10,7 +13,7 @@
* to decide "this is our own write, ignore it") is a future increment — here we
* only PRODUCE the hash and the per-page push record (see `src/push.ts`).
*/
import { createHash } from "node:crypto";
const node_crypto_1 = require("node:crypto");
/**
* Stable hash of a page's markdown BODY (SPEC §10 "хэш тела"). Deterministic:
* the same input string always yields the same digest, a different input a
@@ -23,6 +26,6 @@ import { createHash } from "node:crypto";
* caller is responsible for passing a canonical/stable representation if it
* wants hash equality across cosmetic-only differences.
*/
export function bodyHash(markdownBody) {
return createHash("sha256").update(markdownBody, "utf8").digest("hex");
function bodyHash(markdownBody) {
return (0, node_crypto_1.createHash)("sha256").update(markdownBody, "utf8").digest("hex");
}

View File

@@ -1,3 +1,4 @@
"use strict";
/**
* Pure reconciliation planner (SPEC §5/§6/§8).
*
@@ -11,6 +12,10 @@
* This module is intentionally PURE (no IO, no git) so the whole plan is
* unit-testable. The actual file writing / git operations happen in pull.ts.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.MASS_DELETE_FRACTION = exports.MASS_DELETE_MIN_EXISTING = void 0;
exports.planReconciliation = planReconciliation;
exports.decideAbsenceDeletions = decideAbsenceDeletions;
/**
* Compute the reconciliation plan.
*
@@ -33,7 +38,7 @@
* path is removed (as an absence/move) so the vault converges to exactly the
* live set.
*/
export function planReconciliation(live, existing) {
function planReconciliation(live, existing) {
// Desired path for each live pageId.
const liveByPageId = new Map();
// Set of all paths that WILL be written (never delete/remove one of these).
@@ -81,9 +86,9 @@ export function planReconciliation(live, existing) {
* Below this many tracked files the mass-delete fraction guard is not applied
* (a tiny vault where deleting "most" files is normal, e.g. 1-of-2).
*/
export const MASS_DELETE_MIN_EXISTING = 4;
exports.MASS_DELETE_MIN_EXISTING = 4;
/** Fraction of tracked files above which a delete plan is a suspected wipe. */
export const MASS_DELETE_FRACTION = 0.5;
exports.MASS_DELETE_FRACTION = 0.5;
/**
* Pure decision: should the ABSENCE-based deletions (`plan.toDelete`) be applied
* this cycle? Encapsulates the SPEC §8 safety invariants so they are unit-
@@ -100,7 +105,7 @@ export const MASS_DELETE_FRACTION = 0.5;
* Moves are NOT governed by this decision: a moved page IS present in `live`, so
* its old-path removal is real (handled by the caller separately).
*/
export function decideAbsenceDeletions(args) {
function decideAbsenceDeletions(args) {
const { treeComplete, liveCount, existingCount, deleteCount } = args;
// No tracked files, or nothing to delete -> trivially fine to "apply".
if (existingCount === 0 || deleteCount === 0)
@@ -109,8 +114,8 @@ export function decideAbsenceDeletions(args) {
return { apply: false, reason: "incomplete-fetch" };
if (liveCount === 0)
return { apply: false, reason: "empty-live" };
if (existingCount >= MASS_DELETE_MIN_EXISTING &&
deleteCount > existingCount * MASS_DELETE_FRACTION) {
if (existingCount >= exports.MASS_DELETE_MIN_EXISTING &&
deleteCount > existingCount * exports.MASS_DELETE_FRACTION) {
return { apply: false, reason: "mass-delete" };
}
return { apply: true };

View File

@@ -1,3 +1,4 @@
"use strict";
/**
* Pure helpers extracted from the docmost-sync Phase-0 idempotency harness
* (`src/roundtrip.ts`). Only the IO-free comparison utilities are vendored —
@@ -5,13 +6,16 @@
* `DocmostClient` live path and `process.exit`) is NOT vendored (plan §2.1:
* the roundtrip harness moves into the package's tests, not the engine).
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.stripBlockIds = stripBlockIds;
exports.firstDivergence = firstDivergence;
/**
* Recursively strip every `attrs.id` from a ProseMirror node tree. Block ids
* are regenerated by `markdownToProseMirror` (SPEC §11), so they must be
* ignored when comparing the semantic shape of two documents. Returns a NEW
* tree; the input is not mutated.
*/
export function stripBlockIds(node) {
function stripBlockIds(node) {
if (Array.isArray(node)) {
return node.map(stripBlockIds);
}
@@ -36,7 +40,7 @@ export function stripBlockIds(node) {
* Find the first divergence between two values via a recursive deep compare.
* Returns a short path + the two differing values, or null if they are equal.
*/
export function firstDivergence(a, b, path = "$") {
function firstDivergence(a, b, path = "$") {
if (a === b)
return null;
const ta = typeof a;

View File

@@ -1,3 +1,4 @@
"use strict";
/**
* Deterministic filename strategy (SPEC §12).
*
@@ -6,6 +7,9 @@
* functions are intentionally dependency-free and pure, so they are trivially
* unit-testable.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.sanitizeTitle = sanitizeTitle;
exports.disambiguate = disambiguate;
// Printable characters forbidden in file names on common filesystems (mainly
// Windows): / \ < > : " | ? *. Each match is replaced with a single "-".
// Spaces are NOT in this set; whitespace is normalized separately below.
@@ -64,7 +68,7 @@ function stripControlChars(input) {
* result, an all-dots result, or a reserved Windows device name by prefixing
* with "_".
*/
export function sanitizeTitle(title) {
function sanitizeTitle(title) {
let name = stripControlChars(title ?? "")
.replace(FORBIDDEN_PRINTABLE_RE, "-")
.replace(WHITESPACE_RUN_RE, " ")
@@ -92,6 +96,6 @@ export function sanitizeTitle(title) {
* to the same name. Appends a stable suffix built from the page's `slugId`, so
* the result stays deterministic across runs (SPEC §12: `Title ~slugId`).
*/
export function disambiguate(name, slugId) {
function disambiguate(name, slugId) {
return `${name} ~${slugId}`;
}

View File

@@ -1,3 +1,6 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.stabilizePageFile = stabilizePageFile;
/**
* Normalize-on-write helper (SPEC §11 "Резолюция").
*
@@ -12,7 +15,7 @@
* Already-stable content is unaffected (the pass is idempotent), so re-pulls of
* unchanged pages produce identical bytes and git sees no diff.
*/
import { convertProseMirrorToMarkdown, markdownToProseMirror, serializeDocmostMarkdownBody, } from "../lib/index.js";
const index_1 = require("../lib/index");
/**
* Produce the self-contained `.md` file text for a page from its raw
* ProseMirror `content` + identity meta, in the verified fixpoint form.
@@ -26,11 +29,11 @@ import { convertProseMirrorToMarkdown, markdownToProseMirror, serializeDocmostMa
* idempotent for already-stable content, and the convergence point for the
* known converter asymmetries.
*/
export async function stabilizePageFile(content, meta) {
const md1 = convertProseMirrorToMarkdown(content);
const doc2 = await markdownToProseMirror(md1);
const stableBody = convertProseMirrorToMarkdown(doc2);
async function stabilizePageFile(content, meta) {
const md1 = (0, index_1.convertProseMirrorToMarkdown)(content);
const doc2 = await (0, index_1.markdownToProseMirror)(md1);
const stableBody = (0, index_1.convertProseMirrorToMarkdown)(doc2);
// The meta shape is exactly what `exportPageBody` writes; cast to the lib's
// DocmostMdMeta (a superset with optional fields) for the serializer.
return serializeDocmostMarkdownBody(meta, stableBody);
return (0, index_1.serializeDocmostMarkdownBody)(meta, stableBody);
}