Skip to content

fix(routes): guard page move against self-parent and NULL-path cycles (#891) - #986

Merged
laboef1900 merged 2 commits into
devfrom
feature/issue-891-move-cycle-guard
Jul 11, 2026
Merged

fix(routes): guard page move against self-parent and NULL-path cycles (#891)#986
laboef1900 merged 2 commits into
devfrom
feature/issue-891-move-cycle-guard

Conversation

@laboef1900

Copy link
Copy Markdown
Contributor

Summary

  • Replace the brittle materialized-path substring cycle guard in PUT /api/pages/:id/move with a recursive WITH RECURSIVE ancestors CTE that walks the target parent's ancestor chain.
  • Fixes two gaps: a page could become its own parent (parentId === page.id), and descendant-cycle detection was silently skipped for Confluence-synced pages whose materialized path is NULL.
  • The walk resolves parent_id against both confluence_id and numeric id (the same dual key the file's tree queries use) and uses UNION (dedup) so pre-existing corrupt cycles cannot loop.

Closes #891.

Root cause

The guard if (parentPath && parentPath.includes(\/${page.id}/`))relied solely on the target parent's materializedpathstring.'/5'.includes('/5/')is false (self-parent slips through), and the whole check is skipped whenpathisNULL(Confluence pages), letting aparent_id` cycle be written and corrupting descendant paths/depths.

Fix

  • Walk the ancestor chain upward from the proposed parent; if the moved page's id appears anywhere in that set (including the seed row, catching self-parenting), reject with 400 before any UPDATE runs.
  • No change to computePath/computeDepth or the descendant-rewrite UPDATE.

Testing

  • TDD: added two mocked-DB regression tests in backend/src/routes/knowledge/local-spaces.test.ts (self-parent, and NULL-path Confluence descendant cycle); both fail before the fix (endpoint returns 200 and performs the UPDATE), pass after (cycle-check returns a row → 400, no UPDATE). Updated the existing happy-path move test with one extra no-cycle mock for the added query.
  • cd backend && npx vitest run src/routes/knowledge/local-spaces.test.ts (30 passed) - eslint (pass) - tsc --noEmit (pass)

Generated with Claude Code

laboef1900 and others added 2 commits July 11, 2026 00:19
…#891)

The move endpoint's cycle guard relied on the target parent's materialized path string, which let a page become its own parent and skipped the check entirely for Confluence-synced pages whose path is NULL. Replaced it with a recursive ancestor-chain walk (dual-keyed on confluence_id/numeric id, UNION-deduped) that rejects both cases before any UPDATE runs; covered by two new mocked-DB regression tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tgres tests (#891)

Review follow-up on #986: the recursive-CTE guard was only exercised
against a mocked DB, and concurrent moves could still commit a mutual
parent cycle; moves now run in a transaction under an advisory lock.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@laboef1900
laboef1900 merged commit 2778469 into dev Jul 11, 2026
5 checks passed
@laboef1900
laboef1900 deleted the feature/issue-891-move-cycle-guard branch July 13, 2026 05:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

PUT /pages/:id/move allows a page to become its own parent (and misses cycles when the target parent has no materialized path)

1 participant