fix: add page hierarchy cycle contract

This commit is contained in:
Philipinho
2026-09-01 22:30:09 +01:00
parent 5cef473a2b
commit dc8ed0ff06
2 changed files with 76 additions and 0 deletions
@@ -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' });
});
});
@@ -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<T extends CycleTrackedRow>(
rows: readonly T[],
rootPageId: string,
): void {
if (rows.some((row) => row.isCycle)) {
throw new PageHierarchyCycleError(rootPageId);
}
}
export function stripPageTraversalMetadata<T extends CycleTrackedRow>(
row: T,
): Omit<T, 'isCycle'> {
const { isCycle: _isCycle, ...publicRow } = row;
return publicRow;
}