feat(sync): add git vault layer (§5) and the Docmost->vault pull cycle (§6)
Turn the read-only mirror into a git-backed pull cycle. Read-only toward Docmost.
- git.ts (VaultGit): system-git wrapper, all ops cwd=vaultPath (vault is its own
repo under data/vault, never the source repo); ensureRepo/branches main+docmost,
commit with provenance (author/committer identity + Docmost-Sync-Source trailer,
§7.3), merge with conflict surfacing (no auto-resolve, §9), isMergeInProgress;
GIT_DIR/GIT_WORK_TREE stripped from env (§12 cwd isolation)
- stabilize.ts: normalize-on-write (one export->import->export fixpoint pass, §11)
- reconcile.ts: pure planReconciliation (add/update/move/delete by pageId) +
decideAbsenceDeletions gate
- pull.ts: write/commit on docmost -> merge into main; listSpaceTree completeness
signal suppresses absence-deletions on a partial fetch (§8); mass-delete guard;
merge-in-progress guard makes re-runs converge (§12); move old-path removal only
on successful write
- docmost-client: listSpaceTree({pages, complete}) without touching the 1:1-copied
enumerateSpacePages
- tests: reconcile planner + decideAbsenceDeletions, VaultGit incl. real temp-repo
merge conflict, listSpaceTree completeness (586 green)
Push to a git remote and the FS->Docmost direction are deferred to the next increment.
This commit is contained in:
@@ -2619,6 +2619,69 @@ export class DocmostClient {
|
||||
return this.enumerateSpacePages(spaceId, rootPageId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Completeness-tracking variant of the space tree walk (SPEC §8).
|
||||
*
|
||||
* Same iterative breadth-first walk as the private `enumerateSpacePages`
|
||||
* (kept 1:1 with upstream for backport), but it does NOT silently swallow
|
||||
* partial fetches: it returns `{ pages, complete }`, where `complete` is
|
||||
* `false` if ANY branch's children fetch threw (the branch is skipped and the
|
||||
* walk continues) OR if the hard `MAX_NODES` cap was hit before the queue
|
||||
* drained. The caller uses this signal to SUPPRESS absence-based deletions on
|
||||
* an incomplete fetch — per SPEC §8, a page missing from a partial tree is NOT
|
||||
* proof it was deleted ("детекция удаления — точный запрос, а не вывод 'pageId
|
||||
* пропал из дерева'").
|
||||
*/
|
||||
async listSpaceTree(
|
||||
spaceId: string,
|
||||
rootPageId?: string,
|
||||
): Promise<{ pages: any[]; complete: boolean }> {
|
||||
const MAX_NODES = 10000;
|
||||
const result: any[] = [];
|
||||
const visited = new Set<string>();
|
||||
let complete = true;
|
||||
|
||||
// Seed the queue with the starting level (subtree children or roots). A
|
||||
// failure to fetch even the seed level means the result is incomplete.
|
||||
let queue: any[];
|
||||
try {
|
||||
queue = await this.listSidebarPages(spaceId, rootPageId);
|
||||
} catch (e: any) {
|
||||
return { pages: result, complete: false };
|
||||
}
|
||||
|
||||
while (queue.length > 0 && result.length < MAX_NODES) {
|
||||
const node = queue.shift();
|
||||
if (!node || typeof node !== "object" || !node.id) continue;
|
||||
|
||||
// Skip already-seen ids to guard against cycles / duplicate references.
|
||||
if (visited.has(node.id)) continue;
|
||||
visited.add(node.id);
|
||||
|
||||
result.push(node);
|
||||
|
||||
if (node.hasChildren) {
|
||||
try {
|
||||
const children = await this.listSidebarPages(spaceId, node.id);
|
||||
for (const child of children) queue.push(child);
|
||||
} catch (e: any) {
|
||||
// A failure fetching one node's children must not abort the whole
|
||||
// walk: skip this branch and keep enumerating the rest, but RECORD
|
||||
// that the tree we return is incomplete (SPEC §8).
|
||||
complete = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we stopped because the node cap was hit while the queue still had
|
||||
// work, the tree is incomplete too.
|
||||
if (queue.length > 0 && result.length >= MAX_NODES) {
|
||||
complete = false;
|
||||
}
|
||||
|
||||
return { pages: result, complete };
|
||||
}
|
||||
|
||||
/**
|
||||
* "Changes since T" scan (SPEC §16). There is NO server-side `updatedAt`
|
||||
* filter in Docmost and `/pages/recent` is CURSOR-paginated, so this is a
|
||||
|
||||
Reference in New Issue
Block a user