feat(sync): FS->Docmost push #3 — move/rename apply (§5 path-as-truth)
Complete the push action coverage (create/update/delete/move/rename/noop). - push.ts classifyRenameMoves (pure): the file PATH is the source of truth for tree position (§5) — new parent resolved from the enclosing folder's <dir>.md page, not the stale meta.parentPageId. Emit move iff parent changed, rename iff meta.title changed; a pure path-only rename is a NOOP (no Docmost call — the path is local, identity is pageId) - applyPushActions: move (move_page, reparent) THEN rename (rename_page); noop records and calls nothing; per-page isolation + refs-only-on-success preserved - resolveParentPageId reads <dir>.md meta via readFile (current) / git.showFileAtRef(last-pushed) (prev), matching buildVaultLayout - review fixes: prefetch wrapped in per-page try/catch so a tree-read throw isolates one page (§12), not the batch; failures.kind attributes the op that actually threw (rename-after-move -> "rename") - tests (+13): classifier (move/rename/both/noop/to-root), apply (calls/no-calls, ordering, isolation); 724 -> 737 green (x2 stable); corpus STABLE Deferred (final increment): live main() daemon, FS-watcher/debounce (§7.1), git-remote push (§7.2), pull-side bodyHash/updatedAt consumption, fractional-index position, escalate-on-divergent-docmost.
This commit is contained in:
365
src/push.ts
365
src/push.ts
@@ -13,14 +13,20 @@
|
|||||||
* - A with pageId -> update (restored/copied file; the page already exists).
|
* - A with pageId -> update (restored/copied file; the page already exists).
|
||||||
* - M -> update content (collab/Yjs path, SPEC §2/§15.6).
|
* - M -> update content (collab/Yjs path, SPEC §2/§15.6).
|
||||||
* - D -> delete_page (pageId recovered from the PRE-IMAGE meta).
|
* - D -> delete_page (pageId recovered from the PRE-IMAGE meta).
|
||||||
* - R -> rename/move (RECORDED ONLY here; see the TODO below).
|
* - R -> rename/move (CLASSIFIED here, APPLIED in push #3).
|
||||||
|
*
|
||||||
|
* MOVE/RENAME APPLY (push #3) — DONE here. `classifyRenameMoves` (PURE) resolves
|
||||||
|
* each `renamesMoves` entry into the Docmost op(s) it needs, comparing the PATH-
|
||||||
|
* derived parent (SPEC §5: the file path is the source of truth for tree
|
||||||
|
* position, NOT stale `meta.parentPageId`) and the meta title; `applyPushActions`
|
||||||
|
* then calls `move_page` / `rename_page` (both for a reparent+retitle), or
|
||||||
|
* records a NO-OP for a cosmetic local-only file-path rename.
|
||||||
*
|
*
|
||||||
* SCOPE OF THIS INCREMENT — what is intentionally NOT here yet (next increment),
|
* SCOPE OF THIS INCREMENT — what is intentionally NOT here yet (next increment),
|
||||||
* left as explicit TODO markers:
|
* left as explicit TODO markers:
|
||||||
* - TODO(next-increment): move/rename APPLY — resolving move-vs-rename and the
|
* - TODO(next): the precise fractional-index `position` for `move_page`
|
||||||
* new parentPageId, then calling `move_page` / `rename_page` (SPEC §6/§8).
|
* (SPEC §16). `applyPushActions` passes `position` UNDEFINED for now and the
|
||||||
* `computePushActions` already CLASSIFIES R into `renamesMoves`, and
|
* client supplies a valid default; computing a key between siblings is next.
|
||||||
* `applyPushActions` returns them as `deferred` without any client call.
|
|
||||||
* - loop-guard PRODUCTION (SPEC §10) — DONE here: `applyPushActions` records,
|
* - loop-guard PRODUCTION (SPEC §10) — DONE here: `applyPushActions` records,
|
||||||
* per applied page, the pushed body hash + the write's `updatedAt` (see
|
* per applied page, the pushed body hash + the write's `updatedAt` (see
|
||||||
* `ApplyPushResult.pushed`) and fast-forwards the `docmost` mirror after a
|
* `ApplyPushResult.pushed`) and fast-forwards the `docmost` mirror after a
|
||||||
@@ -72,6 +78,104 @@ export interface RenameMoveAction {
|
|||||||
newPath: string;
|
newPath: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A CLASSIFIED rename/move (push #3): a `RenameMoveAction` resolved into the
|
||||||
|
* Docmost op(s) it actually needs. The file PATH is the source of truth for tree
|
||||||
|
* position (SPEC §5: "истина связи — pageId, не путь" — the path is COSMETIC and
|
||||||
|
* LOCAL, the page identity is its pageId), so we compare the RESOLVED parent of
|
||||||
|
* the new path against the resolved parent of the old path, and the title in the
|
||||||
|
* current meta against the title in the previous meta. Each sub-op is emitted
|
||||||
|
* ONLY when something real changed:
|
||||||
|
* - `move` — the resolved parent page changed (reparent in Docmost). A `null`
|
||||||
|
* `parentPageId` means the new parent is ROOT (the file sits at the space
|
||||||
|
* root, no enclosing folder).
|
||||||
|
* - `rename` — the page title changed (a pure title edit in Docmost).
|
||||||
|
* - `noop` — neither changed: a purely LOCAL file-path rename (same parent,
|
||||||
|
* same title). The page identity is its pageId, so Docmost is NOT called.
|
||||||
|
* `move` and `rename` are independent and may BOTH be present (reparent + retitle).
|
||||||
|
*/
|
||||||
|
export interface RenameMoveActionClassified {
|
||||||
|
pageId: string;
|
||||||
|
oldPath: string;
|
||||||
|
newPath: string;
|
||||||
|
/** Present iff the resolved parent changed -> `move_page` (reparent). */
|
||||||
|
move?: { parentPageId: string | null };
|
||||||
|
/** Present iff the title changed -> `rename_page` (title-only). */
|
||||||
|
rename?: { title: string };
|
||||||
|
/** True iff neither parent nor title changed (cosmetic local-only rename). */
|
||||||
|
noop?: true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Injected resolvers for the PURE `classifyRenameMoves` (push #3). Both are PURE
|
||||||
|
* given a path + side; the real `main` (a follow-up) wires them to the file tree
|
||||||
|
* (`readFile` for `current`, `git.showFileAtRef` for `prev`), tests pass plain
|
||||||
|
* lookups. SPEC §5 path-as-truth:
|
||||||
|
* - `metaAt`: the file's `docmost:meta` at that side (for the title).
|
||||||
|
* - `resolveParentPageId`: the pageId of the page whose FILE is the parent
|
||||||
|
* FOLDER's `.md` (one level up from the given path), or `null` for ROOT.
|
||||||
|
*/
|
||||||
|
export interface ClassifyRenameMovesDeps {
|
||||||
|
metaAt: (path: string, side: MetaSide) => DocmostMdMeta | null;
|
||||||
|
resolveParentPageId: (path: string, side: MetaSide) => string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PURE classifier for the `renamesMoves` produced by `computePushActions`
|
||||||
|
* (push #3, SPEC §5/§6/§8). Resolves each `{pageId, oldPath, newPath}` into the
|
||||||
|
* Docmost op(s) it needs, with NO IO (both resolvers are injected).
|
||||||
|
*
|
||||||
|
* SPEC §5 — the file PATH is the source of truth for tree position, NOT the
|
||||||
|
* (possibly stale) `meta.parentPageId`. So the NEW parent is resolved from
|
||||||
|
* `newPath`'s enclosing folder, and the OLD parent from `oldPath`'s enclosing
|
||||||
|
* folder, via `deps.resolveParentPageId`. The title comes from the meta.
|
||||||
|
*
|
||||||
|
* For each entry:
|
||||||
|
* - `newParent = resolveParentPageId(newPath, 'current')`,
|
||||||
|
* `oldParent = resolveParentPageId(oldPath, 'prev')`.
|
||||||
|
* - `newTitle = metaAt(newPath,'current')?.title`,
|
||||||
|
* `oldTitle = metaAt(oldPath,'prev')?.title`.
|
||||||
|
* - include `move` iff `newParent !== oldParent` (a real reparent),
|
||||||
|
* - include `rename` iff `newTitle` is a NON-EMPTY string AND differs from
|
||||||
|
* `oldTitle` (a real title edit; an empty/absent new title is never a rename),
|
||||||
|
* - if NEITHER applies -> `noop: true` (a cosmetic local-only file-path rename;
|
||||||
|
* the page is its pageId, so Docmost is not touched).
|
||||||
|
*/
|
||||||
|
export function classifyRenameMoves(
|
||||||
|
renamesMoves: RenameMoveAction[],
|
||||||
|
deps: ClassifyRenameMovesDeps,
|
||||||
|
): RenameMoveActionClassified[] {
|
||||||
|
return renamesMoves.map((rm) => {
|
||||||
|
const newParent = deps.resolveParentPageId(rm.newPath, "current");
|
||||||
|
const oldParent = deps.resolveParentPageId(rm.oldPath, "prev");
|
||||||
|
const newTitle = deps.metaAt(rm.newPath, "current")?.title;
|
||||||
|
const oldTitle = deps.metaAt(rm.oldPath, "prev")?.title;
|
||||||
|
|
||||||
|
const out: RenameMoveActionClassified = {
|
||||||
|
pageId: rm.pageId,
|
||||||
|
oldPath: rm.oldPath,
|
||||||
|
newPath: rm.newPath,
|
||||||
|
};
|
||||||
|
// A reparent: the new path's resolved parent page differs from the old's.
|
||||||
|
if (newParent !== oldParent) {
|
||||||
|
out.move = { parentPageId: newParent };
|
||||||
|
}
|
||||||
|
// A title edit: only when there is a real, non-empty new title that changed.
|
||||||
|
if (
|
||||||
|
typeof newTitle === "string" &&
|
||||||
|
newTitle.length > 0 &&
|
||||||
|
newTitle !== oldTitle
|
||||||
|
) {
|
||||||
|
out.rename = { title: newTitle };
|
||||||
|
}
|
||||||
|
// Neither changed -> a purely LOCAL file-path rename; do NOT call Docmost.
|
||||||
|
if (!out.move && !out.rename) {
|
||||||
|
out.noop = true;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** The classified set of push actions (PURE output of `computePushActions`). */
|
/** The classified set of push actions (PURE output of `computePushActions`). */
|
||||||
export interface PushActions {
|
export interface PushActions {
|
||||||
creates: CreateAction[];
|
creates: CreateAction[];
|
||||||
@@ -257,7 +361,7 @@ export const DOCMOST_BRANCH = "docmost";
|
|||||||
* Injectable IO for `applyPushActions`. The real `main` (NEXT increment) wires
|
* Injectable IO for `applyPushActions`. The real `main` (NEXT increment) wires
|
||||||
* these to the live client, `node:fs/promises`, and the vault git wrapper; this
|
* these to the live client, `node:fs/promises`, and the vault git wrapper; this
|
||||||
* increment drives them only through FAKES in tests (no live destructive run).
|
* increment drives them only through FAKES in tests (no live destructive run).
|
||||||
* - `client`: the create/update/delete subset of `DocmostClient`.
|
* - `client`: the create/update/delete/move/rename subset of `DocmostClient`.
|
||||||
* - `readFile`/`writeFile`: read a changed file's body / write a file back
|
* - `readFile`/`writeFile`: read a changed file's body / write a file back
|
||||||
* (by vault-relative path; the applier does not resolve absolute paths so
|
* (by vault-relative path; the applier does not resolve absolute paths so
|
||||||
* fakes stay trivial).
|
* fakes stay trivial).
|
||||||
@@ -268,13 +372,23 @@ export const DOCMOST_BRANCH = "docmost";
|
|||||||
export interface ApplyPushDeps {
|
export interface ApplyPushDeps {
|
||||||
client: Pick<
|
client: Pick<
|
||||||
DocmostClient,
|
DocmostClient,
|
||||||
"importPageMarkdown" | "createPage" | "deletePage"
|
| "importPageMarkdown"
|
||||||
|
| "createPage"
|
||||||
|
| "deletePage"
|
||||||
|
| "movePage"
|
||||||
|
| "renamePage"
|
||||||
>;
|
>;
|
||||||
/** Read a changed file's full text by its vault-relative path. */
|
/** Read a changed file's full text by its vault-relative path. */
|
||||||
readFile: (path: string) => Promise<string>;
|
readFile: (path: string) => Promise<string>;
|
||||||
/** Write a file's full text by its vault-relative path. */
|
/** Write a file's full text by its vault-relative path. */
|
||||||
writeFile: (path: string, text: string) => Promise<void>;
|
writeFile: (path: string, text: string) => Promise<void>;
|
||||||
git: Pick<VaultGit, "updateRef" | "fastForwardBranch">;
|
/**
|
||||||
|
* `updateRef` advances `refs/docmost/last-pushed`; `fastForwardBranch` advances
|
||||||
|
* the `docmost` mirror after a clean push. `showFileAtRef` reads a file's text
|
||||||
|
* at a ref (used by the move/rename classifier to resolve the PREVIOUS parent
|
||||||
|
* folder's `.md` at `refs/docmost/last-pushed`, SPEC §5 path-as-truth).
|
||||||
|
*/
|
||||||
|
git: Pick<VaultGit, "updateRef" | "fastForwardBranch" | "showFileAtRef">;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A file whose meta was rewritten with a freshly-assigned pageId (post-create). */
|
/** A file whose meta was rewritten with a freshly-assigned pageId (post-create). */
|
||||||
@@ -306,20 +420,38 @@ export interface PushedPageRecord {
|
|||||||
* refs are NOT advanced when there is any failure, so a re-run retries cleanly.
|
* refs are NOT advanced when there is any failure, so a re-run retries cleanly.
|
||||||
*/
|
*/
|
||||||
export interface PushFailure {
|
export interface PushFailure {
|
||||||
kind: "update" | "create" | "delete";
|
kind: "update" | "create" | "delete" | "move" | "rename";
|
||||||
/** The pageId for update/delete; absent for a create that never got one. */
|
/** The pageId for update/delete/move/rename; absent for a never-id'd create. */
|
||||||
pageId?: string;
|
pageId?: string;
|
||||||
/** The vault-relative path for create/update; absent for a delete. */
|
/** The vault-relative path for create/update/move/rename; absent for delete. */
|
||||||
path?: string;
|
path?: string;
|
||||||
/** The error message captured from the thrown error. */
|
/** The error message captured from the thrown error. */
|
||||||
error: string;
|
error: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Structured outcome of `applyPushActions` (counts + write-backs + deferred). */
|
/**
|
||||||
|
* A rename/move action that resolved to a NO-OP (push #3, SPEC §5): a purely
|
||||||
|
* LOCAL file-path rename whose resolved parent AND title are both unchanged. The
|
||||||
|
* page identity is its pageId and the path is COSMETIC/local-only, so Docmost is
|
||||||
|
* NOT called — the skip is recorded here (with the reason) for logging.
|
||||||
|
*/
|
||||||
|
export interface PushNoop {
|
||||||
|
pageId: string;
|
||||||
|
oldPath: string;
|
||||||
|
newPath: string;
|
||||||
|
/** Why no Docmost op was emitted (currently always a path-only rename). */
|
||||||
|
reason: "path-only-rename";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Structured outcome of `applyPushActions` (counts + write-backs + noops). */
|
||||||
export interface ApplyPushResult {
|
export interface ApplyPushResult {
|
||||||
created: number;
|
created: number;
|
||||||
updated: number;
|
updated: number;
|
||||||
deleted: number;
|
deleted: number;
|
||||||
|
/** Pages reparented in Docmost via `move_page` (push #3, SPEC §5/§16). */
|
||||||
|
moved: number;
|
||||||
|
/** Pages retitled in Docmost via `rename_page` (push #3, SPEC §5/§6). */
|
||||||
|
renamed: number;
|
||||||
/**
|
/**
|
||||||
* Files whose `docmost:meta` was rewritten with the pageId Docmost assigned on
|
* Files whose `docmost:meta` was rewritten with the pageId Docmost assigned on
|
||||||
* create — these now need a FOLLOW-UP commit (the meta on disk changed). The
|
* create — these now need a FOLLOW-UP commit (the meta on disk changed). The
|
||||||
@@ -339,8 +471,12 @@ export interface ApplyPushResult {
|
|||||||
* (SPEC §12). Non-empty here means the refs were NOT advanced.
|
* (SPEC §12). Non-empty here means the refs were NOT advanced.
|
||||||
*/
|
*/
|
||||||
failures: PushFailure[];
|
failures: PushFailure[];
|
||||||
/** Rename/move actions NOT executed this increment (apply is deferred). */
|
/**
|
||||||
deferred: RenameMoveAction[];
|
* Rename/move actions that resolved to a NO-OP — a purely LOCAL file-path
|
||||||
|
* rename (same parent, same title). NO Docmost call was made for these (SPEC
|
||||||
|
* §5: the page is its pageId, the path is local-only). Recorded for logging.
|
||||||
|
*/
|
||||||
|
noops: PushNoop[];
|
||||||
/** Diff rows the planner could not classify (carried through for logging). */
|
/** Diff rows the planner could not classify (carried through for logging). */
|
||||||
skipped: PushActions["skipped"];
|
skipped: PushActions["skipped"];
|
||||||
/** Whether `refs/docmost/last-pushed` was advanced (only on a CLEAN push). */
|
/** Whether `refs/docmost/last-pushed` was advanced (only on a CLEAN push). */
|
||||||
@@ -369,7 +505,15 @@ export interface ApplyPushResult {
|
|||||||
* tracked. The write-back is recorded in `writtenBack` (a follow-up commit
|
* tracked. The write-back is recorded in `writtenBack` (a follow-up commit
|
||||||
* is needed — NEXT increment).
|
* is needed — NEXT increment).
|
||||||
* - DELETE: `client.deletePage(pageId)` — soft-delete to Trash (SPEC §8).
|
* - DELETE: `client.deletePage(pageId)` — soft-delete to Trash (SPEC §8).
|
||||||
* - RENAME/MOVE: NOT executed — returned as `deferred` (NEXT increment).
|
* - RENAME/MOVE (push #3, SPEC §5/§6/§16): classify each `renamesMoves` entry
|
||||||
|
* with `classifyRenameMoves` (resolvers read the parent FOLDER's `.md` for
|
||||||
|
* the parent pageId — path-as-truth — and the meta for the title), then:
|
||||||
|
* - `move` -> `client.movePage(pageId, parentPageId, position?)` (reparent;
|
||||||
|
* `position` is UNDEFINED for now — the client supplies a default),
|
||||||
|
* - `rename` -> `client.renamePage(pageId, title)` (title-only),
|
||||||
|
* - BOTH -> move (reparent) THEN rename (title), in that order,
|
||||||
|
* - `noop` -> NO client call; recorded in `noops` (a cosmetic local-only
|
||||||
|
* file-path rename: the page is its pageId, the path is local, SPEC §5).
|
||||||
*
|
*
|
||||||
* FAIL-SAFE / per-page isolation (SPEC §12 resumability). Each page's operation
|
* FAIL-SAFE / per-page isolation (SPEC §12 resumability). Each page's operation
|
||||||
* is wrapped in its own try/catch: a single failing page is recorded in
|
* is wrapped in its own try/catch: a single failing page is recorded in
|
||||||
@@ -408,9 +552,12 @@ export async function applyPushActions(
|
|||||||
let created = 0;
|
let created = 0;
|
||||||
let updated = 0;
|
let updated = 0;
|
||||||
let deleted = 0;
|
let deleted = 0;
|
||||||
|
let moved = 0;
|
||||||
|
let renamed = 0;
|
||||||
const writtenBack: WrittenBackPage[] = [];
|
const writtenBack: WrittenBackPage[] = [];
|
||||||
const pushed: PushedPageRecord[] = [];
|
const pushed: PushedPageRecord[] = [];
|
||||||
const failures: PushFailure[] = [];
|
const failures: PushFailure[] = [];
|
||||||
|
const noops: PushNoop[] = [];
|
||||||
|
|
||||||
// 1. UPDATES — collab/Yjs write path (SPEC §2/§15.6), never a raw overwrite.
|
// 1. UPDATES — collab/Yjs write path (SPEC §2/§15.6), never a raw overwrite.
|
||||||
// Each update is isolated: a thrown page is recorded and the batch goes on.
|
// Each update is isolated: a thrown page is recorded and the batch goes on.
|
||||||
@@ -489,8 +636,105 @@ export async function applyPushActions(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. RENAME/MOVE — DEFERRED (NEXT increment): no client call. Returned as
|
// 4. RENAME/MOVE (push #3, SPEC §5/§6/§16). Classify each entry against the
|
||||||
// `deferred` so the caller can see what still needs the move/rename apply.
|
// tree-backed resolvers (the NEW parent comes from the new path's enclosing
|
||||||
|
// folder `.md`, the OLD parent from the old path's at last-pushed — PATH is
|
||||||
|
// the truth, not stale `meta.parentPageId`; the title from the meta), then
|
||||||
|
// apply only the real ops. Each page is isolated like the cases above: a
|
||||||
|
// thrown op is recorded in `failures` and the batch continues. ORDER for a
|
||||||
|
// page that needs both: reparent (move) FIRST, then retitle (rename).
|
||||||
|
if (actions.renamesMoves.length > 0) {
|
||||||
|
// The classifier is PURE over sync resolvers; the tree reads are async, so
|
||||||
|
// prefetch every (path, side) lookup it will make into plain tables first.
|
||||||
|
const parentTable = new Map<string, string | null>();
|
||||||
|
const metaTable = new Map<string, DocmostMdMeta | null>();
|
||||||
|
// A tree read (readFile / git.showFileAtRef) throwing must isolate THAT page
|
||||||
|
// into `failures`, NOT abort the whole batch (§12 resumability). The helpers
|
||||||
|
// already swallow their own errors, but this per-entry try/catch keeps the
|
||||||
|
// batch-isolation invariant holding regardless of future changes to them.
|
||||||
|
const prefetchFailed = new Set<string>();
|
||||||
|
for (const rm of actions.renamesMoves) {
|
||||||
|
// newParent + newTitle from the CURRENT tree; oldParent + oldTitle from the
|
||||||
|
// last-pushed pre-image (`prev`). Keyed by `path|side` so duplicates fold.
|
||||||
|
try {
|
||||||
|
parentTable.set(
|
||||||
|
`${rm.newPath}|current`,
|
||||||
|
await resolveParentPageIdViaTree(deps, rm.newPath, "current"),
|
||||||
|
);
|
||||||
|
parentTable.set(
|
||||||
|
`${rm.oldPath}|prev`,
|
||||||
|
await resolveParentPageIdViaTree(deps, rm.oldPath, "prev"),
|
||||||
|
);
|
||||||
|
metaTable.set(
|
||||||
|
`${rm.newPath}|current`,
|
||||||
|
await metaAtViaTree(deps, rm.newPath, "current"),
|
||||||
|
);
|
||||||
|
metaTable.set(
|
||||||
|
`${rm.oldPath}|prev`,
|
||||||
|
await metaAtViaTree(deps, rm.oldPath, "prev"),
|
||||||
|
);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
prefetchFailed.add(rm.pageId);
|
||||||
|
failures.push({
|
||||||
|
kind: "move",
|
||||||
|
pageId: rm.pageId,
|
||||||
|
path: rm.newPath,
|
||||||
|
error: errMessage(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const classified = classifyRenameMoves(
|
||||||
|
actions.renamesMoves.filter((rm) => !prefetchFailed.has(rm.pageId)),
|
||||||
|
{
|
||||||
|
metaAt: (path, side) => metaTable.get(`${path}|${side}`) ?? null,
|
||||||
|
resolveParentPageId: (path, side) =>
|
||||||
|
parentTable.get(`${path}|${side}`) ?? null,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const c of classified) {
|
||||||
|
if (c.noop) {
|
||||||
|
// Cosmetic local-only file-path rename — no Docmost op (SPEC §5).
|
||||||
|
noops.push({
|
||||||
|
pageId: c.pageId,
|
||||||
|
oldPath: c.oldPath,
|
||||||
|
newPath: c.newPath,
|
||||||
|
reason: "path-only-rename",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Track which op is in flight so a failure is attributed to the op that
|
||||||
|
// ACTUALLY threw: for a page needing both, a move that succeeds then a
|
||||||
|
// rename that throws must be recorded as `rename`, not `move`.
|
||||||
|
let failingKind: "move" | "rename" = c.move ? "move" : "rename";
|
||||||
|
try {
|
||||||
|
// Reparent FIRST so the page is in its new tree position, THEN retitle.
|
||||||
|
if (c.move) {
|
||||||
|
failingKind = "move";
|
||||||
|
// TODO(next): compute a fractional-index position between siblings
|
||||||
|
// (SPEC §16). `position` is UNDEFINED here; the client supplies a valid
|
||||||
|
// default. Pass `parentPageId: null` for a move to the space ROOT.
|
||||||
|
await client.movePage(c.pageId, c.move.parentPageId);
|
||||||
|
moved++;
|
||||||
|
}
|
||||||
|
if (c.rename) {
|
||||||
|
failingKind = "rename";
|
||||||
|
await client.renamePage(c.pageId, c.rename.title);
|
||||||
|
renamed++;
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
// Isolate the failed page: the op that ACTUALLY threw is recorded so a
|
||||||
|
// re-run can retry. A move that threw before its rename leaves `rename`
|
||||||
|
// for the next run (idempotent re-apply); refs are NOT advanced (below).
|
||||||
|
failures.push({
|
||||||
|
kind: failingKind,
|
||||||
|
pageId: c.pageId,
|
||||||
|
path: c.newPath,
|
||||||
|
error: errMessage(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 5. Advance the refs ONLY on a CLEAN push (no failures) AND when a pushed
|
// 5. Advance the refs ONLY on a CLEAN push (no failures) AND when a pushed
|
||||||
// commit is supplied. A partial push must advance NEITHER ref, so a re-run
|
// commit is supplied. A partial push must advance NEITHER ref, so a re-run
|
||||||
@@ -514,10 +758,12 @@ export async function applyPushActions(
|
|||||||
created,
|
created,
|
||||||
updated,
|
updated,
|
||||||
deleted,
|
deleted,
|
||||||
|
moved,
|
||||||
|
renamed,
|
||||||
writtenBack,
|
writtenBack,
|
||||||
pushed,
|
pushed,
|
||||||
failures,
|
failures,
|
||||||
deferred: actions.renamesMoves,
|
noops,
|
||||||
skipped: actions.skipped,
|
skipped: actions.skipped,
|
||||||
lastPushedAdvanced,
|
lastPushedAdvanced,
|
||||||
docmostFastForward,
|
docmostFastForward,
|
||||||
@@ -529,6 +775,89 @@ function errMessage(err: unknown): string {
|
|||||||
return err instanceof Error ? err.message : String(err);
|
return err instanceof Error ? err.message : String(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SPEC §5 path-as-truth: the parent FOLDER's `.md` file for a vault-relative
|
||||||
|
* (forward-slash) path. `buildVaultLayout` puts a page with children at
|
||||||
|
* `<...>/Title.md` and nests its children under `<...>/Title/`, so for
|
||||||
|
* `newPath = <dir>/Child.md` the parent page's file is `<dir>.md` (the enclosing
|
||||||
|
* folder, one level up). A path with NO enclosing folder (`Child.md`, at the
|
||||||
|
* space root) has no parent folder file -> `null` (the parent is ROOT).
|
||||||
|
*/
|
||||||
|
export function parentFolderFile(path: string): string | null {
|
||||||
|
const slash = path.lastIndexOf("/");
|
||||||
|
if (slash < 0) return null; // root-level file: no enclosing folder.
|
||||||
|
return `${path.slice(0, slash)}.md`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the `resolveParentPageId(path, side)` resolver `classifyRenameMoves`
|
||||||
|
* needs, reading the PARENT FOLDER's `.md` (SPEC §5 path-as-truth):
|
||||||
|
* - `current` -> `deps.readFile(<dir>.md)` (the live working tree),
|
||||||
|
* - `prev` -> `git.showFileAtRef('refs/docmost/last-pushed', <dir>.md)` (the
|
||||||
|
* last-pushed pre-image),
|
||||||
|
* then parse its `docmost:meta` and return that page's pageId. A root-level path
|
||||||
|
* (no enclosing folder), a missing/unreadable parent file, or a parent file with
|
||||||
|
* no parseable pageId all resolve to `null` (parent is ROOT / unknown ->
|
||||||
|
* `parentPageId: null`, SPEC §16 "parentPageId: null -> в корень").
|
||||||
|
*
|
||||||
|
* The IO is async, so this returns an ASYNC resolver; the call sites prefetch the
|
||||||
|
* parent pageIds (the classifier itself stays pure/sync over a plain table).
|
||||||
|
*/
|
||||||
|
async function resolveParentPageIdViaTree(
|
||||||
|
deps: Pick<ApplyPushDeps, "readFile" | "git">,
|
||||||
|
path: string,
|
||||||
|
side: MetaSide,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const parentFile = parentFolderFile(path);
|
||||||
|
if (parentFile === null) return null; // root-level: parent is ROOT.
|
||||||
|
let text: string | null;
|
||||||
|
try {
|
||||||
|
text =
|
||||||
|
side === "current"
|
||||||
|
? await deps.readFile(parentFile)
|
||||||
|
: await deps.git.showFileAtRef(LAST_PUSHED_REF, parentFile);
|
||||||
|
} catch {
|
||||||
|
// Parent folder file missing/unreadable at that side -> treat as ROOT.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (text === null) return null; // showFileAtRef returns null when absent.
|
||||||
|
try {
|
||||||
|
const { meta } = parseDocmostMarkdown(text);
|
||||||
|
return meta?.pageId ?? null;
|
||||||
|
} catch {
|
||||||
|
// Unparseable parent meta -> no resolvable parent pageId.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the file `docmost:meta` at a side for the rename/move classifier (the
|
||||||
|
* title comes from here). Mirrors `resolveParentPageIdViaTree`'s IO sides:
|
||||||
|
* `current` reads the working tree, `prev` reads `refs/docmost/last-pushed`.
|
||||||
|
* Returns `null` on a missing/unreadable/unparseable file.
|
||||||
|
*/
|
||||||
|
async function metaAtViaTree(
|
||||||
|
deps: Pick<ApplyPushDeps, "readFile" | "git">,
|
||||||
|
path: string,
|
||||||
|
side: MetaSide,
|
||||||
|
): Promise<DocmostMdMeta | null> {
|
||||||
|
let text: string | null;
|
||||||
|
try {
|
||||||
|
text =
|
||||||
|
side === "current"
|
||||||
|
? await deps.readFile(path)
|
||||||
|
: await deps.git.showFileAtRef(LAST_PUSHED_REF, path);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (text === null) return null;
|
||||||
|
try {
|
||||||
|
return parseDocmostMarkdown(text).meta ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pull an `updatedAt` out of a create/update client result, if present. The
|
* Pull an `updatedAt` out of a create/update client result, if present. The
|
||||||
* shape is `{ data: { updatedAt? }, ... }` (createPage) or a flatter object;
|
* shape is `{ data: { updatedAt? }, ... }` (createPage) or a flatter object;
|
||||||
|
|||||||
@@ -33,6 +33,18 @@ function makeClient(opts?: { createId?: string }) {
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
deletePage: vi.fn(async (_pageId: string) => ({ success: true })),
|
deletePage: vi.fn(async (_pageId: string) => ({ success: true })),
|
||||||
|
movePage: vi.fn(
|
||||||
|
async (
|
||||||
|
_pageId: string,
|
||||||
|
_parentPageId: string | null,
|
||||||
|
_position?: string,
|
||||||
|
) => ({ success: true }),
|
||||||
|
),
|
||||||
|
renamePage: vi.fn(async (pageId: string, title: string) => ({
|
||||||
|
success: true,
|
||||||
|
pageId,
|
||||||
|
title,
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
@@ -42,9 +54,14 @@ function makeClient(opts?: { createId?: string }) {
|
|||||||
* (advance the `docmost` mirror, the loop-close). `ffResult` configures what the
|
* (advance the `docmost` mirror, the loop-close). `ffResult` configures what the
|
||||||
* ff returns (default a successful advance).
|
* ff returns (default a successful advance).
|
||||||
*/
|
*/
|
||||||
function makeGit(opts?: { ffResult?: { ok: boolean; reason?: string } }) {
|
function makeGit(opts?: {
|
||||||
|
ffResult?: { ok: boolean; reason?: string };
|
||||||
|
/** Pre-image tree at `refs/docmost/last-pushed` (path -> text). */
|
||||||
|
prevTree?: Record<string, string>;
|
||||||
|
}) {
|
||||||
const updateRefCalls: { ref: string; target: string }[] = [];
|
const updateRefCalls: { ref: string; target: string }[] = [];
|
||||||
const ffCalls: { branch: string; toCommit: string }[] = [];
|
const ffCalls: { branch: string; toCommit: string }[] = [];
|
||||||
|
const prevTree = opts?.prevTree ?? {};
|
||||||
const git = {
|
const git = {
|
||||||
updateRef: vi.fn(async (ref: string, target: string) => {
|
updateRef: vi.fn(async (ref: string, target: string) => {
|
||||||
updateRefCalls.push({ ref, target });
|
updateRefCalls.push({ ref, target });
|
||||||
@@ -53,6 +70,11 @@ function makeGit(opts?: { ffResult?: { ok: boolean; reason?: string } }) {
|
|||||||
ffCalls.push({ branch, toCommit });
|
ffCalls.push({ branch, toCommit });
|
||||||
return opts?.ffResult ?? { ok: true };
|
return opts?.ffResult ?? { ok: true };
|
||||||
}),
|
}),
|
||||||
|
// The move/rename classifier reads the PREVIOUS parent folder's `.md` at
|
||||||
|
// refs/docmost/last-pushed via this; `null` when absent there (SPEC §5).
|
||||||
|
showFileAtRef: vi.fn(async (_ref: string, path: string) =>
|
||||||
|
path in prevTree ? prevTree[path] : null,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
return { git, updateRefCalls, ffCalls };
|
return { git, updateRefCalls, ffCalls };
|
||||||
}
|
}
|
||||||
@@ -189,23 +211,237 @@ describe('applyPushActions — delete (soft-delete to Trash, SPEC §8)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('applyPushActions — rename/move is DEFERRED (NEXT increment)', () => {
|
// FS→Docmost push #3 (SPEC §5/§6/§16): the move/rename APPLY. The classifier
|
||||||
it('returns renames/moves as `deferred` with NO client call', async () => {
|
// resolves the parent from the FILE PATH (the enclosing folder's `.md`), not
|
||||||
const client = makeClient();
|
// stale `meta.parentPageId`, then `applyPushActions` calls move_page / rename_page
|
||||||
const { git } = makeGit();
|
// (both for a reparent+retitle) or records a path-only NO-OP with NO client call.
|
||||||
const fs = makeFs();
|
|
||||||
|
/**
|
||||||
|
* Helper: a self-contained file with the given pageId + title in its meta. Used
|
||||||
|
* both to seed the working tree (fs) and the prev tree (git.showFileAtRef).
|
||||||
|
*/
|
||||||
|
function fileWith(meta: { pageId: string; title?: string }): string {
|
||||||
|
return serializeDocmostMarkdownBody(
|
||||||
|
{ version: 1, pageId: meta.pageId, ...(meta.title ? { title: meta.title } : {}) },
|
||||||
|
'body',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('applyPushActions — move (parent changed, title same; SPEC §5/§16)', () => {
|
||||||
|
it('calls movePage(pageId, newParent) and NOT renamePage', async () => {
|
||||||
|
// The page moved from the space root (Doc.md) under a folder (Parent/Doc.md).
|
||||||
|
// The new parent page's file is `Parent.md`; its meta carries the parent id.
|
||||||
|
const client = makeClient();
|
||||||
|
const { git } = makeGit({
|
||||||
|
// Prev pre-image: the file used to sit at the root (parent ROOT).
|
||||||
|
prevTree: { 'Doc.md': fileWith({ pageId: 'p-mv', title: 'Doc' }) },
|
||||||
|
});
|
||||||
|
const fs = makeFs({
|
||||||
|
// Current tree: the moved file + its new parent folder's `.md`.
|
||||||
|
'Parent/Doc.md': fileWith({ pageId: 'p-mv', title: 'Doc' }),
|
||||||
|
'Parent.md': fileWith({ pageId: 'parent-id', title: 'Parent' }),
|
||||||
|
});
|
||||||
|
|
||||||
const rm = { pageId: 'p-mv', oldPath: 'Old.md', newPath: 'New.md' };
|
|
||||||
const res = await applyPushActions(
|
const res = await applyPushActions(
|
||||||
deps(client, git, fs),
|
deps(client, git, fs),
|
||||||
actions({ renamesMoves: [rm] }),
|
actions({
|
||||||
|
renamesMoves: [
|
||||||
|
{ pageId: 'p-mv', oldPath: 'Doc.md', newPath: 'Parent/Doc.md' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(res.deferred).toEqual([rm]);
|
expect(res.moved).toBe(1);
|
||||||
// NOTHING was pushed for the move this increment.
|
expect(res.renamed).toBe(0);
|
||||||
expect(client.importPageMarkdown).not.toHaveBeenCalled();
|
expect(client.movePage).toHaveBeenCalledTimes(1);
|
||||||
expect(client.createPage).not.toHaveBeenCalled();
|
// Reparented under `parent-id`; position left UNDEFINED (client default).
|
||||||
expect(client.deletePage).not.toHaveBeenCalled();
|
expect(client.movePage).toHaveBeenCalledWith('p-mv', 'parent-id');
|
||||||
|
expect(client.renamePage).not.toHaveBeenCalled();
|
||||||
|
expect(res.noops).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('applyPushActions — move-to-root (newParent null; SPEC §16)', () => {
|
||||||
|
it('calls movePage(pageId, null) when the file lands at the space root', async () => {
|
||||||
|
const client = makeClient();
|
||||||
|
const { git } = makeGit({
|
||||||
|
// Prev: the file used to live under `Parent/`, so its old parent is the
|
||||||
|
// page whose file is `Parent.md` (parent-id).
|
||||||
|
prevTree: {
|
||||||
|
'Parent/Doc.md': fileWith({ pageId: 'p-mv', title: 'Doc' }),
|
||||||
|
'Parent.md': fileWith({ pageId: 'parent-id', title: 'Parent' }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Current: the file is now at the root -> no enclosing folder -> parent ROOT.
|
||||||
|
const fs = makeFs({ 'Doc.md': fileWith({ pageId: 'p-mv', title: 'Doc' }) });
|
||||||
|
|
||||||
|
const res = await applyPushActions(
|
||||||
|
deps(client, git, fs),
|
||||||
|
actions({
|
||||||
|
renamesMoves: [
|
||||||
|
{ pageId: 'p-mv', oldPath: 'Parent/Doc.md', newPath: 'Doc.md' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.moved).toBe(1);
|
||||||
|
expect(client.movePage).toHaveBeenCalledWith('p-mv', null);
|
||||||
|
expect(client.renamePage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('applyPushActions — rename (same parent, title changed; SPEC §5/§6)', () => {
|
||||||
|
it('calls renamePage(pageId, title) and NOT movePage', async () => {
|
||||||
|
// Same enclosing folder on both sides (parent unchanged), only the title
|
||||||
|
// changed in meta -> a pure rename.
|
||||||
|
const client = makeClient();
|
||||||
|
const { git } = makeGit({
|
||||||
|
prevTree: {
|
||||||
|
'Folder/Old.md': fileWith({ pageId: 'p-rn', title: 'Old Title' }),
|
||||||
|
'Folder.md': fileWith({ pageId: 'folder-id', title: 'Folder' }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const fs = makeFs({
|
||||||
|
'Folder/New.md': fileWith({ pageId: 'p-rn', title: 'New Title' }),
|
||||||
|
'Folder.md': fileWith({ pageId: 'folder-id', title: 'Folder' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await applyPushActions(
|
||||||
|
deps(client, git, fs),
|
||||||
|
actions({
|
||||||
|
renamesMoves: [
|
||||||
|
{ pageId: 'p-rn', oldPath: 'Folder/Old.md', newPath: 'Folder/New.md' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.renamed).toBe(1);
|
||||||
|
expect(res.moved).toBe(0);
|
||||||
|
expect(client.renamePage).toHaveBeenCalledTimes(1);
|
||||||
|
expect(client.renamePage).toHaveBeenCalledWith('p-rn', 'New Title');
|
||||||
|
expect(client.movePage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('applyPushActions — both (reparent + retitle; move THEN rename)', () => {
|
||||||
|
it('calls movePage first, then renamePage', async () => {
|
||||||
|
const callOrder: string[] = [];
|
||||||
|
const client = makeClient();
|
||||||
|
client.movePage.mockImplementation(async () => {
|
||||||
|
callOrder.push('move');
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
|
client.renamePage.mockImplementation(async (pageId: string, title: string) => {
|
||||||
|
callOrder.push('rename');
|
||||||
|
return { success: true, pageId, title };
|
||||||
|
});
|
||||||
|
const { git } = makeGit({
|
||||||
|
// Prev: at root (parent ROOT) with the old title.
|
||||||
|
prevTree: { 'Old.md': fileWith({ pageId: 'p-x', title: 'Old' }) },
|
||||||
|
});
|
||||||
|
const fs = makeFs({
|
||||||
|
// Current: under a new folder AND retitled.
|
||||||
|
'NewParent/New.md': fileWith({ pageId: 'p-x', title: 'New' }),
|
||||||
|
'NewParent.md': fileWith({ pageId: 'np-id', title: 'NewParent' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await applyPushActions(
|
||||||
|
deps(client, git, fs),
|
||||||
|
actions({
|
||||||
|
renamesMoves: [
|
||||||
|
{ pageId: 'p-x', oldPath: 'Old.md', newPath: 'NewParent/New.md' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.moved).toBe(1);
|
||||||
|
expect(res.renamed).toBe(1);
|
||||||
|
expect(client.movePage).toHaveBeenCalledWith('p-x', 'np-id');
|
||||||
|
expect(client.renamePage).toHaveBeenCalledWith('p-x', 'New');
|
||||||
|
// Order matters: reparent FIRST, then retitle.
|
||||||
|
expect(callOrder).toEqual(['move', 'rename']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('applyPushActions — noop (path-only rename; NO Docmost call; SPEC §5)', () => {
|
||||||
|
it('calls NEITHER movePage NOR renamePage and records the noop', async () => {
|
||||||
|
// Same enclosing folder AND same title on both sides: a purely LOCAL file
|
||||||
|
// rename. The page is its pageId; the path is cosmetic -> Docmost untouched.
|
||||||
|
const client = makeClient();
|
||||||
|
const { git } = makeGit({
|
||||||
|
prevTree: {
|
||||||
|
'Folder/A.md': fileWith({ pageId: 'p-noop', title: 'Same' }),
|
||||||
|
'Folder.md': fileWith({ pageId: 'folder-id', title: 'Folder' }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const fs = makeFs({
|
||||||
|
'Folder/B.md': fileWith({ pageId: 'p-noop', title: 'Same' }),
|
||||||
|
'Folder.md': fileWith({ pageId: 'folder-id', title: 'Folder' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await applyPushActions(
|
||||||
|
deps(client, git, fs),
|
||||||
|
actions({
|
||||||
|
renamesMoves: [
|
||||||
|
{ pageId: 'p-noop', oldPath: 'Folder/A.md', newPath: 'Folder/B.md' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.moved).toBe(0);
|
||||||
|
expect(res.renamed).toBe(0);
|
||||||
|
// ZERO Docmost calls for a cosmetic rename.
|
||||||
|
expect(client.movePage).not.toHaveBeenCalled();
|
||||||
|
expect(client.renamePage).not.toHaveBeenCalled();
|
||||||
|
expect(res.noops).toEqual([
|
||||||
|
{
|
||||||
|
pageId: 'p-noop',
|
||||||
|
oldPath: 'Folder/A.md',
|
||||||
|
newPath: 'Folder/B.md',
|
||||||
|
reason: 'path-only-rename',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('applyPushActions — move whose client call throws (SPEC §12 isolation)', () => {
|
||||||
|
it('isolates the failure into `failures` and does NOT advance the refs', async () => {
|
||||||
|
const client = makeClient();
|
||||||
|
client.movePage.mockImplementation(async () => {
|
||||||
|
throw new Error('move boom');
|
||||||
|
});
|
||||||
|
const { git, updateRefCalls, ffCalls } = makeGit({
|
||||||
|
prevTree: { 'Doc.md': fileWith({ pageId: 'p-mv', title: 'Doc' }) },
|
||||||
|
});
|
||||||
|
const fs = makeFs({
|
||||||
|
'Parent/Doc.md': fileWith({ pageId: 'p-mv', title: 'Doc' }),
|
||||||
|
'Parent.md': fileWith({ pageId: 'parent-id', title: 'Parent' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await applyPushActions(
|
||||||
|
deps(client, git, fs),
|
||||||
|
actions({
|
||||||
|
renamesMoves: [
|
||||||
|
{ pageId: 'p-mv', oldPath: 'Doc.md', newPath: 'Parent/Doc.md' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
'sha-move-fail',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.moved).toBe(0);
|
||||||
|
expect(res.failures).toEqual([
|
||||||
|
{
|
||||||
|
kind: 'move',
|
||||||
|
pageId: 'p-mv',
|
||||||
|
path: 'Parent/Doc.md',
|
||||||
|
error: 'move boom',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
// A failure means the refs are NOT advanced — a re-run retries cleanly (§12).
|
||||||
|
expect(res.lastPushedAdvanced).toBe(false);
|
||||||
|
expect(updateRefCalls).toEqual([]);
|
||||||
|
expect(ffCalls).toEqual([]);
|
||||||
|
expect(git.updateRef).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
263
test/classify-rename-moves.test.ts
Normal file
263
test/classify-rename-moves.test.ts
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { classifyRenameMoves } from '../src/push.js';
|
||||||
|
import type {
|
||||||
|
ClassifyRenameMovesDeps,
|
||||||
|
MetaSide,
|
||||||
|
RenameMoveAction,
|
||||||
|
} from '../src/push.js';
|
||||||
|
import type { DocmostMdMeta } from '../packages/docmost-client/src/lib/markdown-document.js';
|
||||||
|
|
||||||
|
// FS→Docmost push #3 (SPEC §5/§6/§16). `classifyRenameMoves` is the PURE half of
|
||||||
|
// the move/rename apply: it resolves each `{pageId, oldPath, newPath}` into the
|
||||||
|
// Docmost op(s) it needs, with NO IO (both resolvers are injected). The key
|
||||||
|
// design (SPEC §5) is that the file PATH is the source of truth for tree
|
||||||
|
// position — the NEW parent comes from the new path, the OLD parent from the old
|
||||||
|
// path — and the title comes from the meta. An op is emitted ONLY when something
|
||||||
|
// really changed; a path-only rename (same parent + same title) is a noop and
|
||||||
|
// NEVER calls Docmost.
|
||||||
|
|
||||||
|
/** Build `metaAt` from a `path|side -> meta` table. */
|
||||||
|
function metaTable(
|
||||||
|
table: Record<string, DocmostMdMeta | null>,
|
||||||
|
): (path: string, side: MetaSide) => DocmostMdMeta | null {
|
||||||
|
return (path, side) => {
|
||||||
|
const key = `${path}|${side}`;
|
||||||
|
return key in table ? table[key] : null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build `resolveParentPageId` from a `path|side -> parentPageId|null` table. */
|
||||||
|
function parentTable(
|
||||||
|
table: Record<string, string | null>,
|
||||||
|
): (path: string, side: MetaSide) => string | null {
|
||||||
|
return (path, side) => {
|
||||||
|
const key = `${path}|${side}`;
|
||||||
|
return key in table ? table[key] : null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function deps(
|
||||||
|
metas: Record<string, DocmostMdMeta | null>,
|
||||||
|
parents: Record<string, string | null>,
|
||||||
|
): ClassifyRenameMovesDeps {
|
||||||
|
return {
|
||||||
|
metaAt: metaTable(metas),
|
||||||
|
resolveParentPageId: parentTable(parents),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function meta(partial: Partial<DocmostMdMeta>): DocmostMdMeta {
|
||||||
|
return { version: 1, ...partial };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('classifyRenameMoves — move-only (parent changed, title same)', () => {
|
||||||
|
it('emits move (new parent) and NO rename', () => {
|
||||||
|
const rms: RenameMoveAction[] = [
|
||||||
|
{ pageId: 'p1', oldPath: 'Doc.md', newPath: 'Parent/Doc.md' },
|
||||||
|
];
|
||||||
|
const out = classifyRenameMoves(
|
||||||
|
rms,
|
||||||
|
deps(
|
||||||
|
{
|
||||||
|
// Same title on both sides.
|
||||||
|
'Parent/Doc.md|current': meta({ title: 'Doc' }),
|
||||||
|
'Doc.md|prev': meta({ title: 'Doc' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Parent changed: root (null) -> 'parent-id'.
|
||||||
|
'Parent/Doc.md|current': 'parent-id',
|
||||||
|
'Doc.md|prev': null,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(out).toEqual([
|
||||||
|
{
|
||||||
|
pageId: 'p1',
|
||||||
|
oldPath: 'Doc.md',
|
||||||
|
newPath: 'Parent/Doc.md',
|
||||||
|
move: { parentPageId: 'parent-id' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(out[0].rename).toBeUndefined();
|
||||||
|
expect(out[0].noop).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('classifyRenameMoves — rename-only (same parent, title changed)', () => {
|
||||||
|
it('emits rename (new title) and NO move', () => {
|
||||||
|
const rms: RenameMoveAction[] = [
|
||||||
|
{ pageId: 'p2', oldPath: 'Folder/Old.md', newPath: 'Folder/New.md' },
|
||||||
|
];
|
||||||
|
const out = classifyRenameMoves(
|
||||||
|
rms,
|
||||||
|
deps(
|
||||||
|
{
|
||||||
|
'Folder/New.md|current': meta({ title: 'New Title' }),
|
||||||
|
'Folder/Old.md|prev': meta({ title: 'Old Title' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Same parent on both sides.
|
||||||
|
'Folder/New.md|current': 'folder-id',
|
||||||
|
'Folder/Old.md|prev': 'folder-id',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(out).toEqual([
|
||||||
|
{
|
||||||
|
pageId: 'p2',
|
||||||
|
oldPath: 'Folder/Old.md',
|
||||||
|
newPath: 'Folder/New.md',
|
||||||
|
rename: { title: 'New Title' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(out[0].move).toBeUndefined();
|
||||||
|
expect(out[0].noop).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('classifyRenameMoves — both (parent AND title changed)', () => {
|
||||||
|
it('emits BOTH move and rename', () => {
|
||||||
|
const rms: RenameMoveAction[] = [
|
||||||
|
{ pageId: 'p3', oldPath: 'Old.md', newPath: 'NewParent/New.md' },
|
||||||
|
];
|
||||||
|
const out = classifyRenameMoves(
|
||||||
|
rms,
|
||||||
|
deps(
|
||||||
|
{
|
||||||
|
'NewParent/New.md|current': meta({ title: 'New' }),
|
||||||
|
'Old.md|prev': meta({ title: 'Old' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'NewParent/New.md|current': 'np-id',
|
||||||
|
'Old.md|prev': null,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(out).toEqual([
|
||||||
|
{
|
||||||
|
pageId: 'p3',
|
||||||
|
oldPath: 'Old.md',
|
||||||
|
newPath: 'NewParent/New.md',
|
||||||
|
move: { parentPageId: 'np-id' },
|
||||||
|
rename: { title: 'New' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(out[0].noop).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('classifyRenameMoves — noop (path-only rename, same parent + title)', () => {
|
||||||
|
it('emits noop and NEITHER move NOR rename (SPEC §5: page is its pageId)', () => {
|
||||||
|
const rms: RenameMoveAction[] = [
|
||||||
|
{ pageId: 'p4', oldPath: 'Folder/A.md', newPath: 'Folder/B.md' },
|
||||||
|
];
|
||||||
|
const out = classifyRenameMoves(
|
||||||
|
rms,
|
||||||
|
deps(
|
||||||
|
{
|
||||||
|
'Folder/B.md|current': meta({ title: 'Same' }),
|
||||||
|
'Folder/A.md|prev': meta({ title: 'Same' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'Folder/B.md|current': 'folder-id',
|
||||||
|
'Folder/A.md|prev': 'folder-id',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(out).toEqual([
|
||||||
|
{
|
||||||
|
pageId: 'p4',
|
||||||
|
oldPath: 'Folder/A.md',
|
||||||
|
newPath: 'Folder/B.md',
|
||||||
|
noop: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(out[0].move).toBeUndefined();
|
||||||
|
expect(out[0].rename).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('classifyRenameMoves — move-to-root (newParent null)', () => {
|
||||||
|
it('emits move with parentPageId null when the file lands at the space root', () => {
|
||||||
|
const rms: RenameMoveAction[] = [
|
||||||
|
{ pageId: 'p5', oldPath: 'Parent/Doc.md', newPath: 'Doc.md' },
|
||||||
|
];
|
||||||
|
const out = classifyRenameMoves(
|
||||||
|
rms,
|
||||||
|
deps(
|
||||||
|
{
|
||||||
|
'Doc.md|current': meta({ title: 'Doc' }),
|
||||||
|
'Parent/Doc.md|prev': meta({ title: 'Doc' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// New parent is ROOT (null), old parent was 'parent-id'.
|
||||||
|
'Doc.md|current': null,
|
||||||
|
'Parent/Doc.md|prev': 'parent-id',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(out).toEqual([
|
||||||
|
{
|
||||||
|
pageId: 'p5',
|
||||||
|
oldPath: 'Parent/Doc.md',
|
||||||
|
newPath: 'Doc.md',
|
||||||
|
move: { parentPageId: null },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(out[0].rename).toBeUndefined();
|
||||||
|
expect(out[0].noop).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('classifyRenameMoves — title guards', () => {
|
||||||
|
it('an EMPTY new title is NOT a rename (even if it differs from old)', () => {
|
||||||
|
const rms: RenameMoveAction[] = [
|
||||||
|
{ pageId: 'p6', oldPath: 'Folder/A.md', newPath: 'Folder/B.md' },
|
||||||
|
];
|
||||||
|
const out = classifyRenameMoves(
|
||||||
|
rms,
|
||||||
|
deps(
|
||||||
|
{
|
||||||
|
// New title is empty -> never a rename; same parent -> overall noop.
|
||||||
|
'Folder/B.md|current': meta({ title: '' }),
|
||||||
|
'Folder/A.md|prev': meta({ title: 'Had A Title' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'Folder/B.md|current': 'folder-id',
|
||||||
|
'Folder/A.md|prev': 'folder-id',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(out[0].rename).toBeUndefined();
|
||||||
|
expect(out[0].move).toBeUndefined();
|
||||||
|
expect(out[0].noop).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a missing new meta is NOT a rename; a parent change still yields a move', () => {
|
||||||
|
const rms: RenameMoveAction[] = [
|
||||||
|
{ pageId: 'p7', oldPath: 'Doc.md', newPath: 'Parent/Doc.md' },
|
||||||
|
];
|
||||||
|
const out = classifyRenameMoves(
|
||||||
|
rms,
|
||||||
|
deps(
|
||||||
|
{
|
||||||
|
// No current meta entry at all (resolver returns null).
|
||||||
|
'Doc.md|prev': meta({ title: 'Doc' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'Parent/Doc.md|current': 'parent-id',
|
||||||
|
'Doc.md|prev': null,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(out[0].move).toEqual({ parentPageId: 'parent-id' });
|
||||||
|
expect(out[0].rename).toBeUndefined();
|
||||||
|
expect(out[0].noop).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('classifyRenameMoves — empty input', () => {
|
||||||
|
it('returns an empty array for no rename/move entries', () => {
|
||||||
|
expect(classifyRenameMoves([], deps({}, {}))).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user