# Conflicts: # apps/server/src/core/ai-chat/ai-chat.service.spec.ts # apps/server/src/core/ai-chat/ai-chat.service.ts
127 lines
5.6 KiB
TypeScript
127 lines
5.6 KiB
TypeScript
/**
|
|
* Pure reconciliation planner (SPEC §5/§6/§8).
|
|
*
|
|
* Given the desired live set of files (computed from the current Docmost tree)
|
|
* and the set of files currently tracked in the vault, compute what to write,
|
|
* what to move (old path to remove), and what to delete. Identity is `pageId`
|
|
* (the stable file<->page anchor, SPEC §4): a page that keeps its pageId but
|
|
* changes relPath is a MOVE, not delete+add; a tracked pageId that is gone from
|
|
* the live tree is a DELETE.
|
|
*
|
|
* This module is intentionally PURE (no IO, no git) so the whole plan is
|
|
* unit-testable. The actual file writing / git operations happen in pull.ts.
|
|
*/
|
|
/** A page that SHOULD exist in the vault at a given path. */
|
|
export interface LiveEntry {
|
|
pageId: string;
|
|
/** Vault-relative path (forward-slash), e.g. `Space/Parent/Child.md`. */
|
|
relPath: string;
|
|
}
|
|
/** A page currently tracked in the vault (pageId parsed from its meta). */
|
|
export interface ExistingEntry {
|
|
pageId: string;
|
|
/** Vault-relative path (forward-slash) of the tracked file. */
|
|
relPath: string;
|
|
}
|
|
/** A page to (re)write at its destination path. */
|
|
export interface WriteEntry {
|
|
pageId: string;
|
|
relPath: string;
|
|
}
|
|
/** A page that moved: written at its NEW relPath, with the OLD path removed. */
|
|
export interface MovedEntry {
|
|
pageId: string;
|
|
fromRelPath: string;
|
|
toRelPath: string;
|
|
/**
|
|
* Whether the old path (`fromRelPath`) is SAFE to remove. False when another
|
|
* live page will (re)write that exact path (path reuse): removing it would
|
|
* destroy real data, so the caller must skip the removal. The move itself is
|
|
* still recorded (the new path is written regardless).
|
|
*/
|
|
removeOldPath: boolean;
|
|
}
|
|
/** The full reconciliation plan. */
|
|
export interface ReconciliationPlan {
|
|
/**
|
|
* Pages present in `live` -> (re)write at their relPath. This naturally
|
|
* covers add, content-update (same path) AND move (same pageId, new path),
|
|
* since every live page is (re)written regardless of whether it existed.
|
|
*/
|
|
toWrite: WriteEntry[];
|
|
/**
|
|
* Vault-relative paths to delete because their tracked pageId is ABSENT from
|
|
* `live` (page removed/trashed). This set is ONLY absence-based deletions —
|
|
* the OLD paths of moved pages are NOT here (they live in `moved` and are
|
|
* applied separately by the caller). Keeping the two apart lets pull.ts gate
|
|
* absence deletions behind the incomplete-fetch suppression + mass-delete
|
|
* guard (SPEC §8) while still applying real moves.
|
|
*/
|
|
toDelete: string[];
|
|
/**
|
|
* Tracked pages whose relPath changed. The caller writes the page at
|
|
* `toRelPath`, then removes `fromRelPath` — but ONLY after the new-path write
|
|
* succeeded. The old path is NOT in `toDelete`.
|
|
*/
|
|
moved: MovedEntry[];
|
|
}
|
|
/**
|
|
* Compute the reconciliation plan.
|
|
*
|
|
* Rules:
|
|
* - Every `live` page is written at its relPath (covers add + update + move).
|
|
* - A tracked pageId present in `live` whose relPath changed is `moved`; its
|
|
* OLD relPath goes into `moved` ONLY (the caller removes it after the new
|
|
* path is written) and is NEVER added to `toDelete`.
|
|
* - A tracked pageId NOT present in `live` is an ABSENCE delete; its relPath
|
|
* is added to `toDelete`.
|
|
*
|
|
* Notes:
|
|
* - Safety filter (no data loss): no path that is a live TARGET path of any
|
|
* page is ever deleted/removed (a write owns it). This applies to BOTH the
|
|
* absence `toDelete` set AND a moved page's old-path removal — if a moved
|
|
* page's OLD path is reused by ANOTHER live page, the move records no old
|
|
* path to remove, because that path will be (re)written.
|
|
* - `existing` may legitimately contain duplicate pageIds (two stray files
|
|
* carrying the same meta pageId); each such file that is not the live target
|
|
* path is removed (as an absence/move) so the vault converges to exactly the
|
|
* live set.
|
|
*/
|
|
export declare function planReconciliation(live: LiveEntry[], existing: ExistingEntry[]): ReconciliationPlan;
|
|
/**
|
|
* Below this many tracked files the mass-delete fraction guard is not applied
|
|
* (a tiny vault where deleting "most" files is normal, e.g. 1-of-2).
|
|
*/
|
|
export declare const MASS_DELETE_MIN_EXISTING = 4;
|
|
/** Fraction of tracked files above which a delete plan is a suspected wipe. */
|
|
export declare const MASS_DELETE_FRACTION = 0.5;
|
|
/** Why absence-based deletions were (or were not) applied this cycle. */
|
|
export type DeletionDecision = {
|
|
apply: true;
|
|
} | {
|
|
apply: false;
|
|
reason: "incomplete-fetch" | "empty-live" | "mass-delete";
|
|
};
|
|
/**
|
|
* Pure decision: should the ABSENCE-based deletions (`plan.toDelete`) be applied
|
|
* this cycle? Encapsulates the SPEC §8 safety invariants so they are unit-
|
|
* testable without live creds or git:
|
|
*
|
|
* - `treeComplete === false` (a partial Docmost tree fetch) -> SUPPRESS. A page
|
|
* missing from a partial tree is NOT proof of deletion (SPEC §8); we must not
|
|
* delete merely-absent files this cycle. (Writes/updates/moves still happen.)
|
|
* - The live fetch returned 0 pages while files are tracked -> SUPPRESS
|
|
* (almost always a failed fetch, never a real "delete everything").
|
|
* - The plan would delete more than `MASS_DELETE_FRACTION` of a non-trivial
|
|
* vault -> SUPPRESS as a mass-deletion guard (defense in depth).
|
|
*
|
|
* Moves are NOT governed by this decision: a moved page IS present in `live`, so
|
|
* its old-path removal is real (handled by the caller separately).
|
|
*/
|
|
export declare function decideAbsenceDeletions(args: {
|
|
treeComplete: boolean;
|
|
liveCount: number;
|
|
existingCount: number;
|
|
deleteCount: number;
|
|
}): DeletionDecision;
|