Compare commits

...

78 Commits

Author SHA1 Message Date
claude code agent 227
f3dbcec0fd refactor(git-sync): DRY the move+body emission; cover M-side ghost-move (F7,F8)
F8: extract emitMoveWithBody helper (renamesMoves + body update with
    basePath=oldPath) and call it at all three rename emission sites (ghost-move
    A, ghost-move M, R/C) — byte-identical behavior, single F4 rationale. Helper
    placed above computePushActions so the planner JSDoc stays attached.
F7: add an M-side ghost-move test (D+M same pageId) asserting the move and the
    body update carry basePath=oldPath — the previously-untested branch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 16:10:59 +03:00
claude code agent 227
63f948df10 fix(git-sync): deliver body on rename+edit via honest 3-way merge; cover CREATE strip; fix env doc (F4-F6)
F4: a rename/move + body edit in one diff used to lose the edit (renamed pages
    went only into renamesMoves, never updates). Now computePushActions also
    emits an updates entry for renames, AND threads the OLD path via a new
    UpdateAction.basePath so applyPushActions resolves the 3-way merge base from
    the pre-rename file. Without it the base lookup at the new path returns null
    and degrades to a 2-way merge that rolls back concurrent Docmost edits; with
    it the edited block wins while a concurrent edit to another block survives.
    A plain (status M) update carries no basePath and is byte-identical to before.
F5: test the CREATE path stripping conflict markers (autoMergeConflicts on).
F6: .env.example documents GIT_SYNC_REMOTE_TEMPLATE as deferred/inert scaffolding.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 14:40:02 +03:00
claude code agent 227
fbaaa84419 test(git-sync): accurate null-edge docstring + fill round placeholder (F2/F3)
F2: the real-git modify/delete null-edge test docstring overclaimed it
caught loss of the `?? theirs` fallback end-to-end. git itself leaves
theirs in the working tree (stage 3) so commitMerge's `git add -A` would
stage it even with the bug — the assertions pass on broken logic. Reword
to state it verifies the clean-merge happy path; the real F1 regression
guard lives in the fake-fs apply-pull-actions.test.ts.

F3: fill the `round-?` placeholder with `round-2` in both new blocks to
match the file convention (header: 'QA #119 round-2').

Comment-only; no production or test-logic changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 02:04:47 +03:00
claude code agent 227
32cb9eb1e3 test(git-sync): cover null-edge conflict resolution in applyPullActions (F1)
The genuine-conflict branch in applyPullActions resolves to `ours ?? theirs`,
but the two stages where a side is ABSENT had NO test — the existing conflict
tests only fed stages where both ours and theirs are non-null. This is the
data-preservation core on the published `main`: a regression (dropping the
`?? theirs`, or wrongly writing on both-null) would silently lose a surviving
Docmost edit or resurrect a both-deleted page.

Adds four tests:
- apply-pull-actions.test.ts (fake-git, controlled stages): modify/delete
  (ours=null, theirs!=null -> keep THEIRS) and delete/delete (both null ->
  write nothing, deletion staged by commitMerge's `git add -A`).
- pull-conflict-normalize.test.ts (real-git 3-way): modify/delete built by
  deleting on main + modifying on docmost (stage 2 absent -> theirs kept,
  committed clean, no markers); delete/delete built via a rename/rename(1to2)
  on the shared base file, which records the original path as both-deleted
  (stages 2 AND 3 absent -> nothing written, deletion committed off main).

Production logic at pull.ts:487-497 held — pure test-coverage fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 00:23:55 +03:00
claude code agent 227
b47751349f fix(git-sync): kill spurious marker-leaking conflict, concurrent-edit loss, flapping HEAD
Three more git-sync QA defects from the 2nd live pass on PR #119, plus a
callout-fidelity nit:

1. SPURIOUS conflict leaked raw markers into canonical main (root cause). On an
   ordinary round-trip the only difference between the docmost mirror (normalize-
   on-write) and a user's raw push is trailing/empty-line normalization, which made
   git's line-based docmost->main merge CONFLICT, and the wedge fix then committed
   the file WITH literal <<<<<<< / ======= / >>>>>>> markers onto main (git and the
   DB silently diverged for cycles). Fix: on a conflict, normalize trailing/empty
   lines on BOTH sides (showStage :2:/:3:) before comparing — a trailing-only diff
   is recognized as spurious and resolved to the clean normalized form. A GENUINE
   same-block conflict is auto-resolved to OURS (git wins, mirroring the live-doc
   3-way rule); the docmost side stays on the `docmost` branch + page history. Raw
   markers NEVER reach main again.

2. Concurrent UI<->git edit silently lost the UI side. The git->Docmost 3-way merge
   ran against a live Y.Doc that hadn't yet received the user's debounced in-flight
   edit, so git clean-applied (no conflict detected) and the edit vanished even on a
   different block. Fix: flush the pending debounced store before the merge so the
   in-flight edit is drained into the live doc first — a different-block edit is
   merged, a same-block one is detected and pinned to history (recoverable).

3. Smart-HTTP HEAD flapped to the read-only `docmost` mirror (~1/4 of clones). The
   engine transiently checks out `docmost` mid-pull and the host advertises whatever
   HEAD resolves to. Fix: VaultGit.pinHeadToMain(); the cycle restores HEAD->main in
   a finally; and the upload-pack ref advertisement is served HEAD-pinned under the
   per-space lock so it can never observe a mid-cycle HEAD.

4. (callout) clampCalloutType now mirrors the editor's GITHUB_ALERT_TYPE_MAP for
   non-schema aliases (tip->success, caution->danger, important->info) instead of
   flatly collapsing to info. The editor schema genuinely supports only the six
   banner types, so unknown types still fall back to info (by design).

Tests: deterministic real-git trailing-blank round-trip (no conflict, no markers,
in sync over 2 cycles) + genuine-conflict no-marker-leak; HEAD advertisement
stability; pre/post-flush concurrent-edit survival; serveReadAdvertisement lock
pin; widened callout-alias coverage. Engine vitest + server tsc + collaboration /
git-http / orchestrator specs all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 22:05:32 +03:00
claude code agent 227
b7e5cb6970 fix(git-sync): push 503 starvation + concurrent-edit marker leak/silent loss
Bug #1 (push 503 starvation): an external receive-pack that briefly overlapped
a poll cycle immediately 503'd because the per-space single-writer lock was
held. Add a BOUNDED retry-acquire on the PUSH path only (SpaceLockService
.withSpaceLock acquireRetry: capped exponential backoff up to ~5s); a transient
overlap now waits and succeeds, a genuinely stuck cycle still 503s after the
bound. The poll cycle passes no retry (immediate skip). Push result stays
deterministic: the receive-pack only runs once the lock is held, so a 503 never
leaves a half-applied ref.

Bug #2 (concurrent-edit marker leak + silent same-block loss):
- Marker leak (a): the push UPDATE path stripped markers for the body sent to
  Docmost but left raw <<<<<<</>>>>>>> committed on the published `main` vault
  forever (autoMergeConflicts ON). Now the cleaned body is written back to the
  vault file + recorded in writtenBack so runPush commits it on `main` and the
  vault converges to clean bytes.
- Marker leak (b): pin merge.conflictStyle=merge in ensureRepo and teach
  stripConflictMarkers/hasConflictMarkers about the diff3 `|||||||` base section
  (drop the marker AND the stale base region) so diff3/zdiff3 conflicts can
  never leak `|||||||` + base content into a page. Also scrub the 3-way merge
  BASE markdown.
- Silent same-block loss: the block 3-way merge still resolves same-block
  conflicts deterministically to git, but it is no longer silent: diff3Plan now
  reports a conflict count (mergeXmlFragments3WayWithStats), gitSyncWriteBody
  logs it, and the persistence boundary-snapshot now fires for git-sync writes
  over a non-git-sync baseline so the human's pre-merge content is preserved in
  page history (recoverable). Full both-preserved persisted-conflict UI remains
  the deferred redesign.

Tests: space-lock bounded-retry (success/stuck/poll-immediate); push vault-clean
+ diff3 |||||||  strip; ensureRepo conflictStyle pin; diff3Plan/3-way conflict
counts; persistence git-sync boundary snapshot. Server tsc clean; git-sync
vitest + server collaboration/git-sync jest all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 20:03:21 +03:00
claude code agent 227
906733b5c8 fix(git-sync): address PR #119 review #4 — symlink guard, dead-code cull, changelog + warnings/suggestions
Blocking (review id 2514):
- [security] Forbid symlinks in vaults. ensureServable now sets
  core.symlinks=false in each vault's local git config (a pushed symlink is
  checked out as a plain file, never a real link), and the engine cycle wraps
  every read/write/mkdir in an lstat/realpath guard (new path-guard.ts) that
  refuses a path that is — or traverses — a symlink, or whose realpath escapes
  the vault root. Prevents a writer from publishing /etc/passwd or the server
  .env, or writing outside the vault. Adds unit tests (path-guard.test.ts) +
  a read-guard integration test (cycle.test.ts) + real lstat/realpath in the
  roundtrip integration test.
- [simplification] Delete dead lib/diff.ts + test/diff.test.ts and drop the
  now-unused @fellow/prosemirror-recreate-transform dependency.
- [documentation] Add a CHANGELOG [Unreleased] → Added entry for git-sync.

Warnings:
- [test-coverage] Cover the CREATE-branch conflict-markers guard (a new .md with
  markers and no gitmost_id is recorded as a create failure, never created).

Suggestions:
- [stability] Bound each `git config` in ensureServable with a timeout.
- [authz] Trigger endpoint resolves spaceId workspace-scoped and 404s a foreign
  space before any vault directory is created.
- [stability] Attribute git-initiated moves to the service account
  (lastUpdatedById), via an optional actor param on PageService.movePage.
- [documentation] Document the per-space autoMergeConflicts toggle in AGENTS.md.
- [test-coverage] Cover the unterminated `:::` callout fence fallback.
- [simplification] Move test-only roundtrip-helpers.ts out of src/ into test/.

Architecture:
- Move the Yjs/ProseMirror merge primitives (yjs-body-merge, three-way-merge,
  lcs + specs) into collaboration/merge/, breaking the collaboration →
  integrations/git-sync dependency cycle this PR introduced.
- Port the schema-surface drift gate to packages/mcp (the mcp schema mirror had
  none); pins 52 entries.

Deferred (with rationale in the review thread): the incremental-pull perf
warning (correctness-neutral; needs a high-water-mark design + its own tests on
the data-loss-critical path) and the redis-sync rolling-deploy mixed-version
edge (the deficient behavior is in already-released old-instance code; the new
code is correct on both sides; impact is a transient rollout-window artifact).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:39:12 +03:00
a
f020739bfd refactor(git-sync): address PR #119 review #3 — honest gitRemote scaffolding comments, env example, shared ESM bridge
1. gitRemote is NOT yet consumed (the vendored engine has no remote-push path,
   SPEC §7). Corrected the buildSettings docstring (it wrongly called gitRemote
   "load-bearing") and marked the env -> validation -> getter -> buildSettings
   chain as inert SCAFFOLDING for the deferred remote-push feature at all three
   sites. Kept the wiring (harmless; removing only churns).

2. .env.example: document that GIT_SYNC_REMOTE_TEMPLATE substitutes the literal
   "{spaceId}" per-space (with the example), so an operator doesn't point every
   space at one remote.

3. Extracted the copy-pasted CJS->ESM dynamic-import bridge
   (`new Function('s','return import(s)')`) into one shared
   common/helpers/esm-import.ts; git-sync.loader, docmost-client.loader and
   mcp.service now import it and keep their own typed loadX() wrappers.

Deferred (notes only, not implemented):
- lcs.ts + three-way-merge.ts could move into packages/git-sync, but that engine
  is vendored (manual re-sync) — added a one-line note at three-way-merge.ts to
  revisit once the re-sync story is settled.
- schema-core single source + BullMQ/fencing remain documented from prior rounds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:10:10 +03:00
a
22e3fcdeba fix(git-sync): address PR #119 review #2 — throttle /git Basic auth, fix mcp schema drift + warnings/tests
Must-fix:
- Throttle the raw /git HTTP-Basic path: it bypasses Nest/ThrottlerGuard, so
  verifyUserCredentials (bcrypt) ran unthrottled. Wrap it in the SAME
  FailedLoginLimiter the /mcp path uses (5/60s; per-IP, per-IP+email, global
  per-email keys; atomic tryReserve BEFORE bcrypt; success resets, non-credential
  errors release). The (threshold+1)-th attempt now gets 429 pre-bcrypt. Sweep
  timer + onModuleDestroy mirror McpService.
- Fix the mcp schema mirror drift: packages/mcp details `open` attr now reads via
  hasAttribute (matches editor-ext canon + git-sync copy); getAttribute dropped a
  bare `<details open>` state. (build/ is gitignored — rebuilt locally.)

Tests added:
- /git brute-force throttle: pre-bcrypt 429 on the 6th failure; success resets;
  non-credential error releases the budget.
- git-http-backend lost-lock AbortSignal: already-aborted -> no spawn + 500;
  live abort mid-request -> SIGTERM + response closed.
- orchestrator divergentDocmost -> WARN + flag surfaced in status (+ clean case).
- pollTick re-entrancy guard skips an overlapping tick.
- datasource NotFound early-throws (getPageJson/move/rename) + updatedAt:undefined
  stale-read branch (importPageMarkdown/createPage).

Suggestions:
- space.repo updateGitSyncSettings: parameterize the jsonb key (`${prefKey}::text`)
  instead of sql.raw (latent-injection footgun); value stays sql.lit. Spec updated.
- pollTick re-entrancy guard (private `polling` flag).
- page-change.listener docstring: honest about the move/rename/delete over-skip
  (loop-guard keys only on lastUpdatedSource) -> ~poll-interval latency, not loss.
- AGENTS.md: document the root /git smart-HTTP route + GitSyncModule.
- Remove redundant redteam-provenance.spec.ts (covered e2e in
  persistence.extension.spec.ts:145).
- Extract the duplicated SIGTERM->SIGKILL+finish block (watchdog + abort) into
  terminateChild; centralize watchdog-timer teardown in done().

Architecture (deferred, documented): mcp schema header now carries the three-copy
keep-in-sync + schema-core note; the editor-ext contract test documents that the
mcp copy and attribute-behaviour drift (details `open`) are not mechanically
covered yet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:10:10 +03:00
a
7179f8a5b2 fix(git-sync): address PR #119 review — close 403/404 space-existence leak + warnings/tests/arch
Security (must-fix):
- /git smart-HTTP gate: an authenticated NON-member of a git-sync space now gets
  404 (not 403), so the 403<->404 difference can no longer be used to brute-force
  which spaces exist / have git-sync enabled. 403 is reserved for a MEMBER who
  lacks the required role (existence already known). New gate input
  userIsSpaceMember; decision-table + service specs extended.

Config (must-fix):
- Remove the dead GIT_SYNC_SSH_KEY_PATH knob (getter + validation field + two
  .env.example lines) — it had zero consumers and advertised a nonexistent push
  capability.

Stability/docs (warnings):
- Wire the lost-lock AbortSignal into runReceivePack -> git http-backend so the
  receive-pack child is killed if the per-space lock lapses mid-write.
- Raise the divergent-`docmost` (invariant §5) push refusal from info -> warn and
  surface divergentDocmost in the run status (/status).
- Comment the stale read-after-debounced-collab-write updatedAt in
  importPageMarkdown (deferred §10 loop-guard must not trust it).
- Fix the Dockerfile comment: the loader uses require.resolve + dynamic import(),
  it deliberately does NOT require('@docmost/git-sync').
- Merge the two near-identical space toggle handlers into one parameterized
  handler; add the 2 missing en-US i18n keys for the auto-merge switch (ru-RU not
  maintained for these git-sync strings, mirrored).

Tests:
- isGitSyncHttpEnabled() default-branch (unset -> isGitSyncEnabled fallback).
- agentSourceFields 'git-sync' case (source stamped, chat key omitted).
- editor-ext name-level schema contract (vendored mirror superset of editor-ext
  node/mark types) + the new shared resolver + non-member 404 gate cases.

Architecture:
- Extract resolveRequestWorkspace shared by DomainMiddleware + GitHttpService
  (the two real self-hosted/cloud copies; McpService has no cloud branch).
- Document the in-process setInterval multi-replica limitation + BullMQ/fencing
  future direction (deferred, not implemented).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:10:10 +03:00
a
fe4adf23a0 fix(git-sync): unwedge per-page conflicts, preserve callout types, flush collab on disconnect
Addresses QA findings on PR #119 (issues #235/#236).

SYNC-WEDGE (HIGH): one same-line conflict on one page froze sync for the
WHOLE space in both directions forever. The pull's docmost->main merge left
the vault mid-merge, so every later cycle's isMergeInProgress() check returned
skipped:"merge-in-progress" and skipped the entire space with no recovery.
- pull.ts now COMMITS a conflicting merge with markers in place (commitMerge):
  cleanly-merged pages land, the conflicted page carries its markers on main and
  is isolated by the existing push-side conflict-marker skip (markers never reach
  Docmost), and the next cycle is no longer wedged. conflictedPaths is surfaced.
- cycle.ts now RECOVERS a vault left mid-merge by a prior/pre-fix cycle: it
  aborts the stale merge (merge --abort, hard-reset fallback) and continues,
  instead of skipping the space forever.
- git.ts: listUnmergedPaths / commitMerge / abortMerge / resetHardToHead.

CALLOUT TYPE FIDELITY: git-sync's CALLOUT_TYPES was missing "note" and "default"
(editor-canonical types), so [!note]/[!default] callouts flattened to [!info] on
every round-trip. Aligned the list with @docmost/editor-ext getValidCalloutType.

LOSS-ON-FAST-CLOSE: editing a page then closing the tab inside the collab
debounce window (~3-18s) lost the edit, because with unloadImmediately:false
Hocuspocus does not flush the debounced onStoreDocument on the last-client
disconnect. PersistenceExtension.onDisconnect now flushes the pending store
(debouncer.executeNow) on the last disconnect only, with no redundant write.

DUPLICATION re-verify (#1): the schema-default merge-key normalization is intact;
faithful toYdoc-based reproduction shows callout + rich content resync with 0 ops
and no growth/strip across cycles -> the re-report was leftover vault data, not a
live regression. Locked with a callout regression spec.

Tests: git-sync 688 pass (incl. real-VaultGit wedge-recovery integration); server
git-sync+collaboration 285 pass; new callout merge/fidelity + onDisconnect-flush
specs. tsc --noEmit clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:10:10 +03:00
claude code agent 227
eefe17600c test(git-sync): mock db.transaction in movePage provenance specs
After rebasing onto develop, movePage runs its cycle-check + UPDATE inside
executeTx(this.db) (develop #207 advisory-lock/atomic cycle-guard). The
git-sync provenance specs still passed a bare `{}` db, so executeTx hit
`db.transaction is not a function`. Reuse the same trxStub Proxy + transaction
mock the develop movePage specs use so both the advisory-lock `sql.execute(trx)`
and updatePage resolve. Production movePage keeps BOTH develop's lock/cycle
guard AND git-sync's provenance stamping; this only updates the test harness.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:10:10 +03:00
claude code agent 227
32e99c6e42 fix(editor,git-sync): parse details open as a boolean so open state survives render/round-trip
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:10:10 +03:00
claude code agent 227
e48d7720e9 fix(git-sync): propagate nested details open; drop dead delete-cap wiring; cover lost-lock abort + lose-prone atom round-trips
Addresses review 1863 (delta) on PR #119.

MUST-FIX:
- detailsToHtml (the raw-HTML path used for a details nested inside
  columns/spanned cells) now emits `<details${open}>`, mirroring the
  top-level case, so `open` no longer silently drops every round trip.
- Remove the dead `resolveApplyClient` delete-cap hook from the engine
  `runCycle`: the orchestrator stopped passing it, so the hook + its
  dry-run pass were inert. Deletes are soft (Trash) + always logged and
  engine convergence is the guard, so no cap is re-added — just the dead
  wiring removed.

TEST COVERAGE:
- space-lock: heartbeat refresh CAS-miss (eval -> 0) and Redis-error
  (eval throws) both abort the in-flight fn's signal.
- cycle: a pre-aborted signal (and an abort during the pull read) throws
  before the push apply / first destructive phase.
- converter: htmlEmbed source VALUE + height survive; encode/decode
  UTF-8 symmetry and '' -> ''; footnote definition body + ref/def id
  match; transclusionReference both ids survive; fix the bad
  transclusionSource fixture (wrong `pageId` attr + empty content ->
  schema `id` + a block child); nested details `open` parity test.
- orchestrator: autoMergeConflicts:true reaches engine settings; default
  false on a missing settings row.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:10:10 +03:00
claude code agent 227
42e618ec7f fix(git-sync): normalize merge key against schema defaults — cover all node/mark default-attr duplication triggers (image, link, highlight, …)
The point-fix (7a7b840e) excluded only `indent: 0` via a hardcoded one-attribute
denylist (`DEFAULT_KEY_ATTRS`) applied solely to ELEMENT attributes. The same
divergence recurs for every attribute whose editor-ext (server) schema default the
LIVE Yjs doc materializes (`TiptapTransformer.toYdoc(tiptapExtensions)`) but the
git round-trip does not: the engine's `markdownToProseMirror` emits those attrs as
explicit `null` (verified live: link mark `internal: null`, heading/paragraph
`indent: null`), which `y-prosemirror` then drops — so the same block keys
differently on the two sides, the three-way merge anchors on nothing, and the body
is re-appended every reconcile cycle (unbounded, no client connected). The denylist
also could not reach MARK attributes at all (marks are serialized raw in the
XmlText delta), so the link mark's `internal` mismatch survived.

Replace the denylist with a normalization derived from the ACTUAL ProseMirror
schema (`getSchema(tiptapExtensions)`, memoized): in `serializeXmlNode`, drop any
ELEMENT attribute whose value equals its node's schema default (or is
null/undefined), and normalize each XmlText delta op's MARK attributes the same way
against `schema.marks[name].spec.attrs`. The volatile block `id` stays excluded and
genuine non-default values (a real `indent: 2`, `align: "left"`, `link.href`,
highlight color) stay in the key. This is general — it covers indent, image.align,
link.internal, highlight.colorName, youtube/pdf and any future node/mark — not
another per-attribute denylist. Schema build is wrapped so a degenerate test stub
(`tiptapExtensions: []`) degrades to dropping only null/undefined.

Tests: new `yjs-body-merge.schema-defaults.spec.ts` models image/link/highlight
both hand-built and through the REAL `TiptapTransformer.toYdoc` materialization
(live defaults vs engine-style explicit nulls, base stale-by-one) — RED before
(4 ops / growth), GREEN after (0 ops). Existing idempotency + open-editor
convergence suites still pass (261 server collab+git-sync tests, tsc clean).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:10:10 +03:00
claude code agent 227
857a0064f7 fix(git-sync): make reconcile import truly idempotent — stop runaway whole-body duplication
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 merge 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 on every
reconcile cycle. Each grown export then diverged from the last-pushed base by one
more unit — a self-sustaining, unbounded whole-body duplication loop with no
client connected.

The prior fix (0c7b73f7) excluded the volatile block `id` from the key, which
was necessary but not sufficient: `indent: 0` is a CONTENT attribute the editor
stamps as a default, so it was never stripped. Normalize editor-materialized
schema defaults (`indent: 0`) out of the block key — only the default value, so a
genuine `indent: 2` still diffs and lands — so a live block compares equal to its
git-round-tripped twin and the resync is a true no-op.

Regression test (yjs-body-merge.idempotency.spec.ts) encodes the invariant on a
body of byte-identical units (heading + paragraph + callout + table with empty
cells): a live fragment carrying indent:0 + ids merged against the git-derived
fragment (neither) with a stale-by-one base applies 0 ops and does not grow — RED
before, GREEN after.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:10:10 +03:00
claude code agent 227
daf6c9ea16 fix(git-sync): propagate remote custom-event handler errors instead of 30s timeout
When a git-sync body write (gitSyncWriteBody) is routed to the collab instance
that owns the doc, the handler runs remotely inside handleRedisMessage and CAN
throw (markdown->ProseMirror transform). Previously the throw was uncaught: the
customEventComplete reply was never published, so the origin's writePageBody
promise only rejected after customEventTTL (~30s) as a generic 'TIMEOUT', and an
unhandledRejection escaped the async messageBuffer listener on the owning
instance.

Now the owner wraps handleEventLocally in try/catch and, on throw, publishes a
customEventComplete carrying an `error` field on the same correlation channel.
The origin's pendingReplies holds {resolve, reject} and rejects promptly with the
real Error. The TTL TIMEOUT remains as the fallback for a genuinely lost reply.
The no-throw and local (same-instance) paths are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:10:10 +03:00
claude code agent 227
9e69d917ee fix(git-sync): converge git-ingest with open editor sessions — stop silent revert/data-loss on live pages
A git push to a page with an OPEN editor was silently reverted: the git
commit landed and the DB body updated, but the page in the browser stayed
on the old content and the editor's next autosave overwrote the git change.

Root cause (distributed, not in the merge): writeBody applied the body
merge via collabGateway.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
landed in the detached doc + DB, but the live editor's Y.Doc never received
the Yjs update; its debounced autosave then persisted its STALE state over
the DB, reverting the git change (and, for concurrent edits to different
paragraphs, losing the git side). In one process the bug is invisible
because the direct connection already shares the editor's doc.

Fix: route the body write through the existing custom-event channel (the
same mechanism comment-marks and updatePageContent use) so the merge runs
on the instance that OWNS the live doc. Its update is then broadcast to
every connection (Document.handleUpdate) and the editor's CRDT converges on
the merged result. New CollaborationGateway.writePageBody dispatches to a
new gitSyncWriteBody handler (builds incoming/base docs before opening the
connection — crash-safe — then 3-way/2-way merges into the live fragment);
without redis it runs locally on the single (owning) instance. writeBody
now just forwards the converted ProseMirror bodies + service userId.

Evidence:
- git-ingest-convergence.spec.ts: deterministic two-Y.Doc repro. PATH B
  (undelivered update) asserts the LOSS (the bug); PATH A (update delivered,
  as the owner-routed write does) asserts the git change SURVIVES and that
  concurrent edits to different paragraphs both survive.
- collaboration.handler.git-sync.spec.ts: exercises the real gitSyncWriteBody
  against a shared doc wired to a connected "editor" doc (models the
  owning-instance broadcast) — editor converges, concurrent edit preserved,
  crash-safe on transform failure.
- gitmost-datasource.service.spec.ts: writeBody now routes via writePageBody
  (RED before this change — it called openDirectConnection).

Honest scope: the failure is cross-instance; full multi-instance convergence
needs a live Hocuspocus + redis and is not provable in a unit test, so the
convergence invariant is captured at the Yjs update-exchange level.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:10:10 +03:00
claude code agent 227
2594828758 fix(git-sync): idempotent first-block reconciliation — stop start-of-doc content duplicating every sync cycle
The block-level body merge keyed each block by its full attribute set,
including the per-block UniqueID the editor stamps on every heading/paragraph.
A body arriving from git is parsed from clean markdown and carries no block
ids, so a live block (id present) never matched the same block coming from git
(no id). The three-way merge's LCS could not anchor on it, and an incoming
block with no matching anchor — content inserted at the TOP of the page — was
re-added on every push/pull cycle: a non-convergent, unbounded duplication loop.

Exclude the volatile 'id' attribute from the block comparison key
(serializeXmlNode) so blocks compare by content across the git round-trip.
The merge keeps the live block INSTANCE (and its id, and any in-flight edit)
for an anchor — picks are by index, not key — so identity is preserved while
reconciliation becomes idempotent. Mirrors canonicalize.ts, which already
strips the regenerated block id from the round-trip idempotency comparison.

Adds a RED-before-fix repro modelling the live-id vs git-no-id asymmetry and
asserting no block growth across cycles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:10:10 +03:00
claude code agent 227
b5ce63a956 feat(git-sync): Obsidian-native callouts (> [!type]) instead of :::type
Callouts now export as Obsidian's blockquote-callout syntax — `> [!type]` opener
plus a `>`-prefixed body — so they render as real callouts when the vault is
opened in Obsidian, instead of `:::type` (Docusaurus-style) which Obsidian shows
as a plain blockquote.

- Export (markdown-converter `case "callout"`): `> [!type]` + each body line
  blockquote-prefixed (a blank line becomes a bare `>` so the callout is not
  split). Nested callouts naturally become `> > [!type]`.
- Import (preprocessCallouts): a new branch recognizes `> [!type]` openers and
  the contiguous `>`-prefixed body, strips one blockquote level and recurses (so
  nested callouts work), emitting the same callout div the `:::` path produces.
  The legacy `:::type` parser is KEPT so existing vaults keep importing. A plain
  blockquote (no `[!type]`) stays a blockquote.

Tests: 4 converter golden tests updated to the new `> [!type]` output; 4 new
import tests (simple, nested, round-trip, plain-blockquote-untouched). The §13.1
gate still round-trips callout losslessly through the real server schema.
git-sync vitest 675 (+1 expected-fail), gate 27.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:10:10 +03:00
claude code agent 227
e777ebcf4f feat(git-sync): remove the per-cycle delete cap; deletes apply + are logged every cycle
The delete cap (GIT_SYNC_MAX_DELETES_PER_CYCLE, default 5) was a defense-in-depth
guard that SUPPRESSED a cycle's deletions when the planned count exceeded the
limit. In practice it was a crutch over engine correctness that also blocked
legitimate deletes: deleting a folder with many child pages is a normal action,
and git-sync deletes are SOFT (Trash, reversible), so a blocking limit has little
upside and real downside. There is also no user-facing surface to "confirm" a
large delete from a background sync — the only channel is the operator log.

So: drop the cap entirely. Deletes apply unconditionally; every cycle already
logs its full push plan, per-action `delete: <pageId>` lines, and completion
counts through the engine `log`, so what was deleted (and what was skipped) is
always recorded. Engine correctness (the reconcile/layout/round-trip tests) is
what prevents phantom deletions — not a blocking cap.

Removed: orchestrator `resolveApplyClient` cap hook + `maxDeletes`,
`getGitSyncMaxDeletesPerCycle`, the `GIT_SYNC_MAX_DELETES_PER_CYCLE` env/validation/.env.example,
and the cap tests. (The engine's generic optional `resolveApplyClient` hook is
left as an unused extension point.)

server tsc clean, git-sync + environment jest 174.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:10:10 +03:00
claude code agent 227
abd6e3948b fix(git-sync): preserve subpages.recursive and details.open on round trip
Found proactively by deepening the round-trip test from node-TYPE survival to
ATTRIBUTE fidelity (distinctive attr values per node). Two real losses (the
other 3 candidates — mathInline/mathBlock/pageEmbed — were verified to be
correct; the probe had used wrong attr names):

- subpages `recursive`: the converter emitted a bare div and the schema mirror
  didn't model the attr, so a recursive subpages reverted to non-recursive on a
  round trip. Now emits `data-recursive="true"` and the mirror parses it back
  (matching @docmost/editor-ext).
- details `open`: the `open` (collapsed/expanded) state lives on the details
  node, but the converter emitted the `<details>` wrapper from the summary case
  without it, so the state was dropped. The wrapper now carries `open`.

The round-trip test now also asserts attribute fidelity (12 cases) so these are
locked. Schema-surface snapshot updated for the new subpages attr.

git-sync vitest 671 (+1 expected-fail), §13.1 gate 27.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:10:10 +03:00
claude code agent 227
5125296bfa fix(git-sync): subpages round-trips (was {{SUBPAGES}} literal) + exhaustive all-node round-trip test
subpages exported to the literal `{{SUBPAGES}}`, which has no markdown/HTML
inverse, so on re-import it came back as a plain paragraph holding the visible
text "{{SUBPAGES}}" — the embed rendered as that literal string on the page
after a sync (round-trip data loss, seen live). It now emits the schema-matching
`<div data-type="subpages">` like every other embed node, so the schema's
parseHTML rebuilds the subpages node. Also dropped the leaf-atom content-hole
in the subpages renderHTML.

New committed regression coverage:
- packages/git-sync/test/roundtrip-all-nodes.test.ts — exhaustive serialize ->
  deserialize round trip for ALL 40 node/mark types; each asserts the node/mark
  survives and no `{{...}}` literal leaks. This is the test that caught subpages.
- §13.1 gate (git-sync-converter-gate.spec.ts): subpages added to the green
  corpus (round-trips through the REAL server schema).
- Corrected two PR-authored tests that asserted the old {{SUBPAGES}} loss as
  "by design" — they now assert the fixed round trip.

Also folds in review #1679 coverage-gap tests (no prod change): orchestrator
pollTick/enabledSpaces, datasource 3-way merge dispatch, page.repo
last_updated_source provenance SQL.

git-sync vitest 659 (+1 expected-fail), server tsc clean, server specs green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:10:10 +03:00
claude code agent 227
452a752264 fix(git-sync): don't run a Docmost cycle on receive-pack info/refs (fixes deterministic push 503)
A git push is a two-request exchange: GET info/refs?service=git-receive-pack
(ref advertisement) then POST git-receive-pack (the pack). The git-HTTP host
classified BOTH as serviceKind 'write' and routed both through
ingestExternalPush, which takes the per-space lock and runs a FULL Docmost
reconcile cycle. So the read-only info/refs advertisement held the lock while a
cycle ran, and the client's immediately-following POST git-receive-pack collided
with that still-running cycle and got 503 — deterministically, every push (and
Obsidian Git's "scan" failed for the same reason, since it probes push
capability via the same receive-pack info/refs).

Fix: only the actual pack-receiving write (POST git-receive-pack) runs under the
lock + 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
info/refs?service=git-receive-pack advertisement. Authz is unchanged (the gate
still requires write permission for receive-pack refs); only the side effect of
running a cycle on a read-only request is removed.

Verified end-to-end on a live stand: clone, then `git push` of a new file lands
the page in Docmost (was 503 on every push before). Regression test added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:10:10 +03:00
claude code agent 227
a40a00d5c5 feat(git-sync): per-space toggle for conflict-marker handling on push (#13)
Red-team #13 (conflict markers reaching Docmost) is now a per-space policy
exposed as a UI toggle, instead of a hardcoded behavior. New boolean
`gitSync.autoMergeConflicts` (default FALSE), mirroring the existing per-space
`gitSync.enabled` flag end-to-end (jsonb space settings -> update-space DTO ->
space.service -> client types -> space settings form switch):

- OFF (default, safe): a page whose committed body still has unresolved git
  conflict markers is NOT pushed — it is recorded as a per-page push FAILURE
  ("unresolved conflict markers — resolve in git first"). Recording a failure
  (not a soft skip) deliberately HOLDS refs/docmost/last-pushed so the conflict
  commit is never marked pushed and a later pull cannot clobber the user's
  in-progress resolution; the page retries until the conflict is resolved in git.
- ON: the marker lines are stripped and both sides' content is pushed (the prior
  behavior), so the conflict becomes visible/fixable inside Docmost.

The engine Settings carries `autoMergeConflicts`; runPush threads it into the
update AND create paths. The orchestrator's buildSettings reads the per-space
flag from jsonb (strict opt-in like `enabled`, default false).

Tests: redteam-push-cycle #13 rewritten (default -> not pushed + failure + refs
held; ON -> strip-and-push); space.service + edit-space-form + orchestrator
specs extended. git-sync vitest 618, server jest space+git-sync 163, client
edit-space-form 11, server/client tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:10:10 +03:00
claude code agent 227
81c0226be7 docs(git-sync): document GIT_SYNC_BACKEND_TIMEOUT_MS, drop dead consts, fix dangling plan refs
Address the non-red-team documentation/cleanup items from review #1679:
- Document the GIT_SYNC_BACKEND_TIMEOUT_MS watchdog (git http-backend) in
  .env.example and add it to the environment validation schema — it was used
  (getGitSyncBackendTimeoutMs, default 120000) but undocumented/unvalidated.
- Remove the dead GIT_SYNC_DEBOUNCE_MS_DEFAULT / GIT_SYNC_POLL_INTERVAL_MS_DEFAULT
  exports (never imported; environment.service is the single source of defaults).
- Redirect the dangling `plan §X.Y` comment references to issue #194 (the
  git-sync spec moved there when docs/git-sync-plan.md was deleted by this PR).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:10:10 +03:00
claude code agent 227
d5079aa1d8 fix(git-sync): red-team hardening — 12 confirmed sync-breaking bugs + regression tests
A 10-agent red-team pass on the two-way Docmost<->git sync surfaced 16 ranked
findings (9 others triaged out as already-defended). Wrote a reproduction test
per finding (each asserts the CORRECT behavior, so it fails on the bug), then
fixed the production code so every repro goes green. All confirmed bugs:

Round-trip data loss (markdown-converter.ts + docmost-schema.ts mirror):
- #1 editor-ext node types silently dropped on export — ported the 8 missing
  canon nodes (footnoteReference/footnotesList/footnoteDefinition, htmlEmbed,
  status, pageEmbed, transclusionSource/Reference) into the git-sync schema
  mirror and added converter cases that emit their schema-matching HTML instead
  of flattening unknown nodes to '' (this was the critical data-loss flagged in
  review #1679: footnotes/htmlEmbed lost on sync). Snapshot surface updated.
- #2 top-level image lost width/height/align/attachmentId — now emits an HTML
  <img> (like video/diagrams) when it carries layout attrs; bare images stay
  ![](src). Image node parses width/height as strings so they re-import.
- #3 code block containing a ``` fence corrupted on round-trip — outer fence is
  now widened to (longest-inner-backtick-run + 1).
- #16 deep nesting threw RangeError (page never synced) — added a depth guard
  (MAX_NODE_DEPTH=400) so the converter never overflows the stack.

Push/layout/cycle (engine):
- #4 disambiguation ' ~slugId' suffix corrupted Docmost titles + order-dependent
  layout — deterministic, order-independent sibling disambiguation; suffix is
  stripped from a path-derived title ONLY when the new name is exactly the old
  title plus the suffix (never a genuine retitle ending in ' ~token').
- #6 retry-adopt by (parent,title) clobbered the wrong duplicate-title sibling —
  ambiguous (parent,title) is no longer adopted (falls back to fresh create).
- #12 a new child under a new parent was created at ROOT — creates are ordered
  parent-before-child with an in-memory created-id map for parent resolution.
- #13 git conflict markers could reach Docmost — bodies are scanned and the
  marker lines stripped (a '=======' line is only treated as a conflict
  separator inside a <<<<<<< ... >>>>>>> block, so setext headings are safe).
- #15 a divergent `docmost` mirror was escalated by runPush but dropped by
  runCycle — RunCycleResult now forwards divergentDocmost to the orchestrator.

Server (merge / lock / provenance):
- #9 3-way merge lost a human's block edit when git inserted an adjacent block —
  finer-grained diff3 region merge (via lcs) preserves non-overlapping human
  edits; genuine same-block conflicts still resolve git-wins.
- #10 single-writer race — module-static liveLocks closes the same-process TOCTOU
  window, and a heartbeat refresh that cannot confirm the lock now aborts the
  cycle at its next write checkpoint (cooperative AbortSignal threaded through
  runCycle). Cross-process fencing tokens remain a follow-up.
- #14 sticky-agent provenance overrode an explicit actor='git-sync' write,
  blinding the listener loop-guard — resolveSource now lets an explicit actor
  win over the sticky-agent fallback (explicit agent still wins).

Verified: git-sync vitest 617 pass (+1 expected-fail), server unit jest 1541
pass, server tsc clean. A review pass over the fixes caught and corrected a
title-suffix over-strip, an inert abort signal, a document-wide conflict-marker
strip, and two leaf-atom content-holes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:10:10 +03:00
claude code agent 227
b536a41ad3 chore(git-sync): drop stray build/ artifacts re-introduced during rebase
build/ is gitignored and compiled in CI/Docker; a few files leaked back into
the tree while replaying commits onto develop. Remove them so the package keeps
a single source of truth (src/).
2026-06-28 15:10:10 +03:00
claude_code
28d2560dfd fix(git-sync): address PR #119 review (#1571)
Resolve the code-review findings from comment #1571 on PR #119.

Engine (packages/git-sync):
- Idempotent CREATE on retry: before createPage, look the page up in the
  live Docmost tree by (parentPageId, title) and ADOPT it instead of
  duplicating when a prior cycle created it but failed to persist the
  pageId back to disk. Only trust a COMPLETE tree for the lookup; fall
  back to createPage otherwise. Covered by new tests incl. a complete=false
  regression-lock.
- Route applyPullActions diagnostics through an injected logger instead of
  bare console (thread log from the cycle).
- Add a timeout to the git execFile chokepoint (runRaw) so a hung git
  subprocess cannot wedge a sync cycle.
- Translate remaining Russian code comments to English.
- Remove dead standalone-CLI code (parseArgs/PushParsedArgs,
  parseSettings/envSchema, loadSettingsOrExit + config-errors.ts) and the
  matching index exports/specs; keep the Settings type.
- Fix the dangling docs link in package.json.
- Add a schema-surface snapshot guard so any drift in the vendored
  document schema is a loud, must-review CI failure (+ provenance header).

Server (apps/server):
- Add a configurable watchdog timeout to the spawned git http-backend so a
  stalled push cannot hold the per-space lock forever
  (GIT_SYNC_BACKEND_TIMEOUT_MS).
- Close the in-process TOCTOU window in SpaceLockService.withSpaceLock by
  reserving the slot synchronously before acquire.
- Add tests: removePage git-sync provenance (both branches), ensureServable
  force-push-protection git configs, and the phase-B+ datasource methods.

Docs / build:
- AGENTS.md: list git-sync as the fifth workspace package and note the
  three schema mirrors; fix the dangling git-sync-plan.md backlog link.
- pnpm-lock.yaml: add the missing @docmost/git-sync workspace link so
  pnpm install --frozen-lockfile (CI default) succeeds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:10:10 +03:00
claude code agent 227
52959de2f3 chore(mcp): drop build/ + node_modules leftovers after rebase
These files (build/lib/footnote-analyze.js, build/lib/footnote-lex.js from the
merged footnote work, and the y-prosemirror node_modules symlink) survived the
rebase because this branch's earlier "stop committing build/ and node_modules"
commit predated them. They are gitignored (packages/mcp/build/) and generated /
symlinked, so untrack them to keep the branch consistent with that decision.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:10:10 +03:00
claude code agent 227
5da12e89f9 refactor(git-sync): internalize the engine — first-class ESM, no vendoring bridge (#119 review)
Closes the architecture item from the #119 review: drop the "vendored from
docmost-sync" framing and the CJS↔ESM `Function('import()')` bridge so the engine
is a normal first-class gitmost package.

Part 1 — vendoring markers removed (prose only, zero behavior change): reworded
"VENDORED into gitmost" / "vendored from docmost-sync" / "Engine LOGIC is
byte-identical" / "it's a port" comments across the engine. Behavior-bearing
strings are untouched: BOT_AUTHOR_NAME/EMAIL and the `Docmost-Sync-Source:`
provenance trailers (changing them would break git authorship + the loop-guard).

Part 2 — the package is now ESM (matching the sibling @docmost/mcp): `type: module`,
tsconfig Node16, `.js` extensions on relative imports, and a static
`import { marked }` replacing the `new Function('return import(...)')` /
`loadMarked` hack — the bridge is GONE from the package. The CommonJS NestJS
server loads the now-ESM engine via a new `git-sync.loader.ts` that mirrors the
existing `docmost-client.loader.ts` mcp loader exactly (Function-indirected
dynamic import + cached promise + retry-on-reject). The 4 server consumers
(orchestrator/datasource/vault-registry/git-http-backend) call `await loadGitSync()`
for value exports; types stay `import type` (erased). The converter-gate spec —
which needs the real converter — loads the package's TS source via a jest
moduleNameMapper + isolatedModules (documented in that spec); the other git-sync
specs mock the loader.

Verified: engine builds pure ESM (no Function/require leftover), vitest 614,
editor-ext build, server + client tsc, full server jest 1397/0. Live stand
smoke-test: server starts clean on the ESM engine (no ERR_REQUIRE_ESM), a real
sync cycle runs through the loader, and the basic e2e suite is 12/12 (clone via
git-http-backend, push, pull, delete, 3-way merge — all through the new loader).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:10:10 +03:00
claude code agent 227
3a91e0eca9 test(git-sync): add missing DTO/User imports for the rebased git-sync provenance spec block
The rebase folded develop's agent-provenance PageService spec and the git-sync
provenance spec into one file; the appended git-sync block needs CreatePageDto /
UpdatePageDto / User imports that develop's spec (which used inline `as any`) did
not have. Server tsc + the suite (158 tests, both provenance blocks) green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:10:10 +03:00
claude code agent 227
2e83c9cebf fix(git-sync): git-http stream error handlers + close test gaps (#119 review)
Addresses the stability + test-coverage warnings from the #119 review:

- git-http-backend.service.ts: add `'error'` handlers to child.stdout/stderr. An
  EventEmitter 'error' with no listener (e.g. EPIPE when the client aborts
  mid-response) is rethrown by Node as an uncaught exception and crashes the
  process; now swallowed + logged (never echoed to the client).
- TEST INFRA: a jest setupFile shims `navigator`/`MessageChannel` for the `node`
  testEnvironment. react-dom@18 reads `navigator` at module-init (pulled in via
  @docmost/editor-ext -> @tiptap/react), so every spec transitively importing the
  conversion engine — including git-http.service.spec.ts — previously FAILED TO
  LOAD ("navigator is not defined") and ran ZERO tests. With the shim those specs
  now run (git-sync integration: 11 suites / 133 tests green).
- git-http.service.spec.ts: cover the 503 lock-held push path — `ingestExternalPush`
  rejecting `GitSyncLockHeldError` -> 503 + Retry-After + "git-sync busy, retry",
  no double header write (+ the already-headers-sent no-rewrite path).
- git-http-backend.service.spec.ts: unit-test run() — child 'error'/'close' before
  headers -> 500; normal CGI parse+stream; stdout/stderr 'error' (EPIPE) swallowed;
  synchronous spawn throw -> 500.
- page-change.listener.ts: implement OnModuleDestroy to clearTimeout all pending
  debounce timers on shutdown (+ test).
- .env.example: vaults are non-bare working repos, not "bare repos".

(Docs deleted by the stray commit were restored in 9cdbce54.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:10:10 +03:00
claude code agent 227
f6d22a59a6 fix(git-sync): screen non-page files out of PUSH (CRITICAL — review)
Self-review of phase 3 caught a data-corruption regression: nativeMeta always
supplies the run's spaceId, so the planner's 'create-without-spaceId' skip — which
had doubled as the only filter for non-page files — went dead. An ADDED
.obsidian/*.json, attachment, or dotfile (committed to the vault, no .gitignore)
would then be classified as a CREATE: a junk Docmost page, plus a gitmost_id
frontmatter written INTO the file, corrupting it.

Fix: isPageFile(path) — a .md file with NO dot-segment anywhere — and filter the
diff to page files at the very top of computePushActions, BEFORE any
classification, so non-page A/M/D/R are ignored (design §Адопция). 2 unit tests
pin it (.obsidian/json, attachment, dotfile, dot-segment, .md dotfile all ignored;
real pages still created). 614 engine tests green.

Also: refreshed stale docmost:meta comments to gitmost_id (review SUGGESTION), and
documented the deferred adoption frontmatter-preservation gap (review WARNING) in
page-file.ts + the design doc (do NOT roll native onto a real vault with Obsidian
properties until phase 4 round-trips them).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:10:10 +03:00
claude code agent 227
6baad935f9 docs(git-sync): mark thin-meta phases 2 + 3 done in the plan
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:10:10 +03:00
claude code agent 227
d255afa611 feat(git-sync): phase 3 — PUSH reads native gitmost_id + derives title/parent from path
PUSH now consumes the native-Obsidian format end-to-end:
- identity from the gitmost_id frontmatter (parsePageFile), not docmost:meta;
- title from the FILENAME, parentPageId from the enclosing folder's folder-note
  (parentFolderFile is now FOLDER-NOTE aware: a child's parent is dir/dir.md, and
  a folder-note's own parent is one level up), spaceId from the run (every vault
  file belongs to the vault's space);
- CREATE derives title/parent/space from path + run and writes the assigned
  pageId back as gitmost_id frontmatter (serializePageFile);
- UPDATE pushes the STRIPPED body (current + 3-way-merge base), so the frontmatter
  never leaks into Docmost content; the loop-guard hashes the body.

The PURE delete-sensitive classifier (computePushActions/classifyRenameMoves) is
UNCHANGED — only the injected IO resolvers (metaAt, parent, create write-back)
switched source. nativeMeta always carries the run spaceId, so the legacy
'create-without-spaceId' skip no longer fires through runPush.

Tests rewritten to native fixtures + folder-note parent paths; the noop case is
now a child under a renamed parent folder (filename=title, so a path-only-noop
needs an ancestor rename). parentFolderFile tests cover leaf/folder-note/nested/
dotted. 612 engine tests green; engine rebuilt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:10:10 +03:00
claude code agent 227
73c5c44301 feat(git-sync): phase 2b — PULL writes native gitmost_id frontmatter
PULL now serializes each page as the native-Obsidian format (serializePageFile:
a minimal gitmost_id frontmatter + the fixpoint markdown body) instead of the
heavy docmost:meta envelope. title/parent/space are derived (filename / folder /
repo), so only the pageId is persisted. readExisting recovers identity from the
gitmost_id frontmatter (parsePageFile) instead of docmost:meta.

Extracted stabilizePageBody() (the export->import->export fixpoint, no meta) so
the native writer and the legacy serializer share the same deterministic body —
re-pulls of an unchanged page stay byte-identical (loop-guard).

Tests: read-existing fixtures rewritten to gitmost_id; apply-pull asserts the
written text is native frontmatter and carries NO docmost:meta (regression
guard). 611 engine tests green.

NOTE: PUSH still reads docmost:meta — the end-to-end cycle is intentionally NOT
runnable until phase 3 (PUSH reads frontmatter + derives title/parent from path)
lands; no vault is wiped/deployed until then.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:10:10 +03:00
claude code agent 227
8c42c4f0d6 feat(git-sync): phase 2a — folder-note layout (parent -> Folder/Folder.md)
Native-Obsidian structure: a page WITH children now lives at its folder-note
<name>/<name>.md (LostPaul Folder Notes convention) with its children alongside;
a leaf stays <name>.md. Folder-notes claim their canonical path before a
same-named child, so the child (a leaf) is the one disambiguated, never the
folder-note — a folder X/ always contains its own note X.

Format-agnostic and safe in isolation: only the destination PATH changes, the
file content/serialization is untouched, so an existing parent relocates via the
move-by-id path (no delete). The frontmatter format flip (pull+push) is next.

6 new layout unit tests (leaf / parent / nested / child-named-as-parent /
twin-parents / childless). 611 engine tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:10:10 +03:00
claude code agent 227
071eae4e2a feat(git-sync): drop legacy docmost:meta back-compat (vaults wipe+rebuild)
Per owner: test data, no migration. parsePageFile no longer reads the old
docmost:meta block — a file without a gitmost_id frontmatter is simply un-tracked
(adopt). Vaults are a cache: rm -rf on the transition, rebuilt native from
Docmost. Simplifies the format work (no fallback). Doc updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:10:10 +03:00
claude code agent 227
a91405632e feat(git-sync): native-Obsidian format — phase 1 = page-file (frontmatter gitmost_id)
Pivot the thin-meta design to "the vault IS a native Obsidian vault": clean
markdown + a minimal YAML frontmatter `gitmost_id:` (the durable pageId, travels
with the file so identity survives any move); folders mirror the page tree with
the parent's body as a folder-note `<Folder>/<Folder>.md` (LostPaul Folder Notes
convention); links as `[[wikilinks]]` (basename-resolved → reparent never breaks a
link, only retitle does); collisions disambiguated Obsidian-style; `.obsidian/`
and non-page files left untouched (no .gitignore). Verified the conventions
against the Obsidian/Folder-Notes docs.

Replaces the abandoned `.gitmost/index.json` sidecar (path-keyed → fragile to
git-undetected renames; the in-file id is self-sufficient): removes vault-index.ts.
Adds lib/page-file.ts — parsePageFile/serializePageFile (frontmatter id + clean
body) with a LEGACY `docmost:meta` fallback for migration. 6 unit tests; engine
suite green. Not yet wired into pull/push — no behavior change. Design doc
rewritten to the native-Obsidian format.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:10:10 +03:00
claude code agent 227
5d4eb8ede2 docs(git-sync): thin-meta design — identity must travel with the file (B/C) + link-sync phase
Captures the design discussion: a path-keyed sidecar is NOT a safe source of
truth (a git-undetected rename loses the page), so the id must travel WITH the
file — either as a slugId suffix in the filename (B) or a minimal YAML frontmatter
`id:` (C); both robust, B/C is the open UX decision (author leans C for clean
names). The sidecar may remain an optional path->id cache. Adds phase 6 — link
sync between notes: Docmost links are by pageId (survive rename), vault markdown
links are by path (rewrite on rename, Obsidian-style); independent of B/C and the
format phases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:10:10 +03:00
claude code agent 227
aa1ee64b7a feat(git-sync): thin-meta phase 1 — the .gitmost/index.json sidecar module
Pure read/write/lookup for the vault sidecar index that will hold page identity
(pageId) + collision token (slugId) keyed by file path, so the .md files can be
clean markdown. parseVaultIndex is tolerant (missing/garbage/bad entries degrade
to empty/skipped — never crashes a cycle); serializeVaultIndex is deterministic
(sorted keys -> stable diffs, no churn). Lookups (pageIdAt, pathForPageId reverse,
trackedPageIds) + mutations (set/remove/move). NOT wired into pull/push yet — no
behavior change. 5 unit tests; engine suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:10:10 +03:00
claude code agent 227
53febfd5b9 docs(git-sync): design for thin meta + third-party-editor support
All service metadata moves into a single `.gitmost/index.json` sidecar; the `.md`
files become clean markdown (Obsidian & any editor work directly). The page tree
mirrors the folder structure (folder = parent page; the parent's body lives in
`<Folder>/index.md`); collisions disambiguate by a `~<slugId>` filename suffix
with identity tracked by pageId in the index (safe renames, never delete+create —
backed by 5133bb34). Bare files/folders from a third-party editor are adopted into
pages. Includes the migration path off the current `docmost:meta`-in-file format
and a phased plan (each phase gated by engine unit tests + the browser e2e +
isolated shell e2e). Agreed with the owner 2026-06-24.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:10:10 +03:00
claude code agent 227
a2ac08c04c test(git-sync): e2e suites provision a throwaway space — never touch real data
The shell e2e suites defaulted to the General space and created/edited pages
there, polluting real content (and, when several enabled spaces raised poll
contention, flaking on 503s). Now each suite creates its OWN throwaway,
git-sync-enabled space at setup, runs everything against it, and deletes the
space (+ its vault) on exit. Set SPACE_ID explicitly to opt into an existing
space. Also gives the basic suite the 503-retry push helper the advanced one
already had. Verified isolated: basic 12/12, advanced 23/23, no spaces/users/
pages left behind, the real space untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:10:10 +03:00
claude code agent 227
40ca04eb08 fix(git-sync): never trash a page whose pageId still exists in the tree (cross-cycle move) + browser e2e
Follow-up to 4376c5a6, found by a real BROWSER e2e (the flow the in-diff fix
missed). When the layout reshuffle's two halves land in SEPARATE sync cycles, the
later cycle's diff has only the DELETE of the old path — the matching add was
already pushed — so in-diff D+A coalescing can't see it, and the live page was
still trashed.

Robust fix on the identity invariant the reviewer (and the user) called out: a
page EXISTS iff its pageId is in the vault, regardless of filename. runPush now
collects the pageIds present at ANY path in the current `main` tree and passes
them to computePushActions; a deleted file whose pageId is still tracked
elsewhere is a MOVE, never a deletion. (Built only when the diff has deletes.)

Adds apps/server/test/git-sync-browser-e2e.cjs — a Playwright test that drives the
REAL Docmost web UI: log in, create several untitled pages, type a title, sync,
assert NOTHING is trashed. Reproduced the data loss before this fix; 5/5 green and
stable after. Engine suite 600 green (+2 computePushActions cases:
pageId-still-present -> skip; pageId-gone -> real delete).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:10:10 +03:00
claude code agent 227
393875d910 test(git-sync): e2e guard for the untitled-page + retitle data-loss reshuffle
Reproduces the browser bug at the API level: create several untitled pages (all
collapse to the `_` fallback name), retitle one, sync — assert NO page is
trashed and all survive. Caught the data-loss bug fixed in 4376c5a6.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:10:10 +03:00
claude code agent 227
c3dbee9fbf fix(git-sync): never trash a page that only MOVED (pageId-identity, not git rename heuristics) — data loss
CRITICAL data-loss bug: creating pages in Docmost (which start UNTITLED) and then
typing a title could soft-delete OTHER pages. Untitled pages all serialize to the
`_` fallback filename; the layout disambiguates them (`_.md`, `_ ~slug.md`).
Retitling one frees the bare `_` and another untitled page's file relocates into
it. git's rename detection (`-M`) can't see the move (the tiny meta-only files are
too dissimilar), so `git diff` reports it as DELETE(old) + ADD/MODIFY(new). The
push took the DELETE literally and trashed a live page.

Root cause is that the push trusted git's path-level rename heuristic for page
IDENTITY. Identity is the pageId. Fix: before emitting any delete, coalesce by
pageId — a pageId that is BOTH deleted (pre-image) AND present on the surviving
side (current meta of an ADD or a MODIFY, since a relocation into an occupied path
shows as M) is one page that MOVED, classified as a rename/move and NEVER a delete.

Reproduced + verified on a live stand: 4 untitled pages + retitle one trashed a
different page before; after the fix, retitling one (and stress-retitling all)
trashes nothing. Engine suite 598 green; 3 new computePushActions cases (ghost
D+A move -> rename; real delete still deletes; unrelated D+A stay delete+update).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:10:10 +03:00
claude code agent 227
ea1f8da906 test(git-sync): basic e2e operates on a dedicated page + cleans up (no real-page pollution)
The push / 3-way-merge cases edited the FIRST real `.md` in the vault, leaving
`E2E-PUSH-*` / `E2E-MERGE-*` marker headings accumulating in a real page, and the
Docmost->git case left its created page in the Trash. Now the suite creates a
dedicated `E2E-SyncTarget-*` page and targets only that, and a teardown
hard-deletes every `E2E-*` fixture page and converges the vault on exit — so runs
never mutate real content and leave the stand clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:10:10 +03:00
claude code agent 227
9baaf1ea58 test(git-sync): add advanced e2e suite — authz, protocol hardening, concurrency, data-loss guard
Output of a generate→critique subagent pass on "what the feature's tests do NOT
cover", implemented + verified against the live stand (20/20). Complements the
basic two-way suite. Covers:

- protocol shape: unknown service subpath -> 400; unknown content-type -> 415
  (global allowlist); PUT/DELETE on pack endpoints -> 400;
- path-traversal: `..%2f..`, `%2e%2e%2f`, bare `.git` space-id -> 400/404, no
  escape, never a file leak;
- authz boundaries: a gitSync-DISABLED space -> 404 (existence hidden) and flips
  to 200 when enabled; a READER member can fetch (200) but is FORBIDDEN to push
  (403); a NON-member of an enabled space gets 403 (NOT 404 — the critic caught a
  wrong generator assumption here; pinned as a contract);
- concurrency: a push while the per-space Redis lock is held -> 503 + Retry-After,
  and the receive-pack does NOT mutate the vault;
- idempotency: repeated no-op cycles never churn `main` / `refs/docmost/last-pushed`;
- data-loss guard (PR #119): deleting MORE than GIT_SYNC_MAX_DELETES_PER_CYCLE is
  HELD — none trashed AND last-pushed does not advance past the delete commit
  (retry-safe, not silently dropped).

Auto-creates/tears down its fixtures (reader/non-member users, a 2nd space) and
resets the vault cache on exit so re-runs and the basic suite stay green. Needs
the vault dir + Redis container reachable (see header). A structural rename/move
case was intentionally left to the engine unit suite (git rename-similarity on
meta-only fixture pages is a fixture artifact, not a feature bug).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:10:10 +03:00
claude code agent 227
71375e25ee chore(git-sync): drop now-unused dirname import (PR #119 review)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:10:10 +03:00
claude code agent 227
e528988d71 test(git-sync): add a live two-way smart-HTTP e2e suite
A runnable end-to-end suite that drives a LIVE git-sync stand over the real /git
remote — the integration counterpart to the unit tests. 10 checks across the full
feature:
- the auth/authz gate: no creds -> 401, wrong password -> 401, unknown space ->
  404 (existence never revealed), valid creds on a sync space -> 200;
- fetch: git clone over HTTP returns the vault markdown;
- push: a git-side edit propagates into the Docmost page;
- Docmost -> git: a page created via the API materializes as a vault file;
- delete: `git rm` + push soft-deletes the Docmost page (Trash);
- 3-way merge: a new git edit is added without clobbering prior page content.

Parameterized via env (SERVER/SPACE_ID/EMAIL/PASSWORD/DB_CONTAINER) and isolates
its own test page. It boots nothing — see the header for the stand prerequisites
(GIT_SYNC_ENABLED + a per-space gitSync flag + a service user). This is the suite
that caught the smart-HTTP PATH_INFO 404 bug.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:10:10 +03:00
claude code agent 227
dc7a0ec9f5 refactor(git-sync): move the PULL->PUSH cycle into the engine as runCycle (PR #119 review, arch #1)
The reconcile choreography (ensureRepo -> merge-check -> ensureBranch ->
checkout('docmost') -> pull -> push) was hand-rolled in the app orchestrator's
driveCycle, duplicating an order the vendored engine owns and could drift from on
upgrade — the failure mode is data clobber. Lift it into @docmost/git-sync as a
single entry point, `runCycle(deps)`. The orchestrator now calls runCycle and
keeps only the lock (its caller) and the gitmost-specific delete-cap POLICY,
injected as the `resolveApplyClient` hook (the engine does the dry-run, hands the
hook the planned delete count — Infinity if planning failed — and uses whatever
client it returns for the apply). driveCycle drops from ~150 lines to ~30.

Tests:
- engine test/cycle.test.ts: composition (merge-in-progress short-circuit;
  ensureRepo->ensureBranch->checkout staging order before the pull; the cap hook
  is consulted with the planned count; no dry-run when no hook).
- engine test/cycle-roundtrip.test.ts: runCycle against a REAL VaultGit in a temp
  repo with a faked Docmost client — a git-originated CREATE flows pull->push and
  the assigned pageId is written back; an unresolved merge short-circuits before
  any client call.
- orchestrator spec rewired to mock runCycle and assert the wiring + the
  resolveApplyClient cap policy (the engine-internal cycle-order/merge tests moved
  to the engine).

Validated end to end on a live stand (real Postgres/Redis + server): a git clone
-> edit -> push over the /git remote round-trips the change into the Docmost page
through the refactored cycle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:10:10 +03:00
claude code agent 227
969c00aaf1 fix(git-sync): drop the .git suffix from git http-backend PATH_INFO (smart-HTTP 404)
The /git smart-HTTP host 404'd EVERY fetch and push: PATH_INFO was built as
`/<spaceId>.git/<subpath>`, so `git http-backend` resolved the repo at
`<GIT_PROJECT_ROOT>/<spaceId>.git` — which does not exist. The vault is a NON-bare
working repo (the engine needs a working tree) at `<dataDir>/<spaceId>`, so the
CGI repo path must be `<spaceId>` (git http-backend serves the `.git` inside).
The URL's conventional `.git` suffix is already stripped to `spaceId` by
parseGitPath; re-appending it for PATH_INFO was the bug.

Found by standing up a full e2e stand (real Postgres/Redis + server + a real git
clone/push over the /git remote): clone and push both 404'd until this fix, after
which a clone → edit → push round-trips the change all the way into the Docmost
page.

Also extracts the CGI-env construction into a pure, exported `buildGitBackendCgiEnv`
and adds unit tests (the env build was previously untested — the gap this bug hid
in): a regression guard pinning PATH_INFO to `/<spaceId>/<subpath>` (no `.git`),
plus method/query/content-type/remote-user forwarding and the conditional
GIT_PROTOCOL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:10:10 +03:00
claude code agent 227
085a30575f test(git-sync): cover ingestExternalPush in the orchestrator spec (PR #119 review)
Closes the test-coverage warning that the smart-HTTP push ingest path was
unexercised. Adds 5 cases: receive-pack streams BEFORE the Docmost cycle; a
held lock throws GitSyncLockHeldError and runs neither the receive-pack nor the
cycle; a post-push cycle error is swallowed (the push is durable, poll retries)
while the lock is still released; a missing service user runs the receive-pack
but skips the immediate cycle; and a globally-disabled git-sync refuses without
touching the lock.

(The 503/Retry-After mapping in git-http.service is the sibling warning; its spec
is in the repo's pre-existing set of jest suites that can't load locally via the
react-dom/tiptap transform chain, so that case is left for CI.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:10:10 +03:00
claude code agent 227
95bc9fe98d refactor(git-sync): extract SpaceLockService from the orchestrator (PR #119 review, arch #2)
The per-space single-writer lock — Redis CAS leader lock (SET NX PX, DEL-CAS and
PEXPIRE-CAS Lua), the in-process mutex, the per-process instanceId and the
heartbeat — lived inline in GitSyncOrchestrator. Extract it into a dedicated
@Injectable() SpaceLockService exposing one narrow surface, withSpaceLock(spaceId,
fn), so the lock is the orchestrator's only Redis-lock touch-point and is testable
in isolation. The orchestrator now injects SpaceLockService and both consumers
(runOnce, ingestExternalPush) go through spaceLock.withSpaceLock — behavior
unchanged (same sentinel returns, same 503-on-lock-held contract). Orchestrator
drops 591→472 lines.

Adds space-lock.service.spec.ts asserting the lock SEMANTICS against a fake Redis
(the test-coverage warning from the review): the SET NX/PX args, the DEL-CAS and
PEXPIRE-CAS Lua + ARGV[1]=instanceId, plus the lock-held / in-progress / throw-
still-releases paths. The orchestrator spec is unchanged in count and stays green
(it now builds the real SpaceLockService over its mock Redis).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:10:10 +03:00
claude code agent 227
cca0bfe306 docs(git-sync): remove dangling references to the deleted git-sync-plan doc (PR #119 review)
The implementation spec docs/git-sync-plan.md was removed as completed, but ~44
code comments still cited it as "plan §N". Strip those citations (comments only),
keeping each comment grammatical. The vendored engine's own "SPEC §N" references
point at a different, still-present spec and are left untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:10:10 +03:00
claude code agent 227
0dbf85b129 refactor(git-sync): drop dead DebounceEntry.workspaceId field (PR #119 review)
The debounce map value carried `workspaceId`, but the scheduled cycle closes over
the `workspaceId` argument directly — the field was written and never read.
Replace the entry struct with `Map<string, NodeJS.Timeout>` (the timer handle is
all the map tracks). No behavior change. (page-change.listener.spec is in the
repo's pre-existing set of jest suites that can't load locally via the
react-dom/tiptap transform chain — unaffected by this change; tsc clean.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:10:10 +03:00
claude code agent 227
fb357cd52e refactor(git-sync): extract shared buildLcsTable for the two block diffs (PR #119 review)
The two-way block diff (yjs-body-merge.diffBlocks) and the three-way merge
planner (three-way-merge.lcsPairs) built the identical backward-filled LCS DP
table inline. Extract it to lcs.ts (buildLcsTable); each caller keeps its own
traceback. No behavior change — merge specs unchanged and green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:10:10 +03:00
claude code agent 227
177d8a31d4 fix(git-sync): hold refs on suppressed deletes + stamp delete/restore provenance (PR #119 review)
Two stability warnings from the #119 review:

1. delete-cap no longer drops deletions forever. When planned deletes exceed
   GIT_SYNC_MAX_DELETES_PER_CYCLE the apply client's deletePage now THROWS
   instead of resolving to a no-op. A throw is recorded by the engine as a
   per-page failure, so `refs/docmost/last-pushed` is NOT advanced past the
   commit that dropped the files — the next cycle re-diffs from the un-advanced
   ref and re-plans the same deletes (a transient over-cap is retried, not
   silently dropped and then recreated by the next pull). Previously a resolving
   no-op let the engine count `deleted++` with no failure, advance the ref, and
   never replay the deletions.

2. git-sync soft-delete and restore now stamp provenance. deletePage routes
   GIT_SYNC_PROVENANCE through pageService.removePage, and restorePage stamps
   lastUpdatedSource='git-sync' on the restore update — so the page-change
   listener's loop-guard (skip when lastUpdatedSource==='git-sync') recognizes
   both as its own writes instead of scheduling a wasted echo cycle. Done via a
   backward-compatible optional `lastUpdatedSource` param on
   pageRepo.removePage/restorePage (omitted for ordinary user deletes/restores).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:10:10 +03:00
claude code agent 227
8fa32e8438 docs(git-sync): document GIT_SYNC_* env vars; fix stale/non-English comments (PR #119 review)
Addresses the documentation/convention warnings from the #119 review:
- .env.example: add the GIT-SYNC block (9 GIT_SYNC_* vars with defaults), noting
  GIT_SYNC_SERVICE_USER_ID is required when sync is enabled.
- yjs-body-merge.ts: translate the Russian review note in the docstring to
  English (comments-only-in-English rule).
- persistence.extension.ts: correct the stale "git-sync writes are full-body
  replaces" rationale — a git-sync write is now a block-level merge into the live
  doc, which is why it is debounced like a human edit rather than snapshotted.
- history-item.tsx: the GitSyncBadge version is created on the PUSH path (writing
  the git body back into the doc), not by the pull — fix the comment.
- edit-space-form.tsx: log the raw error in the git-sync toggle catch instead of
  swallowing it (AGENTS.md).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:10:10 +03:00
claude code agent 227
807ff1f5f5 chore(mcp): stop committing build/ and node_modules; build in CI/Docker
Same hygiene fix as git-sync (review #2), applied to packages/mcp which had the
identical pre-existing problem: committed build/ (20 files) + node_modules (28,
pnpm symlinks with a baked /home/claude store path).

- git rm --cached packages/mcp/{build,node_modules}.
- .gitignore: add packages/mcp/build/ (packages/*/node_modules/ already covers it).
- Build where consumed: apps/server `pretest` and the CI Test workflow now build
  @docmost/mcp too. The Dockerfile builder already runs `pnpm build` (nx builds
  mcp) and already COPYs packages/mcp/build into the runtime image.

Verified: wiped build/, rebuilt via `pnpm --filter @docmost/mcp build`; the mcp
server suites (96 tests) pass against the freshly-built, non-committed output.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:10:10 +03:00
claude code agent 227
fa89cba023 feat(git-sync): three-way body merge using the last-synced base (no edit loss)
Upgrades the 2-way body merge to a real diff3 three-way merge (review #5), so a
block ONLY the human changed is KEPT when git changed a DIFFERENT block — the
2-way merge would revert it to git's stale version.

Engine: the push update loop reads the last-synced pre-image
(`git.showFileAtRef(refs/docmost/last-pushed, path)`) and passes it as the
optional `baseMarkdown` to `client.importPageMarkdown` (the common ancestor).

Server: gitmost-datasource converts base+incoming, and writeBody runs a block-
level diff3 (new three-way-merge.ts `diff3Plan`): live-only change -> keep live,
git-only change -> take git, both-changed -> git wins (conflict policy), inserts/
deletes from either side preserved. Without a base (createPage) it falls back to
the 2-way merge. Crash-safety unchanged (docs built before the connection opens).

Tests: three-way-merge.spec.ts (14 — every diff3 case incl. the cross-block
preservation and conflict policy), yjs-body-merge 3-way (real Y.Docs: human's
block instance preserved while git's block is applied), plus an engine test that
the base is forwarded from showFileAtRef. Existing push assertions updated for the
new base arg. git-sync 589 pass; server merge/datasource/gate 62 pass; typecheck
clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:09:57 +03:00
claude code agent 227
3386bf2865 fix(git-sync): merge git body into the live doc block-by-block (no clobber)
Supersedes the active-session "defer" guard with a real merge (review #5 —
"запись делать через мерж", not skip-while-editing).

writeBody no longer does delete-all + re-insert (which discarded a concurrent
editor's in-flight changes on every sync). It now diffs the live body against the
incoming git body at TOP-LEVEL BLOCK granularity (LCS over a canonical structural
serialization) and applies only the minimal inserts/deletes:
- a block a human is editing is left UNTOUCHED when git changed a DIFFERENT block;
- an unchanged resync is a complete 0-op write;
- Yjs CRDT-merges the minimal ops with concurrent edits.

New yjs-body-merge.ts (mergeXmlFragments + cloneXmlNode + diffBlocks) is pure-Yjs
and unit-tested with real Y.Docs (8 tests): identical->0 ops, edit-one-block keeps
the other block instances, append/delete keep neighbours, marks survive the
cross-doc clone. Crash-safety kept: the incoming doc is built before the
connection opens, so a transform failure can't empty the body.

Removed: the ActiveEditSessionError defer path and the now-unused
CollaborationGateway.getActiveEditorCount.

Honest limitation: this is a 2-way merge — for a block BOTH sides changed since the
last sync, git wins (no common ancestor to decide). A full 3-way merge would need
the last-synced base plumbed from the engine; the dominant cases (unchanged
resync, edits to different blocks) are now lossless.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:09:57 +03:00
claude code agent 227
98253cf614 chore(git-sync): stop committing build/ and node_modules; build in CI/Docker
Review finding #2: packages/git-sync/build/ (the COMPILED engine) and the
package's node_modules/ were committed. Prod executed the committed build/ while
CI/tests ran src/ and never rebuilt it — so a fix in src/ could pass tests while
stale compiled code shipped (a silent src/prod skew). The committed node_modules
were pnpm symlinks with a baked machine-local store path (/home/claude/...),
useless and misleading for everyone else.

- git rm --cached packages/git-sync/{build,node_modules} (42 + 31 files).
- .gitignore: ignore packages/*/node_modules/ and packages/git-sync/build/.
- Build the package where it is actually consumed: apps/server `pretest` now
  builds @docmost/git-sync (its suite imports the built build/index.js), and the
  CI Test workflow gains an explicit "Build git-sync" step. The Dockerfile builder
  already runs `pnpm build` (nx builds the package) and now COPYs the fresh build/.

Verified: wiped build/, rebuilt via `pnpm --filter @docmost/git-sync build`, then
the server converter gate (26/26, imports the rebuilt package) and the git-sync
suite (588 passed) both pass against the freshly-built, non-committed output.

NOTE: packages/mcp/ has the same committed-build/node_modules pattern (pre-existing,
out of this PR's scope) and should get the same treatment in a follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:09:57 +03:00
claude code agent 227
181a8330f3 fix(git-sync): don't clobber pages with a live editing session; crash-safe body write
Review finding #5: the git -> page body write (writeBody) did a full-body replace
(delete-all + re-insert) on the shared Yjs doc. Applied while a human is editing
the page, it discarded their in-flight changes; and TiptapTransformer.toYdoc ran
AFTER the fragment was cleared, so a conversion failure could leave the page with
an empty body.

Fixes:
- Active-session guard: CollaborationGateway.getActiveEditorCount(documentName)
  reports live human (websocket) editor sessions for a doc, excluding server-side
  direct connections. writeBody now throws ActiveEditSessionError when an editor
  is connected. The engine's push loop already isolates each importPageMarkdown in
  try/catch and does not advance the loop-guard on failure, so the write is simply
  retried on the next poll once the editor disconnects — never a clobber.
- Crash-safe conversion: build the replacement Yjs update BEFORE opening the
  connection / clearing the fragment, so a transform failure can never leave the
  body empty.

Also updates the server-side converter gate spec to the corrected round-trip
shape: the block-image hoist no longer leaves a leading empty paragraph (the
git-sync converter fix in 7d39c16b, now reaching the built package).

A true merge of git content into a live Yjs session is out of scope (it needs a
real 3-way text merge with no shared update lineage); deferring the write while a
page is being edited is the safe, owner-approved minimum.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:09:57 +03:00
claude code agent 227
02daccc453 fix(docker): ship packages/git-sync into the runtime image
The server requires @docmost/git-sync (main: ./build/index.js) at runtime, but
the installer stage copied only editor-ext and mcp — so the image built fine and
then crashed on startup with `Cannot find module '@docmost/git-sync'`. Copy the
package's freshly-built build/ + package.json, mirroring the mcp/editor-ext COPY
lines. (Addresses review finding #1 on PR #119.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:09:57 +03:00
claude code agent 227
d06cf97ed6 test(git-sync): exhaustive converter coverage + fix 3 round-trip data-loss bugs
Coder↔reviewer design loop (9 rounds, reviewer verdict: exhaustive) produced
92 specs; implemented +123 tests (465 -> 588 passing). The new round-trip
coverage exposed three genuine data-loss bugs in the Markdown<->ProseMirror
converter, all now FIXED (round-trip is lossless for these):

1. pageBreak was lost on export (no converter case -> rendered to "" and the
   node vanished). Now emits <div data-type="pageBreak"></div>, which the schema
   parses back -> round-trips.
2. A block image between blocks left an empty <p> artifact after import-hoisting,
   producing a phantom blank-gap diff on every sync. markdownToProseMirror now
   strips content-less paragraphs after generateJSON — with a schema-validity
   guard that keeps the obligatory single empty paragraph in `content: "block+"`
   containers (tableCell/tableHeader/blockquote/column/callout/doc), so empty
   cells/quotes never become an invalid `content: []`.
3. The `code` mark combined with another mark was not byte-stable (emitted nested
   HTML that the schema's `code` `excludes:"_"` collapsed on import). The
   converter now emits code-only when `code` co-occurs, matching the editor.

New coverage spans media/diagram/details/columns/math/mention attribute
round-trips, converter emission branches, git error paths, and engine decision
branches. A dedicated test pins the empty-container schema validity (the review
catch on the bug-2 fix).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:09:57 +03:00
claude_code
04032ae677 feat(git-sync): serve spaces over smart-HTTP (gitmost as a two-way git host)
Expose each git-sync-enabled space as a clonable/pushable git repo over HTTP,
so `git clone https://<user>:<pass>@<host>/git/<spaceId>.git` works and external
pushes flow back into Docmost pages — gitmost itself acts as the git host (no
external GitHub/Gitea, no SSH).

Transport: shell out to `git http-backend` (CGI; git is already in the runtime
image) which implements the full smart-HTTP protocol (info/refs, upload-pack,
receive-pack, protocol v2). A raw Fastify route `/git/*` (mounted at the root,
outside the `/api` prefix) bridges the request/response to the CGI; passthrough
content-type parsers for the git media types stream the raw body to stdin.

Reuse the existing engine: clients push the vault's `main` branch, whose commits
beyond `refs/docmost/last-pushed` the engine already reconciles into Docmost.

- http/git-http.service.ts — auth (HTTP Basic -> AuthService.verifyUserCredentials),
  self-resolved workspace (DomainMiddleware does not run for this raw route),
  per-space gating (global + per-space gitSync flags, 404 hides existence),
  CASL authz (Read=fetch, Manage=push), dispatch.
- http/git-http-backend.service.ts — spawn `git http-backend`, binary-safe CGI
  response parsing (Status/headers/body), stream to the socket.
- http/git-http.helpers.ts — pure path parse, service->kind mapping, gate decision
  (unit-tested); rejects literal and percent-encoded path traversal.
- orchestrator: extract reusable withSpaceLock (CAS-guarded lock heartbeat so a
  long push cannot let the lock expire mid-cycle) and add ingestExternalPush
  (receive-pack + Docmost cycle under one lock; 503 on contention).
- vault-registry: ensureServable() — ensureRepo + idempotent receive.denyCurrentBranch
  =updateInstead / denyNonFastForwards / http.receivepack / http.uploadpack.
- env: GIT_SYNC_HTTP_ENABLED (defaults to GIT_SYNC_ENABLED) + validation.
- main.ts: register the /git/* route and the git content-type parsers.

Tests: pure helpers, CGI parsing, and the GitHttpService handler (auth/gate/authz
+ workspace resolution). Server tsc + git-sync/env suites green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:09:57 +03:00
claude_code
d9d1d54aaa test(git-sync): add reviewer-requested coverage across engine, server, client
Implements the test cases called out in the PR #119 review threads
(code-review, test-strategy report, red-team) — TESTS ONLY, no production
code changes.

packages/git-sync (vitest):
- lib converter/markdown gaps: pageBreak data-loss (it.fails repro),
  subpages lossy round-trip, nested/fenced callouts, ol->taskList bridge,
  column.width number<->string drift, empty details.
- engine units: parentFolderFile, planReconciliation swap/chained move,
  buildVaultLayout last-resort-by-id, firstDivergence, applyPushActions /
  applyPullActions failure isolation.
- real temp-git integration: diffNameStatus -z rename+add/modify
  alignment, copy-line behavior, per-invocation committer identity (no
  leak into repo/global config).
- ENFORCED type-level GitSyncClient contract via vitest typecheck over a
  *.test-d.ts file (tsconfig.vitest.json; build tsconfig untouched).

apps/server (jest):
- orchestrator: delete-cap neutralization + fail-safe, Redis lock / mutex
  skip ladder + release-on-throw, merge guard, pull/push order, remote
  template substitution, poll lifecycle.
- page-change listener: loop-guard, debounce coalescing, id resolution,
  error swallowing.
- vault registry, controller authz (trigger + status), env
  validation/getters, page.service git-sync provenance stamping,
  persistence precedence (agent > git-sync > user) + no boundary snapshot,
  space.service audit-delta, space.repo jsonb-merge, converter-gate corpus
  extension (mention/math/details/marks).

apps/client (vitest + testing-library):
- history-item git-sync badge: render gating + non-clickable.
- edit-space-form toggle: initial state, optimistic payload, rollback on
  error, disabled states.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:09:57 +03:00
claude code agent 227
593f181bbc fix(git-sync): address review — configurable poll, always-on loop-guard, cleanup
Comprehensive-review follow-ups (APPROVE WITH SUGGESTIONS; no critical issues):
- poll interval is now actually configurable: replaced the hardcoded
  @Interval('git-sync-poll', 15000) with a dynamic SchedulerRegistry interval
  registered in onModuleInit from getGitSyncPollIntervalMs() (cleared in
  onModuleDestroy); /status and the real cadence now share one config source.
  Boots logging 'poll interval registered (Nms)'.
- loop-guard now ALWAYS applies: the lastUpdatedSource==='git-sync' skip was
  nested inside the !spaceId/!workspaceId branch, so structural self-writes
  (CREATE/MOVE/RESTORE/SOFT_DELETE, which carry spaceId+workspaceId) bypassed it
  and re-triggered cycles. Fetch the page row once, guard unconditionally, then
  resolve space/workspace.
- remove the dead PAGE_CONTENT_UPDATED subscription (it's a BullMQ job, never an
  EventEmitter event; body edits arrive via PAGE_UPDATED).
- fix the stale datasource comment (PageService DOES stamp 'git-sync' now).
- env getters: parseInt radix 10 + NaN/<=0 fallback for poll/debounce (+ max
  deletes), with 6 new environment.service.spec tests.

tsc clean; jest 723 pass; live cycle re-verified post-refactor (ran, push
applied, unflagged 92-page space untouched).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:09:57 +03:00
claude code agent 227
582e1976cc feat(git-sync): client 'Git sync' provenance badge + git in runtime image (Phase D)
- page-history history-item: a lastUpdatedSource==='git-sync' version renders a
  neutral gray 'Git sync' badge (git-merge icon), NOT the agent badge/deep-link
  (it is not an agent edit). +2 i18n keys.
- Dockerfile: install git in the installer (runtime) stage — VaultGit shells out
  to git, so assertGitAvailable() needs the binary at runtime.
Client tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:09:57 +03:00
claude code agent 227
e0e01157c2 feat(git-sync): per-space 'Enable Git sync' toggle (Phase C, §7.1)
UI opt-in for git-sync, mirroring the existing sharing/comments settings pattern
(no new endpoint, no new mechanism; orchestrator read query untouched):
- UpdateSpaceDto.gitSyncEnabled?: boolean.
- SpaceRepo.updateGitSyncSettings: jsonb-merge into settings.gitSync.<key>
  (COALESCE || jsonb_build_object — never clobbers sibling sharing/comments);
  stored as a real jsonb boolean so the orchestrator's
  settings->'gitSync'->>'enabled' = 'true' matches.
- SpaceService.updateSpace handles the flag (audit diff) via the existing
  CASL-guarded space update path (Manage/Settings).
- client: Switch in edit-space-form (optimistic mutate + revert-on-error,
  readOnly-aware) + space types + 2 i18n keys.
- space.service.spec extended (calls updateGitSyncSettings; no-op when undefined).
tsc clean (server+client); jest src/core/space 4 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:09:57 +03:00
claude code agent 227
8373360a67 fix(git-sync): branch choreography + strict scoping + delete cap (Phase B hardening)
Fixes found by the live pull/push e2e:
- CRITICAL: driveCycle never checked out the 'docmost' branch before
  applyPullActions, so Docmost content was written straight onto 'main',
  clobbering local file edits before push could diff them. Now checkout
  'docmost' before pull (applyPullActions commits there then checks out main +
  merges) — mirrors the engine's pull main(). Round-trip now works both ways.
- add an unresolved-merge guard (SPEC §9): skip the cycle if the vault is
  mid-merge instead of failing on checkout.
- SAFETY: enabledSpaces() is now STRICT opt-in — only spaces with
  settings.gitSync.enabled===true; removed the all-spaces fallback that synced
  every space (incl. a 92-page one) the moment GIT_SYNC_ENABLED flipped.
- SAFETY: per-cycle delete cap (GIT_SYNC_MAX_DELETES_PER_CYCLE, default 5):
  dry-run the push, and if planned deletes exceed the cap, run the apply with
  deletePage neutralized — phantom absence-deletions from a non-convergent vault
  can't soft-delete real pages. Fails safe if the dry-run throws.
- fix manual trigger: TriggerGitSyncDto.spaceId needs @IsUUID or the global
  whitelist ValidationPipe strips it (arrived undefined -> vault 'undefined').

Live-verified on an isolated flagged space: push (vault file edit -> Docmost
content, stamped lastUpdatedSource='git-sync') and pull (Docmost rename -> vault
file + meta) both work; an unrelated 92-page space stayed untouched throughout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:09:57 +03:00
claude code agent 227
e2493cafa9 feat(git-sync): GitSyncModule orchestrator + config + listener (Phase A.4b/B)
Control plane wiring (plan §5-§11):
- PageService create/update/movePage now honor provenance actor 'git-sync'
  (stamp lastUpdatedSource='git-sync'), closing the A.4a gap.
- EnvironmentService: GIT_SYNC_ENABLED / DATA_DIR / REMOTE_TEMPLATE /
  POLL_INTERVAL_MS / DEBOUNCE_MS / SERVICE_USER_ID (required-if-enabled) /
  SSH_KEY_PATH + validation.
- VaultRegistryService: per-space vault path + cached VaultGit.
- GitSyncOrchestrator: per-space Redis leader-lock (SET NX PX + CAS-Lua release,
  randomUUID instanceId) + in-process mutex; runOnce drives the vendored engine
  PULL (readExisting->computePullActions->applyPullActions) then PUSH (runPush)
  with the bound native GitSyncClient + VaultGit; @Interval poll-safety gated on
  GIT_SYNC_ENABLED; imports plain ScheduleModule (TelemetryModule owns forRoot).
- PageChangeListener: @OnEvent PAGE_* -> per-space debounce -> runOnce, with a
  best-effort lastUpdatedSource==='git-sync' loop-guard.
- GitSyncController: admin POST /api/git-sync/trigger + GET /status (ops/e2e).
- GitSyncModule registered in app.module. Enabled-space enumeration uses
  settings.gitSync.enabled, falling back to all live spaces until Phase C writes
  the flag (master gate = GIT_SYNC_ENABLED).

tsc clean; 713 tests/71 suites pass; dev server hot-reloaded the module (route
live, DI graph boots). Live pull/push round-trip verified next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:09:57 +03:00
claude code agent 227
5a4d9f84d7 feat(git-sync): native GitmostDataSource + 'git-sync' provenance (Phase A.4a)
Native data plane for git-sync (plan §3, §8.1):
- provenance: widen actor to 'user'|'agent'|'git-sync' (jwt-payload,
  auth-provenance decorator); PersistenceExtension resolves lastUpdatedSource
  with precedence agent > git-sync > user, debounced history (like a human edit,
  not the agent's immediate snapshot).
- GitmostDataSourceService implements @docmost/git-sync's GitSyncClient natively:
  reads via PageRepo/SpaceRepo (listSpaceTree complete:true, getPageJson), writes
  via PageService (create/removePage soft-delete/movePage with computed fractional
  position/update-rename/restore) + the writeBody linchpin through collab
  openDirectConnection('page.'+id, {actor:'git-sync'}) mirroring
  collaboration.handler withYdocConnection 'replace'. bind({workspaceId,userId})
  returns the context-bound client for the orchestrator.
- 10 unit/contract tests (mapping + soft-delete + move-position), tsc clean.

Known gap (closed in A.4b): PageService.create/update/movePage only branch on
actor==='agent'; git-sync provenance is already passed through so the row source
marker propagates once PageService honors 'git-sync'. Module/orchestrator/config
come next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:09:57 +03:00
claude code agent 227
70bd0dba4d feat(git-sync): vendor IO engine (pull/push/git/settings) with GitSyncClient seam (Phase A.3)
Vendor the IO engine from docmost-sync into packages/git-sync/src/engine:
- git.ts (VaultGit, execFile shell-out — verbatim)
- pull.ts (readExisting, computePullActions, applyPullActions)
- push.ts (classifyRenameMoves, computePushActions, applyPushActions, runPush)
- settings.ts adapted (pure parseSettings + Settings type; no process.env binding
  — the server builds Settings from EnvironmentService later), config-errors.ts.
CLI main()/import.meta entrypoints dropped (server drives in-process).

Client seam: new engine/client.types.ts defines GitSyncClient; pull.ts/push.ts
now use Pick<GitSyncClient, ...> instead of the non-vendored DocmostClient. Engine
logic byte-identical except a zod4-compat fix in config-errors (zod4 dropped the
issue.received==='undefined' signal; match /received undefined/ on the message).

Ported the engine unit tests (compute/apply pull+push actions, classify-rename-
moves, run-push, settings, config-errors) incl. real-git temp-repo tests: 431
pass / 3 expected-fail (was 314/3). REST/CLI-coupled upstream tests skipped
(noted). CJS build clean. No apps/server wiring yet (next step).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:09:57 +03:00
claude code agent 227
b0cd4bd6cf 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>
2026-06-28 15:09:57 +03:00
claude code agent 227
56ab17fbc2 feat(git-sync): vendor pure converter + engine into @docmost/git-sync (Phase A.1)
First step of docs/git-sync-plan.md. New workspace package @docmost/git-sync
vendoring the PURE parts from docmost-sync (HEAD b03eb35):
- lib: markdown-converter, markdown-document, canonicalize, docmost-schema,
  node-ops, diff, and an extracted markdown-to-prosemirror (only the pure
  marked->HTML->generateJSON path from upstream collaboration.ts; no websocket).
- engine (pure, no IO): reconcile, layout, sanitize, stabilize, loop-guard.
Ported the upstream pure-module + round-trip corpus tests (vitest): 314 pass,
3 expected upstream known-limitation fails. tsc clean. No server wiring yet.

docmost-schema inlines getStyleProperty (as packages/mcp does — @tiptap/core
3.20.4 doesn't export it). IO engine (pull/push/git/settings) deferred to later
Phase A/B steps; the editor-ext idempotency gate (plan §13.1) is the next step.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:09:57 +03:00
299 changed files with 36753 additions and 17661 deletions

View File

@@ -203,3 +203,45 @@ MCP_DOCMOST_PASSWORD=
# FAILS CLOSED if Redis is unavailable (default: 1,000,000 tokens per workspace # FAILS CLOSED if Redis is unavailable (default: 1,000,000 tokens per workspace
# per rolling day). # per rolling day).
# SHARE_AI_WORKSPACE_TOKEN_BUDGET_PER_DAY=1000000 # 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=
#
# SCAFFOLDING for the DEFERRED remote-push feature (SPEC §7) — NOT yet
# implemented and currently INERT. The vendored sync engine does not consume
# this value anywhere (git push to a remote is deferred), so setting it has NO
# effect today: vaults remain local-only regardless. It is validated and carried
# only so the wiring is ready for when remote push lands. The intended future
# shape is a per-space URL template where the literal "{spaceId}" is substituted
# per space (e.g. git@host:vault-{spaceId}.git).
# GIT_SYNC_REMOTE_TEMPLATE=
#
# 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
#

View File

@@ -68,6 +68,13 @@ jobs:
- name: Build editor-ext - name: Build editor-ext
run: pnpm --filter @docmost/editor-ext build 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 - name: Run unit tests
run: pnpm -r test run: pnpm -r test

6
.gitignore vendored
View File

@@ -5,6 +5,12 @@ data
# compiled output # compiled output
/dist /dist
/node_modules /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
logs logs

View File

@@ -182,7 +182,7 @@ tea issues create --repo vvzvlad/gitmost --labels feature \
## Monorepo layout ## Monorepo layout
pnpm workspace (`pnpm@10.4.0`) orchestrated by **Nx**. Four workspace packages: pnpm workspace (`pnpm@10.4.0`) orchestrated by **Nx**. Five workspace packages:
| Path | Name | Stack | Role | | Path | Name | Stack | Role |
| --- | --- | --- | --- | | --- | --- | --- | --- |
@@ -190,6 +190,7 @@ pnpm workspace (`pnpm@10.4.0`) orchestrated by **Nx**. Four workspace packages:
| `apps/client` | `client` | React 18 + Vite + Mantine 8 + TanStack Query + Jotai | SPA frontend | | `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/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/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`. `build` targets are Nx-cached and dependency-ordered (`dependsOn: ["^build"]`), so `editor-ext` builds before the apps. `nx.json` sets `affected.defaultBase: main`.
@@ -243,8 +244,10 @@ Migration files live in `apps/server/src/database/migrations/` and are named `YY
The API server is a Fastify app with a global `/api` prefix (`main.ts` excludes `robots.txt`, public share pages, and `mcp` from the prefix). A `preHandler` hook enforces that a resolved `workspaceId` exists for most `/api` routes (multi-tenant by hostname/subdomain via `DomainMiddleware`). Auth is JWT (cookie + bearer); authorization is **CASL** (`core/casl`) — every data access is scoped to the user's abilities. The API server is a Fastify app with a global `/api` prefix (`main.ts` excludes `robots.txt`, public share pages, and `mcp` from the prefix). A `preHandler` hook enforces that a resolved `workspaceId` exists for most `/api` routes (multi-tenant by hostname/subdomain via `DomainMiddleware`). Auth is JWT (cookie + bearer); authorization is **CASL** (`core/casl`) — every data access is scoped to the user's abilities.
Two routes are mounted **outside** the `/api` prefix at the root, as raw Fastify routes that bypass the Nest pipeline (so neither `DomainMiddleware` nor `ThrottlerGuard` runs for them — each resolves the workspace and throttles itself): `/mcp` (the embedded MCP server, see below) and `/git/<spaceId>.git/...` (the git-sync smart-HTTP host, see below). Both share `mcp-auth.helpers.ts` (HTTP-Basic parsing, `FailedLoginLimiter`, `clientIp`) and the common `resolveRequestWorkspace` helper.
### Module structure (server) ### Module structure (server)
`AppModule` wires integration modules (`integrations/*`: storage [local/S3/Azure], mail, queue [BullMQ on Redis], security, telemetry, throttle, `mcp`, `ai`) plus `CoreModule`, `DatabaseModule`, and `CollaborationModule`. `CoreModule` (`core/*`) holds the domain modules: `page`, `space`, `comment`, `workspace`, `user`, `auth`, `group`, `attachment`, `search`, `share`, `ai-chat`, etc. Each domain module follows NestJS controller → service → repo layering; DB repos live under `database/repos` and are injected app-wide from the global `DatabaseModule`. `AppModule` wires integration modules (`integrations/*`: storage [local/S3/Azure], mail, queue [BullMQ on Redis], security, telemetry, throttle, `mcp`, `ai`, `git-sync`) plus `CoreModule`, `DatabaseModule`, and `CollaborationModule`. `CoreModule` (`core/*`) holds the domain modules: `page`, `space`, `comment`, `workspace`, `user`, `auth`, `group`, `attachment`, `search`, `share`, `ai-chat`, etc. Each domain module follows NestJS controller → service → repo layering; DB repos live under `database/repos` and are injected app-wide from the global `DatabaseModule`.
**EE removal artifact:** `app.module.ts` still contains a `try/require('./ee/ee.module')` stub. That path no longer exists, so the require fails and is swallowed (it only hard-exits when `CLOUD === 'true'`). Treat EE as gone — do not add code that depends on it. **EE removal artifact:** `app.module.ts` still contains a `try/require('./ee/ee.module')` stub. That path no longer exists, so the require fails and is swallowed (it only hard-exits when `CLOUD === 'true'`). Treat EE as gone — do not add code that depends on it.
@@ -260,10 +263,16 @@ The API server is a Fastify app with a global `/api` prefix (`main.ts` excludes
- `core/ai-chat/embedding/` — RAG indexer + a BullMQ consumer on `AI_QUEUE` that embeds pages into `page_embeddings` (vector search), complementing Postgres full-text search. Pages are (re)indexed on edit; `AI_EMBEDDING_TIMEOUT_MS` bounds a hung embeddings endpoint. - `core/ai-chat/embedding/` — RAG indexer + a BullMQ consumer on `AI_QUEUE` that embeds pages into `page_embeddings` (vector search), complementing Postgres full-text search. Pages are (re)indexed on edit; `AI_EMBEDDING_TIMEOUT_MS` bounds a hung embeddings endpoint.
- `core/ai-chat/external-mcp/` — admins can attach external MCP servers (e.g. Tavily) to give the agent web access. **`ssrf-guard.ts` validates outbound MCP URLs against SSRF** — keep that guard in the path when touching external-MCP connection logic. - `core/ai-chat/external-mcp/` — admins can attach external MCP servers (e.g. Tavily) to give the agent web access. **`ssrf-guard.ts` validates outbound MCP URLs against SSRF** — keep that guard in the path when touching external-MCP connection logic.
### Git-sync (native two-way Docmost ↔ git Markdown sync)
`integrations/git-sync/` (`GitSyncModule`) + the vendored pure engine in `packages/git-sync`. Off by default; gated by the `GIT_SYNC_ENABLED` master switch (and `GIT_SYNC_SERVICE_USER_ID`, the account git-originated writes are attributed to). Per-space opt-in via `space.settings.gitSync.enabled`, with a second per-space toggle `space.settings.gitSync.autoMergeConflicts` that changes PUSH behavior for a still-conflicted page (one carrying `<<<<<<<`/`>>>>>>>` markers): **off (the safe default)** records a per-page failure and holds the refs so the user resolves the git conflict first (markers never reach Docmost); **on** strips the marker lines and pushes both sides' content. Each enabled space gets an on-disk working "vault" repo; the `GitSyncOrchestrator` runs a debounced + poll-backstop reconcile cycle (PULL Docmost→vault, PUSH vault→Docmost) under a per-space Redis leader lock + in-process mutex (`SpaceLockService`). Writes go through the collaboration layer (so concurrent human edits aren't clobbered) and are stamped `lastUpdatedSource = 'git-sync'` for the listener loop-guard. The in-process `setInterval` orchestration + best-effort lock (no fencing tokens) is a known multi-replica limitation — BullMQ + fencing is the documented future direction.
- **`/git` smart-HTTP host** (`integrations/git-sync/http/`, gated additionally by `GIT_SYNC_HTTP_ENABLED`, which defaults to `GIT_SYNC_ENABLED`): a raw root-mounted Fastify route `/git/<spaceId>.git/...` (registered in `main.ts`, NOT under `/api`) that bridges `git clone`/`fetch`/`push` to `git http-backend`. It authenticates HTTP Basic against `AuthService` (throttled by a `FailedLoginLimiter` mirroring the `/mcp` path), authorizes via `SpaceAbilityFactory` (read = fetch, Manage = push), and gates existence so a non-member gets the SAME 404 as a missing/sync-disabled space (never 403 — that would leak space existence). A push runs the receive-pack under the space lock, then a reconcile cycle.
- **Schema mirror:** `packages/git-sync/src/lib/docmost-schema.ts` is one of the **three** hand-synced copies of the Tiptap document schema (see Client structure) — keep it in lockstep with `editor-ext` (canonical) and `packages/mcp`.
### Client structure ### 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: 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. - **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 `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. - 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.
- API access goes through `apps/client/src/lib/api-client.ts` (axios). The `@` alias maps to `apps/client/src`. - 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`. - 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`.

View File

@@ -12,6 +12,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- **Native two-way Docmost ↔ git Markdown sync.** Opt-in per space (Space
settings → a git-sync toggle, plus an `autoMergeConflicts` toggle that controls
whether a still-conflicted page is held back or pushed with its conflict
markers stripped): each enabled space is mirrored to an on-disk git "vault" of
Markdown files and reconciled in both directions (Docmost → vault and vault →
Docmost) on a debounced + poll-backstop cycle, under a per-space lock, writing
through the collaboration layer so concurrent human edits aren't clobbered.
Git-originated changes are attributed to a configurable service account and
carry a "git-sync" provenance badge in page history. Optionally exposes a `/git`
smart-HTTP host so you can `git clone`/`fetch`/`push` a space directly (HTTP
Basic auth, space-permission authorized). Off by default and configured via the
`GIT_SYNC_*` environment variables, including `GIT_SYNC_ENABLED`,
`GIT_SYNC_SERVICE_USER_ID`, and `GIT_SYNC_HTTP_ENABLED` (see `.env.example`).
(#119)
- **Quick-create regular and temporary notes from the Home and Space screens.** - **Quick-create regular and temporary notes from the Home and Space screens.**
The Home screen now shows a second action next to "New note" that creates a The Home screen now shows a second action next to "New note" that creates a
*temporary* note (one that auto-moves to Trash after the workspace lifetime), *temporary* note (one that auto-moves to Trash after the workspace lifetime),

View File

@@ -17,8 +17,9 @@ RUN pnpm build
FROM base AS installer FROM base AS installer
# git: required by the git-sync VaultGit (shells out to git)
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends curl bash \ && apt-get install -y --no-install-recommends curl bash git \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
@@ -38,6 +39,14 @@ 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/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/build /app/packages/mcp/build
COPY --from=builder /app/packages/mcp/package.json /app/packages/mcp/package.json COPY --from=builder /app/packages/mcp/package.json /app/packages/mcp/package.json
# git-sync: the server loads @docmost/git-sync at runtime via the loader
# (git-sync.loader.ts), which deliberately does NOT `require()` it — the package is
# ESM-only, so the loader uses `require.resolve` + a dynamic `import()`. Without
# these copied build artifacts that resolve/import fails and the server crashes on
# first use. 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 root package files
COPY --from=builder /app/package.json /app/package.json COPY --from=builder /app/package.json /app/package.json

View File

@@ -1217,6 +1217,8 @@
"Ran tool {{name}}": "Ran tool {{name}}", "Ran tool {{name}}": "Ran tool {{name}}",
"AI-agent": "AI-agent", "AI-agent": "AI-agent",
"Edited by AI agent on behalf of {{name}}": "Edited by AI agent on behalf of {{name}}", "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", "Endpoints": "Endpoints",
"where we fetch models": "where we fetch models", "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.", "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.",
@@ -1241,6 +1243,10 @@
"MCP server": "MCP server", "MCP server": "MCP server",
"expose the workspace": "expose the workspace", "expose the workspace": "expose the workspace",
"Enable MCP server": "Enable MCP server", "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.",
"Auto-merge conflicts on push": "Auto-merge conflicts on push",
"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.": "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.",
"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.", "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}}", "Resolves to {{url}}": "Resolves to {{url}}",
"Model": "Model", "Model": "Model",

View File

@@ -0,0 +1,37 @@
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>
);
}

View File

@@ -0,0 +1,227 @@
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");
});
});

View File

@@ -1,6 +1,7 @@
import { Text, Group, UnstyledButton, Avatar, Tooltip } from "@mantine/core"; import { Text, Group, UnstyledButton, Avatar, Tooltip } from "@mantine/core";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { AiAgentBadge } from "@/components/ui/ai-agent-badge.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 { formattedDate } from "@/lib/time";
import classes from "./css/history.module.css"; import classes from "./css/history.module.css";
import clsx from "clsx"; import clsx from "clsx";
@@ -41,6 +42,7 @@ const HistoryItem = memo(function HistoryItem({
const contributors = historyItem.contributors; const contributors = historyItem.contributors;
const hasContributors = contributors && contributors.length > 0; const hasContributors = contributors && contributors.length > 0;
const isAgentEdit = historyItem.lastUpdatedSource === "agent"; const isAgentEdit = historyItem.lastUpdatedSource === "agent";
const isGitSyncEdit = historyItem.lastUpdatedSource === "git-sync";
return ( return (
<UnstyledButton <UnstyledButton
@@ -108,6 +110,10 @@ const HistoryItem = memo(function HistoryItem({
onActivate={() => setHistoryModalOpen(false)} onActivate={() => setHistoryModalOpen(false)}
/> />
)} )}
{isGitSyncEdit && (
<GitSyncBadge authorName={historyItem.lastUpdatedBy?.name} />
)}
</Group> </Group>
</UnstyledButton> </UnstyledButton>
); );

View File

@@ -0,0 +1,240 @@
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);
});
});

View File

@@ -1,5 +1,14 @@
import { Group, Box, Button, TextInput, Stack, Textarea } from "@mantine/core"; import {
import React from "react"; Group,
Box,
Button,
TextInput,
Stack,
Textarea,
Divider,
Switch,
} from "@mantine/core";
import React, { useState } from "react";
import { useForm } from "@mantine/form"; import { useForm } from "@mantine/form";
import { zod4Resolver } from "mantine-form-zod-resolver"; import { zod4Resolver } from "mantine-form-zod-resolver";
import { z } from "zod/v4"; import { z } from "zod/v4";
@@ -29,6 +38,37 @@ export function EditSpaceForm({ space, readOnly }: EditSpaceFormProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const updateSpaceMutation = useUpdateSpaceMutation(); const updateSpaceMutation = useUpdateSpaceMutation();
const [gitSyncEnabled, setGitSyncEnabled] = useState<boolean>(
space?.settings?.gitSync?.enabled ?? false,
);
const [autoMergeConflicts, setAutoMergeConflicts] = useState<boolean>(
space?.settings?.gitSync?.autoMergeConflicts ?? false,
);
// One parameterized handler for both git-sync space toggles: they differ only by
// the local state setter, the mutation payload field, and the error label. The
// update is optimistic and reverts the local state on failure (the mutation
// surfaces a toast via onError; the raw error is still logged per AGENTS.md).
const handleToggle = async (
field: "gitSyncEnabled" | "autoMergeConflicts",
value: boolean,
previous: boolean,
setLocal: (next: boolean) => void,
errorLabel: string,
) => {
setLocal(value); // optimistic update
try {
await updateSpaceMutation.mutateAsync({
spaceId: space.id,
[field]: value,
});
} catch (err) {
setLocal(previous); // revert on failure
console.error(errorLabel, err);
}
};
const form = useForm<FormValues>({ const form = useForm<FormValues>({
validate: zod4Resolver(formSchema), validate: zod4Resolver(formSchema),
initialValues: { initialValues: {
@@ -104,6 +144,43 @@ export function EditSpaceForm({ space, readOnly }: EditSpaceFormProps) {
</Group> </Group>
)} )}
</form> </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) =>
handleToggle(
"gitSyncEnabled",
event.currentTarget.checked,
gitSyncEnabled,
setGitSyncEnabled,
"Failed to toggle git-sync for space",
)
}
/>
<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) =>
handleToggle(
"autoMergeConflicts",
event.currentTarget.checked,
autoMergeConflicts,
setAutoMergeConflicts,
"Failed to toggle git-sync auto-merge-conflicts",
)
}
/>
</Box> </Box>
</> </>
); );

View File

@@ -13,9 +13,15 @@ export interface ISpaceCommentsSettings {
allowViewerComments?: boolean; allowViewerComments?: boolean;
} }
export interface ISpaceGitSyncSettings {
enabled?: boolean;
autoMergeConflicts?: boolean;
}
export interface ISpaceSettings { export interface ISpaceSettings {
sharing?: ISpaceSharingSettings; sharing?: ISpaceSharingSettings;
comments?: ISpaceCommentsSettings; comments?: ISpaceCommentsSettings;
gitSync?: ISpaceGitSyncSettings;
} }
export interface ISpace { export interface ISpace {
@@ -35,6 +41,8 @@ export interface ISpace {
// for updates // for updates
disablePublicSharing?: boolean; disablePublicSharing?: boolean;
allowViewerComments?: boolean; allowViewerComments?: boolean;
gitSyncEnabled?: boolean;
autoMergeConflicts?: boolean;
} }
interface IMembership { interface IMembership {

View File

@@ -23,7 +23,7 @@
"migration:reset": "tsx src/database/migrate.ts down-to NO_MIGRATIONS", "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", "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", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"pretest": "pnpm --filter @docmost/editor-ext build", "pretest": "pnpm --filter @docmost/editor-ext build && pnpm --filter @docmost/git-sync build && pnpm --filter @docmost/mcp build",
"test": "jest", "test": "jest",
"test:int": "jest --config test/jest-integration.json", "test:int": "jest --config test/jest-integration.json",
"test:watch": "jest --watch", "test:watch": "jest --watch",
@@ -41,6 +41,7 @@
"@aws-sdk/s3-request-presigner": "3.1050.0", "@aws-sdk/s3-request-presigner": "3.1050.0",
"@azure/storage-blob": "12.31.0", "@azure/storage-blob": "12.31.0",
"@clickhouse/client": "^1.18.2", "@clickhouse/client": "^1.18.2",
"@docmost/git-sync": "workspace:*",
"@docmost/mcp": "workspace:*", "@docmost/mcp": "workspace:*",
"@docmost/pdf-inspector": "1.9.6", "@docmost/pdf-inspector": "1.9.6",
"@fastify/cookie": "^11.0.2", "@fastify/cookie": "^11.0.2",
@@ -188,7 +189,12 @@
] ]
} }
], ],
"^.+\\.(t|j)sx?$": "ts-jest" "^.+\\.(t|j)sx?$": [
"ts-jest",
{
"isolatedModules": true
}
]
}, },
"transformIgnorePatterns": [ "transformIgnorePatterns": [
"/node_modules/(?!(\\.pnpm/)?(nanoid|uuid|image-dimensions|marked|happy-dom|lib0)(@|/))" "/node_modules/(?!(\\.pnpm/)?(nanoid|uuid|image-dimensions|marked|happy-dom|lib0)(@|/))"
@@ -198,11 +204,17 @@
], ],
"coverageDirectory": "../coverage", "coverageDirectory": "../coverage",
"testEnvironment": "node", "testEnvironment": "node",
"setupFiles": [
"<rootDir>/../test/jest.setup.ts"
],
"moduleNameMapper": { "moduleNameMapper": {
"^@docmost/db/(.*)$": "<rootDir>/database/$1", "^@docmost/db/(.*)$": "<rootDir>/database/$1",
"^@docmost/transactional/(.*)$": "<rootDir>/integrations/transactional/$1", "^@docmost/transactional/(.*)$": "<rootDir>/integrations/transactional/$1",
"^@docmost/ee/(.*)$": "<rootDir>/ee/$1", "^@docmost/ee/(.*)$": "<rootDir>/ee/$1",
"^src/(.*)$": "<rootDir>/$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"
} }
} }
} }

View File

@@ -28,6 +28,7 @@ import { ClsModule } from 'nestjs-cls';
import { NoopAuditModule } from './integrations/audit/audit.module'; import { NoopAuditModule } from './integrations/audit/audit.module';
import { ThrottleModule } from './integrations/throttle/throttle.module'; import { ThrottleModule } from './integrations/throttle/throttle.module';
import { McpModule } from './integrations/mcp/mcp.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 { AiModule } from './integrations/ai/ai.module';
import { AiChatModule } from './core/ai-chat/ai-chat.module'; import { AiChatModule } from './core/ai-chat/ai-chat.module';
@@ -89,6 +90,7 @@ try {
TelemetryModule, TelemetryModule,
ThrottleModule, ThrottleModule,
McpModule, McpModule,
GitSyncModule,
AiModule, AiModule,
AiChatModule, AiChatModule,
...enterpriseModules, ...enterpriseModules,

View File

@@ -149,6 +149,45 @@ export class CollaborationGateway {
return this.hocuspocus.openDirectConnection(documentName, context); 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 *Can be used before calling openDirectConnection directly
*/ */

View File

@@ -0,0 +1,262 @@
// 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('FLUSHES the pending debounced store BEFORE merging so an in-flight edit survives (finding #2)', async () => {
// QA #119 finding #2: the 3-way merge must run against the latest live-doc
// state. A concurrent UI edit that is still in-flight (the store is debounced)
// must be drained into the live doc BEFORE git merges, or git clean-applies and
// the edit is silently dropped — even on a DIFFERENT block. Model the drain via
// the pending-store flush: when it runs, the in-flight block-0 edit lands.
const shared = new Y.Doc();
const frag = shared.getXmlFragment('default');
shared.transact(() => {
frag.insert(
0,
[
{ text: 'alpha', id: 'p1' },
{ text: 'beta', id: 'p2' },
].map((s) => {
const el = new Y.XmlElement('paragraph');
el.setAttribute('id', s.id);
const t = new Y.XmlText();
t.insert(0, s.text);
el.insert(0, [t]);
return el;
}),
);
});
const order: string[] = [];
const debouncer = {
isDebounced: jest.fn(() => true),
executeNow: jest.fn(async () => {
order.push('flush');
// The in-flight client edit to block 0 only lands once the pending store
// is flushed (i.e. the event loop is drained) — BEFORE the merge.
shared.transact(() =>
((frag.get(0) as Y.XmlElement).get(0) as Y.XmlText).insert(5, ' EDIT'),
);
}),
};
const openDirectConnection = jest.fn(async () => ({
transact: async (fn: (doc: Y.Doc) => void) => {
order.push('merge');
fn(shared);
},
disconnect: jest.fn(async () => undefined),
}));
const hocuspocus = { openDirectConnection, debouncer } as any;
const handler = new CollaborationHandler();
await handler.getHandlers(hocuspocus).gitSyncWriteBody('page.x', {
prosemirrorJson: pmDoc('alpha', 'beta2'), // git changes block 1
baseProsemirrorJson: pmDoc('alpha', 'beta'),
userId: 'svc-user',
});
// The flush ran, and it ran BEFORE the merge transaction.
expect(debouncer.executeNow).toHaveBeenCalledTimes(1);
expect(order).toEqual(['flush', 'merge']);
// Both the in-flight block-0 edit and git's block-1 change survive — the
// pre-flush bug would have produced ['alpha', 'beta2'] (UI edit dropped).
expect(texts(shared.getXmlFragment('default'))).toEqual([
'alpha EDIT',
'beta2',
]);
});
it('does not flush when no store is pending (isDebounced false)', async () => {
const { hocuspocus, shared } = fakeHocuspocus([{ text: 'a', id: 'p1' }]);
const executeNow = jest.fn();
(hocuspocus as any).debouncer = {
isDebounced: jest.fn(() => false),
executeNow,
};
const handler = new CollaborationHandler();
await handler.getHandlers(hocuspocus).gitSyncWriteBody('page.x', {
prosemirrorJson: pmDoc('a', 'b'),
userId: 'svc-user',
});
expect(executeNow).not.toHaveBeenCalled();
expect(texts(shared.getXmlFragment('default'))).toEqual(['a', 'b']);
});
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']);
});
});

View File

@@ -8,6 +8,10 @@ import {
import { setYjsMark, updateYjsMarkAttribute, YjsSelection } from './yjs.util'; import { setYjsMark, updateYjsMarkAttribute, YjsSelection } from './yjs.util';
import * as Y from 'yjs'; import * as Y from 'yjs';
import { User } from '@docmost/db/types/entity.types'; import { User } from '@docmost/db/types/entity.types';
import {
mergeXmlFragments,
mergeXmlFragments3WayWithStats,
} from './merge/yjs-body-merge';
export type CollabEventHandlers = ReturnType< export type CollabEventHandlers = ReturnType<
CollaborationHandler['getHandlers'] CollaborationHandler['getHandlers']
@@ -112,9 +116,130 @@ 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;
// CONCURRENT-EDIT FLUSH (QA #119, finding #2). The 3-way merge below runs
// against the LIVE Y.Doc, so a concurrent UI edit is only preserved if it
// is already part of that doc. A user's edit is debounced before it lands
// (the editor batches; the collab store is debounced up to 10s), so the
// merge could otherwise run against a PRE-EDIT doc: git would then
// clean-apply (no same-block conflict detected) and the in-flight UI edit
// — even on a DIFFERENT block — would be silently dropped.
//
// Flushing the pending debounced store here (a) drains the event loop so a
// just-arrived client Yjs update is applied to the live doc BEFORE we
// merge, and (b) persists the live doc so the merge baseline is current
// even on the doc-reload-from-DB path. After the flush the merge sees the
// latest state, so an edit on a different block is MERGED (not overwritten)
// and a genuine same-block edit is detected as a conflict -> the
// boundary-snapshot in PersistenceExtension pins it to page history
// (recoverable) instead of vanishing silently.
await this.flushPendingStore(hocuspocus, documentName);
// 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) {
const { conflicts } = mergeXmlFragments3WayWithStats(
liveFrag,
targetFrag,
baseDoc.getXmlFragment('default'),
);
// SAME-BLOCK conflict contract (SPEC §9): a block both the human
// and git changed resolves to GIT (deterministic). Make that
// OBSERVABLE rather than silent — log it. The losing human content
// is NOT destroyed: the persistence extension's boundary snapshot
// pins the pre-merge page state to history on this user->git-sync
// transition, so it stays recoverable.
if (conflicts > 0) {
this.logger.warn(
`git-sync merge for ${documentName}: ${conflicts} same-block ` +
`conflict(s) resolved to the git version; the prior page ` +
`state is preserved in page history (recoverable).`,
);
}
} else {
mergeXmlFragments(liveFrag, targetFrag);
}
},
);
},
}; };
} }
/**
* Flush any pending DEBOUNCED store for `documentName` so the live Y.Doc and the
* DB are current BEFORE a git-sync merge reads them (QA #119, finding #2 —
* concurrent UI edit silently lost). Mirrors the PersistenceExtension.onDisconnect
* flush: only acts when a store is actually pending (`isDebounced`), runs the
* SAME scheduled payload (`executeNow`, preserving the edit's context/actor), and
* never throws — a flush failure must not abort the git-sync write. Awaiting it
* also drains the event loop, so a client Yjs update sitting in the socket buffer
* is applied to the live doc before the merge transaction runs.
*/
private async flushPendingStore(
hocuspocus: Hocuspocus,
documentName: string,
): Promise<void> {
const debounceId = `onStoreDocument-${documentName}`;
try {
const debouncer = (hocuspocus as any)?.debouncer;
if (!debouncer?.isDebounced?.(debounceId)) return;
await debouncer.executeNow(debounceId);
} catch (err) {
this.logger.warn(
`git-sync pre-merge flush failed for ${documentName}: ` +
(err instanceof Error ? err.message : String(err)),
);
}
}
async withYdocConnection( async withYdocConnection(
hocuspocus: Hocuspocus, hocuspocus: Hocuspocus,
documentName: string, documentName: string,

View File

@@ -0,0 +1,89 @@
import { PersistenceExtension } from './persistence.extension';
/**
* Regression for the QA #119 "loss-on-fast-close" data loss: editing a page then
* closing the tab within the collab debounce window (~3-18s) lost the edit
* because, with `unloadImmediately: false`, Hocuspocus does NOT flush the
* debounced onStoreDocument on a last-client disconnect. PersistenceExtension
* now flushes the pending store on the LAST disconnect (and only then).
*/
describe('PersistenceExtension.onDisconnect flush (loss-on-fast-close)', () => {
function makeExt(): PersistenceExtension {
// onDisconnect touches none of the injected deps; pass casts.
return new PersistenceExtension(
null as any,
null as any,
null as any,
null as any,
null as any,
null as any,
null as any,
null as any,
);
}
function makeData(opts: {
clientsCount: number;
isDebounced: boolean;
isLoading?: boolean;
}) {
const executeNow = jest.fn(async () => undefined);
const isDebounced = jest.fn(() => opts.isDebounced);
return {
executeNow,
isDebounced,
payload: {
clientsCount: opts.clientsCount,
context: {},
document: { isLoading: opts.isLoading ?? false } as any,
documentName: 'page.abc',
instance: { debouncer: { isDebounced, executeNow } } as any,
requestHeaders: {},
requestParameters: new URLSearchParams(),
socketId: 's',
} as any,
};
}
it('flushes the pending store when the LAST client disconnects', async () => {
const ext = makeExt();
const { executeNow, payload } = makeData({
clientsCount: 0,
isDebounced: true,
});
await ext.onDisconnect(payload);
expect(executeNow).toHaveBeenCalledTimes(1);
expect(executeNow).toHaveBeenCalledWith('onStoreDocument-page.abc');
});
it('does NOT flush while other editors remain connected', async () => {
const ext = makeExt();
const { executeNow, payload } = makeData({
clientsCount: 2,
isDebounced: true,
});
await ext.onDisconnect(payload);
expect(executeNow).not.toHaveBeenCalled();
});
it('does NOT write when nothing is pending (already persisted)', async () => {
const ext = makeExt();
const { executeNow, payload } = makeData({
clientsCount: 0,
isDebounced: false,
});
await ext.onDisconnect(payload);
expect(executeNow).not.toHaveBeenCalled();
});
it('does NOT flush a doc that is still loading (load error guard)', async () => {
const ext = makeExt();
const { executeNow, payload } = makeData({
clientsCount: 0,
isDebounced: true,
isLoading: true,
});
await ext.onDisconnect(payload);
expect(executeNow).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,223 @@
// 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');
});
// --- boundary snapshot for a git-sync store over a HUMAN baseline -----------
// SPEC §9 observable-loss guard (bug #2): a git-sync body write is a block-level
// 3-way merge whose same-block rule is "git wins". To keep a concurrent human
// edit RECOVERABLE rather than silently overwritten, a git-sync store over a
// prior NON-git-sync baseline pins that prior state to page history first —
// exactly like the agent path. So saveHistory MUST be called here.
it('DOES pin a boundary snapshot for a git-sync store over a prior human state', async () => {
const { ext, pageHistoryRepo } = build({ lastUpdatedSource: 'user' });
await ext.onStoreDocument(
makeStorePayload({ user: { id: 'svc-user' }, actor: 'git-sync' }),
);
expect(pageHistoryRepo.saveHistory).toHaveBeenCalledTimes(1);
});
// --- negative: a git-sync store over a git-sync baseline does NOT re-pin -----
// The boundary is pinned once on the transition INTO git-sync; a subsequent
// git-sync store over an already-git-sync baseline must not churn history.
it('does NOT re-pin a boundary snapshot for a git-sync store over a git-sync baseline', async () => {
const { ext, pageHistoryRepo } = build({ lastUpdatedSource: 'git-sync' });
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();
});
});

View File

@@ -2,6 +2,7 @@ import {
afterUnloadDocumentPayload, afterUnloadDocumentPayload,
Extension, Extension,
onChangePayload, onChangePayload,
onDisconnectPayload,
onLoadDocumentPayload, onLoadDocumentPayload,
onStoreDocumentPayload, onStoreDocumentPayload,
} from '@hocuspocus/server'; } from '@hocuspocus/server';
@@ -52,7 +53,17 @@ export function resolveSource(
stickyTouched: boolean, stickyTouched: boolean,
contextActor?: string, contextActor?: string,
): ProvenanceSource { ): ProvenanceSource {
return stickyTouched || contextActor === 'agent' ? 'agent' : 'user'; // 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';
} }
/** /**
@@ -154,6 +165,40 @@ export class PersistenceExtension implements Extension {
return new Y.Doc(); return new Y.Doc();
} }
/**
* LOSS-ON-FAST-CLOSE FIX (QA #119). When the LAST editor disconnects, FLUSH any
* pending (debounced) store to the DB IMMEDIATELY instead of waiting out the
* up-to-10s `debounce` window.
*
* The collab server runs with `unloadImmediately: false` (collaboration.gateway),
* so on a last-client disconnect Hocuspocus does NOT flush the debounced
* onStoreDocument — it relies on the timer firing later. A quick edit-then-close
* (closing the tab within the debounce window, ~3-18s) therefore left the edit
* only in the soon-to-be-unloaded in-memory Y.Doc; meanwhile git-sync mirrored
* the STALE/empty DB body to the vault (the reported "59-byte frontmatter-only"
* data loss). Running the already-scheduled store now closes that window.
*
* Gated tightly so it never adds a redundant write: only on the LAST disconnect
* (`clientsCount === 0`), only for a fully-loaded doc, and only when a store is
* actually pending (`isDebounced`). `executeNow` runs the SAME payload Hocuspocus
* scheduled (preserving the edit's context/actor) and clears the timer.
*/
async onDisconnect(data: onDisconnectPayload) {
const { instance, document, documentName, clientsCount } = data;
if (clientsCount > 0) return;
if (!document || document.isLoading) return;
const debounceId = `onStoreDocument-${documentName}`;
if (!instance?.debouncer?.isDebounced(debounceId)) return;
try {
await instance.debouncer.executeNow(debounceId);
} catch (err) {
this.logger.error(
`onDisconnect flush failed for ${documentName}: ` +
(err instanceof Error ? err.message : String(err)),
);
}
}
async onStoreDocument(data: onStoreDocumentPayload) { async onStoreDocument(data: onStoreDocumentPayload) {
const { documentName, document, context } = data; const { documentName, document, context } = data;
@@ -176,6 +221,11 @@ export class PersistenceExtension implements Extension {
// Sticky agent marker: 'agent' if any agent edit landed in this window, OR // 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 // if the current writer is the agent (covers a store with no prior onChange
// agent event in the same window). §15 H2. // 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( const lastUpdatedSource = resolveSource(
this.consumeAgentTouched(documentName), this.consumeAgentTouched(documentName),
context?.actor, context?.actor,
@@ -224,21 +274,30 @@ export class PersistenceExtension implements Extension {
//this.logger.debug('Contributors error:' + err?.['message']); //this.logger.debug('Contributors error:' + err?.['message']);
} }
// Approach A — boundary snapshot before the agent's first edit. // Approach A — boundary snapshot before a MACHINE write overwrites a
// When this store is the agent's and the page's currently persisted // human (or other-source) baseline. When this store is from a machine
// state was authored by a human, pin that human state as its own // source — the AGENT or GIT-SYNC — and the page's currently persisted
// history version BEFORE the agent overwrites it. `page` still holds // state was authored by a DIFFERENT source, pin that prior state as its
// the OLD content/provenance here, so saveHistory(page) captures the // own history version BEFORE the machine write overwrites it. `page`
// pre-agent state tagged 'user'. The agent's new content is // still holds the OLD content/provenance here, so saveHistory(page)
// snapshotted later by the debounced PAGE_HISTORY job ('agent'). Skip // captures the pre-write state. The machine's new content is snapshotted
// if the prior state is already agent-authored (boundary already // later by the debounced PAGE_HISTORY job.
// pinned on the user->agent transition), if the page is effectively //
// empty, or if the latest existing snapshot already equals this human // For GIT-SYNC this is the OBSERVABLE-LOSS guard (SPEC §9 conflict
// state (avoid duplicates). // contract): a git-sync body write is a block-level 3-way merge whose
if ( // same-block rule is "git wins". Without this pin, a concurrent human
lastUpdatedSource === 'agent' && // edit to a block git also changed would be overwritten with NO trace.
page.lastUpdatedSource !== 'agent' // Pinning the pre-merge state here means the human's content is always
) { // RECOVERABLE via page history rather than silently lost — git still
// wins the live doc deterministically, but nothing is destroyed.
//
// Skip if the prior state was already authored by THIS machine source
// (boundary already pinned on the transition into it), if the page is
// effectively empty, or if the latest existing snapshot already equals
// the prior state (avoid duplicates).
const isMachineWrite =
lastUpdatedSource === 'agent' || lastUpdatedSource === 'git-sync';
if (isMachineWrite && page.lastUpdatedSource !== lastUpdatedSource) {
const lastHistory = await this.pageHistoryRepo.findPageLastHistory( const lastHistory = await this.pageHistoryRepo.findPageLastHistory(
pageId, pageId,
{ includeContent: true, trx }, { includeContent: true, trx },

View File

@@ -0,0 +1,208 @@
// 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);
});
});

View File

@@ -51,9 +51,15 @@ export class RedisSyncExtension<TCE extends CustomEvents> implements Extension {
private instance!: Hocuspocus; private instance!: Hocuspocus;
private readonly customEvents: TCE; private readonly customEvents: TCE;
private replyIdCounter: number = 0; private replyIdCounter: number = 0;
// @ts-ignore private pendingReplies: Record<
private pendingReplies: Record<number, PromiseWithResolvers<any>['resolve']> = number,
{}; {
// @ts-ignore
resolve: PromiseWithResolvers<any>['resolve'];
// @ts-ignore
reject: PromiseWithResolvers<any>['reject'];
}
> = {};
constructor(configuration: Configuration<TCE>) { constructor(configuration: Configuration<TCE>) {
const { const {
@@ -176,25 +182,45 @@ export class RedisSyncExtension<TCE extends CustomEvents> implements Extension {
} }
if (type === 'customEventStart') { if (type === 'customEventStart') {
const { documentName, eventName, payload, replyTo, replyId } = msg; const { documentName, eventName, payload, replyTo, replyId } = msg;
const res = await this.handleEventLocally( let reply: RSAMessageCustomEventComplete;
eventName as Extract<keyof TCE, string>, try {
documentName, const res = await this.handleEventLocally(
payload, eventName as Extract<keyof TCE, string>,
); documentName,
const reply: RSAMessageCustomEventComplete = { payload,
type: 'customEventComplete', );
replyId, reply = {
payload: res, 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),
};
}
this.pub.publish(`${replyTo}`, this.pack(reply)); this.pub.publish(`${replyTo}`, this.pack(reply));
return; return;
} }
if (type === 'customEventComplete') { if (type === 'customEventComplete') {
const { replyId, payload } = msg; const { replyId, payload, error } = msg;
const resolveFn = this.pendingReplies[replyId]; const pending = this.pendingReplies[replyId];
if (!resolveFn) return; if (!pending) return;
delete this.pendingReplies[replyId]; delete this.pendingReplies[replyId];
resolveFn(payload); if (error !== undefined) {
pending.reject(new Error(error));
} else {
pending.resolve(payload);
}
return; return;
} }
const { socketId } = msg; const { socketId } = msg;
@@ -273,11 +299,22 @@ export class RedisSyncExtension<TCE extends CustomEvents> implements Extension {
}; };
const msg = this.pack(proxyMessage); const msg = this.pack(proxyMessage);
this.pub.publish(`${this.msgChannel}:${proxyTo}`, msg); this.pub.publish(`${this.msgChannel}:${proxyTo}`, msg);
// @ts-ignore // Manual deferred (no Promise.withResolvers) so this runs on Node < 22 too.
const { promise, resolve, reject } = Promise.withResolvers(); let resolve!: (v: unknown) => void;
this.pendingReplies[replyId] = resolve; let reject!: (e: unknown) => void;
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
this.pendingReplies[replyId] = { resolve, reject };
setTimeout(() => { setTimeout(() => {
reject('TIMEOUT'); // 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');
}
}, this.customEventTTL); }, this.customEventTTL);
return promise as Promise<ReturnType<TCE[TName]>>; return promise as Promise<ReturnType<TCE[TName]>>;
} }

View File

@@ -72,6 +72,10 @@ export type RSAMessageCustomEventComplete = {
type: 'customEventComplete'; type: 'customEventComplete';
replyId: number; replyId: number;
payload: unknown; 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 = export type RSAMessage =

View File

@@ -0,0 +1,535 @@
/**
* 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 `![alt](src)` 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 `![](src)`.
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);
});
});

View File

@@ -0,0 +1,26 @@
/**
* 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;
}

View File

@@ -0,0 +1,20 @@
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']);
});
});

View File

@@ -0,0 +1,159 @@
import {
diff3Plan,
diff3PlanWithConflicts,
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',
]);
});
// Bug #2 observability: diff3PlanWithConflicts reports SAME-BLOCK conflicts so
// the caller can surface the "git wins" loss (log + history pin) instead of
// dropping the human side silently.
describe('diff3PlanWithConflicts (same-block conflict reporting)', () => {
it('reports 0 conflicts when sides changed DIFFERENT blocks (clean merge)', () => {
const r = diff3PlanWithConflicts(
['1', '2', '3'],
['H', '2', '3'],
['1', '2', 'G'],
);
expect(r.conflicts).toBe(0);
expect(apply(r.picks, ['H', '2', '3'], ['1', '2', 'G'])).toEqual([
'H',
'2',
'G',
]);
});
it('reports 1 conflict and git wins when BOTH rewrote the SAME block', () => {
const r = diff3PlanWithConflicts(
['1', '2', '3'],
['1', 'H', '3'], // human rewrote block 2
['1', 'G', '3'], // git rewrote block 2
);
expect(r.conflicts).toBe(1);
// Git wins the contested block; the human 'H' is NOT in the picks.
expect(apply(r.picks, ['1', 'H', '3'], ['1', 'G', '3'])).toEqual([
'1',
'G',
'3',
]);
});
it('does NOT count a git-only region (no human content to lose) as a conflict', () => {
const r = diff3PlanWithConflicts(
['1', '2', '3'],
['1', '2', '3'], // human unchanged
['1', '9', '3'], // git rewrote block 2
);
expect(r.conflicts).toBe(0);
});
});
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']);
});
});

View File

@@ -0,0 +1,274 @@
/**
* 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.
*
* LOCATION (deferred): this and its `lcs.ts` sibling are pure, framework-free and
* could conceptually live in `packages/git-sync` (the engine). They are kept in
* the server integration on purpose: `packages/git-sync` is a VENDORED engine
* (pinned upstream, manually re-synced), so adding first-party files there
* complicates the re-sync story, and the only consumer today is the server. Move
* them into the engine only once the vendoring re-sync story is settled.
*/
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;
}
/**
* The merged block order PLUS how many regions resolved as a genuine SAME-BLOCK
* conflict (both sides rewrote the same base block — `tryMergeRegion` returned
* null and git won the whole region, so the live/human version of those blocks
* is NOT in `picks`). `conflicts > 0` is the OBSERVABLE signal the caller uses to
* surface "git won a concurrent same-block edit" (log it + pin the human
* baseline to page history) instead of dropping the human side silently.
*/
export interface Diff3Result {
picks: Pick[];
conflicts: 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. Thin wrapper over
* `diff3PlanWithConflicts` (kept for the existing pure-array callers/tests).
*/
export function diff3Plan(o: string[], a: string[], b: string[]): Pick[] {
return diff3PlanWithConflicts(o, a, b).picks;
}
/**
* Like `diff3Plan` but also reports the SAME-BLOCK conflict count (see
* `Diff3Result`). A region where both the human and git rewrote the same base
* block cannot be merged automatically; the rule is deterministic — GIT WINS the
* whole region — but the human's version of those blocks is then absent from the
* picks, so we count it so the caller can make the loss observable/recoverable
* rather than silent (the documented conflict contract).
*/
export function diff3PlanWithConflicts(
o: string[],
a: string[],
b: string[],
): Diff3Result {
const oToA = matchMap(lcsPairs(o, a));
const oToB = matchMap(lcsPairs(o, b));
const res: Pick[] = [];
let conflicts = 0;
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 {
// SAME-BLOCK CONFLICT: count it ONLY when the human side actually had
// content in this region that git's win discards (live region non-empty).
// A region only git rewrote (live region empty) is not a human loss.
if (aEnd > ai) conflicts++;
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 { picks: res, conflicts };
}

View File

@@ -0,0 +1,171 @@
import { TiptapTransformer } from '@hocuspocus/transformer';
import * as Y from 'yjs';
import {
markdownToProseMirror,
convertProseMirrorToMarkdown,
} from '@docmost/git-sync';
import { tiptapExtensions } from '../collaboration.util';
import { mergeXmlFragments, mergeXmlFragments3Way } from './yjs-body-merge';
/**
* Regression for the QA #119 callout findings (body-duplication re-verify +
* "callout strips the whole body"). These reproduce the ACTUAL live merge path:
*
* live = TiptapTransformer.toYdoc(editor JSON, tiptapExtensions) (the
* collaboration server's materialization — schema defaults stamped)
* git = toYdoc(markdownToProseMirror(convertProseMirrorToMarkdown(editor)))
* (the engine round-trip the push side feeds into writePageBody)
*
* A page containing a callout (with a neighbouring heading + paragraphs) must:
* - merge with ZERO ops on an unchanged resync (no duplication — bug #1), and
* - NEVER lose blocks / collapse to empty (no strip — bug #2),
* across repeated cycles, for every editor-canonical callout type.
*/
const toYdoc = (content: unknown[]) =>
TiptapTransformer.toYdoc(
{ type: 'doc', content },
'default',
tiptapExtensions as any,
);
const blockTypes = (f: Y.XmlFragment) =>
f.toArray().map((n: any) => n.nodeName);
function editorPage(calloutType: string) {
return [
{
type: 'heading',
attrs: { id: 'h1', level: 1 },
content: [{ type: 'text', text: 'Title here' }],
},
{
type: 'paragraph',
attrs: { id: 'p1' },
content: [{ type: 'text', text: 'Para before callout' }],
},
{
type: 'callout',
attrs: { type: calloutType },
content: [
{
type: 'paragraph',
attrs: { id: 'pc' },
content: [{ type: 'text', text: 'Inside the callout' }],
},
],
},
{
type: 'paragraph',
attrs: { id: 'p2' },
content: [{ type: 'text', text: 'Para after callout' }],
},
];
}
async function gitRoundTrip(content: unknown[]): Promise<any[]> {
const md = await convertProseMirrorToMarkdown({ type: 'doc', content });
const json = await markdownToProseMirror(md);
return json.content;
}
describe('git-sync callout merge is idempotent + non-destructive (QA #119)', () => {
for (const type of ['info', 'note', 'warning', 'danger', 'success', 'default']) {
it(`callout(${type}) resyncs with 0 ops and never strips the body`, async () => {
const editor = editorPage(type);
const gitContent = await gitRoundTrip(editor);
const liveDoc = toYdoc(editor);
const live = liveDoc.getXmlFragment('default');
const before = live.toArray().length;
expect(before).toBe(4);
// 2-way: live vs the git round-trip -> no-op (no dup, no strip).
let applied = -1;
liveDoc.transact(() => {
applied = mergeXmlFragments(live, toYdoc(gitContent).getXmlFragment('default'));
});
expect(applied).toBe(0);
expect(live.toArray().length).toBe(before);
// 3-way across 4 cycles with base == git (the steady-state) -> stable.
for (let cycle = 0; cycle < 4; cycle++) {
let a = -1;
liveDoc.transact(() => {
a = mergeXmlFragments3Way(
live,
toYdoc(gitContent).getXmlFragment('default'),
toYdoc(gitContent).getXmlFragment('default'),
);
});
expect(a).toBe(0);
expect(live.toArray().length).toBe(before);
expect(blockTypes(live)).toEqual([
'heading',
'paragraph',
'callout',
'paragraph',
]);
}
});
}
it('3-way with a stale base (callout JUST added) keeps the callout + neighbours', async () => {
// base = the previously-synced version WITHOUT the callout (git round-trip);
// the human just inserted the callout -> the merge must KEEP everything.
const prev = [
{ type: 'heading', attrs: { id: 'h1', level: 1 }, content: [{ type: 'text', text: 'Title here' }] },
{ type: 'paragraph', attrs: { id: 'p1' }, content: [{ type: 'text', text: 'Para before callout' }] },
{ type: 'paragraph', attrs: { id: 'p2' }, content: [{ type: 'text', text: 'Para after callout' }] },
];
const editor = editorPage('info');
const baseContent = await gitRoundTrip(prev);
const gitContent = await gitRoundTrip(editor);
const liveDoc = toYdoc(editor);
const live = liveDoc.getXmlFragment('default');
liveDoc.transact(() => {
mergeXmlFragments3Way(
live,
toYdoc(gitContent).getXmlFragment('default'),
toYdoc(baseContent).getXmlFragment('default'),
);
});
// Body survives in full — NOT stripped to empty / a lone paragraph.
expect(blockTypes(live)).toEqual([
'heading',
'paragraph',
'callout',
'paragraph',
]);
});
});
describe('git-sync callout type fidelity (QA "callout type -> [!info]")', () => {
for (const type of ['info', 'note', 'warning', 'danger', 'success', 'default']) {
it(`preserves callout type "${type}" across the engine round-trip`, async () => {
const content = editorPage(type);
const gitContent = await gitRoundTrip(content);
const co = gitContent.find((b: any) => b.type === 'callout');
expect(co?.attrs?.type).toBe(type);
});
}
it('maps a known GitHub/Obsidian alias to the editor banner (tip -> success)', async () => {
// `tip` is not a schema callout type — it is an input alias the editor itself
// maps onto the supported set (GITHUB_ALERT_TYPE_MAP: tip -> success). git-sync
// mirrors that so the ingest lands on the closest banner instead of flatly info.
const content = editorPage('tip');
const gitContent = await gitRoundTrip(content);
const co = gitContent.find((b: any) => b.type === 'callout');
expect(co?.attrs?.type).toBe('success');
});
it('flattens a genuinely unknown callout type to info', async () => {
const content = editorPage('banana'); // not a type and not a known alias
const gitContent = await gitRoundTrip(content);
const co = gitContent.find((b: any) => b.type === 'callout');
expect(co?.attrs?.type).toBe('info');
});
});

View File

@@ -0,0 +1,198 @@
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);
});
});

View File

@@ -0,0 +1,316 @@
import { TiptapTransformer } from '@hocuspocus/transformer';
import * as Y from 'yjs';
import { tiptapExtensions } from '../collaboration.util';
import { mergeXmlFragments, mergeXmlFragments3Way } from './yjs-body-merge';
/**
* Regression for the BUG CLASS behind the runaway whole-body duplication: the
* point-fix (7a7b840e) only normalized `indent: 0`, but the SAME divergence
* recurs for every attribute whose editor-ext (server) schema default the live
* Yjs doc MATERIALIZES while the git round-trip — which comes through the engine
* schema (different, usually null, defaults) plus `y-prosemirror`'s null-attr
* dropping — does NOT carry. Confirmed triggers beyond `indent`:
*
* - `image.align` : editor-ext default "center" (materialized) vs engine
* default null (dropped) -> element-attr divergence.
* - link mark `internal`: editor-ext default false (materialized) vs engine
* default null -> MARK-attr divergence (the prior denylist
* could not reach marks at all — they are serialized raw in
* the XmlText delta).
*
* `highlight.colorName` is normalized too (defense-in-depth); it is NOT a strong
* real-world trigger because BOTH schemas default it to null, but the schema-
* derived normalization handles it for free and stays idempotent.
*
* The fix derives the defaults from the ACTUAL ProseMirror schema (getSchema of
* the server tiptapExtensions) and drops any element- OR mark-attribute equal to
* its schema default (or null/undefined) from the block comparison key — so a
* live block compares equal to its git-round-tripped twin and an unchanged
* resync applies 0 ops. RED before the fix (keys diverge -> ops > 0 / growth),
* GREEN after.
*/
type Attrs = Record<string, unknown>;
function el(
name: string,
attrs: Attrs,
children: (Y.XmlElement | Y.XmlText)[],
): Y.XmlElement {
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;
}
/** Text carrying marks, as the live Yjs doc stores them (XmlText format ops). */
function markedText(s: string, marks: Record<string, unknown>): Y.XmlText {
const t = new Y.XmlText();
t.insert(0, s, marks);
return t;
}
/**
* One byte-identical RICH unit: a paragraph with a LINK, a top-level IMAGE, and
* a paragraph with a HIGHLIGHT. `live` toggles exactly what the editor
* materializes but a git round-trip does not: block `id`, `indent: 0`,
* `image.align: "center"`, the link mark's `internal: false`, and the
* highlight's `colorName: null`.
*/
function richUnit(live: boolean, n: number): Y.XmlElement[] {
const ind: Attrs = live ? { indent: 0 } : {};
const id = (base: string): Attrs => (live ? { id: `${base}${n}` } : {});
const linkMarks = live
? {
link: {
href: 'https://example.com',
target: '_blank',
rel: 'noopener noreferrer nofollow',
class: null,
title: null,
internal: false, // editor-ext default, materialized
},
}
: {
link: {
href: 'https://example.com',
target: '_blank',
rel: 'noopener noreferrer nofollow',
internal: null, // engine default
},
};
const hlMarks = live
? { highlight: { color: '#ffd43b', colorName: null } }
: { highlight: { color: '#ffd43b' } };
const imageAttrs: Attrs = live
? { src: 'https://img.example.com/a.png', align: 'center' } // materialized
: { src: 'https://img.example.com/a.png' }; // align:null dropped on git side
return [
el('paragraph', { ...id('lp'), ...ind }, [
markedText('click here', linkMarks),
]),
el('image', imageAttrs, []),
el('paragraph', { ...id('hp'), ...ind }, [markedText('hot', hlMarks)]),
];
}
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 is idempotent for schema-default attrs (image/link/highlight)', () => {
const UNITS = 3;
it('3-way: live carries image.align/link.internal/indent defaults, base stale-by-one -> 0 ops', () => {
const { doc: liveDoc, frag: live } = fragmentOf(
Array.from({ length: UNITS }, (_, i) => richUnit(true, i)),
);
const { frag: incoming } = fragmentOf(
Array.from({ length: UNITS }, (_, i) => richUnit(false, i)),
);
const { frag: base } = fragmentOf(
Array.from({ length: UNITS - 1 }, (_, i) => richUnit(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('2-way: live carries the materialized defaults -> 0 ops, no growth', () => {
const { doc: liveDoc, frag: live } = fragmentOf(
Array.from({ length: UNITS }, (_, i) => richUnit(true, i)),
);
const { frag: incoming } = fragmentOf(
Array.from({ length: UNITS }, (_, i) => richUnit(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('is a fixpoint across repeated cycles (does not grow)', () => {
const { doc: liveDoc, frag: live } = fragmentOf(
Array.from({ length: UNITS }, (_, i) => richUnit(true, i)),
);
const incoming = () =>
fragmentOf(Array.from({ length: UNITS }, (_, i) => richUnit(false, i)))
.frag;
const base = () =>
fragmentOf(
Array.from({ length: UNITS - 1 }, (_, i) => richUnit(false, i)),
).frag;
const before = blockCount(live);
for (let cycle = 0; cycle < 5; cycle++) {
let applied = -1;
liveDoc.transact(() => {
applied = mergeXmlFragments3Way(live, incoming(), base());
});
expect(applied).toBe(0);
expect(blockCount(live)).toBe(before);
}
});
it('does NOT regress a genuine non-default value (a real link.href / image.align:left still diffs)', () => {
const { doc: liveDoc, frag: live } = fragmentOf([richUnit(true, 0)]);
const base = fragmentOf([richUnit(false, 0)]).frag;
// git genuinely changes the image alignment to a NON-default value.
const incomingUnit = richUnit(false, 0);
(incomingUnit[1] as Y.XmlElement).setAttribute('align', 'left');
const incoming = fragmentOf([incomingUnit]).frag;
liveDoc.transact(() => {
mergeXmlFragments3Way(live, incoming, base);
});
const img = live
.toArray()
.find((b) => (b as Y.XmlElement).nodeName === 'image') as Y.XmlElement;
expect(img.getAttribute('align')).toBe('left');
});
});
/**
* FAITHFUL end-to-end proof through the REAL server transformer: build the live
* doc the way the collaboration server does (defaults omitted in the JSON ->
* TiptapTransformer.toYdoc MATERIALIZES image.align:"center", link.internal:false,
* indent:0) versus the git-derived doc (engine-style: defaults emitted as
* explicit null, no block ids). An unchanged resync must apply 0 ops.
*/
describe('git-sync reconcile is idempotent through the real toYdoc materialization', () => {
const liveContent = [
{
type: 'paragraph',
attrs: { id: 'p1' },
content: [
{
type: 'text',
text: 'click here',
marks: [{ type: 'link', attrs: { href: 'https://example.com' } }],
},
],
},
{ type: 'image', attrs: { src: 'https://img.example.com/a.png' } },
{
type: 'paragraph',
attrs: { id: 'p2' },
content: [
{
type: 'text',
text: 'hot',
marks: [{ type: 'highlight', attrs: { color: '#ffd43b' } }],
},
],
},
];
// git/engine-style: explicit nulls for the engine-default attrs, no ids.
const gitContent = [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'click here',
marks: [
{
type: 'link',
attrs: {
href: 'https://example.com',
target: '_blank',
rel: 'noopener noreferrer nofollow',
class: null,
title: null,
internal: null,
},
},
],
},
],
},
{
type: 'image',
attrs: { src: 'https://img.example.com/a.png', align: null },
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'hot',
marks: [
{ type: 'highlight', attrs: { color: '#ffd43b', colorName: null } },
],
},
],
},
];
const toYdoc = (content: unknown[]) =>
TiptapTransformer.toYdoc(
{ type: 'doc', content },
'default',
tiptapExtensions as any,
);
it('3-way: materialized-default live vs engine-style git, base stale-by-one -> 0 ops', () => {
const liveDoc = toYdoc(liveContent);
const targetDoc = toYdoc(gitContent);
const baseDoc = toYdoc(gitContent.slice(0, gitContent.length - 1));
const live = liveDoc.getXmlFragment('default');
const before = live.toArray().length;
let applied = -1;
liveDoc.transact(() => {
applied = mergeXmlFragments3Way(
live,
targetDoc.getXmlFragment('default'),
baseDoc.getXmlFragment('default'),
);
});
expect(applied).toBe(0);
expect(live.toArray().length).toBe(before);
});
it('2-way: materialized-default live vs engine-style git -> 0 ops', () => {
const liveDoc = toYdoc(liveContent);
const targetDoc = toYdoc(gitContent);
const live = liveDoc.getXmlFragment('default');
const before = live.toArray().length;
let applied = -1;
liveDoc.transact(() => {
applied = mergeXmlFragments(live, targetDoc.getXmlFragment('default'));
});
expect(applied).toBe(0);
expect(live.toArray().length).toBe(before);
});
});

View File

@@ -0,0 +1,373 @@
import * as Y from 'yjs';
import {
mergeXmlFragments,
mergeXmlFragments3Way,
mergeXmlFragments3WayWithStats,
cloneXmlNode,
diffBlocks,
} from './yjs-body-merge';
// Build a Y.XmlFragment('default') in `doc` from a list of paragraph specs.
// Each spec is the paragraph's plain text (a single XmlText child).
function buildFragment(doc: Y.Doc, paragraphs: string[]): Y.XmlFragment {
const frag = doc.getXmlFragment('default');
const blocks = paragraphs.map((text) => {
const el = new Y.XmlElement('paragraph');
const t = new Y.XmlText();
if (text) t.insert(0, text);
el.insert(0, [t]);
return el;
});
if (blocks.length) frag.insert(0, blocks);
return frag;
}
function texts(frag: Y.XmlFragment): string[] {
return frag.toArray().map((el) => (el as Y.XmlElement).toArray()
.map((c) => (c as Y.XmlText).toString())
.join(''));
}
describe('yjs-body-merge', () => {
describe('diffBlocks (LCS edit script)', () => {
it('identical sequences produce only keeps (no edits)', () => {
const ops = diffBlocks(['a', 'b', 'c'], ['a', 'b', 'c']);
expect(ops.every((o) => o.op === 'keep')).toBe(true);
});
it('a single changed middle element is one del + one ins', () => {
const ops = diffBlocks(['a', 'b', 'c'], ['a', 'B', 'c']);
expect(ops.filter((o) => o.op === 'del')).toHaveLength(1);
expect(ops.filter((o) => o.op === 'ins')).toHaveLength(1);
expect(ops.filter((o) => o.op === 'keep')).toHaveLength(2);
});
});
describe('mergeXmlFragments', () => {
it('identical content is a complete no-op (0 ops) — never clobbers an unchanged resync', () => {
const live = new Y.Doc();
const target = new Y.Doc();
const liveFrag = buildFragment(live, ['one', 'two', 'three']);
const targetFrag = buildFragment(target, ['one', 'two', 'three']);
// Capture block identities to prove they are left untouched.
const before = liveFrag.toArray();
let applied = -1;
live.transact(() => {
applied = mergeXmlFragments(liveFrag, targetFrag);
});
expect(applied).toBe(0);
// Same Y.XmlElement instances — nothing was deleted/recreated.
expect(liveFrag.toArray()).toEqual(before);
expect(texts(liveFrag)).toEqual(['one', 'two', 'three']);
});
it('a human edit to one block survives a git change to a DIFFERENT block', () => {
// Live: the human has the doc open; block 0 holds their edit. Git changed
// only block 2. The merge must touch ONLY block 2 and leave block 0 (and
// its in-flight edit) exactly as-is.
const live = new Y.Doc();
const target = new Y.Doc();
const liveFrag = buildFragment(live, ['HUMAN EDIT', 'shared', 'old tail']);
const targetFrag = buildFragment(target, [
'HUMAN EDIT',
'shared',
'new tail from git',
]);
const block0Before = liveFrag.get(0); // the human's block instance
const block1Before = liveFrag.get(1);
let applied = -1;
live.transact(() => {
applied = mergeXmlFragments(liveFrag, targetFrag);
});
// Only block 2 was replaced: one del + one ins.
expect(applied).toBe(2);
// The human's block and the shared block are the SAME instances (untouched).
expect(liveFrag.get(0)).toBe(block0Before);
expect(liveFrag.get(1)).toBe(block1Before);
// Block 2 now carries git's content.
expect(texts(liveFrag)).toEqual([
'HUMAN EDIT',
'shared',
'new tail from git',
]);
});
it('appends a new trailing block without disturbing existing ones', () => {
const live = new Y.Doc();
const target = new Y.Doc();
const liveFrag = buildFragment(live, ['a', 'b']);
const targetFrag = buildFragment(target, ['a', 'b', 'c']);
const a = liveFrag.get(0);
const b = liveFrag.get(1);
let applied = -1;
live.transact(() => {
applied = mergeXmlFragments(liveFrag, targetFrag);
});
expect(applied).toBe(1); // single insert
expect(liveFrag.get(0)).toBe(a);
expect(liveFrag.get(1)).toBe(b);
expect(texts(liveFrag)).toEqual(['a', 'b', 'c']);
});
it('deletes a removed block, keeping its neighbours', () => {
const live = new Y.Doc();
const target = new Y.Doc();
const liveFrag = buildFragment(live, ['a', 'b', 'c']);
const targetFrag = buildFragment(target, ['a', 'c']);
const a = liveFrag.get(0);
let applied = -1;
live.transact(() => {
applied = mergeXmlFragments(liveFrag, targetFrag);
});
expect(applied).toBe(1); // single delete
expect(liveFrag.get(0)).toBe(a);
expect(texts(liveFrag)).toEqual(['a', 'c']);
});
it('a fully different body is replaced (and stays valid)', () => {
const live = new Y.Doc();
const target = new Y.Doc();
const liveFrag = buildFragment(live, ['x', 'y']);
const targetFrag = buildFragment(target, ['p', 'q', 'r']);
live.transact(() => mergeXmlFragments(liveFrag, targetFrag));
expect(texts(liveFrag)).toEqual(['p', 'q', 'r']);
});
});
describe('mergeXmlFragments3Way', () => {
it('keeps a human edit to one block while applying a git change to another (3-way)', () => {
// base (last synced): [a, b, c]. Human edited block 0 in the live doc; git
// changed block 2 in the incoming file. 3-way must keep BOTH — the 2-way
// merge would instead revert the human's block 0 to git's stale version.
const base = new Y.Doc();
const live = new Y.Doc();
const target = new Y.Doc();
const baseFrag = buildFragment(base, ['a', 'b', 'c']);
const liveFrag = buildFragment(live, ['HUMAN', 'b', 'c']);
const targetFrag = buildFragment(target, ['a', 'b', 'GIT']);
const humanBlock = liveFrag.get(0); // the human's live instance
live.transact(() =>
mergeXmlFragments3Way(liveFrag, targetFrag, baseFrag),
);
// Human's block preserved as the SAME instance; git's change applied.
expect(liveFrag.get(0)).toBe(humanBlock);
expect(texts(liveFrag)).toEqual(['HUMAN', 'b', 'GIT']);
});
it('a block both sides changed resolves to git (conflict policy)', () => {
const base = new Y.Doc();
const live = new Y.Doc();
const target = new Y.Doc();
const baseFrag = buildFragment(base, ['a', 'b', 'c']);
const liveFrag = buildFragment(live, ['a', 'HUMAN', 'c']);
const targetFrag = buildFragment(target, ['a', 'GIT', 'c']);
live.transact(() =>
mergeXmlFragments3Way(liveFrag, targetFrag, baseFrag),
);
expect(texts(liveFrag)).toEqual(['a', 'GIT', 'c']);
});
// Bug #2 observability: the stats variant reports the same-block conflict so
// the handler can log it + the persistence layer can pin the human baseline.
it('reports the same-block conflict count via mergeXmlFragments3WayWithStats', () => {
const base = new Y.Doc();
const live = new Y.Doc();
const target = new Y.Doc();
const baseFrag = buildFragment(base, ['a', 'b', 'c']);
const liveFrag = buildFragment(live, ['a', 'HUMAN', 'c']);
const targetFrag = buildFragment(target, ['a', 'GIT', 'c']);
let result!: { applied: number; conflicts: number };
live.transact(() => {
result = mergeXmlFragments3WayWithStats(liveFrag, targetFrag, baseFrag);
});
expect(result.conflicts).toBe(1);
expect(texts(liveFrag)).toEqual(['a', 'GIT', 'c']);
});
it('reports 0 conflicts for a clean different-block 3-way merge', () => {
const base = new Y.Doc();
const live = new Y.Doc();
const target = new Y.Doc();
const baseFrag = buildFragment(base, ['a', 'b', 'c']);
const liveFrag = buildFragment(live, ['HUMAN', 'b', 'c']);
const targetFrag = buildFragment(target, ['a', 'b', 'GIT']);
let result!: { applied: number; conflicts: number };
live.transact(() => {
result = mergeXmlFragments3WayWithStats(liveFrag, targetFrag, baseFrag);
});
expect(result.conflicts).toBe(0);
expect(texts(liveFrag)).toEqual(['HUMAN', 'b', 'GIT']);
});
it('git change with no concurrent human edit (live == base) applies cleanly', () => {
const base = new Y.Doc();
const live = new Y.Doc();
const target = new Y.Doc();
const baseFrag = buildFragment(base, ['a', 'b']);
const liveFrag = buildFragment(live, ['a', 'b']);
const targetFrag = buildFragment(target, ['a', 'B2']);
live.transact(() =>
mergeXmlFragments3Way(liveFrag, targetFrag, baseFrag),
);
expect(texts(liveFrag)).toEqual(['a', 'B2']);
});
});
// Regression: start-of-document content duplicating on every two-way sync.
//
// The LIVE Docmost doc stamps a per-block UniqueID on every heading/paragraph;
// a body arriving FROM git is parsed from clean markdown and carries NO block
// ids. If the merge comparison key includes that `id`, an unchanged live block
// never matches the SAME block coming from git, so the three-way merge cannot
// anchor on it — and an incoming block with no anchor (content inserted at the
// TOP of the page) is RE-ADDED on every cycle, an unbounded duplication loop.
// These tests model that exact id-asymmetry and assert the reconciliation is
// IDEMPOTENT (no block growth). They are RED before excluding `id` from the
// key in `serializeXmlNode`.
describe('idempotent reconciliation with live block ids (start-of-doc dup)', () => {
// Build a fragment from block specs. `id` is set only when provided, mirroring
// the live doc (ids present) vs a git-parsed body (ids absent).
type Spec = { tag: 'heading' | 'paragraph'; text: string; id?: string };
function buildDoc(doc: Y.Doc, specs: Spec[]): Y.XmlFragment {
const frag = doc.getXmlFragment('default');
const blocks = specs.map((s) => {
const el = new Y.XmlElement(s.tag);
if (s.id) el.setAttribute('id', s.id);
if (s.tag === 'heading') el.setAttribute('level', '2');
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 textsOf = (frag: Y.XmlFragment): string[] =>
frag.toArray().map((el) =>
(el as Y.XmlElement)
.toArray()
.map((c) => (c as Y.XmlText).toString())
.join(''),
);
it('re-merging the SAME git body does NOT re-add the top block (idempotent)', () => {
// last-synced base (from git markdown): NO block ids.
const base = new Y.Doc();
const baseFrag = buildDoc(base, [
{ tag: 'heading', text: 'Title' },
{ tag: 'paragraph', text: 'Some paragraph.' },
{ tag: 'paragraph', text: 'End block.' },
]);
// live Docmost doc: SAME content, but every block carries a UniqueID.
const live = new Y.Doc();
const liveFrag = buildDoc(live, [
{ tag: 'heading', text: 'Title', id: 'ida' },
{ tag: 'paragraph', text: 'Some paragraph.', id: 'idb' },
{ tag: 'paragraph', text: 'End block.', id: 'idc' },
]);
// incoming git body: the user inserted a heading at the very TOP.
const buildTarget = (): Y.XmlFragment =>
buildDoc(new Y.Doc(), [
{ tag: 'heading', text: 'TOPDUP' },
{ tag: 'heading', text: 'Title' },
{ tag: 'paragraph', text: 'Some paragraph.' },
{ tag: 'paragraph', text: 'End block.' },
]);
// First sync: the top block is added once.
live.transact(() =>
mergeXmlFragments3Way(liveFrag, buildTarget(), baseFrag),
);
expect(textsOf(liveFrag)).toEqual([
'TOPDUP',
'Title',
'Some paragraph.',
'End block.',
]);
// Subsequent sync of the SAME git body against the SAME base must be a
// NO-OP — not a second copy of the top block. Before the fix this re-adds
// 'TOPDUP', growing the doc on every cycle.
live.transact(() =>
mergeXmlFragments3Way(liveFrag, buildTarget(), baseFrag),
);
expect(textsOf(liveFrag)).toEqual([
'TOPDUP',
'Title',
'Some paragraph.',
'End block.',
]);
expect(textsOf(liveFrag).filter((t) => t === 'TOPDUP')).toHaveLength(1);
});
it('an unchanged git body (live ids, none in git) is a complete no-op', () => {
// base == git body (no pending git change); live is the same content with
// ids. With `id` in the key the whole body looks rewritten; the merge must
// still leave live byte-identical (block instances untouched).
const base = new Y.Doc();
const baseFrag = buildDoc(base, [
{ tag: 'heading', text: 'Title' },
{ tag: 'paragraph', text: 'Body.' },
]);
const live = new Y.Doc();
const liveFrag = buildDoc(live, [
{ tag: 'heading', text: 'Title', id: 'ida' },
{ tag: 'paragraph', text: 'Body.', id: 'idb' },
]);
const before = liveFrag.toArray();
let applied = -1;
live.transact(() => {
applied = mergeXmlFragments3Way(
liveFrag,
buildDoc(new Y.Doc(), [
{ tag: 'heading', text: 'Title' },
{ tag: 'paragraph', text: 'Body.' },
]),
baseFrag,
);
});
expect(applied).toBe(0);
// Same live block instances (ids preserved) — nothing recreated.
expect(liveFrag.toArray()).toEqual(before);
});
});
describe('cloneXmlNode', () => {
it('preserves text marks (XmlText delta) across docs', () => {
const src = new Y.Doc();
const srcFrag = src.getXmlFragment('default');
const el = new Y.XmlElement('paragraph');
const t = new Y.XmlText();
t.insert(0, 'plain ');
t.insert(6, 'bold', { bold: true });
el.insert(0, [t]);
srcFrag.insert(0, [el]);
const dst = new Y.Doc();
const dstFrag = dst.getXmlFragment('default');
dstFrag.insert(0, [cloneXmlNode(srcFrag.get(0) as Y.XmlElement)]);
const clonedText = (dstFrag.get(0) as Y.XmlElement).get(0) as Y.XmlText;
expect(clonedText.toDelta()).toEqual([
{ insert: 'plain ' },
{ insert: 'bold', attributes: { bold: true } },
]);
});
});
});

View File

@@ -0,0 +1,369 @@
import * as Y from 'yjs';
import { getSchema } from '@tiptap/core';
import type { Schema } from '@tiptap/pm/model';
import { tiptapExtensions } from '../collaboration.util';
import { diff3PlanWithConflicts } from './three-way-merge';
import { buildLcsTable } from './lcs';
/**
* Block-level merge of an incoming (git) page body into a LIVE Yjs document,
* replacing the previous full-body "delete everything + re-insert" write that
* clobbered concurrent human edits on every sync (review #5 — "do the write as a
* merge").
*
* Strategy: diff the two documents at TOP-LEVEL BLOCK granularity (an LCS over a
* canonical structural serialization of each block) and apply only the minimal
* insert/delete operations. Blocks that are byte-identical on both sides are
* left UNTOUCHED in the live doc — so a human editing one paragraph is unaffected
* when git changes a different paragraph, and an unchanged re-sync is a complete
* no-op (zero Yjs operations). Yjs then CRDT-merges the minimal ops with any
* concurrent edits.
*
* Limitation (honest): this is a 2-way merge (live vs incoming). For a block that
* BOTH sides changed since the last sync it cannot tell which is newer without a
* common ancestor, so the incoming (git) version wins for that one block. A full
* 3-way merge would need the last-synced base plumbed from the engine; the common
* cases — unchanged resync, and edits to DIFFERENT blocks — are handled losslessly.
*/
type XmlNode = Y.XmlElement | Y.XmlText | Y.XmlHook;
/**
* Node attributes that are VOLATILE identity (not content) and so must be
* excluded from the block comparison key.
*
* `id` is the per-block UniqueID the editor stamps on every heading/paragraph
* (and transclusionSource). It exists ONLY in the live Yjs document — a body
* arriving from git is parsed from clean markdown, which carries no block ids
* (`markdownToProseMirror` materializes `id: null`, which the Yjs transform then
* drops). If `id` were part of the key, an UNCHANGED live block (id "abc123")
* would never match the SAME block coming from git (no id), so the three-way
* merge's LCS could not anchor on it. The merge would then treat every live
* block as deleted-and-reinserted and, when an incoming block has no matching
* anchor (e.g. content inserted at the very TOP of the page), RE-ADD a copy of
* it on every sync cycle — a non-convergent, unbounded duplication loop
* (start-of-document content duplicating each push/pull cycle).
*
* Excluding `id` makes blocks compare by CONTENT, so an unchanged block matches
* across the git round-trip and the reconciliation is idempotent. Block identity
* is still preserved in the merged output: `diff3Plan` keeps the LIVE block
* INSTANCE (with its id) for an anchor — picks are by index, not by key — so the
* stable Yjs block (and any in-flight human edit on it) stays put. This mirrors
* `canonicalize.ts`, which already strips the regenerated block `id` from the
* round-trip idempotency comparison for exactly the same reason.
*
* Known limitation (accepted trade-off of content-based matching): two GENUINELY
* DISTINCT blocks whose content is byte-identical now collapse to the same content
* key, so when git deletes one of the duplicates the LCS may drop the OTHER live
* instance instead. The visible result is identical (one copy removed, one kept),
* but a concurrent in-flight human edit on the dropped instance could be lost.
*/
const VOLATILE_KEY_ATTRS = new Set(['id']);
/**
* The editor (ProseMirror) schema, built ONCE from the same `tiptapExtensions`
* the collaboration server uses to materialize Yjs docs. Memoized: building the
* schema is non-trivial and the block key is computed per block per cycle.
*
* Why the schema (not a hardcoded denylist): the LIVE Yjs document is produced by
* `TiptapTransformer.toYdoc(pm, 'default', tiptapExtensions)`, which STAMPS every
* schema-default attribute onto every node and mark — `indent: 0` on every
* paragraph/heading, `image.align: "center"`, the link mark's `internal: false`,
* `highlight.colorName: null`, and so on for youtube/pdf/any future node. A body
* re-imported from git comes through the engine's `markdownToProseMirror`, whose
* schema declares those attrs with DIFFERENT (usually null) defaults; the
* resulting null/absent element attrs are then DROPPED by `y-prosemirror`'s
* toYdoc. So the SAME block carries materialized defaults on the live side and
* nothing on the git side, its key diverges, the three-way merge anchors on
* NOTHING, and the whole body is RE-APPENDED every reconcile cycle — an unbounded
* duplication loop with no client connected.
*
* Deriving the defaults from the actual schema normalizes ALL such attributes
* generally (it is not another per-attribute denylist): any attribute whose value
* equals the schema default — or is null/undefined — is dropped from the key, on
* BOTH element attributes and the mark attributes inside each XmlText delta, so a
* live block compares equal to its git-round-tripped twin and an unchanged resync
* applies zero ops. Genuinely non-default values (a real `indent: 2`, an
* `align: "left"`, a real `link.href`, a real highlight color) are content and
* stay in the key, so real edits still diff and land.
*/
let memoSchema: Schema | null = null;
let memoSchemaTried = false;
function getMergeSchema(): Schema | null {
if (!memoSchemaTried) {
memoSchemaTried = true;
try {
memoSchema = getSchema(tiptapExtensions as any);
} catch {
// Defensive: if the schema can't be built (e.g. a degenerate extension
// set in a unit test that stubs `tiptapExtensions`), fall back to dropping
// only null/undefined attrs. The real server always builds it fine.
memoSchema = null;
}
}
return memoSchema;
}
/** True if `value` is the schema default for `attrName` of `attrSpecs`, or is
* null/undefined (which a git round-trip drops). Such attributes are excluded
* from the comparison key. `attrSpecs` is a ProseMirror node/mark spec attr map
* (`{ [name]: { default } }`); a missing map (unknown node/mark) only drops
* null/undefined. (A non-null value matching an attr declared without a default
* cannot occur — `spec.default === value` is then `undefined === value`, false.) */
function isDefaultAttr(
attrSpecs: Record<string, any> | undefined | null,
attrName: string,
value: unknown,
): boolean {
if (value === null || value === undefined) return true;
const spec = attrSpecs?.[attrName];
return !!spec && spec.default === value;
}
/**
* Normalize one XmlText delta op's mark attributes: drop every mark-attr whose
* value equals the mark's schema default (or is null/undefined), so the link
* mark's materialized `internal: false`/`target: "_blank"` and a highlight's
* `colorName: null` no longer diverge from a git round-trip that carries neither.
* The text (op.insert) and genuinely-set mark attrs (a real `href`, a real
* highlight color) are preserved verbatim. `attributes` maps markName -> mark
* attrs object (or `true`/boolean for attr-less marks); each is handled safely.
*/
function normalizeDelta(delta: any[]): any[] {
const schema = getMergeSchema();
return delta.map((op) => {
if (!op || op.attributes == null || typeof op.attributes !== 'object') {
return op;
}
const marks: Record<string, unknown> = {};
for (const markName of Object.keys(op.attributes).sort()) {
const markVal = op.attributes[markName];
if (markVal === null || markVal === undefined) continue;
if (typeof markVal !== 'object') {
// attr-less mark stored as a primitive (e.g. `true`) — keep as-is.
marks[markName] = markVal;
continue;
}
const markSpec = schema?.marks[markName]?.spec.attrs as
| Record<string, any>
| undefined;
const cleaned: Record<string, unknown> = {};
for (const ak of Object.keys(markVal as object).sort()) {
const av = (markVal as Record<string, unknown>)[ak];
if (isDefaultAttr(markSpec, ak, av)) continue;
cleaned[ak] = av;
}
marks[markName] = cleaned;
}
return { ...op, attributes: marks };
});
}
/**
* Canonical, comparable serialization of a Yjs XML node (structure + text +
* marks + attributes), with attribute keys sorted so equal blocks always produce
* an identical string regardless of attribute insertion order. The volatile
* block `id` (see `VOLATILE_KEY_ATTRS`) and every schema-default attribute (see
* `getMergeSchema`) are excluded at every level — on element attributes AND on
* the mark attributes inside each XmlText delta — so a block compares equal by
* CONTENT across the git round-trip (which materializes neither), keeping the
* merge anchor-able and idempotent.
*/
export function serializeXmlNode(node: unknown): unknown {
if (node instanceof Y.XmlText) {
return { t: normalizeDelta(node.toDelta()) };
}
if (node instanceof Y.XmlElement) {
const attrs = node.getAttributes() as Record<string, unknown>;
const attrSpecs = getMergeSchema()?.nodes[node.nodeName]?.spec.attrs as
| Record<string, any>
| undefined;
const sorted: Record<string, unknown> = {};
for (const k of Object.keys(attrs).sort()) {
if (VOLATILE_KEY_ATTRS.has(k)) continue;
if (isDefaultAttr(attrSpecs, k, attrs[k])) continue;
sorted[k] = attrs[k];
}
return {
n: node.nodeName,
a: sorted,
c: node.toArray().map(serializeXmlNode),
};
}
// XmlHook / unknown: fall back to a stable string so it compares by identity
// of its serialized form (these do not occur in the Docmost block schema).
return { u: String(node) };
}
const key = (node: unknown): string => JSON.stringify(serializeXmlNode(node));
/**
* Deep-clone a detached/owned Yjs XML node into a fresh node that can be inserted
* into ANOTHER document (Yjs types are bound to their doc, so cross-doc moves are
* impossible — we rebuild). Preserves nodeName, attributes, text+marks (via the
* XmlText delta) and the full child subtree.
*/
export function cloneXmlNode(node: XmlNode): Y.XmlElement | Y.XmlText {
if (node instanceof Y.XmlText) {
const t = new Y.XmlText();
const delta = node.toDelta();
if (delta.length) t.applyDelta(delta);
return t;
}
if (node instanceof Y.XmlElement) {
const el = new Y.XmlElement(node.nodeName);
const attrs = node.getAttributes() as Record<string, unknown>;
for (const k of Object.keys(attrs)) el.setAttribute(k, attrs[k] as string);
const kids = node.toArray().map((c) => cloneXmlNode(c as XmlNode));
if (kids.length) el.insert(0, kids);
return el;
}
// Best-effort for any other node type (XmlHook — does not occur in the
// Docmost block schema): an empty paragraph so the merge never crashes.
return new Y.XmlElement('paragraph');
}
type Op = { op: 'keep' } | { op: 'del' } | { op: 'ins'; bi: number };
/**
* LCS-based edit script turning sequence `a` (live block keys) into `b` (incoming
* block keys): a run of keep/del/ins ops. O(n*m) table — fine for page block
* counts.
*/
export function diffBlocks(a: string[], b: string[]): Op[] {
const n = a.length;
const m = b.length;
const dp = buildLcsTable(a, b);
const ops: Op[] = [];
let i = 0;
let j = 0;
while (i < n && j < m) {
if (a[i] === b[j]) {
ops.push({ op: 'keep' });
i++;
j++;
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
ops.push({ op: 'del' });
i++;
} else {
ops.push({ op: 'ins', bi: j });
j++;
}
}
while (i < n) {
ops.push({ op: 'del' });
i++;
}
while (j < m) {
ops.push({ op: 'ins', bi: j });
j++;
}
return ops;
}
/**
* Merge `target` block children into `live`, mutating `live` in place with the
* minimal set of inserts/deletes. MUST be called inside a Yjs transaction.
* Returns the number of block operations applied (0 == content already identical).
*/
export function mergeXmlFragments(
live: Y.XmlFragment,
target: Y.XmlFragment,
): number {
const liveKids = live.toArray();
const targetKids = target.toArray();
const liveKeys = liveKids.map(key);
const targetKeys = targetKids.map(key);
const ops = diffBlocks(liveKeys, targetKeys);
let cursor = 0; // index into the LIVE fragment as we mutate it
let applied = 0;
for (const op of ops) {
if (op.op === 'keep') {
cursor++;
} else if (op.op === 'del') {
live.delete(cursor, 1); // remove the live block at the cursor; do not advance
applied++;
} else {
live.insert(cursor, [cloneXmlNode(targetKids[op.bi] as XmlNode)]);
cursor++;
applied++;
}
}
return applied;
}
/** Outcome of a 3-way block merge: ops applied + same-block conflict count. */
export interface Merge3WayResult {
/** Number of block insert/delete operations spliced into `live`. */
applied: number;
/**
* Regions where the human AND git rewrote the SAME base block. The rule is
* deterministic (GIT WINS the region), so the human's version of those blocks
* is dropped from the live doc. `conflicts > 0` is the OBSERVABLE signal the
* caller uses to LOG the loss and pin the human baseline to page history (so it
* is recoverable), instead of the edit vanishing silently.
*/
conflicts: number;
}
/**
* THREE-WAY block merge: reconcile `live` toward `target` using `base` (the
* last-synced common ancestor) so a block only the human changed is KEPT and a
* block only git changed is taken — instead of git's version always winning
* (review #5). Conflicts (both changed the same block) resolve to git.
*
* Implementation: diff3Plan computes the merged block ORDER (picks from live or
* target); we materialize that as a virtual target fragment and reuse the 2-way
* `mergeXmlFragments` to splice it into `live` minimally (so untouched live block
* instances — and their in-flight edits — stay put). MUST be called inside a Yjs
* transaction. Returns the number of block operations applied. (Use
* `mergeXmlFragments3WayWithStats` when the SAME-BLOCK conflict count is needed.)
*/
export function mergeXmlFragments3Way(
live: Y.XmlFragment,
target: Y.XmlFragment,
base: Y.XmlFragment,
): number {
return mergeXmlFragments3WayWithStats(live, target, base).applied;
}
/**
* As `mergeXmlFragments3Way`, but also returns the SAME-BLOCK conflict count so
* the caller can make a "git won a concurrent same-block edit" event OBSERVABLE
* (the documented conflict contract: git wins deterministically, but the losing
* human content is never destroyed silently — it is logged and recoverable via
* page history).
*/
export function mergeXmlFragments3WayWithStats(
live: Y.XmlFragment,
target: Y.XmlFragment,
base: Y.XmlFragment,
): Merge3WayResult {
const liveKids = live.toArray();
const targetKids = target.toArray();
const liveKeys = liveKids.map(key);
const targetKeys = targetKids.map(key);
const baseKeys = base.toArray().map(key);
const { picks: plan, conflicts } = diff3PlanWithConflicts(
baseKeys,
liveKeys,
targetKeys,
);
// Build the merged block sequence in a throwaway doc, cloning from whichever
// side each pick came from, then 2-way merge it back into the live fragment.
const merged = new Y.Doc();
const mergedFrag = merged.getXmlFragment('default');
const nodes = plan.map((p) =>
cloneXmlNode(
(p.src === 'live' ? liveKids[p.index] : targetKids[p.index]) as XmlNode,
),
);
if (nodes.length) mergedFrag.insert(0, nodes);
return { applied: mergeXmlFragments(live, mergedFrag), conflicts };
}

View File

@@ -73,6 +73,32 @@ describe('agentSourceFields', () => {
).toEqual({ lastUpdatedSource: 'agent', lastUpdatedAiChatId: null }); ).toEqual({ lastUpdatedSource: 'agent', lastUpdatedAiChatId: null });
}); });
it("stamps ONLY the source column 'git-sync' (no chat key) for a git-sync write", () => {
// The git-sync data plane (issue #194 §8.1) has no internal ai_chats row, so
// it stamps the *Source column 'git-sync' and OMITS the chat key entirely
// (unlike the agent branch, which also writes aiChatId). Pinned directly here
// because the page.service.spec only exercises it indirectly.
expect(
agentSourceFields(
{ actor: 'git-sync', aiChatId: null },
'lastUpdatedSource',
'lastUpdatedAiChatId',
),
).toEqual({ lastUpdatedSource: 'git-sync' });
});
it("ignores any aiChatId on a git-sync write (chat key never written)", () => {
// Even if a non-null aiChatId is present, the git-sync branch must not emit
// the chat key.
expect(
agentSourceFields(
{ actor: 'git-sync', aiChatId: 'should-be-ignored' },
'createdSource',
'aiChatId',
),
).toEqual({ createdSource: 'git-sync' });
});
it('returns {} for a user write so the column keeps its default', () => { it('returns {} for a user write so the column keeps its default', () => {
expect( expect(
agentSourceFields( agentSourceFields(

View File

@@ -9,6 +9,8 @@ import { ProvenanceSource } from '../../core/auth/dto/jwt-payload';
* cannot fake an 'agent' marker. * cannot fake an 'agent' marker.
*/ */
export interface AuthProvenanceData { 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; actor: ProvenanceSource;
aiChatId: string | null; aiChatId: string | null;
} }
@@ -60,6 +62,14 @@ export function agentSourceFields<S extends string, C extends string>(
sourceKey: S, sourceKey: S,
chatKey: C, chatKey: C,
): Partial<Record<S, ProvenanceSource> & Record<C, string | null>> { ): 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 {}; if (provenance?.actor !== 'agent') return {};
return { return {
[sourceKey]: 'agent', [sourceKey]: 'agent',

View File

@@ -0,0 +1,18 @@
/**
* Dynamic ESM import bridge for a CommonJS build.
*
* The server compiles with `module: commonjs`, and TypeScript downlevels a
* literal `import()` expression to `require()` — which cannot load an ESM-only
* package (`@docmost/mcp`, `@docmost/git-sync`). Indirecting through `new
* Function` hides the `import()` from the TS downleveler so the REAL dynamic
* `import()` survives to runtime and can load ESM from CommonJS.
*
* This is the single shared copy of that bridge. The per-package typed loaders
* (git-sync.loader.ts, docmost-client.loader.ts, mcp.service.ts) import this and
* keep their own typed `loadX()` wrappers (require.resolve + pathToFileURL +
* memoization) on top.
*/
export const esmImport = new Function(
'specifier',
'return import(specifier)',
) as (specifier: string) => Promise<unknown>;

View File

@@ -0,0 +1,71 @@
import { resolveRequestWorkspace } from './resolve-request-workspace';
// Unit tests for the shared self-hosted/cloud workspace resolver deduplicated out
// of DomainMiddleware + GitHttpService (architecture #11). They must behave
// identically, so this pins the single source of truth.
type AnyMock = jest.Mock;
function build(opts: {
selfHosted: boolean;
first?: { id: string } | null;
byHostname?: { id: string } | null;
}) {
const env = {
isSelfHosted: jest.fn(() => opts.selfHosted),
isCloud: jest.fn(() => !opts.selfHosted),
};
const repo = {
findFirst: jest.fn(async () => opts.first ?? null) as AnyMock,
findByHostname: jest.fn(async () => opts.byHostname ?? null) as AnyMock,
};
return { env, repo };
}
describe('resolveRequestWorkspace', () => {
it('self-hosted: returns the first/default workspace, ignoring the host', async () => {
const { env, repo } = build({ selfHosted: true, first: { id: 'ws-1' } });
const ws = await resolveRequestWorkspace(
env as any,
repo as any,
'anything.example.com',
);
expect(ws).toEqual({ id: 'ws-1' });
expect(repo.findFirst).toHaveBeenCalledTimes(1);
expect(repo.findByHostname).not.toHaveBeenCalled();
});
it('self-hosted: returns null when no workspace is configured', async () => {
const { env, repo } = build({ selfHosted: true, first: null });
expect(await resolveRequestWorkspace(env as any, repo as any, 'h')).toBeNull();
});
it('cloud: resolves by the host-header subdomain', async () => {
const { env, repo } = build({
selfHosted: false,
byHostname: { id: 'ws-acme' },
});
const ws = await resolveRequestWorkspace(
env as any,
repo as any,
'acme.example.com',
);
expect(ws).toEqual({ id: 'ws-acme' });
expect(repo.findByHostname).toHaveBeenCalledWith('acme');
expect(repo.findFirst).not.toHaveBeenCalled();
});
it('cloud: returns null for a blank/missing host (no throw)', async () => {
const { env, repo } = build({ selfHosted: false, byHostname: { id: 'x' } });
expect(await resolveRequestWorkspace(env as any, repo as any, undefined)).toBeNull();
expect(await resolveRequestWorkspace(env as any, repo as any, '')).toBeNull();
expect(repo.findByHostname).not.toHaveBeenCalled();
});
it('cloud: returns null when the subdomain matches no workspace', async () => {
const { env, repo } = build({ selfHosted: false, byHostname: null });
expect(
await resolveRequestWorkspace(env as any, repo as any, 'ghost.example.com'),
).toBeNull();
});
});

View File

@@ -0,0 +1,35 @@
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
import { Workspace } from '@docmost/db/types/entity.types';
import { EnvironmentService } from '../../integrations/environment/environment.service';
/**
* The ONE canonical way to resolve the workspace for an incoming request:
* - self-hosted (single workspace) -> the first/default workspace;
* - cloud (multi-tenant) -> resolved by the host-header subdomain.
* Returns null when none resolves (no workspace configured, or a blank/unknown
* subdomain on cloud). `isSelfHosted()` is `!isCloud()`, so exactly one branch is
* always taken.
*
* Extracted so the self-hosted/cloud branch is not hand-duplicated. Shared by
* `DomainMiddleware` (the normal /api request path) and `GitHttpService` (the raw
* root-mounted /git smart-HTTP host, which Nest middleware does NOT run for) so
* the two cannot drift.
*
* This helper does NOT catch DB errors — callers decide: DomainMiddleware lets a
* throw bubble (as before); GitHttpService wraps it to log + treat as
* unresolvable (-> 404). A blank/missing host on cloud resolves to null rather
* than throwing.
*/
export async function resolveRequestWorkspace(
environmentService: EnvironmentService,
workspaceRepo: WorkspaceRepo,
hostHeader: string | undefined,
): Promise<Workspace | null> {
if (environmentService.isSelfHosted()) {
return (await workspaceRepo.findFirst()) ?? null;
}
// Cloud (isSelfHosted === !isCloud, so this is the only remaining branch).
const subdomain = hostHeader ? hostHeader.split('.')[0] : '';
if (!subdomain) return null;
return (await workspaceRepo.findByHostname(subdomain)) ?? null;
}

View File

@@ -1,7 +1,8 @@
import { Injectable, NestMiddleware, NotFoundException } from '@nestjs/common'; import { Injectable, NestMiddleware } from '@nestjs/common';
import { FastifyRequest, FastifyReply } from 'fastify'; import { FastifyRequest, FastifyReply } from 'fastify';
import { EnvironmentService } from '../../integrations/environment/environment.service'; import { EnvironmentService } from '../../integrations/environment/environment.service';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo'; import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
import { resolveRequestWorkspace } from '../helpers/resolve-request-workspace';
@Injectable() @Injectable()
export class DomainMiddleware implements NestMiddleware { export class DomainMiddleware implements NestMiddleware {
@@ -14,30 +15,19 @@ export class DomainMiddleware implements NestMiddleware {
res: FastifyReply['raw'], res: FastifyReply['raw'],
next: () => void, next: () => void,
) { ) {
if (this.environmentService.isSelfHosted()) { // Shared self-hosted/cloud resolution (the SAME branch the /git host uses),
const workspace = await this.workspaceRepo.findFirst(); // so the logic cannot drift between the two.
if (!workspace) { const workspace = await resolveRequestWorkspace(
//throw new NotFoundException('Workspace not found'); this.environmentService,
(req as any).workspaceId = null; this.workspaceRepo,
return next(); req.headers.host,
} );
// TODO: unify
(req as any).workspaceId = workspace.id;
(req as any).workspace = workspace;
} else if (this.environmentService.isCloud()) {
const header = req.headers.host;
const subdomain = header.split('.')[0];
const workspace = await this.workspaceRepo.findByHostname(subdomain);
if (!workspace) {
(req as any).workspaceId = null;
return next();
}
if (workspace) {
(req as any).workspaceId = workspace.id; (req as any).workspaceId = workspace.id;
(req as any).workspace = workspace; (req as any).workspace = workspace;
} else {
(req as any).workspaceId = null;
} }
next(); next();

View File

@@ -1,4 +1,5 @@
import { pathToFileURL } from 'node:url'; import { pathToFileURL } from 'node:url';
import { esmImport } from '../../../common/helpers/esm-import';
/** /**
* Minimal structural type for the `DocmostClient` class we consume from the * Minimal structural type for the `DocmostClient` class we consume from the
@@ -192,14 +193,8 @@ interface DocmostMcpModule {
SHARED_TOOL_SPECS: Record<string, SharedToolSpec>; SHARED_TOOL_SPECS: Record<string, SharedToolSpec>;
} }
// TS with module:commonjs downlevels a literal `import()` to `require()`, which // The CJS->ESM dynamic-import bridge lives in one shared helper
// cannot load the ESM-only `@docmost/mcp` package. Indirect through Function so // (common/helpers/esm-import.ts). The typed `loadDocmostMcp()` wrapper stays here.
// the real dynamic `import()` survives compilation and can load ESM from
// CommonJS at runtime (same trick as 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. // Memoize the in-flight/loaded module so the dynamic import runs at most once.
let modulePromise: Promise<DocmostMcpModule> | null = null; let modulePromise: Promise<DocmostMcpModule> | null = null;

View File

@@ -3,8 +3,12 @@
* from the SIGNED token claim (never a request body), so 'agent' is unspoofable. * 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 * 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). * 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'; export type ProvenanceSource = 'user' | 'agent' | 'git-sync';
export enum JwtType { export enum JwtType {
ACCESS = 'access', ACCESS = 'access',
@@ -26,7 +30,8 @@ export type JwtPayload = {
// normal user token (treated as 'user'); set only when the internal agent // normal user token (treated as 'user'); set only when the internal agent
// mints a provenance access token so REST writes (create/rename/move page, // mints a provenance access token so REST writes (create/rename/move page,
// comment create/resolve) record a non-spoofable 'agent' marker (§6.5 / §15 // comment create/resolve) record a non-spoofable 'agent' marker (§6.5 / §15
// C3 / §14 N2). // C3 / §14 N2). (git-sync writes use the in-process actor, not a token — see
// the ProvenanceSource note.)
actor?: ProvenanceSource; actor?: ProvenanceSource;
// Nullable: an external MCP agent has no internal ai_chats row, so it carries // Nullable: an external MCP agent has no internal ai_chats row, so it carries
// an 'agent' actor with a null aiChatId. // an 'agent' actor with a null aiChatId.
@@ -39,7 +44,8 @@ export type JwtCollabPayload = {
type: 'collab'; type: 'collab';
// Optional agent-edit provenance, signed into the collab token. Absent for // Optional agent-edit provenance, signed into the collab token. Absent for
// the human collab path (treated as 'user'); set only when the internal agent // the human collab path (treated as 'user'); set only when the internal agent
// mints a provenance collab token (§6.6 / §15 C2). // 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.
actor?: ProvenanceSource; actor?: ProvenanceSource;
// Nullable: an external MCP agent has no internal ai_chats row, so it carries // Nullable: an external MCP agent has no internal ai_chats row, so it carries
// an 'agent' actor with a null aiChatId. // an 'agent' actor with a null aiChatId.

View File

@@ -1,8 +1,11 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { PageService } from './page.service'; import { PageService } from './page.service';
import { MovePageDto } from '../dto/move-page.dto'; import { MovePageDto } from '../dto/move-page.dto';
import { Page } from '@docmost/db/types/entity.types'; import { CreatePageDto } from '../dto/create-page.dto';
import { UpdatePageDto } from '../dto/update-page.dto';
import { Page, User } from '@docmost/db/types/entity.types';
import { DEFAULT_TEMPORARY_NOTE_HOURS } from '../constants/temporary-note.constants'; import { DEFAULT_TEMPORARY_NOTE_HOURS } from '../constants/temporary-note.constants';
import { AuthProvenanceData } from '../../../common/decorators/auth-provenance.decorator';
// Direct instantiation with stub deps. The Test.createTestingModule form failed // Direct instantiation with stub deps. The Test.createTestingModule form failed
// to resolve the @InjectKysely()/@InjectQueue() tokens at compile(), and this // to resolve the @InjectKysely()/@InjectQueue() tokens at compile(), and this
@@ -496,4 +499,295 @@ describe('PageService', () => {
expect(db.selectFrom).not.toHaveBeenCalled(); expect(db.selectFrom).not.toHaveBeenCalled();
}); });
}); });
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 };
};
const createDto: CreatePageDto = {
title: 'New page',
spaceId: 'space-1',
} as any;
it("stamps lastUpdatedSource:'git-sync' on the insertPage payload", async () => {
const { svc, pageRepo } = makeService();
await svc.create('user-1', 'ws-1', createDto, GIT_SYNC);
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();
});
});
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();
});
});
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();
});
});
});
}); });

View File

@@ -948,6 +948,12 @@ export class PageService {
// Optional agent-edit provenance (from the signed access claim). Stamps the // Optional agent-edit provenance (from the signed access claim). Stamps the
// source marker when the agent moves a page via REST (§6.6 REST path). // source marker when the agent moves a page via REST (§6.6 REST path).
provenance?: AuthProvenanceData, provenance?: AuthProvenanceData,
// Optional responsible author. When set (git-sync), the move is ATTRIBUTED
// to that account via `lastUpdatedById` — parity with create/delete/rename,
// which all stamp the service user. A normal user move omits it, leaving
// `lastUpdatedById` untouched (a reparent is not a content edit, so the
// existing author is preserved — unchanged behavior).
actorUserId?: string,
) { ) {
// validate position value by attempting to generate a key // validate position value by attempting to generate a key
try { try {
@@ -1017,6 +1023,9 @@ export class PageService {
{ {
position: dto.position, position: dto.position,
parentPageId: parentPageId, parentPageId: parentPageId,
// Attribute a git-initiated move to the service account (parity with
// create/delete/rename). Omitted for normal user moves -> unchanged.
...(actorUserId ? { lastUpdatedById: actorUserId } : {}),
// Agent-edit provenance: annotate the source on an agent move. A // Agent-edit provenance: annotate the source on an agent move. A
// normal user request leaves the existing source value unchanged. // normal user request leaves the existing source value unchanged.
...agentSourceFields( ...agentSourceFields(
@@ -1289,8 +1298,18 @@ export class PageService {
pageId: string, pageId: string,
userId: string, userId: string,
workspaceId: 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> { ): Promise<void> {
await this.pageRepo.removePage(pageId, userId, workspaceId); const isGitSync = provenance?.actor === 'git-sync';
await this.pageRepo.removePage(
pageId,
userId,
workspaceId,
isGitSync ? 'git-sync' : undefined,
);
} }
private async parseProsemirrorContent( private async parseProsemirrorContent(

View File

@@ -15,4 +15,12 @@ export class UpdateSpaceDto extends PartialType(CreateSpaceDto) {
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
allowViewerComments: boolean; allowViewerComments: boolean;
@IsOptional()
@IsBoolean()
gitSyncEnabled?: boolean;
@IsOptional()
@IsBoolean()
autoMergeConflicts?: boolean;
} }

View File

@@ -22,4 +22,199 @@ describe('SpaceService', () => {
it('should be defined', () => { it('should be defined', () => {
expect(service).toBeDefined(); 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();
});
});
}); });

View File

@@ -213,6 +213,41 @@ 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( updatedSpace = await this.spaceRepo.updateSpace(
{ {
name: updateSpaceDto.name, name: updateSpaceDto.name,

View File

@@ -0,0 +1,157 @@
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" =');
});
});
});

View File

@@ -294,6 +294,11 @@ export class PageRepo {
pageId: string, pageId: string,
deletedById: string, deletedById: string,
workspaceId: 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> { ): Promise<void> {
const currentDate = new Date(); const currentDate = new Date();
@@ -344,6 +349,7 @@ export class PageRepo {
.set({ .set({
deletedById: deletedById, deletedById: deletedById,
deletedAt: currentDate, deletedAt: currentDate,
...(lastUpdatedSource ? { lastUpdatedSource } : {}),
}) })
.where('id', 'in', pageIds) .where('id', 'in', pageIds)
.where('deletedAt', 'is', null) .where('deletedAt', 'is', null)
@@ -374,7 +380,14 @@ export class PageRepo {
} }
} }
async restorePage(pageId: string, workspaceId: string): Promise<void> { 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> {
// First, check if the page being restored has a deleted parent // First, check if the page being restored has a deleted parent
const pageToRestore = await this.db const pageToRestore = await this.db
.selectFrom('pages') .selectFrom('pages')
@@ -425,7 +438,12 @@ export class PageRepo {
// On restore, disarm the death timer: pulling a note out of trash means // 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 // "keep it". Otherwise a deadline now in the past would re-trash it on the
// next cleanup sweep. // next cleanup sweep.
.set({ deletedById: null, deletedAt: null, temporaryExpiresAt: null }) .set({
deletedById: null,
deletedAt: null,
temporaryExpiresAt: null,
...(lastUpdatedSource ? { lastUpdatedSource } : {}),
})
.where('id', 'in', pageIds) .where('id', 'in', pageIds)
.execute(); .execute();

View File

@@ -0,0 +1,146 @@
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, with the
// key as a BOUND, ::text-cast PARAMETER (not sql.raw) — security fix #5.
expect(sql).toMatch(/jsonb_build_object\(\$\d+::text,/);
// 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 stays inlined via sql.lit, but the KEY is now a bound
// parameter, so id + workspaceId + the key are all bound (updatedAt is a Date).
expect(compiled!.parameters).toContain('space-1');
expect(compiled!.parameters).toContain('ws-1');
expect(compiled!.parameters).toContain('enabled');
});
it('binds the prefKey as a ::text parameter (no sql.raw splice) and inlines prefValue via sql.lit', async () => {
const { repo, getCaptured } = makeRepoCapturingSql();
await repo.updateGitSyncSettings('space-1', 'ws-1', 'enabled', false);
const compiled = getCaptured()!;
const sql = compiled.sql.replace(/\s+/g, ' ');
// The key is a bound `$N::text` parameter; the value is the sql.lit literal.
expect(sql).toMatch(/jsonb_build_object\(\$\d+::text, false\)/);
// The literal key must NOT be spliced into the statement text (the footgun).
expect(sql).not.toContain(`'enabled'`);
// The key rides as a bound parameter instead.
expect(compiled.parameters).toContain('enabled');
});
});

View File

@@ -111,6 +111,34 @@ export class SpaceRepo {
.executeTakeFirst(); .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({
// The jsonb key is a BOUND PARAMETER (`${prefKey}::text`), not
// `sql.raw(prefKey)`. The callers here only ever pass the literals
// 'enabled' / 'autoMergeConflicts', but sql.raw would splice the string
// straight into the statement — a latent SQL-injection footgun the moment
// a future caller passes a request-derived key. Parameterizing closes it
// with no behaviour change for the current literal callers.
settings: sql`COALESCE(settings, '{}'::jsonb)
|| jsonb_build_object('gitSync', COALESCE(settings->'gitSync', '{}'::jsonb)
|| jsonb_build_object(${prefKey}::text, ${sql.lit(prefValue)}))`,
updatedAt: new Date(),
})
.where('id', '=', spaceId)
.where('workspaceId', '=', workspaceId)
.returningAll()
.executeTakeFirst();
}
async updateCommentSettings( async updateCommentSettings(
spaceId: string, spaceId: string,
workspaceId: string, workspaceId: string,

View File

@@ -14,4 +14,162 @@ describe('EnvironmentService', () => {
it('should be defined', () => { it('should be defined', () => {
expect(service).toBeDefined(); 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);
});
});
// isGitSyncHttpEnabled is the master gate of the /git smart-HTTP trust boundary.
// When GIT_SYNC_HTTP_ENABLED is UNSET it FALLS BACK to isGitSyncEnabled(); when
// set it is honored verbatim ('true' -> on, anything else -> off). The fallback
// (default) branch is what these tests pin.
describe('isGitSyncHttpEnabled', () => {
const withEnv = (values: Record<string, string | undefined>) =>
new EnvironmentService({
get: (key: string, fallback?: string) => values[key] ?? fallback,
} as any);
it('DEFAULT branch: unset -> falls back to isGitSyncEnabled() === true', () => {
expect(
withEnv({ GIT_SYNC_ENABLED: 'true' }).isGitSyncHttpEnabled(),
).toBe(true);
});
it('DEFAULT branch: unset -> falls back to isGitSyncEnabled() === false', () => {
// Neither key set: the fallback resolves to isGitSyncEnabled() which is
// false by default.
expect(withEnv({}).isGitSyncHttpEnabled()).toBe(false);
expect(
withEnv({ GIT_SYNC_ENABLED: 'false' }).isGitSyncHttpEnabled(),
).toBe(false);
});
it('explicit "true" enables the host regardless of GIT_SYNC_ENABLED', () => {
expect(
withEnv({
GIT_SYNC_HTTP_ENABLED: 'true',
GIT_SYNC_ENABLED: 'false',
}).isGitSyncHttpEnabled(),
).toBe(true);
});
it('explicit non-"true" disables the host even when sync is enabled', () => {
expect(
withEnv({
GIT_SYNC_HTTP_ENABLED: 'false',
GIT_SYNC_ENABLED: 'true',
}).isGitSyncHttpEnabled(),
).toBe(false);
expect(
withEnv({
GIT_SYNC_HTTP_ENABLED: 'maybe',
GIT_SYNC_ENABLED: 'true',
}).isGitSyncHttpEnabled(),
).toBe(false);
});
});
}); });

View File

@@ -332,4 +332,97 @@ export class EnvironmentService {
.map((o) => o.trim()) .map((o) => o.trim())
.filter(Boolean); .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` (`{spaceId}` is
* substituted per-space in the orchestrator). SCAFFOLDING for the deferred
* remote-push feature: the vendored engine has no remote-push path yet (SPEC
* §7), so this value is currently inert — kept so the wiring is ready when the
* engine grows a push path.
*/
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');
}
} }

View File

@@ -0,0 +1,74 @@
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');
});
});

View File

@@ -170,6 +170,55 @@ export class EnvironmentVariables {
}, },
) )
CLICKHOUSE_URL: string; 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;
// SCAFFOLDING for the deferred remote-push feature: the vendored engine does
// not consume gitRemote yet (SPEC §7), so this is currently inert — validated
// here so the wiring is ready when remote push lands.
@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;
} }
export function validate(config: Record<string, any>) { export function validate(config: Record<string, any>) {

View File

@@ -0,0 +1,62 @@
/**
* 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;
/**
* Bounded retry budget for ACQUIRING the per-space lock on the PUSH (external
* receive-pack) path. The poll cycle holds the single-writer lock while it
* processes a whole space, so a legitimate `git push` that arrives during a
* cycle would otherwise IMMEDIATELY 503 (GitSyncLockHeldError) even though the
* cycle is about to release the lock in well under a second for most spaces.
* Under continuous polling that made a majority of pushes 503 non-
* deterministically. So the push path retries the acquire with a small capped
* backoff for up to ~`TOTAL_MS` BEFORE giving up — a transient overlap with a
* cycle no longer fails the push, while a genuinely stuck/long cycle still
* surfaces a 503 after the bound (git then retries the whole push, which is
* safe: the receive-pack only runs ONCE the lock is held, so a 503 never leaves
* a half-applied ref). The POLL cycle itself does NOT retry (it just skips and
* the next tick reconciles), so this is push-only — the smaller blast radius.
*/
export const GIT_SYNC_PUSH_LOCK_RETRY_TOTAL_MS = 5_000;
/** First backoff between push lock-acquire attempts (ms); doubles, capped. */
export const GIT_SYNC_PUSH_LOCK_RETRY_BASE_MS = 100;
/** Cap on the per-attempt push lock-acquire backoff (ms). */
export const GIT_SYNC_PUSH_LOCK_RETRY_MAX_MS = 500;

View File

@@ -0,0 +1,138 @@
// 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, NotFoundException } 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 };
spaceRepo: { findById: AnyMock };
}
function build(opts: { cannot?: boolean; spaceFound?: boolean } = {}): Built {
const { cannot = false, spaceFound = true } = 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 spaceRepo = {
findById: jest.fn(async () => (spaceFound ? { id: 'space-1' } : undefined)),
};
const controller = new GitSyncController(
orchestrator as any,
env as any,
workspaceAbility as any,
spaceRepo as any,
);
return { controller, orchestrator, env, workspaceAbility, ability, spaceRepo };
}
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, spaceRepo } = 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,
);
// The space is resolved workspace-scoped (context workspace, not the body).
expect(spaceRepo.findById).toHaveBeenCalledWith('space-1', 'ctx-ws');
expect(orchestrator.runOnce).toHaveBeenCalledWith('space-1', 'ctx-ws');
expect(res).toEqual({ spaceId: 'space-1', ran: true });
});
it('admin: 404s a spaceId that is not in the workspace and never calls runOnce', async () => {
// A foreign/non-existent space must be rejected BEFORE buildSettings runs
// (which would otherwise create an empty per-space vault directory).
const { controller, orchestrator, spaceRepo } = build({
cannot: false,
spaceFound: false,
});
await expect(
controller.trigger({ spaceId: 'foreign' } as any, USER, WORKSPACE),
).rejects.toBeInstanceOf(NotFoundException);
expect(spaceRepo.findById).toHaveBeenCalledWith('foreign', 'ctx-ws');
expect(orchestrator.runOnce).not.toHaveBeenCalled();
});
});
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,
});
});
});
});

View File

@@ -0,0 +1,109 @@
import {
Body,
Controller,
ForbiddenException,
HttpCode,
HttpStatus,
NotFoundException,
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 { SpaceRepo } from '@docmost/db/repos/space/space.repo';
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,
private readonly spaceRepo: SpaceRepo,
) {}
/** 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);
// Verify the client-supplied spaceId BELONGS to this workspace before doing
// any work (review): without this, `runOnce` -> `buildSettings` reads the
// raw `spaces` row and creates an empty per-space vault directory for a
// foreign/non-existent space before the content read finally 404s. Resolve
// it workspace-scoped and 404 early.
const space = await this.spaceRepo.findById(dto.spaceId, workspace.id);
if (!space) {
throw new NotFoundException('Space not found');
}
// 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(),
),
};
}
}

View File

@@ -0,0 +1,53 @@
import { pathToFileURL } from 'node:url';
import { esmImport } from '../../common/helpers/esm-import';
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;
}
// The CJS->ESM dynamic-import bridge lives in one shared helper
// (common/helpers/esm-import.ts); see it for why `import()` must be hidden from
// the TS commonjs downleveler. The typed `loadGitSync()` wrapper stays here.
// 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;
}

View File

@@ -0,0 +1,62 @@
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 {}

View File

@@ -0,0 +1,414 @@
// 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');
});
it('(abort) an ALREADY-aborted signal -> no spawn, 500 lock-lost', async () => {
// The per-space lock was already lost before run() reached the spawn: we must
// NOT start writing the working tree after a possible lock takeover.
const child = fakeChild();
spawnMock.mockReturnValue(child);
const service = buildService();
const res = fakeRes();
const controller = new AbortController();
controller.abort();
await service.run(baseRequest, fakeReq(), res, controller.signal);
expect(spawnMock).not.toHaveBeenCalled();
expect(res.statusCode).toBe(500);
expect(res.end).toHaveBeenCalledWith('Internal server error');
});
it('(abort) a live signal aborted mid-request -> child SIGTERM + response closed', async () => {
// The lock lapses mid-push: the abort fires, the child is killed (SIGTERM,
// then SIGKILL on escalation), and the response is finished.
const child = fakeChild();
spawnMock.mockReturnValue(child);
const service = buildService();
const res = fakeRes();
const warnSpy = jest.spyOn(Logger.prototype, 'warn');
const controller = new AbortController();
const p = service.run(baseRequest, fakeReq(), res, controller.signal);
await flush(); // let run() reach the spawn + wire the abort listener
controller.abort();
await p;
expect(child.kill).toHaveBeenCalledWith('SIGTERM');
expect(warnSpy).toHaveBeenCalled();
// No headers were sent before the abort -> a clean 500 is sent and ended.
expect(res.statusCode).toBe(500);
expect(res.writableEnded).toBe(true);
});
});
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();
});
});

View File

@@ -0,0 +1,406 @@
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.
*
* `signal` (optional) is the git-sync per-space lock's lost-lock abort signal.
* A receive-pack writes `main`'s working tree, so if the lock lapses mid-push
* (heartbeat CAS miss / Redis outage) the signal fires and we kill the child —
* preventing it from continuing to write the working tree while another replica
* may have taken over the lock and started a cycle (warning #3).
*/
async run(
parsed: GitHttpBackendRequest,
rawReq: IncomingMessage,
rawRes: ServerResponse,
signal?: AbortSignal,
): 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;
// Set once the child exists so the abort handler can target it.
let onAbort: (() => void) | null = null;
// The watchdog timer; cleared centrally in done() so EVERY settle path
// (close, error, timeout, abort) tears it down exactly once.
let watchdogTimer: ReturnType<typeof setTimeout> | undefined;
const done = () => {
if (settled) return;
settled = true;
if (watchdogTimer) clearTimeout(watchdogTimer);
// Detach the abort listener so a later lock loss does not fire into a
// request that already finished.
if (onAbort) {
signal?.removeEventListener('abort', onAbort);
onAbort = null;
}
resolve();
};
// Reject early if the lock was already lost before we even spawned: do not
// start writing the working tree after a possible lock takeover.
if (signal?.aborted) {
if (!rawRes.headersSent) this.send500(rawRes, 'lock-lost');
else
try {
rawRes.end();
} catch {
/* ignore */
}
return done();
}
let child: ReturnType<typeof spawn>;
try {
child = spawn('git', ['http-backend'], { env });
} catch (err) {
this.send500(rawRes, 'spawn-failed', err);
return done();
}
// Lost-lock abort: the per-space lock lapsed mid-request. Kill the child so
// a receive-pack stops writing `main`'s working tree before another replica
// (which may now hold the lock) starts a cycle. Same kill+finish path the
// watchdog uses (extracted into terminateChild).
onAbort = () => {
this.terminateChild(
child,
rawRes,
headerParsed,
'lock-lost',
'git http-backend aborted (git-sync lock lost mid-request); killing child',
done,
);
};
signal?.addEventListener('abort', onAbort);
// 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. `.unref()` so the
// timer never keeps the event loop alive; ALWAYS cleared in done().
watchdogTimer = setTimeout(() => {
this.terminateChild(
child,
rawRes,
headerParsed,
'timeout',
`git http-backend timed out after ` +
`${this.environmentService.getGitSyncBackendTimeoutMs()}ms; killing child`,
done,
);
}, this.environmentService.getGitSyncBackendTimeoutMs());
watchdogTimer.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) => {
// The watchdog timer is cleared centrally in done().
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) => {
// The watchdog timer is cleared centrally in done().
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 */
});
});
}
/**
* Kill the child (SIGTERM, then SIGKILL after a grace period if it ignores the
* term) and finish the HTTP response cleanly, then settle. Shared by the two
* forced-termination paths — the watchdog timeout and the lost-lock abort —
* which differ ONLY by the log line and the send500 `reason`. If no response
* has started a clean 500 is sent; otherwise the in-flight stream is just
* ended. Never throws (a thrown kill/end would crash the request).
*/
private terminateChild(
child: ReturnType<typeof spawn>,
rawRes: ServerResponse,
responseStarted: boolean,
send500Reason: string,
logMessage: string,
done: () => void,
): void {
this.logger.warn(logMessage);
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 (!responseStarted && !rawRes.headersSent) {
this.send500(rawRes, send500Reason);
} else {
try {
rawRes.end();
} catch {
/* ignore */
}
}
done();
}
/** 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 */
}
}
}

View File

@@ -0,0 +1,211 @@
// 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,
userIsSpaceMember: 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 a MEMBER lacks the required permission (reader on write)', () => {
// A member of the space (existence already known to them) who lacks the role:
// 403 leaks nothing new.
expect(
decideGitHttpGate({
...base,
serviceKind: 'write',
userIsSpaceMember: true,
permissionGranted: false,
}),
).toEqual({ kind: 'forbidden' });
});
it('404 (NOT 403) when an authenticated NON-member hits a git-sync space', () => {
// SECURITY: a non-member must be indistinguishable from a missing/disabled
// space. If this returned 403, the 403↔404 difference would let any
// authenticated workspace user brute-force slugs to discover which spaces
// exist and which have git-sync enabled.
expect(
decideGitHttpGate({
...base,
serviceKind: 'write',
userIsSpaceMember: false,
permissionGranted: false,
}),
).toEqual({ kind: 'not-found' });
// Same for a read by a non-member.
expect(
decideGitHttpGate({
...base,
serviceKind: 'read',
userIsSpaceMember: false,
permissionGranted: false,
}),
).toEqual({ kind: 'not-found' });
});
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' });
});
});

View File

@@ -0,0 +1,164 @@
// 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, OR the authenticated user is NOT a member of the
* space (has no role at all) -> 404 (never reveal existence).
* 5. a MEMBER of the space who lacks the required perm (e.g. a reader trying to
* push) -> 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 — OR a space they are not
* a member of — gets 404 (not 403). Folding non-membership into the 404 branch is
* a SECURITY requirement: if a non-member got 403 here (as a "permission denied")
* while a non-existent / sync-disabled space got 404, the 403↔404 difference would
* let any authenticated workspace user brute-force slugs to discover which spaces
* exist and which have git-sync enabled — including spaces they cannot see. 403 is
* therefore reserved for the one case where existence is ALREADY known to the
* caller because they ARE a member (so it leaks nothing new): a member without the
* required role. `userIsSpaceMember` is the resolved "the user has SOME role in
* this space" boolean (false when SpaceAbilityFactory.createForUser throws
* NotFound / the user has no role).
*/
export function decideGitHttpGate(input: {
hasCredentials: boolean;
credentialsValid: boolean;
serviceKind: GitHttpServiceKind | null;
gitSyncEnabled: boolean;
gitHttpEnabled: boolean;
spaceExists: boolean;
spaceGitSyncEnabled: boolean;
/** The user has SOME role in the space (false = non-member -> 404, not 403). */
userIsSpaceMember: 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 ||
// A non-member must be indistinguishable from a missing/disabled space: 404,
// never 403 (otherwise the 403↔404 split leaks space existence — see above).
!input.userIsSpaceMember
) {
return { kind: 'not-found' };
}
if (!input.permissionGranted) return { kind: 'forbidden' };
return { kind: 'proceed' };
}

View File

@@ -0,0 +1,643 @@
// 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,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { CREDENTIALS_MISMATCH_MESSAGE } from '../../../core/auth/auth.constants';
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;
serveReadAdvertisement: 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),
// The read-advertisement wrapper pins HEAD under the lock then serves; the
// mock just runs the serve callback so the read path still hits backend.run.
serveReadAdvertisement: jest.fn(
async (_spaceId: string, serve: () => Promise<void>) => serve(),
),
};
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('upload-pack ref advertisement is served HEAD-pinned via serveReadAdvertisement (bug #3)', async () => {
// GET info/refs?service=git-upload-pack carries the HEAD symref a clone reads
// for its default branch, so it must be served with HEAD pinned to `main`
// (under the lock) — not streamed raw — or a clone racing a mid-pull cycle
// would default to the read-only `docmost` mirror.
const built = build({ abilityCan: true });
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'),
});
await built.service.handle(req, reply);
expect(built.orchestrator.serveReadAdvertisement).toHaveBeenCalledTimes(1);
expect(built.orchestrator.serveReadAdvertisement.mock.calls[0][0]).toBe(
'space-1',
);
// The wrapper still streams the backend (the mock runs the serve callback).
expect(built.backend.run).toHaveBeenCalledTimes(1);
expect(built.orchestrator.ingestExternalPush).not.toHaveBeenCalled();
});
it('a POST git-upload-pack pack fetch streams directly (no HEAD-pin needed, resolved by SHA)', async () => {
// The pack negotiation is object-SHA based; only the ref advertisement carries
// the HEAD symref, so the pack POST streams the backend directly (no lock).
const built = build({ abilityCan: true });
const { reply } = fakeReply();
const req = fakeRequest({
url: '/git/space-1.git/git-upload-pack',
method: 'POST',
authorization: basic('dev@example.com', 'pw'),
});
await built.service.handle(req, reply);
expect(built.orchestrator.serveReadAdvertisement).not.toHaveBeenCalled();
expect(built.backend.run).toHaveBeenCalledTimes(1);
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('an authenticated NON-member of a git-sync space -> 404, NOT 403 (no existence leak)', async () => {
// createForUser throws NotFound when the user holds no role in the space (a
// non-member). The gate must return 404 — the SAME response a missing /
// sync-disabled space gives — so a 403↔404 difference cannot be used to
// brute-force which spaces exist / have git-sync enabled (the security fix).
const built = build({ abilityCan: false });
built.abilityFactory.createForUser.mockRejectedValue(
new NotFoundException('Space permissions not found'),
);
const { reply, state } = fakeReply();
const req = fakeRequest({
url: '/git/secret-space.git/info/refs?service=git-upload-pack',
method: 'GET',
authorization: basic('dev@example.com', 'pw'),
});
await built.service.handle(req, reply);
expect(built.abilityFactory.createForUser).toHaveBeenCalledTimes(1);
expect(state.statusCode).toBe(404);
expect(built.backend.run).not.toHaveBeenCalled();
expect(built.orchestrator.ingestExternalPush).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();
});
// --- brute-force throttle (must-fix #1, mirrors the /mcp Basic limiter) -----
describe('HTTP-Basic brute-force throttle', () => {
/** A request with wrong credentials for the given email. */
const wrongCredReq = (email = 'dev@example.com') =>
fakeRequest({
url: '/git/space-1.git/info/refs?service=git-upload-pack',
method: 'GET',
authorization: basic(email, 'wrong'),
});
it('rejects the (threshold+1)-th failed attempt with 429 BEFORE bcrypt', async () => {
const built = build();
// Realistic credential failure: verifyUserCredentials throws the SAME
// UnauthorizedException(CREDENTIALS_MISMATCH_MESSAGE) production throws, so
// isCredentialsFailure matches and the reservation is KEPT (counted).
built.authService.verifyUserCredentials.mockRejectedValue(
new UnauthorizedException(CREDENTIALS_MISMATCH_MESSAGE),
);
// 5 failed attempts (threshold = 5): each runs the credential check -> 401.
for (let i = 0; i < 5; i++) {
const { reply, state } = fakeReply();
await built.service.handle(wrongCredReq(), reply);
expect(state.statusCode).toBe(401);
}
expect(built.authService.verifyUserCredentials).toHaveBeenCalledTimes(5);
// The 6th attempt is throttled: 429, Retry-After, and bcrypt is NOT run.
const { reply, state } = fakeReply();
await built.service.handle(wrongCredReq(), reply);
expect(state.statusCode).toBe(429);
expect(state.headers['Retry-After']).toBe('60');
expect(state.headers['WWW-Authenticate']).toBe('Basic realm="gitmost"');
// Still 5 — the 6th never reached verifyUserCredentials (pre-bcrypt reject).
expect(built.authService.verifyUserCredentials).toHaveBeenCalledTimes(5);
expect(built.backend.run).not.toHaveBeenCalled();
built.service.onModuleDestroy();
});
it('a successful auth resets the limiter so later attempts are not throttled', async () => {
const built = build();
const verify = built.authService.verifyUserCredentials;
// First 4 attempts fail (credential mismatch), then one SUCCEEDS.
verify
.mockRejectedValueOnce(new UnauthorizedException(CREDENTIALS_MISMATCH_MESSAGE))
.mockRejectedValueOnce(new UnauthorizedException(CREDENTIALS_MISMATCH_MESSAGE))
.mockRejectedValueOnce(new UnauthorizedException(CREDENTIALS_MISMATCH_MESSAGE))
.mockRejectedValueOnce(new UnauthorizedException(CREDENTIALS_MISMATCH_MESSAGE))
.mockResolvedValueOnce({ id: 'user-1', email: 'dev@example.com' });
for (let i = 0; i < 4; i++) {
const { reply } = fakeReply();
await built.service.handle(wrongCredReq(), reply);
}
// 5th attempt succeeds -> proceeds (not throttled) and clears the budget.
const okReply = fakeReply();
await built.service.handle(
fakeRequest({
url: '/git/space-1.git/info/refs?service=git-upload-pack',
method: 'GET',
authorization: basic('dev@example.com', 'right'),
}),
okReply.reply,
);
expect(okReply.state.hijacked).toBe(true); // proceeded to the backend
// After the reset, a fresh wrong attempt is evaluated (401), NOT a 429 —
// proving the per-IP/per-IP+email budget was cleared by the success.
verify.mockRejectedValueOnce(
new UnauthorizedException(CREDENTIALS_MISMATCH_MESSAGE),
);
const { reply, state } = fakeReply();
await built.service.handle(wrongCredReq(), reply);
expect(state.statusCode).toBe(401);
built.service.onModuleDestroy();
});
it('a non-credential error releases the reservation (does not burn the budget)', async () => {
const built = build();
// A DB error (not a credentials mismatch) must NOT count toward the limiter.
built.authService.verifyUserCredentials.mockRejectedValue(
new Error('db down'),
);
// 10 such failures — far beyond the threshold — must all be 401, never 429,
// because each releases its reservation.
for (let i = 0; i < 10; i++) {
const { reply, state } = fakeReply();
await built.service.handle(wrongCredReq(), reply);
expect(state.statusCode).toBe(401);
}
expect(built.authService.verifyUserCredentials).toHaveBeenCalledTimes(10);
built.service.onModuleDestroy();
});
});
});

View File

@@ -0,0 +1,464 @@
import {
Injectable,
Logger,
OnModuleDestroy,
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,
FailedLoginLimiter,
clientIp,
isCredentialsFailure,
} from '../../mcp/mcp-auth.helpers';
import { resolveRequestWorkspace } from '../../../common/helpers/resolve-request-workspace';
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 implements OnModuleDestroy {
private readonly logger = new Logger(GitHttpService.name);
/**
* In-process brute-force speed bump for the /git HTTP-Basic path. The raw
* `/git/*` Fastify route bypasses the Nest pipeline (so ThrottlerGuard, which is
* only on controllers, never runs) and there is no fastify rate-limit plugin, so
* without this `verifyUserCredentials` (bcrypt) would run unthrottled on every
* request once GIT_SYNC_HTTP_ENABLED is on. Mirrors the /mcp Basic path EXACTLY
* (FailedLoginLimiter, same 5/60s thresholds, the same per-IP / per-IP+email /
* global-per-email keys) so the two auth seams cannot diverge. A speed bump, not
* a hard boundary (in-process, per replica).
*/
private readonly failedLogins = new FailedLoginLimiter(5, 60_000);
/** Periodic sweep to bound limiter memory (mirrors McpService / mcp http.ts). */
private readonly sweepIntervalMs = 60_000;
private readonly sweepTimer: NodeJS.Timeout;
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,
) {
this.sweepTimer = setInterval(() => {
try {
this.failedLogins.sweep();
} catch (err) {
this.logger.error('git-http failed-login limiter sweep failed', err as Error);
}
}, this.sweepIntervalMs);
// Never keep the event loop alive solely for the sweep timer.
this.sweepTimer.unref?.();
}
onModuleDestroy(): void {
clearInterval(this.sweepTimer);
}
/**
* 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. Delegates to the shared `resolveRequestWorkspace` helper (the
* SAME self-hosted/cloud branch DomainMiddleware uses) and returns just the id:
* - 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 {
// Same self-hosted/cloud resolution DomainMiddleware uses — shared so the
// branch cannot drift between the two call sites.
const workspace = await resolveRequestWorkspace(
this.environmentService,
this.workspaceRepo,
this.headerValue(req.headers['host']),
);
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;
let throttled = false;
if (basic && workspaceId) {
// Brute-force speed bump, mirroring the /mcp Basic path EXACTLY. Reserve
// ALL three keys ATOMICALLY and BEFORE bcrypt (tryReserve folds the check
// and the increment into one synchronous step), so the (threshold+1)-th
// attempt is rejected before verifyUserCredentials/bcrypt ever runs and
// concurrent attempts for one email cannot all observe count=0. The
// reservation IS the recorded failure: a genuine credential failure leaves
// it in place, a SUCCESS clears it (reset), a non-credential error releases
// it (so it cannot burn a victim's budget).
const emailLc = basic.email.toLowerCase();
const ip = clientIp(req);
const ipKey = `ip:${ip}`;
const ipEmailKey = `ip-email:${ip}:${emailLc}`;
// GLOBAL per-email backstop (no IP): the only key that survives IP / XFF
// rotation, so it is the real account-brute defense (see mcp-auth.helpers).
const emailKey = `email:${emailLc}`;
const ipOk = this.failedLogins.tryReserve(ipKey);
const ipEmailOk = this.failedLogins.tryReserve(ipEmailKey);
const emailOk = this.failedLogins.tryReserve(emailKey);
if (!ipOk || !ipEmailOk || !emailOk) {
// Blocked: release only the keys we actually reserved this call so an
// already-throttled request does not over-charge keys still under budget
// (matches the /mcp reserve model). Do NOT run bcrypt.
if (ipOk) this.failedLogins.release(ipKey);
if (ipEmailOk) this.failedLogins.release(ipEmailKey);
if (emailOk) this.failedLogins.release(emailKey);
throttled = true;
} else {
try {
user = await this.authService.verifyUserCredentials(
{ email: basic.email, password: basic.password },
workspaceId,
);
credentialsValid = true;
// Success: clear the per-IP and per-IP+email budgets fully; for the
// GLOBAL per-email key only release the one increment THIS request took
// (do not reset() it, or a victim's own success would wipe a parallel
// attacker's accumulated failures for that email — same rule as /mcp).
this.failedLogins.reset(ipKey);
this.failedLogins.reset(ipEmailKey);
this.failedLogins.release(emailKey);
} catch (err) {
// Only a genuine credentials failure (wrong email/password) keeps the
// reservation (it IS the recorded failure). Any other error — DB error,
// etc. — is NOT a password-guess signal, so release the reservation so
// it cannot burn a victim's limiter budget. credentialsValid stays
// false either way (the gate then 401s).
if (!isCredentialsFailure(err)) {
this.failedLogins.release(ipKey);
this.failedLogins.release(ipEmailKey);
this.failedLogins.release(emailKey);
}
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;
}
}
}
// Brute-force throttle tripped: reject BEFORE the gate (and before any space
// lookup), so a throttled attacker gets a uniform 429 with no bcrypt and no
// existence signal. WWW-Authenticate is still sent so a legitimate client
// re-prompts after the window.
if (throttled) {
reply
.header('WWW-Authenticate', WWW_AUTHENTICATE)
.header('Retry-After', '60')
.status(429)
.send('Too many failed authentication attempts. Try again later.');
return;
}
// --- resolve the space + per-space gating + CASL ------------------------
let spaceExists = false;
let spaceGitSyncEnabled = false;
let spaceId: string | undefined;
// The user has SOME role in the space. SECURITY: a non-member must get the
// SAME 404 a missing/disabled space gets — never a 403 — or the 403↔404 split
// would let any authenticated user brute-force slugs to learn which spaces
// exist / have sync enabled (the leak this gate's contract forbids). 403 is
// reserved for a MEMBER who lacks the required role (existence already known).
let userIsSpaceMember = false;
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,
);
// createForUser RESOLVED -> the user holds a role in this space (it
// throws NotFound for a non-member). Record membership BEFORE the
// permission check: a member lacking the role -> 403; a non-member ->
// 404 (handled by the gate via userIsSpaceMember=false below).
userIsSpaceMember = true;
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 (a non-member). Leave userIsSpaceMember=false so the gate
// returns 404, NOT 403 — a non-member must not be able to tell this
// space apart from a non-existent one. (Any other error also falls
// here and is treated as non-member -> 404, the safe default that
// never reveals existence.)
userIsSpaceMember = false;
permissionGranted = false;
}
}
}
}
// --- the gate decision (pure) -------------------------------------------
const decision = decideGitHttpGate({
hasCredentials: Boolean(basic),
credentialsValid,
serviceKind,
gitSyncEnabled: this.environmentService.isGitSyncEnabled(),
gitHttpEnabled: this.environmentService.isGitSyncHttpEnabled(),
spaceExists,
spaceGitSyncEnabled,
userIsSpaceMember,
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) {
// The clone's default branch comes from the HEAD symref advertised by the
// upload-pack ref advertisement (or a dumb `GET HEAD`). The engine
// transiently checks out the read-only `docmost` mirror mid-cycle, so serve
// THAT advertisement with HEAD pinned to `main` under the per-space lock so
// a clone never defaults to `docmost` (bug #3). Pack streaming and every
// other read are resolved by object SHA and need no pin, so they stream
// directly (no lock) as before.
const isReadAdvertise =
req.method === 'GET' &&
((parsedPath.subpath === 'info/refs' &&
service === 'git-upload-pack') ||
parsedPath.subpath === 'HEAD');
if (isReadAdvertise) {
await this.orchestrator.serveReadAdvertisement(spaceId, () =>
this.backend.run(backendRequest, rawReq, rawRes),
);
} else {
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,
// The lock's lost-lock signal is threaded into the backend so the
// receive-pack child is killed if the lock lapses mid-write (warning #3).
(signal) => this.backend.run(backendRequest, rawReq, rawRes, signal),
);
} 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);
}
}

View File

@@ -0,0 +1,252 @@
// 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();
}
});
});
});

View File

@@ -0,0 +1,168 @@
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.
*
* KNOWN OVER-SKIP (latency, NOT data loss): the guard keys ONLY on
* `lastUpdatedSource`, and a user MOVE / RENAME / DELETE does NOT change that
* column (only body writes stamp it). So a genuine user move/rename/delete of a
* page whose BODY was last written by git-sync still reads
* `lastUpdatedSource === 'git-sync'` and is dropped on this fast debounced path.
* No change is lost: the poll-safety interval (~GIT_SYNC_POLL_INTERVAL_MS, default
* 15s) re-enumerates the space and reconciles it — the only cost is up to one poll
* interval of extra latency before that structural change reaches git. The
* bodyHash+updatedAt loop-guard above would close this gap precisely.
*/
@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. NOTE this also over-skips a
// user move/rename/delete of a page whose BODY was last written by git-sync
// (those structural ops don't touch lastUpdatedSource) — that change is not
// lost, just deferred to the ~15s poll backstop (see class docstring).
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);
}
}

View File

@@ -0,0 +1,180 @@
import * as Y from 'yjs';
import { mergeXmlFragments3Way } from '../../../collaboration/merge/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',
]);
});
});

View File

@@ -0,0 +1,629 @@
// 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 () => []),
pinHeadToMain: jest.fn(async () => undefined),
...(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("escalates a divergent-`docmost` push refusal to WARN and surfaces the flag in the status", async () => {
const built = build();
const warnSpy = jest
.spyOn(Logger.prototype, 'warn')
.mockImplementation(() => undefined);
// The engine refused to fast-forward a divergent `docmost` mirror (§5).
runCycleMock.mockResolvedValue({ ...OK_CYCLE, divergentDocmost: true });
const res = await built.orchestrator.runOnce('space-1', 'ws-1');
// The flag is surfaced in the returned status (consumable by /status).
expect(res.divergentDocmost).toBe(true);
// And escalated from the engine's info `log` to a WARN naming the space.
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('DIVERGENT'),
);
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('space-1'));
});
it("does NOT warn when the cycle is clean (divergentDocmost falsy)", async () => {
const built = build();
const warnSpy = jest
.spyOn(Logger.prototype, 'warn')
.mockImplementation(() => undefined);
runCycleMock.mockResolvedValue(OK_CYCLE);
const res = await built.orchestrator.runOnce('space-1', 'ws-1');
expect(res.divergentDocmost).toBeUndefined();
expect(warnSpy).not.toHaveBeenCalledWith(
expect.stringContaining('DIVERGENT'),
);
});
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']);
});
// Explicit timeout: ingestExternalPush exhausts the full bounded
// acquire-retry budget (GIT_SYNC_PUSH_LOCK_RETRY_TOTAL_MS = 5_000ms) before it
// gives up and throws, which races jest's DEFAULT 5_000ms test timeout — flaky
// on a loaded/slow runner. Give it headroom so it deterministically observes
// the eventual LockHeldError instead of timing out first.
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();
}, 15_000);
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('serveReadAdvertisement (bug #3 — stable advertised HEAD)', () => {
it('pins HEAD to main and serves under the space lock', async () => {
const built = build();
const serve = jest.fn(async () => undefined);
await built.orchestrator.serveReadAdvertisement('space-1', serve);
// The lock was taken (redis SET NX) and released (CAS eval).
expect(built.redis.set).toHaveBeenCalledTimes(1);
expect(built.redis.eval).toHaveBeenCalled();
// HEAD pinned BEFORE serving, on the right vault.
expect(built.vaultRegistry.getVault).toHaveBeenCalledWith('space-1');
expect(built.vault.pinHeadToMain).toHaveBeenCalledTimes(1);
expect(serve).toHaveBeenCalledTimes(1);
const pinOrder = built.vault.pinHeadToMain.mock.invocationCallOrder[0];
const serveOrder = serve.mock.invocationCallOrder[0];
expect(pinOrder).toBeLessThan(serveOrder);
});
it('serves WITHOUT a pin/lock when git-sync is globally disabled', async () => {
const built = build({ enabled: false });
const serve = jest.fn(async () => undefined);
await built.orchestrator.serveReadAdvertisement('space-1', serve);
expect(serve).toHaveBeenCalledTimes(1);
expect(built.redis.set).not.toHaveBeenCalled();
expect(built.vault.pinHeadToMain).not.toHaveBeenCalled();
});
});
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('skips an overlapping tick while a previous pass is still in flight (re-entrancy guard)', async () => {
const built = build();
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
// Stall the first pass inside enabledSpaces so a second tick fires while it
// is still running.
const enabledSpy = jest
.spyOn(built.orchestrator as any, 'enabledSpaces')
.mockImplementation(async () => {
await gate;
return [{ spaceId: 'space-1', workspaceId: 'ws-1' }];
});
const runOnce = jest
.spyOn(built.orchestrator, 'runOnce')
.mockResolvedValue({ spaceId: 'space-1', ran: true });
const first = (built.orchestrator as any).pollTick();
await Promise.resolve(); // let the first pass set polling=true + await gate
// A second tick during the first must be skipped: it never even enumerates.
await (built.orchestrator as any).pollTick();
expect(enabledSpy).toHaveBeenCalledTimes(1);
release();
await first;
expect(runOnce).toHaveBeenCalledTimes(1);
// After the first pass cleared the flag, a fresh tick runs normally.
await (built.orchestrator as any).pollTick();
expect(enabledSpy).toHaveBeenCalledTimes(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'`);
});
});
});

View File

@@ -0,0 +1,535 @@
import {
Injectable,
Logger,
OnModuleDestroy,
OnModuleInit,
} from '@nestjs/common';
import { SchedulerRegistry } from '@nestjs/schedule';
import {
lstat,
mkdir,
readFile,
realpath,
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';
import {
GIT_SYNC_PUSH_LOCK_RETRY_BASE_MS,
GIT_SYNC_PUSH_LOCK_RETRY_MAX_MS,
GIT_SYNC_PUSH_LOCK_RETRY_TOTAL_MS,
} from '../git-sync.constants';
/** 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 };
/**
* True when the push REFUSED to fast-forward a divergent `docmost` mirror
* (invariant §5 broken — `docmost` no longer mirrors what Docmost contains).
* Surfaced here (not just logged) so /status can report it. No data is lost,
* but it signals an operator-visible drift that needs attention.
*/
divergentDocmost?: boolean;
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`
* and the tunables are load-bearing today.
*
* `gitRemote` is NOT yet consumed: the vendored engine has no remote-push path
* (see engine/git.ts, engine/pull.ts, SPEC §7 — remote push is deferred), so
* the GIT_SYNC_REMOTE_TEMPLATE env -> validation -> getter -> this field chain
* is inert SCAFFOLDING kept in place for the future remote-push feature. It is
* harmless (the engine ignores it) and removing it would only churn; we still
* populate it so the wiring is ready when the engine grows a push path.
*/
private async buildSettings(spaceId: string): Promise<Settings> {
// Scaffolding for the deferred remote-push feature — the engine does not read
// `gitRemote` yet (see the docstring above). Substitute {spaceId} per-space so
// the value is correct the moment the engine starts consuming it.
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.
*
* `runReceivePack` receives the per-space lock's lost-lock `AbortSignal`: a
* receive-pack writes `main`'s working tree (receive.denyCurrentBranch=
* updateInstead), so if the lock is lost mid-push (a long Redis outage drops the
* heartbeat CAS) the signal fires and the receive-pack's `git http-backend`
* child is killed — closing the window where another replica could grab the lock
* and start a cycle while this child is still writing the working tree.
*/
async ingestExternalPush(
spaceId: string,
workspaceId: string,
runReceivePack: (signal: AbortSignal) => 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).
// Pass the lost-lock signal so the receive-pack child is killed if the lock
// lapses mid-write (no concurrent working-tree writer across replicas).
await runReceivePack(signal);
// 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;
},
// BOUNDED retry-acquire (push path only): a push that briefly overlaps a
// poll cycle waits a moment (capped backoff up to the budget) instead of
// immediately 503-ing — the cycle releases the lock in well under a second
// for most spaces, so this turns a transient overlap into a SUCCESS rather
// than a spurious failure. A genuinely long/stuck cycle still skips after
// the bound -> GitSyncLockHeldError -> 503, and git retries the whole push
// (the receive-pack only runs once the lock is held, so there is never a
// half-applied ref on a 503).
{
acquireRetry: {
timeoutMs: GIT_SYNC_PUSH_LOCK_RETRY_TOTAL_MS,
baseMs: GIT_SYNC_PUSH_LOCK_RETRY_BASE_MS,
maxMs: GIT_SYNC_PUSH_LOCK_RETRY_MAX_MS,
},
},
);
// 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);
}
}
/**
* Serve a git smart-HTTP READ ADVERTISEMENT (`GET info/refs?service=git-upload-pack`
* or a dumb `GET HEAD`) with the repo's symbolic `HEAD` deterministically pinned
* to `main` (bug #3). The advertised `HEAD` symref decides a clone's default
* branch; the engine transiently checks out the read-only `docmost` mirror during
* a cycle, so an unsynchronized advertisement could route a clone to `docmost`
* (~1/4 of clones under continuous syncing).
*
* Running the pin + the advertisement under the SAME per-space lock the cycle
* uses guarantees no cycle is mid-flight while we pin (HEAD cannot flap) and that
* the pin never corrupts a cycle's checkout. The advertisement is cheap (a ref
* listing, no pack stream), so holding the lock for it is fine. A bounded
* retry-acquire absorbs a brief overlap with a cycle; if the lock still cannot be
* taken (a long cycle), we fall back to serving WITHOUT the pin — the cycle's
* finally-restore leaves HEAD on `main` between cycles, so the advertisement is
* still almost always correct (degrades only under sustained contention).
*/
async serveReadAdvertisement(
spaceId: string,
serve: () => Promise<void>,
): Promise<void> {
if (!this.environmentService.isGitSyncEnabled()) {
await serve();
return;
}
const result = await this.spaceLock.withSpaceLock(
spaceId,
async () => {
const vault = await this.vaultRegistry.getVault(spaceId);
await vault.pinHeadToMain();
await serve();
},
{
acquireRetry: {
timeoutMs: GIT_SYNC_PUSH_LOCK_RETRY_TOTAL_MS,
baseMs: GIT_SYNC_PUSH_LOCK_RETRY_BASE_MS,
maxMs: GIT_SYNC_PUSH_LOCK_RETRY_MAX_MS,
},
},
);
// Lock contended for the whole budget (in-progress / another replica): serve
// anyway. `serve` (backend.run) never ran inside the lock in this case.
if (typeof result === 'object' && result !== null && 'skipped' in result) {
await serve();
}
}
/**
* 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).
// `lstat`/`realpath` back the engine's symlink guard: both MUST yield
// `null` on ENOENT (a not-yet-created file is the normal write case) so the
// guard can tell "absent" (safe to create) from "is a symlink" (refuse).
// `lstat` does NOT follow the final link; `realpath` resolves it.
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 }),
lstat: (absPath) =>
lstat(absPath).then(
(st) => ({ isSymbolicLink: st.isSymbolicLink() }),
(err: NodeJS.ErrnoException) => {
if (err && err.code === 'ENOENT') return null;
throw err;
},
),
realpath: (absPath) =>
realpath(absPath).then(
(p) => p,
(err: NodeJS.ErrnoException) => {
if (err && err.code === 'ENOENT') return null;
throw err;
},
),
},
// 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}`),
});
// §5 invariant breach: the push refused to fast-forward a divergent `docmost`
// mirror. No data is lost (the refusal is the safety), but the mirror no
// longer reflects Docmost and the next push will keep refusing until an
// operator reconciles it — so escalate from the engine's info `log` to a
// WARN with the spaceId, and surface the flag in the returned status (/status).
if (result.divergentDocmost) {
this.logger.warn(
`git-sync[${spaceId}] push refused to fast-forward a DIVERGENT 'docmost' ` +
`mirror (invariant §5 broken); manual reconciliation required`,
);
}
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.
*
* KNOWN MULTI-REPLICA LIMITATION (deferred — do not silently lose this):
* This is an IN-PROCESS `setInterval` running on EVERY replica. Cross-replica
* single-writer safety currently rests on the per-space Redis lock
* (SpaceLockService) plus best-effort abort-on-failed-heartbeat — NOT on true
* fencing. Under an adversarial schedule (lock TTL lapse during a GC/IO pause)
* two replicas could still briefly believe they hold a space's lock. The
* intended future direction is to move this orchestration to a BullMQ queue
* (one durable, deduplicated job per space instead of N independent interval
* timers) and add FENCING TOKENS so a stale writer's writes are rejected by the
* store. The author deferred fencing tokens; this comment is the breadcrumb so
* the gap is tracked rather than forgotten. See SpaceLockService.liveLocks.
*/
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;
}
}
/** True while a pollTick pass is in flight (re-entrancy guard). */
private polling = false;
/**
* 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).
*
* Re-entrancy guard: a batch of cycles can take LONGER than the poll interval
* (many spaces, slow pushes), so the next interval tick could fire while this
* pass is still running. The per-space lock already prevents overlapping cycles
* for one space, but an overlapping tick still re-runs enabledSpaces() and
* redundant per-space lock attempts for every space. The `polling` flag skips a
* tick while one is already in flight; it is in-process only (each replica
* guards its own ticks — cross-replica overlap is handled by the Redis lock).
*/
private async pollTick(): Promise<void> {
if (!this.environmentService.isGitSyncEnabled()) return;
if (this.polling) return;
this.polling = true;
try {
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);
}
} finally {
this.polling = false;
}
}
}

View File

@@ -0,0 +1,535 @@
// 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: [] },
});
});
it('throws NotFound when the page does not exist', async () => {
const { service, mocks } = build();
mocks.pageRepo.findById.mockResolvedValue(undefined);
await expect(service.bind(CTX).getPageJson('gone')).rejects.toThrow(
/not found/i,
);
});
});
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');
});
it('returns updatedAt:undefined when the page row is gone after the write (stale-read branch)', async () => {
// writeBody succeeds, but the post-write findById returns nothing (e.g. the
// page was concurrently hard-deleted) -> the optional updatedAt is omitted.
const { service, mocks } = build();
mocks.pageRepo.findById.mockResolvedValue(undefined);
const res = await service
.bind(CTX)
.importPageMarkdown('p1', '# Hello\n\nworld');
expect(mocks.collabGateway.writePageBody).toHaveBeenCalledTimes(1);
expect(res.updatedAt).toBeUndefined();
});
// 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',
});
});
it('returns updatedAt:undefined when the fresh page row is missing after create', async () => {
const { service, mocks } = build();
mocks.pageService.create.mockResolvedValue({ id: 'new-id' });
// The post-create findById returns nothing -> the optional updatedAt is
// omitted (the id is still returned from create()).
mocks.pageRepo.findById.mockResolvedValue(undefined);
const res = await service
.bind(CTX)
.createPage('Title', 'body md', 'space-1');
expect(res).toEqual({ data: { id: 'new-id' }, updatedAt: undefined });
});
});
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, actorUserId] =
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 });
// The git-initiated move is attributed to the service user (lastUpdatedById
// parity with create/delete/rename).
expect(actorUserId).toBe('svc-user');
});
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();
});
it('throws NotFound and moves nothing when the page does not exist', async () => {
const { service, mocks } = build();
mocks.pageRepo.findById.mockResolvedValue(undefined);
await expect(
service.bind(CTX).movePage('gone', 'parent-1'),
).rejects.toThrow(/not found/i);
expect(mocks.pageService.movePage).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 });
});
it('throws NotFound and renames nothing when the page does not exist', async () => {
const { service, mocks } = build();
mocks.pageRepo.findById.mockResolvedValue(undefined);
await expect(
service.bind(CTX).renamePage('gone', 'whatever'),
).rejects.toThrow(/not found/i);
expect(mocks.pageService.update).not.toHaveBeenCalled();
});
});
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();
});
});
});

View File

@@ -0,0 +1,434 @@
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(ctx, 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);
// CAVEAT: writeBody merges through collab, whose persistence is DEBOUNCED, so
// this `updatedAt` read can be STALE — it may reflect the row BEFORE the
// debounced flush lands. Currently harmless: the only consumer is the deferred
// §10 loop-guard, which is not yet wired. When that loop-guard is implemented
// it MUST NOT trust this timestamp as a read-after-write of the body change
// (it would misfire on the pre-flush value); it needs a post-flush read (or to
// key off the collab flush completion) instead.
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(
ctx: GitSyncBindContext,
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,
// Attribute the git-initiated move to the service user (lastUpdatedById),
// matching create/delete/rename — the contract is "git-operations are
// attributed to the service account".
ctx.userId,
);
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,
});
}
}

View File

@@ -0,0 +1,101 @@
// 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');
});
});

View File

@@ -0,0 +1,330 @@
// 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');
});
});
// Bug #1 (push 503 starvation): the PUSH path passes a bounded acquireRetry so a
// transient overlap with a poll cycle is retried (and succeeds) instead of an
// immediate 503. A genuinely stuck lock still skips after the bound. The poll
// cycle passes NO retry (immediate skip), so only the push path waits.
describe('bounded acquire-retry (push path)', () => {
const retry = { timeoutMs: 5_000, baseMs: 100, maxMs: 500 };
it('retries the acquire and SUCCEEDS when the lock is briefly held then released', async () => {
const { service, redis } = build();
// First acquire attempt fails (lock briefly held by a cycle), the next
// succeeds — the bounded retry must turn this into a SUCCESS, not a skip.
redis.set
.mockResolvedValueOnce(null) // attempt 1: held
.mockResolvedValueOnce(null) // attempt 2: still held
.mockResolvedValue('OK'); // attempt 3+: released -> acquired
const fn = jest.fn(async () => 'pushed');
const result = await service.withSpaceLock('space-1', fn, {
acquireRetry: retry,
});
expect(result).toBe('pushed');
expect(fn).toHaveBeenCalledTimes(1);
expect(redis.set.mock.calls.length).toBeGreaterThanOrEqual(3);
// The acquired lock is released in finally (DEL-CAS eval).
expect(redis.eval).toHaveBeenCalledTimes(1);
expect(redis.eval.mock.calls[0][0]).toContain('del');
});
it('still skips (lock-held) after the bound when the lock stays stuck — and never runs fn', async () => {
const { service, redis } = build();
redis.set.mockResolvedValue(null); // permanently held
const fn = jest.fn(async () => 'pushed');
const result = await service.withSpaceLock('space-1', fn, {
acquireRetry: { timeoutMs: 300, baseMs: 50, maxMs: 100 },
});
expect(result).toEqual({ skipped: 'lock-held' });
expect(fn).not.toHaveBeenCalled();
// It retried more than once before giving up (bound > one interval).
expect(redis.set.mock.calls.length).toBeGreaterThan(1);
// Never acquired -> never released.
expect(redis.eval).not.toHaveBeenCalled();
});
it('without acquireRetry (poll path) a held lock skips IMMEDIATELY (single attempt)', async () => {
const { service, redis } = build();
redis.set.mockResolvedValue(null);
const fn = jest.fn(async () => 'cycle');
const result = await service.withSpaceLock('space-1', fn);
expect(result).toEqual({ skipped: 'lock-held' });
expect(redis.set).toHaveBeenCalledTimes(1); // no retry
expect(fn).not.toHaveBeenCalled();
});
});
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);
});

View File

@@ -0,0 +1,251 @@
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();
}
}
/**
* Options for `withSpaceLock`. `acquireRetry` (PUSH path only) bounds a
* retry-acquire loop: if the lock cannot be entered on the first try, keep
* retrying with a capped exponential backoff until `timeoutMs` elapses before
* returning the skip sentinel. The poll cycle holds the lock while it
* processes a whole space, so a legitimate external push that briefly overlaps
* a cycle should WAIT a moment rather than immediately 503 (bug: ~60% of
* pushes 503'd under continuous polling). The poll cycle passes NO retry (it
* just skips and the next tick reconciles).
*/
async withSpaceLock<T>(
spaceId: string,
fn: (signal: AbortSignal) => Promise<T>,
options?: {
acquireRetry?: { timeoutMs: number; baseMs: number; maxMs: number };
},
): Promise<T | { skipped: 'lock-held' | 'in-progress' }> {
const retry = options?.acquireRetry;
// Deadline for the bounded retry-acquire (push path). `Date.now()` once so a
// slow first attempt does not over-extend the budget.
const deadline = retry ? Date.now() + retry.timeoutMs : 0;
let attempt = 0;
for (;;) {
// 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(). On any failure this is released before we retry/skip.
const reservation = this.tryReserveInProcess(spaceId);
if (reservation) {
// Could not even reserve in-process (this instance mid-cycle, or another
// live holder in the process). Retry within the bound, else skip.
if (retry && Date.now() < deadline) {
await this.sleep(this.nextBackoff(attempt++, retry, deadline));
continue;
}
return reservation;
}
// Reserved in-process — now contend for the Redis leader lock. Release the
// in-process slot on EVERY non-running path so a retry/skip leaves no leak.
let acquired = false;
try {
acquired = await this.acquire(spaceId);
} finally {
if (!acquired) this.releaseInProcess(spaceId);
}
if (!acquired) {
if (retry && Date.now() < deadline) {
await this.sleep(this.nextBackoff(attempt++, retry, deadline));
continue;
}
return { skipped: 'lock-held' };
}
// Both locks held — run `fn` under the heartbeat, releasing in `finally`.
// 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);
this.releaseInProcess(spaceId);
}
}
}
/**
* Synchronously try to reserve the in-process single-writer slot for a space.
* Returns a skip sentinel when another holder is live (this instance mid-cycle
* -> 'in-progress'; another SpaceLockService in this process -> 'lock-held'),
* or `null` when the slot was reserved (caller MUST `releaseInProcess` later).
* Both checks + the reservation happen with NO await between them so two
* concurrent same-space calls cannot both pass.
*/
private tryReserveInProcess(
spaceId: string,
): { skipped: 'lock-held' | 'in-progress' } | null {
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' };
}
this.running.add(spaceId);
SpaceLockService.liveLocks.set(spaceId, this.instanceId);
return null;
}
/** Release the in-process single-writer slot reserved by tryReserveInProcess. */
private releaseInProcess(spaceId: string): void {
this.running.delete(spaceId);
SpaceLockService.liveLocks.delete(spaceId);
}
/**
* Backoff (ms) before the next push lock-acquire attempt: capped exponential
* (`baseMs * 2^attempt`, ceilinged at `maxMs`) clamped so it never overshoots
* the retry `deadline`. Deterministic (no jitter) so the bound is testable.
*/
private nextBackoff(
attempt: number,
retry: { baseMs: number; maxMs: number },
deadline: number,
): number {
const exp = retry.baseMs * 2 ** attempt;
const capped = Math.min(exp, retry.maxMs);
const remaining = Math.max(0, deadline - Date.now());
return Math.max(0, Math.min(capped, remaining));
}
/** Promise-based delay (extracted so tests can reason about the retry loop). */
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
const t = setTimeout(resolve, ms);
t.unref?.();
});
}
}

View File

@@ -0,0 +1,152 @@
// 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 config writes (incl. the security-critical
// receive.denyNonFastForwards=true and core.symlinks=false) 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),
getGitSyncBackendTimeoutMs: jest.fn(() => 120000),
};
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 force-push-protection + symlink-guard 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'],
// Security-critical (PR #119 review): a pushed symlink is checked out as
// a plain file, never a real link, so it cannot be followed to leak/
// overwrite a file outside the vault.
['core.symlinks', 'false'],
]);
// Every config write targets THIS vault's cwd and is time-bounded so a
// wedged git cannot hang the request path.
for (const [cmd, args, opts] of execFileMock.mock.calls) {
if (cmd === 'git' && args[0] === 'config') {
expect(opts.cwd).toBe('/vaults/space-1');
expect(opts.timeout).toBe(120000);
}
}
});
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);
});
});
});

View File

@@ -0,0 +1,114 @@
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.
* - core.symlinks=false — SECURITY (PR #119 review). A writer could push a
* `.md` entry that is a SYMLINK (e.g. `leak.md -> /etc/passwd` or
* `-> .env`); with symlinks enabled `updateInstead` would materialize a
* real link in the working tree, and the next push cycle would follow it
* and PUBLISH the target's contents as a Docmost page (server-file
* disclosure), or use a symlinked directory to write OUTSIDE the vault on
* pull. With `core.symlinks=false` git checks out such a blob as a PLAIN
* FILE containing the link text, never a real link, defusing the primitive
* at the git layer. (The engine's per-access lstat/realpath guard is the
* second layer — see path-guard.ts.)
*
* All 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'],
['core.symlinks', 'false'],
];
// Bound each `git config` (review suggestion): this runs in the request path
// BEFORE the watchdog, so a wedged git (a stale `.git/config.lock`) would
// otherwise hang the request indefinitely. Mirror the engine's GIT_EXEC
// bound via the configured backend timeout.
const timeout = this.environmentService.getGitSyncBackendTimeoutMs();
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(),
timeout,
maxBuffer: 10 * 1024 * 1024,
});
}
return path;
}
}

View File

@@ -6,6 +6,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core'; import { ModuleRef } from '@nestjs/core';
import { pathToFileURL } from 'node:url'; import { pathToFileURL } from 'node:url';
import { esmImport } from '../../common/helpers/esm-import';
import { IncomingMessage } from 'node:http'; import { IncomingMessage } from 'node:http';
import { FastifyReply, FastifyRequest } from 'fastify'; import { FastifyReply, FastifyRequest } from 'fastify';
import { EnvironmentService } from '../environment/environment.service'; import { EnvironmentService } from '../environment/environment.service';
@@ -63,14 +64,9 @@ const MCP_RESOLVED = Symbol('mcpResolvedConfig');
// (never the token value) so operators can migrate without log spam. // (never the token value) so operators can migrate without log spam.
let warnedLegacyMcpAuth = false; let warnedLegacyMcpAuth = false;
// TS with module:commonjs downlevels a literal import() to require(), which // The CJS->ESM dynamic-import bridge lives in one shared helper
// cannot load the ESM-only @docmost/mcp package. Indirect through Function so // (common/helpers/esm-import.ts); see it for why import() must be hidden from the
// the real dynamic import() survives compilation and can load ESM from // TS commonjs downleveler.
// CommonJS at runtime.
const esmImport = new Function(
'specifier',
'return import(specifier)',
) as (specifier: string) => Promise<unknown>;
@Injectable() @Injectable()
export class McpService implements OnModuleDestroy { export class McpService implements OnModuleDestroy {

View File

@@ -15,6 +15,7 @@ import { InternalLogFilter } from './common/logger/internal-log-filter';
import { EnvironmentService } from './integrations/environment/environment.service'; import { EnvironmentService } from './integrations/environment/environment.service';
import { resolveFrameHeader } from './common/helpers'; import { resolveFrameHeader } from './common/helpers';
import { resolveTrustProxy } from './integrations/environment/trust-proxy.util'; import { resolveTrustProxy } from './integrations/environment/trust-proxy.util';
import { GitHttpService } from './integrations/git-sync/http/git-http.service';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>( const app = await NestFactory.create<NestFastifyApplication>(
@@ -106,6 +107,23 @@ async function bootstrap() {
}, },
); );
// git smart-HTTP POST bodies use these media types. Register PASSTHROUGH
// content-type parsers so Fastify does NOT buffer/parse them (it would
// otherwise reject the unknown type with 415); the /git handler streams the
// raw Node request (request.raw) to `git http-backend` stdin instead. A
// passthrough parser also bypasses the bodyLimit, so large pushes are not
// truncated (the bytes are never buffered by Fastify).
app
.getHttpAdapter()
.getInstance()
.addContentTypeParser(
[
'application/x-git-upload-pack-request',
'application/x-git-receive-pack-request',
],
(_req, payload, done) => done(null, payload),
);
app app
.getHttpAdapter() .getHttpAdapter()
.getInstance() .getInstance()
@@ -153,6 +171,25 @@ async function bootstrap() {
app.useGlobalInterceptors(new TransformHttpResponseInterceptor(reflector)); app.useGlobalInterceptors(new TransformHttpResponseInterceptor(reflector));
app.enableShutdownHooks(); app.enableShutdownHooks();
// git smart-HTTP host (the /git/<spaceId>.git/... subtree). Registered as a
// RAW Fastify route — NOT a Nest controller under the global '/api' prefix —
// so it lives at the ROOT and a single wildcard reliably captures the whole
// multi-segment subtree (avoiding the path-to-regexp v8 wildcard / global-
// prefix-exclude ambiguity in NestJS v11). The handler is resolved from the
// Nest container so all auth/authz/gating still runs. NOTE: Nest middleware
// (DomainMiddleware) does NOT run for this raw root route — it is bound to the
// Nest router under the global '/api' prefix — so request.raw.workspaceId is
// NOT populated here; GitHttpService resolves the workspace itself (mirroring
// DomainMiddleware). The Fastify wildcard '/git/*' captures the multi-segment
// subpath; the handler re-parses req.url itself.
const gitHttpService = app.get(GitHttpService);
app
.getHttpAdapter()
.getInstance()
.all('/git/*', async (request, reply) => {
await gitHttpService.handle(request as any, reply as any);
});
const logger = new Logger('NestApplication'); const logger = new Logger('NestApplication');
process.on('unhandledRejection', (reason, promise) => { process.on('unhandledRejection', (reason, promise) => {

View File

@@ -0,0 +1,102 @@
#!/usr/bin/env node
/*
* git-sync BROWSER e2e — drives the real Docmost web UI with Playwright to
* reproduce the exact user flow that previously caused data loss: pages created
* in the browser start UNTITLED (all collapse to the `_` vault filename); typing
* a title reshuffles that collision and used to TRASH another live page. This
* test creates several pages via the UI, titles one, runs a sync, and asserts
* NOTHING was moved to Trash.
*
* Setup: needs Playwright + a Chromium build. The project should add
* `@playwright/test` as a devDep (`pnpm dlx playwright install chromium`). This
* script resolves playwright-core + the chromium binary from env so it can run
* against an already-installed browser:
* PW_CORE=/path/to/node_modules/playwright-core
* PW_CHROME=/path/to/chrome
* and the live stand env (SERVER/SPACE_ID/EMAIL/PASSWORD/DB_CONTAINER) like the
* shell e2e suites.
*/
const { execSync } = require('node:child_process');
const SERVER = process.env.SERVER || 'http://localhost:3000';
const WEB = process.env.WEB || 'http://localhost:5173';
const SPACE_ID = process.env.SPACE_ID || '019ef1f7-437b-7ae9-9306-809a1729f085';
const SPACE_SLUG = process.env.SPACE_SLUG || 'general';
const EMAIL = process.env.EMAIL || 'admin@test.local';
const PASSWORD = process.env.PASSWORD || 'Test12345!';
const DB = process.env.DB_CONTAINER || 'gitmost-db';
const PW_CORE = process.env.PW_CORE || '/home/claude/pw/node_modules/playwright-core';
const PW_CHROME = process.env.PW_CHROME ||
'/home/claude/.cache/ms-playwright/chromium-1148/chrome-linux/chrome';
const { chromium } = require(PW_CORE);
const psql = (q) =>
execSync(`docker exec ${DB} psql -U docmost -d docmost -tAc "${q}"`, { encoding: 'utf8' }).trim();
const trashedCount = () =>
Number(psql(`select count(*) from pages where space_id='${SPACE_ID}' and deleted_at is not null`));
let cookie = '';
const login = () => {
const out = execSync(
`curl -s -i -X POST ${SERVER}/api/auth/login -H 'Content-Type: application/json' -d '{"email":"${EMAIL}","password":"${PASSWORD}"}'`,
{ encoding: 'utf8' });
cookie = (out.match(/authToken=([^;]+)/) || [])[1] || '';
};
const sync = () => execSync(
`curl -s -b 'authToken=${cookie}' -X POST ${SERVER}/api/git-sync/trigger -H 'Content-Type: application/json' -d '{"spaceId":"${SPACE_ID}"}'`,
{ encoding: 'utf8' });
let pass = 0, fail = 0;
const ok = (m) => { console.log(' \x1b[32mPASS\x1b[0m ' + m); pass++; };
const bad = (m) => { console.log(' \x1b[31mFAIL\x1b[0m ' + m); fail++; };
(async () => {
login();
const trashBefore = trashedCount();
const browser = await chromium.launch({ executablePath: PW_CHROME, args: ['--no-sandbox'] });
const page = await browser.newPage();
try {
// --- log in through the UI ---
await page.goto(`${WEB}/login`, { waitUntil: 'networkidle' });
await page.getByPlaceholder('email@example.com').fill(EMAIL);
await page.getByPlaceholder(/password/i).fill(PASSWORD);
await page.getByRole('button', { name: /sign in|log in|login|войти/i }).click();
await page.waitForTimeout(2000);
ok('logged in via the browser');
// --- create several UNTITLED pages via the UI (the bug trigger) ---
await page.goto(`${WEB}/s/${SPACE_SLUG}`, { waitUntil: 'networkidle' });
await page.waitForTimeout(1200);
const createdUrls = [];
for (let i = 0; i < 3; i++) {
await page.getByRole('button', { name: 'Create page' }).first().click();
await page.waitForTimeout(1500);
createdUrls.push(page.url());
sync(); // each create fires a real git-sync cycle
}
ok('created 3 untitled pages through the UI');
// --- type a title into the page currently open (retitle == the trigger) ---
const titleEditor = page.locator('.tiptap.ProseMirror').first();
await titleEditor.click();
await page.keyboard.type('Заголовок через браузер');
await page.waitForTimeout(1500); // debounced save
sync(); sync();
ok('typed a title into one page');
// --- THE assertion: nothing got trashed by the reshuffle ---
const trashAfter = trashedCount();
if (trashAfter === trashBefore) ok(`no page trashed by the untitled+retitle flow (trash stayed ${trashBefore})`);
else bad(`a page was TRASHED by the browser flow (trash ${trashBefore} -> ${trashAfter}) — DATA LOSS`);
// the titled page must still be live
const titled = Number(psql(`select count(*) from pages where space_id='${SPACE_ID}' and title='Заголовок через браузер' and deleted_at is null`));
if (titled === 1) ok('the titled page is live'); else bad('the titled page is not live');
} finally {
await browser.close();
// cleanup: hard-delete the pages this run created (titled + the untitled ones from this run)
psql(`delete from pages where space_id='${SPACE_ID}' and (title='Заголовок через браузер' or (title='' and created_at > now() - interval '5 minutes'))`);
sync();
}
console.log(`\nRESULTS: ${pass} passed, ${fail} failed`);
process.exit(fail === 0 ? 0 : 1);
})().catch((e) => { console.error(e); process.exit(2); });

View File

@@ -0,0 +1,248 @@
#!/usr/bin/env bash
#
# git-sync ADVANCED end-to-end suite — authz, protocol hardening, concurrency,
# and structural sync (rename / reparent / delete-cap), driven against a LIVE
# stand. Companion to git-sync-e2e.sh (the basic two-way flows). These cases
# need deeper hooks than a plain clone:
# - the vault working repo on the host ($VAULT_DIR/<spaceId>) for ref/SHA asserts,
# - the Redis container ($REDIS_CONTAINER) to inject a held lock (503 path),
# - DB-created fixture users / a second space (auto-created + torn down).
#
# Came out of a generate->critique subagent pass on "what is NOT covered". The
# critic verified the contracts against the code (e.g. a non-member of an
# ENABLED space gets 403, not 404 — only a missing / sync-disabled space 404s).
#
# Usage: apps/server/test/git-sync-e2e-advanced.sh
set -uo pipefail
SERVER="${SERVER:-http://localhost:3000}"
# By default the suite PROVISIONS its own throwaway space (never touches real
# data). Set SPACE_ID explicitly to run against an existing space instead.
SPACE_ID="${SPACE_ID:-}"
EMAIL="${EMAIL:-admin@test.local}"
PASSWORD="${PASSWORD:-Test12345!}"
DB_CONTAINER="${DB_CONTAINER:-gitmost-db}"
DB_USER="${DB_USER:-docmost}"
DB_NAME="${DB_NAME:-docmost}"
REDIS_CONTAINER="${REDIS_CONTAINER:-gitmost-redis}"
VAULT_DIR="${VAULT_DIR:-/tmp/gitmost-vaults}"
LOCK_PREFIX="git-sync:lock:"
BASIC=$(printf '%s:%s' "$EMAIL" "$PASSWORD" | base64 -w0)
GIT_URL="" # set once the space is known (after login/provisioning)
VAULT="" # ditto
PROVISIONED="" # the space id we created (and must delete on exit), if any
WORK=$(mktemp -d /tmp/git-sync-adv.XXXXXX)
COOKIES="$WORK/cookies.txt"
PASS=0; FAIL=0
READER_ID=""; OUTSIDER_ID=""; SPACE2_ID=""
say(){ printf '\n\033[1m== %s\033[0m\n' "$*"; }
ok(){ printf ' \033[32mPASS\033[0m %s\n' "$*"; PASS=$((PASS+1)); }
bad(){ printf ' \033[31mFAIL\033[0m %s\n' "$*"; FAIL=$((FAIL+1)); }
psqlq(){ docker exec "$DB_CONTAINER" psql -U "$DB_USER" -d "$DB_NAME" -tAc "$1" 2>/dev/null | tr -d '[:space:]'; }
api(){ curl -s -b "$COOKIES" "$@"; }
gitc(){ git -c http.extraHeader="Authorization: Basic $BASIC" "$@"; }
code(){ curl -s -o /dev/null -w '%{http_code}' "$@"; } # print HTTP status
basicfor(){ printf '%s:%s' "$1" "$PASSWORD" | base64 -w0; }
sync_now(){ api -X POST "$SERVER/api/git-sync/trigger" -H 'Content-Type: application/json' -d "{\"spaceId\":\"$SPACE_ID\"}" >/dev/null; }
vault_sha(){ git -C "$VAULT" rev-parse "$1" 2>/dev/null; }
# Push retrying on 503 — the smart-HTTP host returns 503+Retry-After when a sync
# cycle holds the lock (a real git client retries; so do we, to dodge poll races).
gpush(){ local out; for _ in $(seq 1 6); do out=$(gitc push origin main 2>&1); echo "$out" | grep -q '503\|busy' && { sleep 2; continue; }; return 0; done; return 1; }
teardown(){
# Hard-delete fixtures by EMAIL/NAME pattern (robust against a mid-run abort
# that never captured an id), so the stand + the basic suite stay clean.
psqlq "delete from space_members where user_id in (select id from users where email like 'e2e-adv-%@test.local');
delete from users where email like 'e2e-adv-%@test.local';
delete from spaces where name like 'E2E-ADV-%';
delete from pages where space_id='$SPACE_ID' and title like 'E2E-ADV-%';" >/dev/null
docker exec "$REDIS_CONTAINER" redis-cli del "${LOCK_PREFIX}${SPACE_ID}" >/dev/null 2>&1
# Delete the throwaway space we created (cascades pages); the delete-cap case
# leaves the vault non-convergent, so dropping the whole space + its vault is
# the clean teardown. (When run against a caller-supplied space, only reset the
# vault — the fixtures above were already removed by pattern.)
if [ -n "$PROVISIONED" ]; then
psqlq "delete from pages where space_id='$PROVISIONED'; delete from spaces where id='$PROVISIONED';" >/dev/null
fi
[ -n "$VAULT" ] && rm -rf "$VAULT"
[ -z "$PROVISIONED" ] && [ -n "$SPACE_ID" ] && sync_now
rm -rf "$WORK"
}
trap teardown EXIT
# Create a workspace user that shares the admin's password hash (so it logs in
# with $PASSWORD). $2 = "reader" adds a reader space membership; "none" = no
# membership (non-member). Echoes the new user id.
make_user(){
local email="$1" role="$2" uid
# grep the bare uuid out of the RETURNING output (psql may append a status tag).
uid=$(docker exec "$DB_CONTAINER" psql -U "$DB_USER" -d "$DB_NAME" -tAc \
"insert into users (id,email,name,password,workspace_id,created_at,updated_at,has_generated_password,is_agent)
select gen_random_uuid(),'$email','$email',password,workspace_id,now(),now(),false,false
from users where email='$EMAIL' returning id;" 2>/dev/null \
| grep -oE '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' | head -1)
if [ "$role" = "reader" ]; then
psqlq "insert into space_members (id,user_id,space_id,role,added_by_id,created_at,updated_at)
values (gen_random_uuid(),'$uid','$SPACE_ID','reader','$uid',now(),now());" >/dev/null
fi
printf '%s' "$uid"
}
# ---------------------------------------------------------------------------
say "setup: login + fixtures"
[ "$(code -c "$COOKIES" -X POST "$SERVER/api/auth/login" -H 'Content-Type: application/json' -d "{\"email\":\"$EMAIL\",\"password\":\"$PASSWORD\"}")" = "200" ] \
&& ok "admin login" || { bad "admin login failed"; exit 1; }
if [ -z "$SPACE_ID" ]; then
slug="adv$(date +%s)$RANDOM"
SPACE_ID=$(api -X POST "$SERVER/api/spaces/create" -H 'Content-Type: application/json' \
-d "{\"name\":\"E2E-ADV Throwaway $slug\",\"slug\":\"$slug\"}" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
[ -n "$SPACE_ID" ] || { bad "could not provision a test space"; exit 1; }
PROVISIONED="$SPACE_ID"
psqlq "update spaces set settings = coalesce(settings,'{}'::jsonb) || '{\"gitSync\":{\"enabled\":true}}'::jsonb where id='$SPACE_ID';" >/dev/null
ok "provisioned throwaway space $SPACE_ID"
fi
GIT_URL="$SERVER/git/$SPACE_ID.git"
VAULT="$VAULT_DIR/$SPACE_ID"
sync_now # initialize the vault for the new space
gitc clone -q "$GIT_URL" "$WORK/c" 2>/dev/null && ok "baseline clone" || { bad "baseline clone failed"; exit 1; }
( cd "$WORK/c" && git config user.email e2e@test && git config user.name e2e )
# ===========================================================================
say "protocol: unparseable / wrong-method requests are rejected (never reach git)"
# A recognized git content-type to an UNKNOWN service subpath reaches the handler
# and is rejected as a bad request (resolveServiceKind -> null -> 400).
[ "$(code -X POST -H "Authorization: Basic $BASIC" -H 'Content-Type: application/x-git-upload-pack-request' "$GIT_URL/git-bogus-pack")" = "400" ] \
&& ok "unknown service subpath -> 400" || bad "unknown service subpath not 400"
# An UNKNOWN content-type is rejected by the global content-type allowlist (415)
# before the git handler even runs — also a valid rejection.
[ "$(code -X POST -H "Authorization: Basic $BASIC" -H 'Content-Type: application/x-git-bogus' "$GIT_URL/git-receive-pack")" = "415" ] \
&& ok "unknown content-type -> 415 (global allowlist)" || bad "unknown content-type not 415"
[ "$(code -X PUT -H "Authorization: Basic $BASIC" "$GIT_URL/git-receive-pack")" = "400" ] \
&& ok "PUT on a pack endpoint -> 400" || bad "PUT not 400"
[ "$(code -X DELETE -H "Authorization: Basic $BASIC" "$GIT_URL/info/refs?service=git-upload-pack")" = "400" ] \
&& ok "DELETE on info/refs -> 400" || bad "DELETE not 400"
# ===========================================================================
say "protocol: path-traversal in space-id / subpath is rejected (no escape)"
for u in \
"$SERVER/git/..%2f..%2f..%2fetc.git/info/refs?service=git-upload-pack" \
"$GIT_URL/%2e%2e%2finfo/refs?service=git-upload-pack" \
"$SERVER/git/.git/info/refs?service=git-upload-pack" ; do
c=$(curl -s --path-as-is -o /dev/null -w '%{http_code}' -H "Authorization: Basic $BASIC" "$u")
case "$c" in 400|404) ok "traversal '${u##*/git/}' -> $c";; *) bad "traversal '${u##*/git/}' got $c (expected 400/404)";; esac
done
# ===========================================================================
say "authz: a sync-DISABLED space is 404 (existence not revealed), not 403"
SPACE2_ID=$(api -X POST "$SERVER/api/spaces/create" -H 'Content-Type: application/json' -d '{"name":"E2E-ADV-Space2","slug":"e2eadvspace2"}' | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
if [ -n "$SPACE2_ID" ]; then
[ "$(code -H "Authorization: Basic $BASIC" "$SERVER/git/$SPACE2_ID.git/info/refs?service=git-upload-pack")" = "404" ] \
&& ok "admin member of a gitSync-disabled space -> 404" || bad "disabled space did not 404"
# enabling it flips to 200 (proves the per-space flag is the gate)
psqlq "update spaces set settings = coalesce(settings,'{}'::jsonb) || '{\"gitSync\":{\"enabled\":true}}'::jsonb where id='$SPACE2_ID';" >/dev/null
[ "$(code -H "Authorization: Basic $BASIC" "$SERVER/git/$SPACE2_ID.git/info/refs?service=git-upload-pack")" = "200" ] \
&& ok "flipping gitSync.enabled=true -> 200" || bad "enabled 2nd space did not 200"
else
bad "could not create a 2nd space"
fi
# ===========================================================================
say "authz: reader can FETCH (200) but is FORBIDDEN to push (403)"
READER_ID=$(make_user "e2e-adv-reader@test.local" reader)
RBASIC=$(basicfor "e2e-adv-reader@test.local")
[ "$(code -H "Authorization: Basic $RBASIC" "$GIT_URL/info/refs?service=git-upload-pack")" = "200" ] \
&& ok "reader fetch -> 200" || bad "reader fetch not 200"
[ "$(code -H "Authorization: Basic $RBASIC" "$GIT_URL/info/refs?service=git-receive-pack")" = "403" ] \
&& ok "reader push (receive-pack) -> 403" || bad "reader push not 403"
# ===========================================================================
say "authz: a NON-member of an enabled space -> 403 (NOT 404)"
OUTSIDER_ID=$(make_user "e2e-adv-outsider@test.local" none)
OBASIC=$(basicfor "e2e-adv-outsider@test.local")
c=$(code -H "Authorization: Basic $OBASIC" "$GIT_URL/info/refs?service=git-upload-pack")
[ "$c" = "403" ] && ok "non-member fetch -> 403 (existence revealed only to members)" || bad "non-member got $c (contract is 403)"
# ===========================================================================
say "concurrency: a push while the per-space lock is held -> 503 + Retry-After"
docker exec "$REDIS_CONTAINER" redis-cli set "${LOCK_PREFIX}${SPACE_ID}" "held-by-test" PX 8000 NX >/dev/null 2>&1
hdr=$(curl -s -D - -o /dev/null -X POST -H "Authorization: Basic $BASIC" \
-H 'Content-Type: application/x-git-receive-pack-request' --data-binary '0000' \
"$GIT_URL/git-receive-pack")
st=$(printf '%s' "$hdr" | head -1 | grep -o '[0-9]\{3\}')
ra=$(printf '%s' "$hdr" | grep -i '^Retry-After:' | tr -d '\r')
main_before=$(vault_sha main)
[ "$st" = "503" ] && ok "push during held lock -> 503" || bad "lock-held push got $st (expected 503)"
[ -n "$ra" ] && ok "503 carries a $ra header" || bad "503 missing Retry-After header"
docker exec "$REDIS_CONTAINER" redis-cli del "${LOCK_PREFIX}${SPACE_ID}" >/dev/null 2>&1
[ "$(vault_sha main)" = "$main_before" ] && ok "receive-pack did not mutate the vault while locked" || bad "vault main changed under a held lock"
# ===========================================================================
say "idempotent re-sync: nothing changes when nothing changed (no churn)"
sync_now
m1=$(vault_sha main); lp1=$(vault_sha refs/docmost/last-pushed)
sync_now; sync_now
m2=$(vault_sha main); lp2=$(vault_sha refs/docmost/last-pushed)
[ "$m1" = "$m2" ] && [ "$lp1" = "$lp2" ] && ok "main + last-pushed SHAs stable across idle cycles" \
|| bad "idle cycles churned refs (main $m1->$m2, last-pushed $lp1->$lp2)"
# (Structural rename/move on the live stand is deliberately NOT scripted here: a
# freshly-API-created page has a meta-only body, so git's rename-similarity
# heuristic classifies a `git mv` of it as delete+add rather than `R`, which is a
# test-fixture artifact, not a feature bug. The rename/move classifier is covered
# deterministically by the engine unit suite — packages/git-sync/test/
# classify-rename-moves.test.ts and node-ops.test.ts.)
# ===========================================================================
say "data-loss guard: deleting MORE than the cap is HELD, not dropped"
# Create cap+2 sibling pages, sync, then git rm all of them in one push.
CAP=$(api "$SERVER/api/git-sync/status" | grep -o '"maxDeletesPerCycle":[0-9]*' | grep -o '[0-9]*')
CAP=${CAP:-5}
N=$((CAP+2))
ids=""
for i in $(seq 1 $N); do
id=$(api -X POST "$SERVER/api/pages/create" -H 'Content-Type: application/json' -d "{\"spaceId\":\"$SPACE_ID\",\"title\":\"E2E-ADV-Del-$i-$RANDOM\"}" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
ids="$ids $id"
done
sync_now
lp_before=$(vault_sha refs/docmost/last-pushed)
rm -rf "$WORK/cd"; gitc clone -q "$GIT_URL" "$WORK/cd" 2>/dev/null
cd "$WORK/cd"; git config user.email e2e@test; git config user.name e2e
for id in $ids; do f=$(grep -rl "$id" --include='*.md' . | head -1); [ -n "$f" ] && git rm -q "$f"; done
git commit -qm "rm $N pages (over cap $CAP)"
gpush
cd "$WORK"
sleep 2
trashed=$(psqlq "select count(*) from pages where space_id='$SPACE_ID' and deleted_at is not null and ($(echo $ids | sed "s/ \?\([0-9a-f-]\+\)/ or id='\1'/g; s/^ or //"));")
lp_after=$(vault_sha refs/docmost/last-pushed)
[ "${trashed:-0}" = "0" ] && ok "none of the $N over-cap deletes were applied (held)" || bad "$trashed pages trashed despite over-cap (data loss!)"
[ "$lp_before" = "$lp_after" ] && ok "last-pushed ref did NOT advance past the delete commit (retry-safe)" || bad "last-pushed advanced over suppressed deletes ($lp_before -> $lp_after)"
# cleanup these pages (hard-delete; they are E2E-ADV-* so teardown also catches them)
# ===========================================================================
say "data-loss guard #2: untitled pages + retitle must NOT trash other pages"
# THE bug from the browser flow: Docmost creates pages UNTITLED (title=''), which
# all serialize to the `_` fallback name. Retitling one reshuffles the `_`
# collision and relocates another's file; git reports the move as delete+add and
# the push used to TRASH the relocated live page. Identity is the pageId now.
ut_before=$(psqlq "select count(*) from pages where space_id='$SPACE_ID' and deleted_at is not null;")
ut_ids=""
for i in 1 2 3 4; do
id=$(api -X POST "$SERVER/api/pages/create" -H 'Content-Type: application/json' -d "{\"spaceId\":\"$SPACE_ID\",\"title\":\"\"}" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
ut_ids="$ut_ids $id"; sync_now
done
# retitle the first one (like typing a title in the editor), then sync twice
first=$(echo $ut_ids | awk '{print $1}')
api -X POST "$SERVER/api/pages/update" -H 'Content-Type: application/json' -d "{\"pageId\":\"$first\",\"title\":\"E2E-ADV-Titled-$RANDOM\"}" >/dev/null
sync_now; sync_now
ut_after=$(psqlq "select count(*) from pages where space_id='$SPACE_ID' and deleted_at is not null;")
live_kept=$(psqlq "select count(*) from pages where space_id='$SPACE_ID' and deleted_at is null and ($(echo $ut_ids | sed "s/ \?\([0-9a-f-]\+\)/ or id='\1'/g; s/^ or //"));")
[ "${ut_after:-9}" = "${ut_before:-0}" ] && ok "no page trashed by the untitled+retitle reshuffle (was the data-loss bug)" || bad "trashed count grew ${ut_before}->${ut_after} (page lost to the reshuffle!)"
[ "${live_kept:-0}" = "4" ] && ok "all 4 untitled/retitled pages still LIVE" || bad "only $live_kept/4 of the untitled pages survived"
# cleanup these via the E2E-ADV teardown (the retitled one) + hard-delete the rest
psqlq "delete from pages where id in ($(echo $ut_ids | sed "s/ \?\([0-9a-f-]\+\)/,'\1'/g; s/^,//"));" >/dev/null
# ===========================================================================
say "RESULTS: $PASS passed, $FAIL failed"
[ "$FAIL" -eq 0 ] && exit 0 || exit 1

221
apps/server/test/git-sync-e2e.sh Executable file
View File

@@ -0,0 +1,221 @@
#!/usr/bin/env bash
#
# git-sync end-to-end test suite.
#
# Exercises the FULL two-way sync against a LIVE gitmost server over the real
# smart-HTTP /git remote: clone (fetch), push (git -> Docmost), Docmost -> git,
# delete -> trash, the 3-way body merge, and the auth/authz gate. This is the
# integration counterpart to the unit suites — it boots nothing itself; it drives
# a running stand.
#
# Prerequisites (a running git-sync stand):
# - server up at $SERVER with GIT_SYNC_ENABLED=true + GIT_SYNC_HTTP_ENABLED=true
# and a configured GIT_SYNC_SERVICE_USER_ID;
# - a space whose settings.gitSync.enabled = true ($SPACE_ID);
# - an admin user ($EMAIL/$PASSWORD) who is a member of that space;
# - the Postgres container reachable for DB assertions ($DB_CONTAINER).
#
# Usage: apps/server/test/git-sync-e2e.sh
# Override any of the env vars below to point at a different stand.
set -uo pipefail
SERVER="${SERVER:-http://localhost:3000}"
# By default the suite PROVISIONS its own throwaway space (so it never touches
# real data). Set SPACE_ID explicitly to run against an existing space instead.
SPACE_ID="${SPACE_ID:-}"
EMAIL="${EMAIL:-admin@test.local}"
PASSWORD="${PASSWORD:-Test12345!}"
DB_CONTAINER="${DB_CONTAINER:-gitmost-db}"
DB_USER="${DB_USER:-docmost}"
DB_NAME="${DB_NAME:-docmost}"
BASIC=$(printf '%s:%s' "$EMAIL" "$PASSWORD" | base64 -w0)
GIT_URL="" # set once the space is known (after login/provisioning)
PROVISIONED="" # the space id we created (and must delete on exit), if any
WORK=$(mktemp -d /tmp/git-sync-e2e.XXXXXX)
COOKIES="$WORK/cookies.txt"
PASS=0
FAIL=0
cleanup() {
# Delete the throwaway space we created (cascades its pages); never touch a
# caller-supplied space beyond our own E2E-* fixtures.
if [ -n "$PROVISIONED" ]; then
docker exec "$DB_CONTAINER" psql -U "$DB_USER" -d "$DB_NAME" -tAc \
"delete from pages where space_id='$PROVISIONED'; delete from spaces where id='$PROVISIONED';" >/dev/null 2>&1
rm -rf "/tmp/gitmost-vaults/$PROVISIONED" 2>/dev/null
elif [ -n "$SPACE_ID" ]; then
docker exec "$DB_CONTAINER" psql -U "$DB_USER" -d "$DB_NAME" -tAc \
"delete from pages where space_id='$SPACE_ID' and title like 'E2E-%';" >/dev/null 2>&1
curl -s -b "$COOKIES" -X POST "$SERVER/api/git-sync/trigger" \
-H 'Content-Type: application/json' -d "{\"spaceId\":\"$SPACE_ID\"}" >/dev/null 2>&1
fi
rm -rf "$WORK"
}
trap cleanup EXIT
say() { printf '\n\033[1m== %s\033[0m\n' "$*"; }
ok() { printf ' \033[32mPASS\033[0m %s\n' "$*"; PASS=$((PASS+1)); }
bad() { printf ' \033[31mFAIL\033[0m %s\n' "$*"; FAIL=$((FAIL+1)); }
gitc() { git -c http.extraHeader="Authorization: Basic $BASIC" "$@"; }
# Push retrying on 503 — the host returns 503+Retry-After when a sync cycle holds
# the per-space lock (a real client retries; so do we, to dodge poll races).
gpush() { local out; for _ in 1 2 3 4 5 6; do out=$(gitc push -q origin main 2>&1); echo "$out" | grep -q '503\|busy' && { sleep 2; continue; }; return 0; done; return 1; }
psqlq() { docker exec "$DB_CONTAINER" psql -U "$DB_USER" -d "$DB_NAME" -tAc "$1" 2>/dev/null; }
api() { curl -s -b "$COOKIES" "$@"; }
# Force one synchronous sync cycle and return when it has applied.
sync_now() {
api -X POST "$SERVER/api/git-sync/trigger" -H 'Content-Type: application/json' \
-d "{\"spaceId\":\"$SPACE_ID\"}" >/dev/null
}
# ----------------------------------------------------------------------------
say "auth: login as the admin"
code=$(curl -s -o /dev/null -w '%{http_code}' -c "$COOKIES" -X POST \
"$SERVER/api/auth/login" -H 'Content-Type: application/json' \
-d "{\"email\":\"$EMAIL\",\"password\":\"$PASSWORD\"}")
[ "$code" = "200" ] && ok "login 200" || { bad "login returned $code"; exit 1; }
# ----------------------------------------------------------------------------
if [ -z "$SPACE_ID" ]; then
say "setup: provision a throwaway git-sync space (never touches real data)"
slug="e2e$(date +%s)$RANDOM"
SPACE_ID=$(api -X POST "$SERVER/api/spaces/create" -H 'Content-Type: application/json' \
-d "{\"name\":\"E2E Throwaway $slug\",\"slug\":\"$slug\"}" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
if [ -n "$SPACE_ID" ]; then
PROVISIONED="$SPACE_ID"
psqlq "update spaces set settings = coalesce(settings,'{}'::jsonb) || '{\"gitSync\":{\"enabled\":true}}'::jsonb where id='$SPACE_ID';" >/dev/null
ok "provisioned space $SPACE_ID"
else
bad "could not provision a test space"; exit 1
fi
fi
GIT_URL="$SERVER/git/$SPACE_ID.git"
# ----------------------------------------------------------------------------
say "gate: smart-HTTP auth/authz"
code=$(curl -s -o /dev/null -w '%{http_code}' "$GIT_URL/info/refs?service=git-upload-pack")
[ "$code" = "401" ] && ok "no credentials -> 401" || bad "no creds expected 401, got $code"
code=$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Basic $(printf '%s:wrong' "$EMAIL" | base64 -w0)" \
"$GIT_URL/info/refs?service=git-upload-pack")
[ "$code" = "401" ] && ok "wrong password -> 401" || bad "wrong creds expected 401, got $code"
code=$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Basic $BASIC" \
"$SERVER/git/00000000-0000-0000-0000-000000000000.git/info/refs?service=git-upload-pack")
[ "$code" = "404" ] && ok "unknown space -> 404 (existence not revealed)" || bad "unknown space expected 404, got $code"
code=$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Basic $BASIC" \
"$GIT_URL/info/refs?service=git-upload-pack")
[ "$code" = "200" ] && ok "valid creds + sync space -> 200" || bad "valid clone gate expected 200, got $code"
# ----------------------------------------------------------------------------
# A DEDICATED test page so the push/merge edits never touch a real page, and so
# a freshly-provisioned (empty) space has content for the fetch test below.
say "setup: create a dedicated test page (edits target only this one)"
TEST_TITLE="E2E-SyncTarget-$RANDOM$RANDOM"
TEST_ID=$(api -X POST "$SERVER/api/pages/create" -H 'Content-Type: application/json' \
-d "{\"spaceId\":\"$SPACE_ID\",\"title\":\"$TEST_TITLE\"}" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
[ -n "$TEST_ID" ] && ok "created test page $TEST_TITLE" || { bad "could not create the test page"; }
sync_now
# ----------------------------------------------------------------------------
say "fetch: clone the space vault over HTTP"
sync_now
if gitc clone -q "$GIT_URL" "$WORK/clone" 2>/dev/null; then
count=$(find "$WORK/clone" -maxdepth 1 -name '*.md' | wc -l)
[ "$count" -ge 1 ] && ok "clone succeeded with $count markdown file(s)" || bad "clone has no .md files"
else
bad "clone failed"
fi
# ----------------------------------------------------------------------------
say "push: a git edit propagates into the (dedicated) Docmost page"
rm -rf "$WORK/cpush"; gitc clone -q "$GIT_URL" "$WORK/cpush" 2>/dev/null
cd "$WORK/cpush" || exit 1
git config user.email e2e@test >/dev/null; git config user.name e2e >/dev/null
target=$(grep -rl "$TEST_ID" --include='*.md' . | head -1)
if [ -n "$target" ]; then
MARK="E2E-PUSH-$RANDOM$RANDOM"
printf '\n## %s\n' "$MARK" >> "$target"
git commit -aqm "e2e push: $MARK"
if gpush; then
sleep 2
has=$(psqlq "select count(*) from pages where id='$TEST_ID' and content::text like '%$MARK%';")
[ "${has:-0}" -ge 1 ] && ok "pushed edit reached the test page" || bad "marker $MARK not in the test page content"
else
bad "git push failed"
fi
else
bad "test page .md not found in the clone"
fi
cd "$WORK" || exit 1
# ----------------------------------------------------------------------------
say "Docmost -> git: a page created in Docmost appears in the vault"
NEW_TITLE="E2E-Created-$RANDOM"
new_id=$(api -X POST "$SERVER/api/pages/create" -H 'Content-Type: application/json' \
-d "{\"spaceId\":\"$SPACE_ID\",\"title\":\"$NEW_TITLE\"}" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
if [ -n "$new_id" ]; then
sync_now
rm -rf "$WORK/clone2"
gitc clone -q "$GIT_URL" "$WORK/clone2" 2>/dev/null
if find "$WORK/clone2" -name "*$NEW_TITLE*.md" | grep -q .; then
ok "new Docmost page '$NEW_TITLE' materialized as a vault file"
else
bad "created page '$NEW_TITLE' did not appear in the vault"
fi
else
bad "could not create a page via the API"
fi
# ----------------------------------------------------------------------------
say "delete: removing a file via git soft-deletes the Docmost page"
cd "$WORK/clone2" 2>/dev/null || cd "$WORK/clone" || exit 1
git config user.email e2e@test >/dev/null; git config user.name e2e >/dev/null
delfile=$(find . -maxdepth 1 -name "*$NEW_TITLE*.md" | head -1)
if [ -n "$delfile" ]; then
git rm -q "$delfile"
git commit -qm "e2e delete: $NEW_TITLE"
if gpush; then
sleep 2
deleted=$(psqlq "select count(*) from pages where space_id='$SPACE_ID' and title='$NEW_TITLE' and deleted_at is not null;")
[ "${deleted:-0}" -ge 1 ] && ok "page '$NEW_TITLE' was soft-deleted (in Trash)" || bad "page '$NEW_TITLE' not soft-deleted after git rm"
else
bad "push (delete) failed"
fi
else
bad "delete target file not found in clone"
fi
cd "$WORK" || exit 1
# ----------------------------------------------------------------------------
say "3-way merge: a git edit to one part keeps the rest of the (test) page"
# Re-clone fresh, append a second unique line to the SAME dedicated page, push,
# then confirm BOTH markers coexist — the body merge did not clobber the first.
rm -rf "$WORK/cmerge"
gitc clone -q "$GIT_URL" "$WORK/cmerge" 2>/dev/null
cd "$WORK/cmerge" || exit 1
git config user.email e2e@test >/dev/null; git config user.name e2e >/dev/null
mfile=$(grep -rl "$TEST_ID" --include='*.md' . | head -1)
if [ -n "$mfile" ]; then
MARK2="E2E-MERGE-$RANDOM$RANDOM"
printf '\n## %s\n' "$MARK2" >> "$mfile"
git commit -aqm "e2e merge: $MARK2"
if gpush; then
sleep 2
both=$(psqlq "select count(*) from pages where id='$TEST_ID' and content::text like '%$MARK2%' and content::text like '%E2E-PUSH-%';")
[ "${both:-0}" -ge 1 ] && ok "new edit added without losing prior content (3-way merge)" || bad "3-way merge lost content (both markers not present)"
else
bad "push (merge) failed"
fi
else
bad "test page .md not found in the clone"
fi
cd "$WORK" || exit 1
# ----------------------------------------------------------------------------
say "RESULTS: $PASS passed, $FAIL failed"
[ "$FAIL" -eq 0 ] && exit 0 || exit 1

View File

@@ -0,0 +1,29 @@
// Jest global setup (runs before each test module loads).
//
// react-dom@18 (pulled in transitively via @docmost/editor-ext -> @tiptap/react
// -> react-dom, e.g. through the math node) reads `navigator` at MODULE-INIT
// time. The server jest config uses `testEnvironment: "node"`, which has no
// `navigator`, so ANY spec that transitively imports the editor schema/engine
// (e.g. the git-sync HTTP service specs, which reach the conversion engine)
// fails to LOAD with "ReferenceError: navigator is not defined". These specs
// never exercise the DOM — they just can't survive the import. Provide the
// minimal browser globals those modules touch at import so the specs run.
/* eslint-disable @typescript-eslint/no-explicit-any */
const g = globalThis as any;
if (typeof g.navigator === "undefined") {
// react-dom only reads navigator.userAgent at init; keep it minimal.
Object.defineProperty(g, "navigator", {
value: { userAgent: "node", platform: "node" },
configurable: true,
writable: true,
});
}
if (typeof g.MessageChannel === "undefined") {
// react-dom's scheduler references MessageChannel at init in some builds.
g.MessageChannel = class {
port1 = { postMessage() {}, close() {}, onmessage: null };
port2 = { postMessage() {}, close() {}, onmessage: null };
};
}

View File

@@ -0,0 +1,139 @@
# git-sync: native-Obsidian vault format
Статус: **дизайн (согласован с владельцем 2026-06-24), к реализации.**
## Цель
Волт спейса должен быть **настоящим Obsidian-волтом**: владелец открывает папку в
Obsidian (с плагином Folder Notes) и получает ровно ту же структуру страниц, не
замечая разницы. Никаких служебных артефактов, которые бы выглядели чужеродно.
Сторонние редакторы кладут «голые» файлы/папки — движок их **адоптирует** в
страницы Docmost.
Сейчас каждый `.md` несёт жирный `<!-- docmost:meta {…} -->` блок — это уезжает.
## Формат
```
<Space-vault>/
Заметка.md # лист: чистый markdown + frontmatter id
Проект/ # страница-родитель = ПАПКА
Проект.md # folder-note: ТЕЛО самой страницы «Проект»
Задача.md # ребёнок
Подпроект/
Подпроект.md # тело «Подпроект»
...
.obsidian/ # конфиг Obsidian — движок НЕ ТРОГАЕТ
```
Каждый файл страницы:
```
---
gitmost_id: 019ef6fc-2638-7ce1-9ce3-2756ce038480
---
<чистый markdown — тело страницы (wiki-ссылки, всё как в Obsidian)>
```
- **Лист** (нет детей) → `<title>.md`.
- **Родитель** (есть дети) → папка `<title>/`, его тело в `<title>/<title>.md`
(folder-note по конвенции плагина LostPaul Folder Notes — заметка с именем
папки внутри неё). Лист, у которого появился первый ребёнок, превращается из
`<title>.md` в `<title>/<title>.md` (безопасный move по id).
- **title** = имя файла (для папки — имя папки). **parentPageId** = ближайшая
родительская папка (её folder-note). **spaceId** = эта репа. Всё выводимо.
- **Идентичность** — `gitmost_id` (= Docmost pageId) во frontmatter. Невыводима,
едет ВМЕСТЕ с файлом → переживает любой move, даже не распознанный git как
rename. (Ключ namespaced `gitmost_id`, не голый `id`, чтобы не конфликтовать с
пользовательскими frontmatter-полями. Имя ключа — последнее на подтверждении.)
- **Коллизии имён** (2+ сиблинга с одним title): как делает сам Obsidian —
добавляем натуральный суффикс ` 2`, ` 3`. id во frontmatter, так что имя файла
чисто косметическое; смена суффикса — безопасный rename (идентичность по id).
Никакого `.gitmost/index.json` (сайдкар отвергнут: path-keyed индекс хрупок к
rename; id во frontmatter самодостаточен). Никаких `docmost:meta`/`docmost:comments`
блоков (комменты и так живут инлайн-марками `<span data-comment-id>` в теле).
## Ссылки между заметками (`[[wikilinks]]`)
Obsidian резолвит `[[Заметка]]` по **basename** (не по полному пути), нормализуя
пробелы/`-`/`_`, с приоритетом короткого пути при неоднозначности.
- В Docmost ссылки — по pageId (mention/reference node), rename переживают.
- В волте — обсидиановские `[[basename]]`.
- Следствие: **reparent (смена папки) ссылку НЕ ломает** (basename тот же),
ломает только **retitle**. Значит переписывать `[[…]]` надо только при смене
имени страницы — узкий случай. (Obsidian сам умеет «update links on rename».)
- Конвертер Docmost-mention ↔ `[[wikilink]]` (обе стороны) + переписывание при
retitle — **отдельная фаза** (см. план), не блокирует формат.
## PULL (Docmost → vault)
1. Прочитать дерево спейса.
2. Layout: лист→`<t>.md`, родитель→`<t>/<t>.md`, коллизии→` 2`/` 3`.
3. Записать `---\ngitmost_id: …\n---\n<тело>` (чистый markdown).
4. Переехавшие файлы — move (по id), не delete.
5. Коммит на `docmost`, merge в `main`.
## PUSH (vault → Docmost)
1. Дифф `last-pushed..main`.
2. Идентичность файла — из frontmatter `gitmost_id`. Родитель — из пути (folder-note
родительской папки).
3. Классификация:
- есть `gitmost_id` в дереве → update/move/rename по id (страховка 5133bb34).
- нет id (новый голый файл от Obsidian) → **adopt**: create page (title=имя,
parent=папка), дописать `gitmost_id` во frontmatter.
- голая папка с детьми без folder-note → создать страницу-родитель, завести
`<folder>/<folder>.md`.
- файл пропал, а id ещё в дереве под другим путём → move. Реально пропал →
delete (под delete-cap).
## Адопция (третья-сторона → Docmost)
- голый `.md` без frontmatter id → create page.
- голая папка с `.md` внутри без folder-note → create страницу-родитель + folder-note.
- `.obsidian/`, аттачменты, dot-файлы, любые не-`.md`**игнор** (не страницы),
лежат в гите как есть, Obsidian ими владеет. Без `.gitignore`.
## Без обратной совместимости
Старый `docmost:meta` формат НЕ поддерживаем (данные тестовые). Волт — кэш: на
переходе `rm -rf` волты спейсов, они пересобираются из Docmost сразу в native-
формате. `parsePageFile` не читает `docmost:meta`; файл без `gitmost_id` frontmatter
— это голый/рукописный файл → адопция (не legacy-страница).
## Краевые случаи
- Git не хранит пустые папки → «родитель без своего файла» невозможен: тело
родителя — это folder-note `<t>/<t>.md`, он и держит папку (плюс дети). Childless
пустая страница → просто `<t>.md`.
- Конфликт folder-note `Папка/Папка.md` с ребёнком title «Папка» → ребёнку суффикс.
- Переименование папки (= rename родителя) → move всего поддерева по id, без
delete+create; ссылки `[[…]]` на сам родитель переписать (basename сменился).
## План фаз (каждая — юниты движка + браузерный e2e + изолированные shell-e2e)
1. ✅ Формат файла: `parsePageFile`/`serializePageFile` (frontmatter id + тело,
`gitmost_id` frontmatter + тело). Юниты. Без смены поведения. (готово)
2. ✅ PULL пишет native-формат (frontmatter + folder-note layout). Волты
wipe+rebuild. (2a — folder-note layout в `buildVaultLayout`; 2b — PULL пишет
`serializePageFile`, `readExisting` читает frontmatter.) (готово)
3. ✅ PUSH берёт идентичность из frontmatter, title из имени файла, родителя из
пути (`parentFolderFile` folder-note-aware). CREATE пишет `gitmost_id` обратно;
UPDATE шлёт чистое тело (без frontmatter) на обе стороны 3-way merge. (готово)
4. Адопция голых файлов/папок (частично в фазе 3: файл без `gitmost_id` → create).
ВАЖНО: тут же сохранить пользовательский frontmatter (Obsidian properties) при
адопции — `parsePageFile` сейчас срезает ведущий frontmatter даже без
`gitmost_id`, а write-back пишет только `gitmost_id`; нужно врезать `gitmost_id`
в существующий frontmatter и сохранять остальные поля И при write-back, И при
следующем pull (иначе pull перезатрёт). До этого native-формат НЕ катить на
реальный Obsidian-волт с properties.
5. Чистка: выпилить старый `docmost:meta` формат-код целиком.
6. Ссылки: конвертер Docmost-mention ↔ `[[wikilink]]` + переписывание при retitle.
## Риски
Смена ФОРМАТА волта на data-loss-чувствительном движке (сегодня ловили тяжёлый баг
с трашем живых страниц). Каждая фаза — за инкрементом, с юнит-тестами движка И
браузерным e2e (`git-sync-browser-e2e.cjs`) + изолированными shell-e2e на
одноразовом спейсе. Без in-place миграций без бэкапа волта.

View File

@@ -0,0 +1,59 @@
import { describe, it, expect } from "vitest";
import { Editor } from "@tiptap/core";
import { Document } from "@tiptap/extension-document";
import { Paragraph } from "@tiptap/extension-paragraph";
import { Text } from "@tiptap/extension-text";
import { Details } from "./details";
import { DetailsSummary } from "./details-summary";
import { DetailsContent } from "./details-content";
// The `details` node's `open` attribute must parse to a strict BOOLEAN. The old
// `getAttribute("open")` returned "" (falsy) for `<details open>` and `null`
// when absent, so a parsed-open details rendered without `open` and collapsed.
// `hasAttribute` yields a real boolean, so open state survives parse → render.
const extensions = [
Document,
Paragraph,
Text,
Details,
DetailsSummary,
DetailsContent,
];
/** Parse an HTML string through the schema and return the first details node. */
function parseDetails(html: string): any {
const editor = new Editor({ extensions, content: html });
const json = editor.getJSON();
const find = (n: any): any => {
if (!n || typeof n !== "object") return undefined;
if (n.type === "details") return n;
if (Array.isArray(n.content)) {
for (const c of n.content) {
const hit = find(c);
if (hit) return hit;
}
}
return undefined;
};
const details = find(json);
editor.destroy();
return details;
}
describe("details node: open attribute parses as a strict boolean", () => {
const body =
'<summary>S</summary><div data-type="detailsContent"><p>b</p></div>';
it("parses <details open> to open === true", () => {
const details = parseDetails(`<details open>${body}</details>`);
expect(details).toBeDefined();
expect(details.attrs.open).toBe(true);
});
it("parses <details> (no open) to open === false", () => {
const details = parseDetails(`<details>${body}</details>`);
expect(details).toBeDefined();
expect(details.attrs.open).toBe(false);
});
});

View File

@@ -39,7 +39,7 @@ export const Details = Node.create<DetailsOptions>({
return { return {
open: { open: {
default: false, default: false,
parseHTML: (e) => e.getAttribute("open"), parseHTML: (e) => e.hasAttribute("open"),
renderHTML: (a) => (a.open ? { open: "" } : {}), renderHTML: (a) => (a.open ? { open: "" } : {}),
}, },
}; };

View File

@@ -1,109 +0,0 @@
/**
* The client seam. `pull.ts`/`push.ts` depend on a narrow STRUCTURAL interface
* rather than any concrete client, because the gitmost server writes NATIVELY —
* through repositories + collab `openDirectConnection`.
*
* `GitSyncClient` is that interface: the native datasource (server side)
* implements it, and the engine only ever uses `Pick<GitSyncClient, ...>`
* subsets of it. The signatures below MIRROR exactly the methods the engine's
* `pull.ts`/`push.ts` actually call (arg shapes + the fields the engine reads
* off each result), so a REST-style client is still structurally assignable and
* the native adapter has a precise contract.
*/
/**
* A page node as returned by `listSpaceTree` (the sidebar/tree walk, no body).
* The engine layout (`buildVaultLayout`) consumes `PageNode` from `./layout`,
* which only requires `id` (+ optional `title`/`slugId`/`parentPageId`); this
* lite shape documents the fields the tree walk surfaces. Real tree nodes also
* carry `position`, `icon`, `hasChildren` — kept open via the index signature.
*/
export interface GitSyncPageNodeLite {
id: string;
slugId?: string;
title?: string;
parentPageId?: string | null;
hasChildren?: boolean;
/** `listSpaceTree` nodes carry extra fields (position, icon, …). */
[key: string]: unknown;
}
/**
* The structural client the engine depends on. Only `Pick<GitSyncClient, ...>`
* subsets are ever used:
* - pull reads: `getPageJson` (+ the tree walk's `listSpaceTree`),
* - push writes: `importPageMarkdown` / `createPage` / `deletePage` /
* `movePage` / `renamePage`,
* - continuous (phase B+): `listRecentSince` / `listTrash` / `restorePage`.
*/
export interface GitSyncClient {
/**
* Full tree of page nodes for the space (or the subtree rooted at
* `rootPageId`), each WITHOUT body content. `complete` is `false` when the
* walk was truncated / a fetch failed — the pull side suppresses absence
* deletions on an incomplete tree (SPEC §8). Native impl returns
* `complete: true` always (reads the DB, not a paginated REST endpoint).
*/
listSpaceTree(spaceId: string, rootPageId?: string): Promise<{
pages: GitSyncPageNodeLite[];
complete: boolean;
}>;
/**
* One page WITH its ProseMirror body content. `applyPullActions` reads
* `id`, `slugId`, `title`, `parentPageId`, `spaceId` (for the file meta) and
* `content` (to stabilize/serialize). `updatedAt` is carried for the
* poll-suppression loop-guard.
*/
getPageJson(pageId: string): Promise<{
id: string;
slugId: string;
title: string;
parentPageId: string | null;
spaceId: string;
updatedAt: string;
content: unknown;
}>;
/**
* Merge a page's body from a self-contained markdown file (meta + body). The
* collab/Yjs write path (SPEC §2/§15.6) — never a raw jsonb overwrite.
* `applyPushActions` reads only an optional `updatedAt` off the result
* (via `extractUpdatedAt`, tolerant of extra fields).
*
* `baseMarkdown` is the last-synced version of the file (`refs/docmost/
* last-pushed`), the common ancestor for a THREE-WAY merge against the live
* doc so concurrent human edits survive (review #5). Optional/null -> 2-way.
*/
importPageMarkdown(pageId: string, fullMarkdown: string, baseMarkdown?: string | null): Promise<{
updatedAt?: string;
[key: string]: unknown;
}>;
/**
* Create a new page and return the assigned id at `data.id`
* (`applyPushActions` reads `result.data.id`, then writes it back into the
* file's meta). An optional top-level/`data.updatedAt` feeds the loop-guard.
*/
createPage(title: string, content: string, spaceId: string, parentPageId?: string): Promise<{
data: {
id: string;
};
updatedAt?: string;
[key: string]: unknown;
}>;
/** Soft-delete a page to Trash (SPEC §8). Result is not inspected. */
deletePage(pageId: string): Promise<unknown>;
/**
* Reparent a page (and optionally set its fractional-index `position`). The
* engine passes `position` UNDEFINED for now; the native impl computes a
* default between siblings. Result is not inspected.
*/
movePage(pageId: string, parentPageId: string | null, position?: string): Promise<unknown>;
/** Change a page's title only (no body touch). Result is not inspected. */
renamePage(pageId: string, title: string): Promise<unknown>;
/**
* Pages updated since `sinceIso` (the poll-safety reconciliation, SPEC §8).
* `spaceId` may be undefined (all spaces); `hardPageCap` bounds the walk.
*/
listRecentSince(spaceId: string | undefined, sinceIso: string | null, hardPageCap?: number): Promise<unknown[]>;
/** List soft-deleted (trashed) pages for the space (deletion detection). */
listTrash(spaceId: string): Promise<unknown[]>;
/** Restore a soft-deleted page from Trash. Result is not inspected. */
restorePage(pageId: string): Promise<unknown>;
}

View File

@@ -1,13 +0,0 @@
/**
* The client seam. `pull.ts`/`push.ts` depend on a narrow STRUCTURAL interface
* rather than any concrete client, because the gitmost server writes NATIVELY —
* through repositories + collab `openDirectConnection`.
*
* `GitSyncClient` is that interface: the native datasource (server side)
* implements it, and the engine only ever uses `Pick<GitSyncClient, ...>`
* subsets of it. The signatures below MIRROR exactly the methods the engine's
* `pull.ts`/`push.ts` actually call (arg shapes + the fields the engine reads
* off each result), so a REST-style client is still structurally assignable and
* the native adapter has a precise contract.
*/
export {};

View File

@@ -1 +0,0 @@
export declare function loadSettingsOrExit<T>(factory: () => T): T;

View File

@@ -1,50 +0,0 @@
import { ZodError } from 'zod';
// Turn a ZodError from settings validation into a clear, actionable startup
// message that names the offending env var(s), then exit(1) — no raw stack
// trace. Mirrors the Python new-project skeleton's load_settings_or_exit.
// A non-ZodError is left to propagate unchanged.
export function loadSettingsOrExit(factory) {
try {
return factory();
}
catch (err) {
if (!(err instanceof ZodError))
throw err;
const missing = [];
const invalid = [];
for (const issue of err.issues) {
const name = issue.path.length ? String(issue.path[0]) : '?';
// A missing required variable surfaces as an `invalid_type` issue whose
// received value was `undefined`. zod 3 exposed `issue.received` directly;
// zod 4 dropped that field and instead folds it into the message
// ("expected string, received undefined"). Detect both shapes so the
// missing-vs-invalid split holds across zod majors. NOTE: an invalid (but
// present) value uses a different code (invalid_format / invalid_value) or
// an `invalid_type` message that reports a non-undefined received (e.g.
// "received NaN" from a coerced number), so neither is misread as missing.
const i = issue;
const isMissing = issue.code === 'invalid_type' &&
(i.received === 'undefined' ||
/received undefined/i.test(i.message ?? ''));
if (isMissing)
missing.push(name);
else
invalid.push(`${name}: ${issue.message}`);
}
const lines = ['Configuration error in environment / .env:'];
if (missing.length) {
lines.push(' Missing required variable(s):');
for (const n of [...new Set(missing)])
lines.push(` - ${n}`);
}
if (invalid.length) {
lines.push(' Invalid value(s):');
for (const item of invalid)
lines.push(` - ${item}`);
}
lines.push('');
lines.push('Set them in .env (see .env.example) and try again.');
process.stderr.write(lines.join('\n') + '\n');
process.exit(1);
}
}

View File

@@ -1,70 +0,0 @@
import { VaultGit } from "./git.js";
import { GitSyncClient } from "./client.types.js";
import { Settings } from "./settings.js";
/**
* Absolute-path filesystem primitives the cycle needs. Injected (not imported)
* so the engine stays IO-free and unit-testable. `mkdir` is recursive; `rm` is
* force (a missing file is a no-op).
*/
export interface CycleFs {
readFile: (absPath: string) => Promise<string>;
writeFile: (absPath: string, text: string) => Promise<void>;
mkdir: (absDir: string) => Promise<void>;
rm: (absPath: string) => Promise<void>;
}
export interface RunCycleDeps {
spaceId: string;
/** The Docmost seam (reads for pull, writes for push). */
client: GitSyncClient;
/** The per-space git vault (a real working repo). */
vault: VaultGit;
/** Engine settings; `vaultPath` roots the relPath -> absolute-path mapping. */
settings: Settings;
fs: CycleFs;
log: (line: string) => void;
/**
* Delete-cap hook (the ONLY caller-specific policy). Called with the push
* dry-run's planned delete count (`Number.POSITIVE_INFINITY` when the dry-run
* itself failed, so the hook can fail safe) and the live client; returns the
* client to use for the REAL apply. The default (omitted) applies every op
* unmodified. gitmost uses it to neutralize deletes when over its cap.
*
* When omitted, NO dry-run is performed (one fewer push planning pass).
*/
resolveApplyClient?: (plannedDeletes: number, client: GitSyncClient) => GitSyncClient;
}
export interface RunCycleResult {
ran: boolean;
/** Set when the cycle short-circuited without running pull/push. */
skipped?: "merge-in-progress";
pull?: {
written: number;
deleted: number;
conflict: boolean;
};
push?: {
mode: string;
failures: number;
};
}
/**
* Run ONE full reconcile cycle for a space: PULL (Docmost -> vault) then PUSH
* (vault -> Docmost), under the engine's required branch choreography. This is
* the single entry point the app drives — it owns the staging order so it can
* never drift from the engine it ships with.
*
* Staging (the ⭐ data-loss-critical order, SPEC §6/§9):
* 1. assertGitAvailable + ensureRepo (the git state store must exist).
* 2. refuse on an unresolved merge (a prior conflicting pull); next checkout
* would fail otherwise.
* 3. ensureBranch('docmost','main') + checkout('docmost'). Pull writes MUST
* land on `docmost`, not `main`: applyPullActions commits on `docmost`,
* then checks out `main` and merges docmost -> main. Writing Docmost
* content straight onto `main` would clobber local file edits before push
* can diff them.
* 4. PULL: readExisting -> listSpaceTree -> computePullActions -> apply.
* 5. PUSH: optional dry-run to feed the delete-cap hook, then the real apply.
*
* Lock + cap POLICY live in the caller; this owns only the mechanics.
*/
export declare function runCycle(deps: RunCycleDeps): Promise<RunCycleResult>;

View File

@@ -1,97 +0,0 @@
import { readExisting, computePullActions, applyPullActions } from "./pull.js";
import { runPush } from "./push.js";
/**
* Run ONE full reconcile cycle for a space: PULL (Docmost -> vault) then PUSH
* (vault -> Docmost), under the engine's required branch choreography. This is
* the single entry point the app drives — it owns the staging order so it can
* never drift from the engine it ships with.
*
* Staging (the ⭐ data-loss-critical order, SPEC §6/§9):
* 1. assertGitAvailable + ensureRepo (the git state store must exist).
* 2. refuse on an unresolved merge (a prior conflicting pull); next checkout
* would fail otherwise.
* 3. ensureBranch('docmost','main') + checkout('docmost'). Pull writes MUST
* land on `docmost`, not `main`: applyPullActions commits on `docmost`,
* then checks out `main` and merges docmost -> main. Writing Docmost
* content straight onto `main` would clobber local file edits before push
* can diff them.
* 4. PULL: readExisting -> listSpaceTree -> computePullActions -> apply.
* 5. PUSH: optional dry-run to feed the delete-cap hook, then the real apply.
*
* Lock + cap POLICY live in the caller; this owns only the mechanics.
*/
export async function runCycle(deps) {
const { spaceId, client, vault, settings, fs, log, resolveApplyClient } = deps;
const vaultRoot = settings.vaultPath;
const abs = (relPath) => `${vaultRoot}/${relPath}`;
// 1. The engine state store is git: make sure the repo + branches exist
// before any tracked-file listing or diff.
await vault.assertGitAvailable();
await vault.ensureRepo();
// 2. Refuse to run on top of an unresolved merge (SPEC §9): a prior
// conflicting pull leaves the vault mid-merge; the next checkout would fail.
if (await vault.isMergeInProgress()) {
log(`vault has an unresolved merge — resolve it (or 'git merge --abort') ` +
`and re-run (SPEC §9); skipping cycle.`);
return { ran: false, skipped: "merge-in-progress" };
}
// 3. Pull writes happen on `docmost`; be on it BEFORE applying (see docstring).
await vault.ensureBranch("docmost", "main");
await vault.checkout("docmost");
// 4. PULL --------------------------------------------------------------------
const existing = await readExisting({
listTracked: () => vault.listTrackedFiles("*.md"),
readFile: (relPath) => fs.readFile(abs(relPath)),
});
const tree = await client.listSpaceTree(spaceId);
const pullActions = computePullActions({
pages: tree.pages,
treeComplete: tree.complete,
existing,
});
const pullResult = await applyPullActions({
client,
git: vault,
writeFile: (absPath, text) => fs.writeFile(absPath, text),
mkdir: (absDir) => fs.mkdir(absDir),
rm: (absPath) => fs.rm(absPath),
}, pullActions, vaultRoot);
// 5. PUSH --------------------------------------------------------------------
const pushDeps = {
settings,
git: vault,
makeClient: () => client,
readFile: (relPath) => fs.readFile(abs(relPath)),
writeFile: (relPath, text) => fs.writeFile(abs(relPath), text),
log,
};
let applyClient = client;
if (resolveApplyClient) {
// Plan the push as a DRY-RUN first to read the delete count, then let the
// caller decide the apply client (e.g. neutralize deletes over a cap). A
// failed dry-run yields Infinity so the hook can fail safe.
let plannedDeletes;
try {
const dry = await runPush(pushDeps, { dryRun: true });
plannedDeletes = dry.planned?.deletes ?? 0;
}
catch (err) {
log(`push dry-run planning failed (${err instanceof Error ? err.message : String(err)}); deferring deletion policy to the cap hook (fail-safe).`);
plannedDeletes = Number.POSITIVE_INFINITY;
}
applyClient = resolveApplyClient(plannedDeletes, client);
}
const pushResult = await runPush({ ...pushDeps, makeClient: () => applyClient }, { dryRun: false });
return {
ran: true,
pull: {
written: pullResult.written,
deleted: pullResult.deleted,
conflict: pullResult.merge.conflict,
},
push: {
mode: pushResult.mode,
failures: pushResult.failures?.length ?? 0,
},
};
}

View File

@@ -1,259 +0,0 @@
/** Bot identity used for engine-authored vault commits (SPEC §7.3). */
export declare const BOT_AUTHOR_NAME = "Docmost Sync";
export declare const BOT_AUTHOR_EMAIL = "docmost-sync@local";
/** Default branch the vault repo is initialized on. */
export declare const DEFAULT_BRANCH = "main";
/**
* One row of `git diff --name-status` (SPEC §6 "ФС → Docmost"). `status` is the
* single-letter change code (`-M` rename detection on), `path` is the (new) file
* path; for a rename/copy (`R`/`C`) `oldPath` is the source and `path` is the
* destination, with `score` carrying git's similarity index (0–100).
*/
export interface DiffEntry {
status: "A" | "M" | "D" | "R" | "C";
/** New (destination) path. For A/M/D it is the only path. */
path: string;
/** Source path — present only for R/C. */
oldPath?: string;
/** Rename/copy similarity score (0–100) — present only for R/C. */
score?: number;
}
/** Result of a `merge`: whether it succeeded cleanly or left conflict markers. */
export interface MergeResult {
/** True when the merge applied cleanly (fast-forward or clean 3-way). */
ok: boolean;
/** True when the merge stopped on conflicts (markers left in the worktree). */
conflict: boolean;
/** Raw combined stdout+stderr, for logging/diagnostics. */
output: string;
}
/** Options for an engine-authored commit (provenance, SPEC §7.3). */
export interface CommitOptions {
authorName: string;
authorEmail: string;
/**
* Trailer lines appended to the commit message body (e.g.
* `Docmost-Sync-Source: docmost`). These are the machine-readable provenance
* the loop-guard keys on (SPEC §12, "commit-attribution").
*/
trailers?: string[];
}
/**
* A git wrapper bound to a single vault path. Construct once per vault; every
* method runs git with `cwd = vaultPath`.
*/
export declare class VaultGit {
private readonly vaultPath;
constructor(vaultPath: string);
/**
* Preflight: verify a runnable `git` binary is on PATH. The daemon shells out
* to system `git` for every vault operation, so a missing binary (e.g. a slim
* container image without git) must fail fast with an actionable message
* rather than a cryptic ENOENT deep inside the first real git call. Presence
* check only — we do NOT gate on a specific version. Runs `git --version`
* with NO `cwd` (the vault dir may not exist yet at preflight time).
*/
assertGitAvailable(): Promise<void>;
/**
* Run a git command in the vault and return trimmed stdout. THIN wrapper over
* the single `runRaw` primitive: throws a clear, unified Error (including
* stderr/stdout) on a non-zero exit.
*/
private run;
/**
* The ONE primitive every git invocation in this module flows through. Builds
* the full argv (`--no-pager -c core.quotepath=false <args>`), env, cwd, and
* maxBuffer, runs git, and NEVER throws — it returns the exit info so callers
* can treat a non-zero exit as either an error (`run`) or a meaningful state
* (e.g. a merge conflict, a porcelain diff that "fails" deliberately).
*
* - argv: ALWAYS prepends `--no-pager -c core.quotepath=false`, so git never
* blocks on a pager and always prints verbatim UTF-8 paths (no octal
* escaping/quoting). `quotepath=false` is the baseline for ALL path-
* printing commands (ls-files, diff --name-only, …).
* - cwd: `opts.cwd === null` -> do NOT set cwd (the preflight, where the
* vault dir may not exist); otherwise `opts.cwd ?? this.vaultPath`.
* - env: `vaultGitEnv(opts?.env)` (cwd-isolation + caller extras).
* - On a spawn/exec error we capture the error `message` too, so a failure
* before git could write to stderr (e.g. ENOENT) is NOT lost.
*/
private runRaw;
/**
* Ensure the vault directory exists and is an initialized git repo on `main`
* with an initial (empty) commit so branches exist. Idempotent: safe to call
* on every run. Sets a LOCAL bot identity for the vault repo if none is set
* (so engine commits never fall back to a global/unset identity).
*/
ensureRepo(): Promise<void>;
/** True if `cwd` is inside a git work-tree (the vault is initialized). */
private isRepo;
/** True if a LOCAL git config key is set in the vault repo. */
private hasLocalConfig;
/** True if the repo has at least one commit (HEAD resolves). */
private hasAnyCommit;
/** True if a branch with the given name exists. */
branchExists(name: string): Promise<boolean>;
/**
* Create `name` from `fromBranch` if it does not already exist. No-op (and no
* checkout) when the branch is already present.
*/
ensureBranch(name: string, fromBranch: string): Promise<void>;
/** Name of the currently checked-out branch. */
currentBranch(): Promise<string>;
/** Check out an existing branch. */
checkout(name: string): Promise<void>;
/** Stage everything (adds, modifications, deletions). */
stageAll(): Promise<void>;
/**
* True if the vault is mid-merge (an unresolved merge from a previous run,
* SPEC §9 / §12). Detected via a `MERGE_HEAD` ref OR any unmerged
* (conflicted) index entries (`git ls-files -u`). The pull cycle checks this
* BEFORE any checkout so a left-over merge produces a clear, actionable
* message instead of a raw "you need to resolve your current index first"
* failure deep inside `checkout`. This is what makes re-runs converge
* (resumability, SPEC §12).
*/
isMergeInProgress(): Promise<boolean>;
/**
* Commit the currently STAGED changes with an explicit author/committer
* identity and the given trailers appended to the message body (SPEC §7.3
* provenance). Returns `true` if a commit was made, `false` if there was
* nothing to commit (graceful no-op). The caller is expected to have staged
* its changes first (e.g. via `stageAll`).
*/
commit(message: string, opts: CommitOptions): Promise<boolean>;
/**
* Low-level commit used by both `commit` and `ensureRepo`'s initial commit.
* Builds the full message with appended trailers and sets author + committer
* identity via env vars (so the committer matches the author, not the repo
* default).
*/
private commitRaw;
/**
* Merge `fromBranch` into the current branch (`git merge --no-edit`).
* Fast-forwards when possible; performs a real 3-way merge otherwise. Conflict
* state is SURFACED (returned), NOT auto-resolved (SPEC §9): the conflict
* markers are left in the worktree for manual resolution by a later increment,
* and — critically — nothing is pushed to Docmost (we never write to Docmost
* anyway).
*/
merge(fromBranch: string): Promise<MergeResult>;
/** True if the index has any unmerged (conflicted) paths. */
private hasUnmergedPaths;
/**
* List tracked files on the current branch (paths relative to the vault
* root, forward-slash separated). An optional glob (a git pathspec) narrows
* the listing, e.g. `"*.md"`.
*
* The target wiki is RUSSIAN, so vault file names routinely contain Cyrillic
* (e.g. `Колонка.md`). With git's DEFAULT `core.quotepath=true`, `ls-files`
* returns non-ASCII paths octal-escaped and double-quoted (`"\320\232..."`),
* which `src/pull.ts` `readExisting` would then parse as garbage paths,
* breaking move/duplicate detection. We defeat that two ways at once:
* - `core.quotepath=false` disables the octal-escape/quoting. It is now the
* `runRaw` argv baseline (prepended to EVERY invocation), so we no longer
* pass it inline here.
* - `-z` emits NUL-delimited RAW UTF-8 paths (no quoting, no newline
* ambiguity), which we split on `\0`.
* We read the RAW stdout (NOT the trimming `run()` helper, which would mangle
* the NUL-delimited bytes) and split on `\0`, dropping empty entries. Paths
* are returned verbatim — git already emits forward slashes.
*/
listTrackedFiles(glob?: string): Promise<string[]>;
/**
* Diff two refs with `--name-status -M -z` and parse the NUL-delimited output
* (SPEC §6: the FS→Docmost push direction diffs `main` against
* `refs/docmost/last-pushed`). Rename detection is ON (`-M`), so a moved/renamed
* file is reported as a single `R` row with both its old and new path instead
* of a delete+add pair — that distinction is what lets the push planner tell a
* move from a delete+create (SPEC §8 "Move vs delete").
*
* `-z` makes git emit NUL-delimited RAW UTF-8 records (the Russian wiki has
* Cyrillic file names) with NO quoting/escaping. The record shape differs by
* status:
* - A/M/D: `status\0path\0`
* - R/C: `Rnnn\0oldPath\0newPath\0` (nnn = similarity score, e.g. `R100`)
* We read the RAW stdout (not the trimming `run()` helper, which would mangle
* the NUL bytes), split on `\0`, drop the trailing empty entry, and walk the
* tokens pulling 1 or 2 path tokens per status. Paths are returned verbatim.
*/
diffNameStatus(fromRef: string, toRef: string): Promise<DiffEntry[]>;
/**
* Resolve a ref/commit-ish to its full SHA, or `null` if it does not exist.
* `rev-parse --verify --quiet` exits non-zero (and prints nothing) for an
* unknown ref, so a non-zero exit maps cleanly to `null`. Used to read
* `refs/docmost/last-pushed` (SPEC §5) — which is absent before the first push.
*/
revParse(ref: string): Promise<string | null>;
/**
* Read a ref to its SHA, or `null` if unset. Thin alias over `revParse`,
* named for the push direction's marker `refs/docmost/last-pushed` (SPEC §5:
* "что из `main` уже отражено в Docmost").
*/
readRef(ref: string): Promise<string | null>;
/**
* Point `ref` at `target` (`git update-ref <ref> <target>`). Used to advance
* `refs/docmost/last-pushed` to the just-pushed `main` commit after a push
* (SPEC §6 step 3 / §5). `target` may be a SHA or any commit-ish git accepts.
*/
updateRef(ref: string, target: string): Promise<void>;
/**
* Fast-forward `branch` to `toCommit` — but ONLY if it is a TRUE fast-forward,
* i.e. the current `branch` tip is an ancestor of `toCommit` (verified via
* `git merge-base --is-ancestor <branch> <toCommit>`). Used to advance the
* `docmost` mirror branch after a clean push (SPEC §6 step 3 / §10): once a
* push succeeds, Docmost already contains the pushed `main` content, so the
* mirror must reflect it — otherwise the NEXT pull would diff our own write
* back and re-pull it (loop-guard).
*
* SAFETY — never force, never clobber divergent history:
* - If `branch` IS an ancestor of `toCommit`, advance it with
* `git update-ref refs/heads/<branch> <toCommit>`. The `docmost` branch is
* NOT checked out during a push (push works on `main`), so updating the ref
* directly is safe and avoids any working-tree touch.
* - If `branch` is NOT an ancestor (divergent / would-be non-fast-forward),
* do NOT move it — return `{ ok: false, reason: 'not-fast-forward' }` and
* let the caller log it. We must never overwrite a `docmost` history that
* has commits the push base does not contain.
*
* Returns `{ ok: true }` when the branch was advanced (or already at
* `toCommit`, a degenerate fast-forward), `{ ok: false, reason }` otherwise.
* A missing `branch` or `toCommit` also yields `{ ok: false }` with a reason.
*/
fastForwardBranch(branch: string, toCommit: string): Promise<{
ok: boolean;
reason?: string;
}>;
/**
* Read a file's content at a specific ref (`git show <ref>:<path>`), or `null`
* if the path does not exist there. Used by the push direction to read the
* PRE-IMAGE of a DELETED file (e.g. at `refs/docmost/last-pushed`) so its
* `docmost:meta` — and therefore its `pageId` — can be recovered to translate
* the deletion into a `delete_page` (SPEC §6/§8: only TRACKED files, i.e. ones
* that had a pageId, are deleted in Docmost). A non-zero exit (path absent at
* that ref) maps to `null` rather than throwing.
*/
showFileAtRef(ref: string, path: string): Promise<string | null>;
}
/**
* Build the environment for a vault git invocation (SPEC §12 cwd-isolation).
* Used by the single `runRaw` primitive every git command flows through, so
* these pins apply uniformly (including the `git --version` preflight).
*
* cwd-isolation is this module's central safety guarantee: every git command
* MUST operate on the vault repo at `cwd: vaultPath` and nothing else. An
* inherited `GIT_DIR` / `GIT_WORK_TREE` in `process.env` would silently
* redirect the operation away from `cwd` (e.g. to the source repo or another
* checkout), defeating that guarantee. So we always strip them, regardless of
* whatever else the caller adds (author/committer identity, etc.).
*
* Exported for unit testing.
*/
export declare function vaultGitEnv(extra?: Record<string, string>): NodeJS.ProcessEnv;
/**
* Build a commit message body with trailer lines appended (SPEC §7.3). The
* trailers are separated from the subject by a blank line so `git interpret-
* trailers` / `git log --format=%(trailers)` parse them as trailers.
* Exported for unit testing.
*/
export declare function buildCommitMessage(subject: string, trailers?: string[]): string;

View File

@@ -1,570 +0,0 @@
/**
* Thin async wrapper over the system `git` binary (SPEC §5: state store = git).
*
* IMPORTANT — VAULT-SCOPED: every operation here runs with `cwd = vaultPath`,
* which is the vault's OWN git repository (default `data/vault`), SEPARATE from
* the gitmost application repo. This module MUST NEVER run git against the
* application repo. `data/` is gitignored, so a nested repo under `data/vault`
* is safe. The pull cycle is READ-ONLY toward Docmost; this module only touches
* the local vault git, never a git remote (push is deferred, see SPEC §7).
*
* Implementation notes:
* - We shell out via `node:child_process` `execFile` (promisified), passing
* ARGS AS AN ARRAY — no shell, so there is no command injection surface even
* if a page title / branch name contains shell metacharacters.
* - EVERY git invocation funnels through the single `runRaw` primitive, which
* ALWAYS prepends `--no-pager -c core.quotepath=false` to the argv (so git
* never blocks on a pager and always prints verbatim UTF-8 paths). There is
* no exception — even the `git --version` preflight goes through `runRaw`.
* - "nothing to commit" is treated as a graceful no-op, not an error.
*/
import { execFile } from "node:child_process";
import { mkdir } from "node:fs/promises";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
/** Bot identity used for engine-authored vault commits (SPEC §7.3). */
export const BOT_AUTHOR_NAME = "Docmost Sync";
export const BOT_AUTHOR_EMAIL = "docmost-sync@local";
/** Default branch the vault repo is initialized on. */
export const DEFAULT_BRANCH = "main";
/**
* A git wrapper bound to a single vault path. Construct once per vault; every
* method runs git with `cwd = vaultPath`.
*/
export class VaultGit {
vaultPath;
constructor(vaultPath) {
this.vaultPath = vaultPath;
}
/**
* Preflight: verify a runnable `git` binary is on PATH. The daemon shells out
* to system `git` for every vault operation, so a missing binary (e.g. a slim
* container image without git) must fail fast with an actionable message
* rather than a cryptic ENOENT deep inside the first real git call. Presence
* check only — we do NOT gate on a specific version. Runs `git --version`
* with NO `cwd` (the vault dir may not exist yet at preflight time).
*/
async assertGitAvailable() {
// Goes through the single `runRaw` primitive like every other invocation.
// `cwd: null` means "do not set a cwd" — the vault dir may not exist yet at
// preflight time, so we must not point git at a missing directory.
const r = await this.runRaw(["--version"], { cwd: null });
if (r.code !== 0) {
const detail = (r.stderr || r.stdout || "").trim();
throw new Error("git binary not found or not runnable — install git (the vault state " +
`store requires it). Underlying error: ${detail}`);
}
}
/**
* Run a git command in the vault and return trimmed stdout. THIN wrapper over
* the single `runRaw` primitive: throws a clear, unified Error (including
* stderr/stdout) on a non-zero exit.
*/
async run(args, opts) {
const r = await this.runRaw(args, opts);
if (r.code !== 0) {
const detail = (r.stderr || r.stdout || "").trim();
throw new Error(`git ${args.join(" ")} failed: ${detail}`);
}
return r.stdout.trim();
}
/**
* The ONE primitive every git invocation in this module flows through. Builds
* the full argv (`--no-pager -c core.quotepath=false <args>`), env, cwd, and
* maxBuffer, runs git, and NEVER throws — it returns the exit info so callers
* can treat a non-zero exit as either an error (`run`) or a meaningful state
* (e.g. a merge conflict, a porcelain diff that "fails" deliberately).
*
* - argv: ALWAYS prepends `--no-pager -c core.quotepath=false`, so git never
* blocks on a pager and always prints verbatim UTF-8 paths (no octal
* escaping/quoting). `quotepath=false` is the baseline for ALL path-
* printing commands (ls-files, diff --name-only, …).
* - cwd: `opts.cwd === null` -> do NOT set cwd (the preflight, where the
* vault dir may not exist); otherwise `opts.cwd ?? this.vaultPath`.
* - env: `vaultGitEnv(opts?.env)` (cwd-isolation + caller extras).
* - On a spawn/exec error we capture the error `message` too, so a failure
* before git could write to stderr (e.g. ENOENT) is NOT lost.
*/
async runRaw(args, opts) {
const cwd = opts?.cwd === null ? undefined : (opts?.cwd ?? this.vaultPath);
try {
const { stdout, stderr } = await execFileAsync("git", ["--no-pager", "-c", "core.quotepath=false", ...args], {
// Generous buffer: file listings / porcelain output on a large vault
// can be sizable.
...(cwd !== undefined ? { cwd } : {}),
maxBuffer: 64 * 1024 * 1024,
env: vaultGitEnv(opts?.env),
});
return { code: 0, stdout, stderr };
}
catch (err) {
const e = err;
return {
code: typeof e.code === "number" ? e.code : 1,
stdout: e.stdout ?? "",
// Preserve the error message when there is no stderr (e.g. a spawn
// failure like ENOENT, where promisified execFile sets stderr to an
// EMPTY STRING — so `||`, not `??`, to fall through to `message`).
stderr: e.stderr || e.message || "",
};
}
}
/**
* Ensure the vault directory exists and is an initialized git repo on `main`
* with an initial (empty) commit so branches exist. Idempotent: safe to call
* on every run. Sets a LOCAL bot identity for the vault repo if none is set
* (so engine commits never fall back to a global/unset identity).
*/
async ensureRepo() {
await mkdir(this.vaultPath, { recursive: true });
if (!(await this.isRepo())) {
// `git init -b main` sets the initial branch on modern git; we still
// guard the branch name below for safety on older binaries.
await this.run(["init", "-b", DEFAULT_BRANCH]);
}
// Set a local identity for the vault repo if unset, so engine commits have
// a deterministic committer even on a machine with no global git config.
if (!(await this.hasLocalConfig("user.name"))) {
await this.run(["config", "user.name", BOT_AUTHOR_NAME]);
}
if (!(await this.hasLocalConfig("user.email"))) {
await this.run(["config", "user.email", BOT_AUTHOR_EMAIL]);
}
// Neutralize correctness-affecting git config in the vault's LOCAL config so
// a user's GLOBAL/system config cannot change porcelain BEHAVIOR (not just
// output) and corrupt the vault. The vault is OUR dedicated repo, so LOCAL
// values (which override global/system) are the right scope. Set
// UNCONDITIONALLY every run — idempotent and cheap; `git config <key>`
// writes to `--local` by default inside the repo. These MUST be in place
// before any add/commit/checkout that could be affected, hence they run
// before the initial-commit block below.
// - core.autocrlf=false — CRITICAL (SPEC §11): a global core.autocrlf=true
// would rewrite LF<->CRLF on add/checkout, making our deterministic,
// byte-stable markdown churn and breaking the round-trip invariant.
// `false` guarantees git stores/checks out verbatim bytes.
// - core.safecrlf=false — avoid CRLF-related warnings/aborts on add.
// - commit.gpgsign=false — the headless daemon must never try to GPG-sign
// a commit (would fail/hang; we already set GIT_TERMINAL_PROMPT=0).
// - core.attributesFile=/dev/null — neutralize the user's GLOBAL
// gitattributes so a global clean/smudge filter (filter.<name>.clean)
// cannot rewrite the STORED blob and break §11 byte-stability (a config
// that core.autocrlf=false does not cover). POSIX-only path, which is
// fine: the daemon runs on Linux (Docker) / macOS. A system
// /etc/gitattributes remains the host admin's domain (out of scope).
// NOTE: these stay PERSISTED LOCAL config (not `-c` flags) on purpose — a
// human running git by hand in the vault must inherit the same neutralized
// behavior; a transient `-c` would not persist. (core.quotepath, by
// contrast, only affects OUR parsing of output and so is baked into the
// `runRaw` argv baseline instead.)
try {
await this.run(["config", "core.autocrlf", "false"]);
await this.run(["config", "core.safecrlf", "false"]);
await this.run(["config", "commit.gpgsign", "false"]);
await this.run(["config", "core.attributesFile", "/dev/null"]);
}
catch (err) {
const detail = err instanceof Error ? err.message : String(err);
throw new Error(`failed to pin vault git config (SPEC §11) — ensure ${this.vaultPath}` +
"/.git/config is writable and not locked (e.g. stale config.lock): " +
detail);
}
// Create the initial empty commit on `main` if the repo has no commits yet,
// so both `main` and (later) `docmost` branches have a common base.
if (!(await this.hasAnyCommit())) {
// Make sure we are on the default branch before the first commit (covers
// the older-git case where `init -b` was not honored).
await this.run(["checkout", "-B", DEFAULT_BRANCH]);
await this.commitRaw("init vault", {
authorName: BOT_AUTHOR_NAME,
authorEmail: BOT_AUTHOR_EMAIL,
allowEmpty: true,
});
}
}
/** True if `cwd` is inside a git work-tree (the vault is initialized). */
async isRepo() {
const r = await this.runRaw(["rev-parse", "--is-inside-work-tree"]);
return r.code === 0 && r.stdout.trim() === "true";
}
/** True if a LOCAL git config key is set in the vault repo. */
async hasLocalConfig(key) {
const r = await this.runRaw(["config", "--local", "--get", key]);
return r.code === 0 && r.stdout.trim().length > 0;
}
/** True if the repo has at least one commit (HEAD resolves). */
async hasAnyCommit() {
const r = await this.runRaw(["rev-parse", "--verify", "HEAD"]);
return r.code === 0;
}
/** True if a branch with the given name exists. */
async branchExists(name) {
const r = await this.runRaw([
"rev-parse",
"--verify",
`refs/heads/${name}`,
]);
return r.code === 0;
}
/**
* Create `name` from `fromBranch` if it does not already exist. No-op (and no
* checkout) when the branch is already present.
*/
async ensureBranch(name, fromBranch) {
if (await this.branchExists(name))
return;
await this.run(["branch", name, fromBranch]);
}
/** Name of the currently checked-out branch. */
async currentBranch() {
return this.run(["rev-parse", "--abbrev-ref", "HEAD"]);
}
/** Check out an existing branch. */
async checkout(name) {
await this.run(["checkout", name]);
}
/** Stage everything (adds, modifications, deletions). */
async stageAll() {
await this.run(["add", "-A"]);
}
/**
* True if the vault is mid-merge (an unresolved merge from a previous run,
* SPEC §9 / §12). Detected via a `MERGE_HEAD` ref OR any unmerged
* (conflicted) index entries (`git ls-files -u`). The pull cycle checks this
* BEFORE any checkout so a left-over merge produces a clear, actionable
* message instead of a raw "you need to resolve your current index first"
* failure deep inside `checkout`. This is what makes re-runs converge
* (resumability, SPEC §12).
*/
async isMergeInProgress() {
// MERGE_HEAD exists exactly while a merge is in progress.
const mergeHead = await this.runRaw([
"rev-parse",
"--verify",
"--quiet",
"MERGE_HEAD",
]);
if (mergeHead.code === 0 && mergeHead.stdout.trim().length > 0)
return true;
// Fallback / belt-and-suspenders: any unmerged index entries also mean the
// working tree is mid-conflict and a checkout would refuse.
const unmerged = await this.runRaw(["ls-files", "-u"]);
return unmerged.code === 0 && unmerged.stdout.trim().length > 0;
}
/**
* Commit the currently STAGED changes with an explicit author/committer
* identity and the given trailers appended to the message body (SPEC §7.3
* provenance). Returns `true` if a commit was made, `false` if there was
* nothing to commit (graceful no-op). The caller is expected to have staged
* its changes first (e.g. via `stageAll`).
*/
async commit(message, opts) {
// Nothing staged -> nothing to commit. Treat as a no-op (SPEC §11: a
// deterministic re-pull of unchanged pages produces identical bytes, so
// git sees no diff and we must not error).
const staged = await this.runRaw([
"diff",
"--cached",
"--quiet",
]);
// `diff --cached --quiet` exits 0 when the index matches HEAD (nothing
// staged), 1 when there are staged changes.
if (staged.code === 0)
return false;
await this.commitRaw(message, opts);
return true;
}
/**
* Low-level commit used by both `commit` and `ensureRepo`'s initial commit.
* Builds the full message with appended trailers and sets author + committer
* identity via env vars (so the committer matches the author, not the repo
* default).
*/
async commitRaw(message, opts) {
const fullMessage = buildCommitMessage(message, opts.trailers);
// `--no-verify` skips pre-commit/commit-msg hooks: a global core.hooksPath
// (or any injected hook) must never interfere with engine commits in our
// dedicated vault repo.
const args = ["commit", "--no-verify", "-m", fullMessage];
if (opts.allowEmpty)
args.push("--allow-empty");
// Route through the single `runRaw` primitive; set author + committer
// identity via env vars (so the committer matches the author, not the repo
// default). Throw via the same unified message on a non-zero exit.
const r = await this.runRaw(args, {
env: {
GIT_AUTHOR_NAME: opts.authorName,
GIT_AUTHOR_EMAIL: opts.authorEmail,
GIT_COMMITTER_NAME: opts.authorName,
GIT_COMMITTER_EMAIL: opts.authorEmail,
},
});
if (r.code !== 0) {
const detail = (r.stderr || r.stdout || "").trim();
throw new Error(`git ${args.join(" ")} failed: ${detail}`);
}
}
/**
* Merge `fromBranch` into the current branch (`git merge --no-edit`).
* Fast-forwards when possible; performs a real 3-way merge otherwise. Conflict
* state is SURFACED (returned), NOT auto-resolved (SPEC §9): the conflict
* markers are left in the worktree for manual resolution by a later increment,
* and — critically — nothing is pushed to Docmost (we never write to Docmost
* anyway).
*/
async merge(fromBranch) {
const r = await this.runRaw(["merge", "--no-edit", fromBranch]);
const output = `${r.stdout}\n${r.stderr}`.trim();
if (r.code === 0) {
return { ok: true, conflict: false, output };
}
// A non-zero exit on merge most commonly means a conflict. Confirm by
// checking for unmerged paths (porcelain "U" status) so we don't mislabel
// an unrelated failure as a conflict.
const conflict = await this.hasUnmergedPaths();
return { ok: false, conflict, output };
}
/** True if the index has any unmerged (conflicted) paths. */
async hasUnmergedPaths() {
const r = await this.runRaw(["diff", "--name-only", "--diff-filter=U"]);
return r.code === 0 && r.stdout.trim().length > 0;
}
/**
* List tracked files on the current branch (paths relative to the vault
* root, forward-slash separated). An optional glob (a git pathspec) narrows
* the listing, e.g. `"*.md"`.
*
* The target wiki is RUSSIAN, so vault file names routinely contain Cyrillic
* (e.g. `Колонка.md`). With git's DEFAULT `core.quotepath=true`, `ls-files`
* returns non-ASCII paths octal-escaped and double-quoted (`"\320\232..."`),
* which `src/pull.ts` `readExisting` would then parse as garbage paths,
* breaking move/duplicate detection. We defeat that two ways at once:
* - `core.quotepath=false` disables the octal-escape/quoting. It is now the
* `runRaw` argv baseline (prepended to EVERY invocation), so we no longer
* pass it inline here.
* - `-z` emits NUL-delimited RAW UTF-8 paths (no quoting, no newline
* ambiguity), which we split on `\0`.
* We read the RAW stdout (NOT the trimming `run()` helper, which would mangle
* the NUL-delimited bytes) and split on `\0`, dropping empty entries. Paths
* are returned verbatim — git already emits forward slashes.
*/
async listTrackedFiles(glob) {
const r = await this.runRaw(["ls-files", "-z", ...(glob ? [glob] : [])]);
if (r.code !== 0) {
const detail = (r.stderr || r.stdout || "").trim();
throw new Error(`git ls-files failed: ${detail}`);
}
return r.stdout.split("\0").filter((p) => p.length > 0);
}
/**
* Diff two refs with `--name-status -M -z` and parse the NUL-delimited output
* (SPEC §6: the FS→Docmost push direction diffs `main` against
* `refs/docmost/last-pushed`). Rename detection is ON (`-M`), so a moved/renamed
* file is reported as a single `R` row with both its old and new path instead
* of a delete+add pair — that distinction is what lets the push planner tell a
* move from a delete+create (SPEC §8 "Move vs delete").
*
* `-z` makes git emit NUL-delimited RAW UTF-8 records (the Russian wiki has
* Cyrillic file names) with NO quoting/escaping. The record shape differs by
* status:
* - A/M/D: `status\0path\0`
* - R/C: `Rnnn\0oldPath\0newPath\0` (nnn = similarity score, e.g. `R100`)
* We read the RAW stdout (not the trimming `run()` helper, which would mangle
* the NUL bytes), split on `\0`, drop the trailing empty entry, and walk the
* tokens pulling 1 or 2 path tokens per status. Paths are returned verbatim.
*/
async diffNameStatus(fromRef, toRef) {
const r = await this.runRaw([
"diff",
"--name-status",
"-M",
"-z",
fromRef,
toRef,
]);
if (r.code !== 0) {
const detail = (r.stderr || r.stdout || "").trim();
throw new Error(`git diff --name-status failed: ${detail}`);
}
// Tokens alternate: <status> <path...> <status> <path...> ... With `-z`,
// each token (status code AND each path) is its own NUL-delimited field.
const tokens = r.stdout.split("\0").filter((t) => t.length > 0);
const entries = [];
let i = 0;
while (i < tokens.length) {
const raw = tokens[i++];
// The status token is e.g. `A`, `M`, `D`, or `R100` / `C075`. The leading
// letter is the change kind; any trailing digits are the similarity score.
const letter = raw[0];
if (letter === "R" || letter === "C") {
const score = Number.parseInt(raw.slice(1), 10);
const oldPath = tokens[i++];
const path = tokens[i++];
if (oldPath === undefined || path === undefined)
break; // malformed tail
entries.push({
status: letter,
path,
oldPath,
...(Number.isFinite(score) ? { score } : {}),
});
}
else if (letter === "A" || letter === "M" || letter === "D") {
const path = tokens[i++];
if (path === undefined)
break; // malformed tail
entries.push({ status: letter, path });
}
else {
// Unknown/other status (e.g. T type-change, U unmerged) — consume one
// path token defensively so the walk stays aligned, but do not emit it
// (the push planner only handles A/M/D/R/C).
i++;
}
}
return entries;
}
/**
* Resolve a ref/commit-ish to its full SHA, or `null` if it does not exist.
* `rev-parse --verify --quiet` exits non-zero (and prints nothing) for an
* unknown ref, so a non-zero exit maps cleanly to `null`. Used to read
* `refs/docmost/last-pushed` (SPEC §5) — which is absent before the first push.
*/
async revParse(ref) {
const r = await this.runRaw(["rev-parse", "--verify", "--quiet", ref]);
if (r.code !== 0)
return null;
const sha = r.stdout.trim();
return sha.length > 0 ? sha : null;
}
/**
* Read a ref to its SHA, or `null` if unset. Thin alias over `revParse`,
* named for the push direction's marker `refs/docmost/last-pushed` (SPEC §5:
* "что из `main` уже отражено в Docmost").
*/
async readRef(ref) {
return this.revParse(ref);
}
/**
* Point `ref` at `target` (`git update-ref <ref> <target>`). Used to advance
* `refs/docmost/last-pushed` to the just-pushed `main` commit after a push
* (SPEC §6 step 3 / §5). `target` may be a SHA or any commit-ish git accepts.
*/
async updateRef(ref, target) {
await this.run(["update-ref", ref, target]);
}
/**
* Fast-forward `branch` to `toCommit` — but ONLY if it is a TRUE fast-forward,
* i.e. the current `branch` tip is an ancestor of `toCommit` (verified via
* `git merge-base --is-ancestor <branch> <toCommit>`). Used to advance the
* `docmost` mirror branch after a clean push (SPEC §6 step 3 / §10): once a
* push succeeds, Docmost already contains the pushed `main` content, so the
* mirror must reflect it — otherwise the NEXT pull would diff our own write
* back and re-pull it (loop-guard).
*
* SAFETY — never force, never clobber divergent history:
* - If `branch` IS an ancestor of `toCommit`, advance it with
* `git update-ref refs/heads/<branch> <toCommit>`. The `docmost` branch is
* NOT checked out during a push (push works on `main`), so updating the ref
* directly is safe and avoids any working-tree touch.
* - If `branch` is NOT an ancestor (divergent / would-be non-fast-forward),
* do NOT move it — return `{ ok: false, reason: 'not-fast-forward' }` and
* let the caller log it. We must never overwrite a `docmost` history that
* has commits the push base does not contain.
*
* Returns `{ ok: true }` when the branch was advanced (or already at
* `toCommit`, a degenerate fast-forward), `{ ok: false, reason }` otherwise.
* A missing `branch` or `toCommit` also yields `{ ok: false }` with a reason.
*/
async fastForwardBranch(branch, toCommit) {
const branchRef = `refs/heads/${branch}`;
// Resolve both endpoints first so a missing ref is a clean refusal, not a
// confusing `merge-base` failure.
const branchSha = await this.revParse(branchRef);
if (branchSha === null) {
return { ok: false, reason: `branch ${branch} does not exist` };
}
const targetSha = await this.revParse(toCommit);
if (targetSha === null) {
return { ok: false, reason: `target ${toCommit} does not resolve` };
}
// Already at the target -> a no-op fast-forward (still ok).
if (branchSha === targetSha)
return { ok: true };
// `merge-base --is-ancestor A B` exits 0 iff A is an ancestor of B. Only a
// true ancestor is a fast-forward; anything else is divergent and refused.
const ancestor = await this.runRaw([
"merge-base",
"--is-ancestor",
branchSha,
targetSha,
]);
if (ancestor.code !== 0) {
return { ok: false, reason: "not-fast-forward" };
}
// Safe to advance: the branch is not checked out during push, so a direct
// ref update avoids a checkout/working-tree touch.
await this.updateRef(branchRef, targetSha);
return { ok: true };
}
/**
* Read a file's content at a specific ref (`git show <ref>:<path>`), or `null`
* if the path does not exist there. Used by the push direction to read the
* PRE-IMAGE of a DELETED file (e.g. at `refs/docmost/last-pushed`) so its
* `docmost:meta` — and therefore its `pageId` — can be recovered to translate
* the deletion into a `delete_page` (SPEC §6/§8: only TRACKED files, i.e. ones
* that had a pageId, are deleted in Docmost). A non-zero exit (path absent at
* that ref) maps to `null` rather than throwing.
*/
async showFileAtRef(ref, path) {
// `git show <ref>:<path>` requires the path relative to the repo root; pass
// it verbatim (forward-slash, matching `listTrackedFiles` / diff output).
const r = await this.runRaw(["show", `${ref}:${path}`]);
if (r.code !== 0)
return null;
return r.stdout;
}
}
/**
* Build the environment for a vault git invocation (SPEC §12 cwd-isolation).
* Used by the single `runRaw` primitive every git command flows through, so
* these pins apply uniformly (including the `git --version` preflight).
*
* cwd-isolation is this module's central safety guarantee: every git command
* MUST operate on the vault repo at `cwd: vaultPath` and nothing else. An
* inherited `GIT_DIR` / `GIT_WORK_TREE` in `process.env` would silently
* redirect the operation away from `cwd` (e.g. to the source repo or another
* checkout), defeating that guarantee. So we always strip them, regardless of
* whatever else the caller adds (author/committer identity, etc.).
*
* Exported for unit testing.
*/
export function vaultGitEnv(extra) {
const env = {
...process.env,
// Locale-independent output (defense in depth). We never parse localized
// prose, but pinning the locale prevents a future regression where some
// git message we DO key on is translated by an inherited LC_ALL/LANG.
LC_ALL: "C",
LANG: "C",
// Never page (we already pass --no-pager, but a stray GIT_PAGER could still
// bite) and never block on an interactive prompt (e.g. credentials) — the
// daemon runs unattended and must not hang.
GIT_PAGER: "cat",
GIT_TERMINAL_PROMPT: "0",
...extra,
};
delete env.GIT_DIR;
delete env.GIT_WORK_TREE;
return env;
}
/**
* Build a commit message body with trailer lines appended (SPEC §7.3). The
* trailers are separated from the subject by a blank line so `git interpret-
* trailers` / `git log --format=%(trailers)` parse them as trailers.
* Exported for unit testing.
*/
export function buildCommitMessage(subject, trailers) {
if (!trailers || trailers.length === 0)
return subject;
return `${subject}\n\n${trailers.join("\n")}`;
}

View File

@@ -1,44 +0,0 @@
/**
* Pure page-tree -> vault path mapping (SPEC §12).
*
* Given the flat list of page nodes for a space (as returned by
* `listAllSpacePages`), compute for every page a deterministic, collision-free
* destination: a folder path (root -> leaf ancestors) plus a file stem (the
* page's own name, no extension). This module is intentionally PURE and
* dependency-free apart from the sanitization helpers, so the whole tree ->
* path logic is unit-testable without any I/O. The names are COSMETIC; identity
* lives in each file's meta block (pageId / slugId).
*/
/** Flat page node as returned by `listAllSpacePages` (no content). */
export interface PageNode {
id: string;
title?: string;
slugId?: string;
parentPageId?: string | null;
hasChildren?: boolean;
}
/** A page's resolved vault destination: folder path + file stem. */
export interface VaultEntry {
/** Folder path, root -> leaf (the page's ancestors). Empty for a root page. */
segments: string[];
/** The page's own file name without extension. */
stem: string;
}
/**
* Build the full vault layout for a space.
*
* Returns a Map keyed by pageId -> `{ segments, stem }`. The result is
* deterministic for a given input and guarantees every full destination path
* (`[...segments, stem].join("/")`) is unique, so no page can silently overwrite
* another.
*
* Disambiguation is layered:
* 1. Sibling collisions (same sanitized title under the same parent) are
* resolved with a stable ` ~<slugId>` suffix (the suffix is itself
* sanitized, since slugId/id is untrusted data that must never inject a
* path separator).
* 2. A final full-path pass catches residual collisions that sibling-scoping
* cannot see — e.g. two pages whose parents are BOTH outside the input set
* both bucket at the root with `segments: []`.
*/
export declare function buildVaultLayout(pages: PageNode[]): Map<string, VaultEntry>;

View File

@@ -1,170 +0,0 @@
/**
* Pure page-tree -> vault path mapping (SPEC §12).
*
* Given the flat list of page nodes for a space (as returned by
* `listAllSpacePages`), compute for every page a deterministic, collision-free
* destination: a folder path (root -> leaf ancestors) plus a file stem (the
* page's own name, no extension). This module is intentionally PURE and
* dependency-free apart from the sanitization helpers, so the whole tree ->
* 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";
/**
* Build the full vault layout for a space.
*
* Returns a Map keyed by pageId -> `{ segments, stem }`. The result is
* deterministic for a given input and guarantees every full destination path
* (`[...segments, stem].join("/")`) is unique, so no page can silently overwrite
* another.
*
* Disambiguation is layered:
* 1. Sibling collisions (same sanitized title under the same parent) are
* resolved with a stable ` ~<slugId>` suffix (the suffix is itself
* sanitized, since slugId/id is untrusted data that must never inject a
* path separator).
* 2. A final full-path pass catches residual collisions that sibling-scoping
* 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) {
// 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();
for (const p of pages) {
if (p && p.id && !byId.has(p.id))
byId.set(p.id, p);
}
// Resolve each node's display name once, deterministically, tracking sibling
// collisions per parent. `usedBySibling` maps a parent key -> set of names
// already taken under that parent. The bucket key is the node's parent ONLY
// when that parent is actually present in `byId`; otherwise (null parent, or
// an orphan whose parent is outside the input set) the node buckets at
// `"__root__"`. This is critical: orphans land at the vault root (see
// `folderSegmentsFor`), so they MUST share the root bucket with real root
// pages to be disambiguated against each other here — making `nameById` final
// before any `segments` are computed, so no ancestor name can drift later.
const usedBySibling = new Map();
const nameById = new Map();
for (const p of pages) {
if (p && p.id && !nameById.has(p.id)) {
const parentKey = p.parentPageId && byId.has(p.parentPageId) ? p.parentPageId : "__root__";
nameById.set(p.id, nameForNode(p, parentKey, usedBySibling));
}
}
// Every id we index above MUST get a resolved name; this helper returns it
// and THROWS if it is somehow absent, rather than silently recomputing a
// DIFFERENT, non-disambiguated name (which would desync a folder segment from
// its target file).
const nameOf = (id) => {
const name = nameById.get(id);
if (name === undefined) {
throw new Error(`buildVaultLayout: no resolved name for page id ${id}`);
}
return name;
};
// Build the folder path for a page by walking parentPageId to the root. The
// page's OWN name is the file stem; its ancestors become folders. A `visited`
// guard prevents an infinite loop on a malformed parent cycle.
const folderSegmentsFor = (node) => {
const ancestors = [];
const visited = new Set();
let current = node.parentPageId
? byId.get(node.parentPageId)
: undefined;
while (current && current.id && !visited.has(current.id)) {
visited.add(current.id);
ancestors.unshift(nameOf(current.id));
current = current.parentPageId
? byId.get(current.parentPageId)
: undefined;
}
return ancestors;
};
// First pass: compute the provisional { segments, stem } for every node.
const layout = new Map();
for (const p of pages) {
if (!p || !p.id || layout.has(p.id))
continue;
layout.set(p.id, {
segments: folderSegmentsFor(p),
stem: nameOf(p.id),
});
}
// FOLDER-NOTE transform (native-Obsidian layout): a page WITH CHILDREN lives at
// `<…>/<stem>/<stem>.md` — its body is the folder-note INSIDE its own folder
// (LostPaul Folder Notes convention), and its children sit alongside it in that
// folder. A leaf stays `<…>/<stem>.md`. Children's segments already point into
// the parent's folder (folderSegmentsFor walks ancestor NAMES), so only the
// parent's own file relocates here; the sibling name pass above already made
// the parent name unique, so folder == file name stays consistent.
for (const p of pages) {
if (!p || !p.id)
continue;
const entry = layout.get(p.id);
if (entry && p.hasChildren) {
entry.segments = [...entry.segments, entry.stem];
}
}
// Final full-path uniqueness pass — a belt-and-suspenders safety net. Note
// that cross-bucket (orphan/root) collisions are now resolved in the name pass
// above (orphans share the "__root__" bucket), so ancestor names are final
// before `segments` are built and this pass should rarely/never re-stem an
// ancestor. It only re-stems the colliding LATER leaf via the sanitized
// slugId/id, then (if still colliding) appends the id.
//
// Process FOLDER-NOTES (pages with children) FIRST so a parent claims its
// canonical `<name>/<name>.md` before a same-named CHILD — the child (a leaf)
// is the one that disambiguates, never the folder-note.
const usedPaths = new Set();
const seenIds = new Set();
const pathKey = (e) => [...e.segments, e.stem].join("/");
const ordered = pages
.filter((p) => Boolean(p && p.id))
.sort((a, b) => Number(Boolean(b.hasChildren)) - Number(Boolean(a.hasChildren)));
for (const p of ordered) {
if (seenIds.has(p.id))
continue;
seenIds.add(p.id);
const entry = layout.get(p.id);
if (!entry)
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));
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));
}
}
usedPaths.add(pathKey(entry));
}
return layout;
}
/**
* Compute a deterministic, collision-free name for a node among its SIBLINGS.
* `usedBySibling` maps a parent key -> set of names already taken, so two
* siblings that sanitize to the same name get a stable ` ~slugId` suffix
* (SPEC §12). The suffix is itself passed through `sanitizeTitle`, because the
* slugId/id is a second untrusted-data channel that must never leak a path
* separator into the name. `parentKey` is supplied by the caller (it resolves
* to `"__root__"` for root pages AND for orphans whose parent is outside the
* input set, so they share one bucket). The name is COSMETIC; identity lives in
* the meta block.
*/
function nameForNode(node, parentKey, usedBySibling) {
let used = usedBySibling.get(parentKey);
if (!used) {
used = new Set();
usedBySibling.set(parentKey, used);
}
let name = 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));
}
used.add(name);
return name;
}

View File

@@ -1,13 +0,0 @@
/**
* Stable hash of a page's markdown BODY (SPEC §10 "хэш тела"). Deterministic:
* the same input string always yields the same digest, a different input a
* different one. Used to recognize our own write later (loop suppression).
*
* We hash the body STRING as-is (UTF-8) with SHA-256 and return lowercase hex.
* SPEC §10 keys on the body hash rather than file bytes; callers decide WHAT
* counts as "the body" (here it is the exact string passed in — typically the
* self-contained markdown that was pushed). No normalization is applied: the
* caller is responsible for passing a canonical/stable representation if it
* wants hash equality across cosmetic-only differences.
*/
export declare function bodyHash(markdownBody: string): string;

View File

@@ -1,136 +0,0 @@
import type { GitSyncClient } from "./client.types.js";
import { type PageNode } from "./layout.js";
import { VaultGit } from "./git.js";
import { type MovedEntry, type DeletionDecision } from "./reconcile.js";
/**
* Injectable IO for `readExisting` (R-Pull-1, test-strategy report §5). The real
* `main` wires these to `git.listTrackedFiles("*.md")` and an `fs.readFile`
* rooted at the vault; tests pass fakes so the parsing/skip rules are unit-
* testable without a real git repo or filesystem.
*/
export interface ReadExistingDeps {
/** List tracked .md paths (forward-slash, vault-relative). */
listTracked: () => Promise<string[]>;
/** Read a tracked file's text by its (forward-slash) vault-relative path. */
readFile: (relPath: string) => Promise<string>;
}
/**
* Read every tracked .md file in the vault and recover `{ pageId, relPath }` from
* its `gitmost_id` frontmatter (native-Obsidian format). Files without a
* `gitmost_id` are skipped (they are not engine-tracked pages yet — e.g. a stray
* hand-written Obsidian file; PUSH adopts those separately).
*
* The IO is injected (R-Pull-1) so this is testable with fakes. Skip rules:
* - a `readFile` rejection (tracked but missing on disk, a mid-operation race)
* -> skipped, NOT thrown; the next pull converges;
* - no `gitmost_id` frontmatter (`parsePageFile` -> id null) -> skipped.
*/
export declare function readExisting(deps: ReadExistingDeps): Promise<{
pageId: string;
relPath: string;
}[]>;
/**
* Input to the PURE `computePullActions` (R-Pull-2). All data, no IO: the live
* tree nodes + completeness flag (from `listSpaceTree`) and the parsed
* `existing` tracked files (from `readExisting`).
*/
export interface PullActionsInput {
/** Live page nodes for the space (from `listSpaceTree`). */
pages: PageNode[];
/** Whether the live tree fetch was COMPLETE (SPEC §8 suppression). */
treeComplete: boolean;
/** Parsed tracked files: `{ pageId, relPath }` (from `readExisting`). */
existing: {
pageId: string;
relPath: string;
}[];
}
/**
* The PURE decisions object computed by `computePullActions` (no IO). It holds
* the reconciliation plan plus the SPEC §8 absence-deletion decision, with the
* suppression already folded in: `toDelete` is the POST-suppression set the
* caller should actually remove (empty when `deletionDecision.apply` is false).
*/
export interface PullActions {
/** Pages to (re)write at their relPath (add + update + move target). */
toWrite: {
pageId: string;
relPath: string;
}[];
/** Moves: write new path, then remove old path (only on a successful write). */
moved: MovedEntry[];
/**
* Absence-based paths to delete AFTER suppression. Empty when the decision
* suppressed deletions this cycle, so the caller can apply it unconditionally.
*/
toDelete: string[];
/** Why absence deletions were (or were not) applied (for logging + tests). */
deletionDecision: DeletionDecision;
/** Tracked-file count (for the suppression log messages). */
existingCount: number;
/** Planned absence-delete count BEFORE suppression (for the log message). */
plannedDeleteCount: number;
}
/**
* PURE pull-action planner (R-Pull-2, test-strategy report §5). Takes the live
* tree nodes + completeness + existing tracked files and returns the full set of
* decisions with NO IO:
*
* - builds the vault layout (deterministic relPath per live page),
* - `planReconciliation` -> toWrite / moved / absence-toDelete,
* - `decideAbsenceDeletions` -> the SPEC §8 suppression (incomplete-fetch +
* empty-live + mass-delete guard), folded IN here so `toDelete` is the
* POST-suppression set (empty when suppressed).
*
* Moves are NOT governed by the suppression: a moved page is present in `live`,
* so its old-path removal is real (the caller still gates it on the write
* succeeding). The expensive content fetch / file write / git ops happen in the
* thin `applyPullActions`.
*/
export declare function computePullActions(input: PullActionsInput): PullActions;
/**
* Injectable IO for `applyPullActions` (R-Pull-2). The real `main` wires these
* to the live client, the vault git wrapper, and `node:fs/promises`; tests pass
* fakes that RECORD calls so the ordering + the move-on-success data-loss guard
* are testable without real git/fs/network.
*/
export interface ApplyPullActionsDeps {
client: Pick<GitSyncClient, "getPageJson">;
git: Pick<VaultGit, "stageAll" | "commit" | "checkout" | "merge">;
/** Write a file by ABSOLUTE path (mkdir of the parent is done internally). */
writeFile: (absPath: string, text: string) => Promise<void>;
/** Recursive mkdir of an ABSOLUTE directory path. */
mkdir: (absDir: string) => Promise<void>;
/** Remove a file by ABSOLUTE path (force: a missing file is a no-op). */
rm: (absPath: string) => Promise<void>;
}
/** Outcome counters from `applyPullActions` (for the summary + tests). */
export interface ApplyResult {
written: number;
movedApplied: number;
deleted: number;
failed: number;
committed: boolean;
merge: {
ok: boolean;
conflict: boolean;
output: string;
};
}
/**
* THIN IO applier (R-Pull-2). Performs the side effects in the EXACT current
* order, with all the original safety guards preserved bit-for-bit:
*
* 1. for each `toWrite`: fetch content (`client.getPageJson`) -> stabilize
* (normalize-on-write fixpoint, SPEC §11) -> mkdir + write. One bad page
* never aborts the pull (bounded-concurrency pool, fault-tolerant).
* 2. apply MOVE old-path removals — ONLY when the planner marked the old path
* removable AND the new-path write SUCCEEDED (the ⭐ data-loss guard: a
* failed move-write keeps the old path so the page never vanishes).
* 3. apply (post-suppression) absence deletes.
* 4. stageAll + commit on `docmost` (subject from ACTUAL written/deleted
* counts) + checkout main + merge docmost (conflicts surfaced, SPEC §9).
*
* `vaultRoot` roots the relPath -> absolute-path conversion for the fs deps.
*/
export declare function applyPullActions(deps: ApplyPullActionsDeps, actions: PullActions, vaultRoot: string): Promise<ApplyResult>;

View File

@@ -1,284 +0,0 @@
/**
* Pull cycle — Docmost -> vault (SPEC §6 "Docmost -> ФС").
*
* This increment turns the read-only mirror into the git-backed pull cycle:
*
* 1. ensureRepo(vault); refuse if a merge is in progress (SPEC §9/§12);
* ensureBranch("docmost", "main") (SPEC §5 branches)
* 2. checkout docmost
* 3. fetch the live tree (listSpaceTree -> {pages, complete}) -> compute the
* desired `live` files (relPath via the pure sanitize/disambiguation layout)
* 4. parse `existing` tracked .md files (pageId + relPath from gitmost_id frontmatter)
* 5. plan = planReconciliation(live, existing) (pure, SPEC §5/§8); toDelete
* is absence-only, moves are separate
* 6. decideAbsenceDeletions: SUPPRESS absence deletions on an incomplete tree
* fetch (SPEC §8) and behind the mass-delete guard (defense in depth)
* 7. write each live page in its fixpoint form (normalize-on-write, SPEC §11);
* apply moved-old-path removals (only when the move write SUCCEEDED) and
* absence-delete removals (only when the decision allowed them)
* 8. stageAll + commit on `docmost` with the provenance trailer (SPEC §7.3)
* 9. checkout main + merge docmost (conflicts are surfaced, NOT auto-resolved,
* SPEC §9); push is deferred (SPEC §7)
* 10. one-line summary
*
* DIRECTION IS Docmost -> vault ONLY. Nothing here ever writes to Docmost
* (read-only: listSpaceTree + getPageJson). All git operations run against
* the vault repo (`cwd = vaultPath`), never the source repo (see ./git.ts).
*
* The client seam is the native `GitSyncClient` (`Pick<GitSyncClient, ...>`);
* the gitmost server drives the engine in-process (there is no standalone CLI
* entry point).
*/
import { dirname } from "node:path";
import { sep } from "node:path";
import { parsePageFile, serializePageFile } from "../lib/page-file.js";
import { buildVaultLayout } from "./layout.js";
import { BOT_AUTHOR_NAME, BOT_AUTHOR_EMAIL, DEFAULT_BRANCH, } from "./git.js";
import { planReconciliation, decideAbsenceDeletions, } from "./reconcile.js";
import { stabilizePageBody } from "./stabilize.js";
// Engine-only mirror branch (SPEC §5): the engine writes here, humans never do.
const DOCMOST_BRANCH = "docmost";
// Machine-readable provenance the loop-guard keys on (SPEC §7.3 / §12).
const SOURCE_TRAILER = "Docmost-Sync-Source: docmost";
// Number of pages fetched/stabilized concurrently. Bounded so a large space
// does not open thousands of simultaneous requests/conversions at once.
const CONCURRENCY = 6;
// How often to log incremental progress (every N completed pages).
const PROGRESS_EVERY = 25;
/** Convert a vault-relative path (forward-slash) to an absolute FS path. */
function relToAbs(vaultRoot, relPath) {
return [vaultRoot, ...relPath.split("/")].join("/");
}
/** Convert an absolute/relative segment list under the vault to a relPath. */
function segmentsToRelPath(segments, stem) {
return [...segments, `${stem}.md`].join("/");
}
/**
* Read every tracked .md file in the vault and recover `{ pageId, relPath }` from
* its `gitmost_id` frontmatter (native-Obsidian format). Files without a
* `gitmost_id` are skipped (they are not engine-tracked pages yet — e.g. a stray
* hand-written Obsidian file; PUSH adopts those separately).
*
* The IO is injected (R-Pull-1) so this is testable with fakes. Skip rules:
* - a `readFile` rejection (tracked but missing on disk, a mid-operation race)
* -> skipped, NOT thrown; the next pull converges;
* - no `gitmost_id` frontmatter (`parsePageFile` -> id null) -> skipped.
*/
export async function readExisting(deps) {
const tracked = await deps.listTracked();
const existing = [];
for (const relPath of tracked) {
// git ls-files always emits forward-slash paths; normalize just in case.
const rel = relPath.split(sep).join("/");
let text;
try {
text = await deps.readFile(rel);
}
catch {
// Tracked but missing on disk (mid-operation race) — skip; the next pull
// converges.
continue;
}
const { id } = parsePageFile(text);
if (id)
existing.push({ pageId: id, relPath: rel });
}
return existing;
}
/**
* PURE pull-action planner (R-Pull-2, test-strategy report §5). Takes the live
* tree nodes + completeness + existing tracked files and returns the full set of
* decisions with NO IO:
*
* - builds the vault layout (deterministic relPath per live page),
* - `planReconciliation` -> toWrite / moved / absence-toDelete,
* - `decideAbsenceDeletions` -> the SPEC §8 suppression (incomplete-fetch +
* empty-live + mass-delete guard), folded IN here so `toDelete` is the
* POST-suppression set (empty when suppressed).
*
* Moves are NOT governed by the suppression: a moved page is present in `live`,
* so its old-path removal is real (the caller still gates it on the write
* succeeding). The expensive content fetch / file write / git ops happen in the
* thin `applyPullActions`.
*/
export function computePullActions(input) {
const { pages, treeComplete, existing } = input;
const layout = buildVaultLayout(pages);
const live = [];
for (const p of pages) {
if (!p || !p.id)
continue;
const entry = layout.get(p.id);
if (!entry)
continue;
live.push({
pageId: p.id,
relPath: segmentsToRelPath(entry.segments, entry.stem),
});
}
// Plan reconciliation (pure). `plan.toDelete` is ABSENCE-based only;
// `plan.moved` carries move old-path removals separately.
const plan = planReconciliation(live, existing);
// Decide whether the ABSENCE-based deletions may be applied this cycle
// (SPEC §8): incomplete-fetch suppression + empty-live + mass-delete guard.
// Moves are NOT governed by this.
const deletionDecision = decideAbsenceDeletions({
treeComplete,
liveCount: live.length,
existingCount: existing.length,
deleteCount: plan.toDelete.length,
});
return {
toWrite: plan.toWrite,
moved: plan.moved,
// Fold the suppression in: a suppressed cycle deletes nothing.
toDelete: deletionDecision.apply ? plan.toDelete : [],
deletionDecision,
existingCount: existing.length,
plannedDeleteCount: plan.toDelete.length,
};
}
/**
* THIN IO applier (R-Pull-2). Performs the side effects in the EXACT current
* order, with all the original safety guards preserved bit-for-bit:
*
* 1. for each `toWrite`: fetch content (`client.getPageJson`) -> stabilize
* (normalize-on-write fixpoint, SPEC §11) -> mkdir + write. One bad page
* never aborts the pull (bounded-concurrency pool, fault-tolerant).
* 2. apply MOVE old-path removals — ONLY when the planner marked the old path
* removable AND the new-path write SUCCEEDED (the ⭐ data-loss guard: a
* failed move-write keeps the old path so the page never vanishes).
* 3. apply (post-suppression) absence deletes.
* 4. stageAll + commit on `docmost` (subject from ACTUAL written/deleted
* counts) + checkout main + merge docmost (conflicts surfaced, SPEC §9).
*
* `vaultRoot` roots the relPath -> absolute-path conversion for the fs deps.
*/
export async function applyPullActions(deps, actions, vaultRoot) {
const { client, git } = deps;
// Emit the SPEC §8 suppression warnings (preserved from the original `main`).
const decision = actions.deletionDecision;
if (!decision.apply) {
if (decision.reason === "incomplete-fetch") {
console.warn("pull: tree fetch incomplete — deletions suppressed this cycle (SPEC §8)");
}
else if (decision.reason === "empty-live") {
console.warn(`pull: live fetch returned 0 pages but ${actions.existingCount} file(s) are ` +
`tracked — deletions suppressed this cycle (SPEC §8). Re-run when ` +
`Docmost is reachable.`);
}
else {
console.warn(`pull: plan would delete ${actions.plannedDeleteCount} of ${actions.existingCount} ` +
`tracked file(s) (mass-delete guard) — deletions suppressed this ` +
`cycle (SPEC §8). Verify the live Docmost tree, then re-run.`);
}
}
// 1. Write each live page in its fixpoint form (normalize-on-write, SPEC §11).
let written = 0;
let failed = 0;
let completed = 0;
let nextIndex = 0;
// pageIds whose write FAILED. A moved page whose new-path write failed must
// NOT have its old path removed (otherwise the page vanishes entirely).
const failedPageIds = new Set();
const writeOne = async (w) => {
try {
const page = await client.getPageJson(w.pageId);
// Native-Obsidian format: a minimal `gitmost_id` frontmatter + the fixpoint
// markdown body. title/parent/space are DERIVED (filename / folder / repo),
// so nothing but the pageId is persisted as meta.
const text = serializePageFile(page.id, await stabilizePageBody(page.content));
const abs = relToAbs(vaultRoot, w.relPath);
await deps.mkdir(dirname(abs));
await deps.writeFile(abs, text);
written++;
}
catch (err) {
failed++;
failedPageIds.add(w.pageId);
console.error(`pull: failed page ${w.pageId}:`, err instanceof Error ? err.message : String(err));
}
finally {
completed++;
if (completed % PROGRESS_EVERY === 0) {
console.log(`pulled ${completed}/${actions.toWrite.length}`);
}
}
};
// Bounded-concurrency pool (dependency-free): a fixed set of runners each
// take the next index until the write list is exhausted. One bad page never
// aborts the whole pull (mirrors the fault-tolerant tree walk).
const runner = async () => {
while (true) {
const i = nextIndex++;
if (i >= actions.toWrite.length)
return;
await writeOne(actions.toWrite[i]);
}
};
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, actions.toWrite.length) || 1 }, () => runner()));
// Helper: `rm` with force:true is a no-op if the file is already gone.
const removePath = async (rel, what) => {
try {
await deps.rm(relToAbs(vaultRoot, rel));
return true;
}
catch (err) {
console.error(`pull: failed to ${what} ${rel}:`, err instanceof Error ? err.message : String(err));
return false;
}
};
// 2. Apply MOVE old-path removals. A moved page IS present in `live`, so its
// old path is genuinely stale — NOT subject to the incomplete-fetch
// suppression. BUT only remove the old path when (a) the planner marked it
// removable (not reused by another live page) AND (b) the new-path write
// actually SUCCEEDED — otherwise we would delete the only copy of a page
// whose move-write failed (⭐ data-loss guard).
let movedApplied = 0;
for (const m of actions.moved) {
if (!m.removeOldPath)
continue;
if (failedPageIds.has(m.pageId)) {
console.warn(`pull: move write for ${m.pageId} failed — keeping old path ` +
`${m.fromRelPath} (SPEC §8)`);
continue;
}
if (await removePath(m.fromRelPath, "remove moved old path"))
movedApplied++;
}
// 3. Apply ABSENCE-based deletions — `actions.toDelete` is ALREADY the
// post-suppression set (empty when the decision suppressed them, SPEC §8).
let deleted = 0;
for (const rel of actions.toDelete) {
if (await removePath(rel, "delete"))
deleted++;
}
// 4. Stage + commit on `docmost` (only if there is something to commit).
// Deterministic stabilized output means unchanged pages produce identical
// bytes -> git sees no diff -> no churn (SPEC §11). The subject reflects the
// ACTUAL work applied (pages written + files deleted), not the planned size,
// so a run with failures does not over-report (SPEC §5 nit).
const subject = deleted > 0
? `docmost: sync ${written} page(s), ${deleted} deleted`
: `docmost: sync ${written} page(s)`;
await git.stageAll();
const committed = await git.commit(subject, {
authorName: BOT_AUTHOR_NAME,
authorEmail: BOT_AUTHOR_EMAIL,
trailers: [SOURCE_TRAILER],
});
// Merge docmost -> main. Conflicts are surfaced and left in git (SPEC §9);
// we never push to Docmost. Push to a git remote is deferred (SPEC §7).
await git.checkout(DEFAULT_BRANCH);
const merge = await git.merge(DOCMOST_BRANCH);
if (merge.conflict) {
console.error("pull: merge of docmost -> main CONFLICTED. Conflict markers were left " +
"in the vault for manual resolution (SPEC §9). Nothing is pushed to " +
"Docmost (read-only). Resolve locally, then re-run.");
}
else if (!merge.ok) {
console.error(`pull: merge of docmost -> main failed: ${merge.output}`);
}
console.log("pull: git push to remote is DEFERRED in this increment (SPEC §7).");
return { written, movedApplied, deleted, failed, committed, merge };
}

Some files were not shown because too many files have changed in this diff Show More