diff --git a/apps/server/src/database/helpers/page-hierarchy-cycle.spec.ts b/apps/server/src/database/helpers/page-hierarchy-cycle.spec.ts new file mode 100644 index 000000000..cc8f82329 --- /dev/null +++ b/apps/server/src/database/helpers/page-hierarchy-cycle.spec.ts @@ -0,0 +1,48 @@ +import { + assertAcyclicPageTraversal, + PageHierarchyCycleError, + stripPageTraversalMetadata, +} from './page-hierarchy-cycle'; + +describe('page hierarchy cycle contract', () => { + it('does nothing when no row is marked as a cycle', () => { + expect(() => + assertAcyclicPageTraversal( + [ + { id: 'page-1', title: 'Root', isCycle: false }, + { id: 'page-2', title: 'Child', isCycle: false }, + ], + 'page-1', + ), + ).not.toThrow(); + }); + + it('throws PageHierarchyCycleError when a row is marked as a cycle', () => { + expect(() => + assertAcyclicPageTraversal( + [{ id: 'page-1', title: 'Root', isCycle: true }], + 'page-1', + ), + ).toThrow(PageHierarchyCycleError); + }); + + it('keeps the root page id on the error for safe logging', () => { + try { + assertAcyclicPageTraversal( + [{ id: 'page-1', title: 'Root', isCycle: true }], + 'root-page', + ); + throw new Error('expected a cycle error'); + } catch (error) { + expect(error).toBeInstanceOf(PageHierarchyCycleError); + expect((error as PageHierarchyCycleError).rootPageId).toBe('root-page'); + expect((error as PageHierarchyCycleError).code).toBe('PAGE_HIERARCHY_CYCLE'); + } + }); + + it('removes traversal metadata without changing the public row fields', () => { + const row = { id: 'page-1', title: 'Root', isCycle: false }; + + expect(stripPageTraversalMetadata(row)).toEqual({ id: 'page-1', title: 'Root' }); + }); +}); diff --git a/apps/server/src/database/helpers/page-hierarchy-cycle.ts b/apps/server/src/database/helpers/page-hierarchy-cycle.ts new file mode 100644 index 000000000..847c9470f --- /dev/null +++ b/apps/server/src/database/helpers/page-hierarchy-cycle.ts @@ -0,0 +1,28 @@ +export type CycleTrackedRow = { + isCycle: boolean; +}; + +export class PageHierarchyCycleError extends Error { + readonly code = 'PAGE_HIERARCHY_CYCLE'; + + constructor(readonly rootPageId: string) { + super('Cyclic page hierarchy detected'); + this.name = 'PageHierarchyCycleError'; + } +} + +export function assertAcyclicPageTraversal( + rows: readonly T[], + rootPageId: string, +): void { + if (rows.some((row) => row.isCycle)) { + throw new PageHierarchyCycleError(rootPageId); + } +} + +export function stripPageTraversalMetadata( + row: T, +): Omit { + const { isCycle: _isCycle, ...publicRow } = row; + return publicRow; +}