Some checks failed
CI / Import/export fidelity gate (push) Blocked by required conditions
CD / Build and push images (push) Successful in 3m53s
CD / Deploy to Test (push) Successful in 12s
CI / Lint, typecheck, test (push) Successful in 4m16s
CI / Build container images (push) Has been skipped
CD / Smoke tests against Test (push) Successful in 1m19s
CD / Promote to Int (push) Successful in 14s
CI / Auth e2e pack (push) Has been cancelled
Pages form a tree via a nullable parent_id self-relation (SetNull backstop; the real trash/purge semantics follow with #107). Slugs and URLs stay flat and pond-unique, so moving a page never breaks links. - Shared: generic parent-id tree helpers in tree.ts (labels re-export them; buildLabelTree keeps its name-sorted behavior), MAX_PAGE_DEPTH=6, parentId on PageView, createPageInputSchema.parentId (nullish), repositionPageInputSchema.parentId (optional; absent = keep parent). - API: create validates the parent (same pond, live, depth); PATCH /pages/:id/position reparents atomically with the placement, rejecting cycles (page_cycle) and depth violations (page_depth_exceeded); GET /ponds/:id/pages nulls parentId when the caller may not read the parent, so hidden page ids never leak. - New error codes translated de+en; hierarchy.db.test.ts covers create, 404s, depth, cycle, atomic reparent, and the permission nulling. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
129 lines
4.5 KiB
TypeScript
129 lines
4.5 KiB
TypeScript
/**
|
|
* Generic parent-id tree helpers (issue #106). Labels (issue #43) and pages
|
|
* (issue #106) both form a forest via a nullable `parentId`; these helpers are
|
|
* the single implementation of the subtree/ancestor/depth walks both trees
|
|
* share. All of them are robust against malformed input (missing parents,
|
|
* cycles): every node is visited at most once, so they always terminate.
|
|
*/
|
|
|
|
/** The minimal shape a tree helper needs: an id and a nullable parent id. */
|
|
export interface TreeItem {
|
|
id: string;
|
|
parentId: string | null;
|
|
}
|
|
|
|
/** A tree item with its children nested, as {@link buildTree} produces it. */
|
|
export type TreeNode<T extends TreeItem> = T & { children: TreeNode<T>[] };
|
|
|
|
/**
|
|
* Nests a flat list into a forest of {@link TreeNode}s. An item whose
|
|
* `parentId` is not present in the list is treated as a root, so no rows are
|
|
* ever dropped — that is also the deliberate fallback for a child whose parent
|
|
* the server hid from the caller (issue #106 permission rule). Siblings keep
|
|
* the input order unless a `compare` function is given; the page sidebar
|
|
* relies on input order because the list arrives in the pond's sort order.
|
|
*/
|
|
export function buildTree<T extends TreeItem>(
|
|
items: T[],
|
|
compare?: (a: T, b: T) => number,
|
|
): TreeNode<T>[] {
|
|
const nodes = new Map<string, TreeNode<T>>();
|
|
for (const item of items) nodes.set(item.id, { ...item, children: [] });
|
|
|
|
const roots: TreeNode<T>[] = [];
|
|
for (const item of items) {
|
|
const node = nodes.get(item.id)!;
|
|
const parent = item.parentId ? nodes.get(item.parentId) : undefined;
|
|
if (parent && parent !== node) parent.children.push(node);
|
|
else roots.push(node);
|
|
}
|
|
|
|
if (compare) {
|
|
for (const node of nodes.values()) node.children.sort(compare);
|
|
roots.sort(compare);
|
|
}
|
|
return roots;
|
|
}
|
|
|
|
/** Indexes items by id → parentId for the ancestor walks below. */
|
|
function parentIndex(items: TreeItem[]): Map<string, string | null> {
|
|
const index = new Map<string, string | null>();
|
|
for (const item of items) index.set(item.id, item.parentId);
|
|
return index;
|
|
}
|
|
|
|
/**
|
|
* Ids of an item and all its descendants (its whole subtree). Used to reject a
|
|
* move that would create a cycle (the new parent may not be inside the moved
|
|
* subtree) and to gather what a subtree-wide operation covers.
|
|
*/
|
|
export function collectSubtreeIds(items: TreeItem[], rootId: string): Set<string> {
|
|
const childrenOf = new Map<string, string[]>();
|
|
for (const item of items) {
|
|
if (!item.parentId) continue;
|
|
const siblings = childrenOf.get(item.parentId) ?? [];
|
|
siblings.push(item.id);
|
|
childrenOf.set(item.parentId, siblings);
|
|
}
|
|
|
|
const subtree = new Set<string>();
|
|
const stack = [rootId];
|
|
while (stack.length > 0) {
|
|
const id = stack.pop()!;
|
|
if (subtree.has(id)) continue;
|
|
subtree.add(id);
|
|
for (const child of childrenOf.get(id) ?? []) stack.push(child);
|
|
}
|
|
return subtree;
|
|
}
|
|
|
|
/**
|
|
* Ancestor ids of an item, nearest first (its parent, grandparent, …). Stops
|
|
* on a missing parent or a cycle.
|
|
*/
|
|
export function collectAncestorIds(items: TreeItem[], itemId: string): string[] {
|
|
const parents = parentIndex(items);
|
|
const ancestors: string[] = [];
|
|
const seen = new Set<string>([itemId]);
|
|
let current = parents.get(itemId) ?? null;
|
|
while (current && !seen.has(current)) {
|
|
ancestors.push(current);
|
|
seen.add(current);
|
|
current = parents.get(current) ?? null;
|
|
}
|
|
return ancestors;
|
|
}
|
|
|
|
/**
|
|
* Depth of an item as a 1-based level (a root is 1). Derived from the ancestor
|
|
* chain, so it is bounded even if the input is malformed.
|
|
*/
|
|
export function nodeDepth(items: TreeItem[], itemId: string): number {
|
|
return collectAncestorIds(items, itemId).length + 1;
|
|
}
|
|
|
|
/**
|
|
* Height of an item's subtree in levels (a leaf is 1, an item with children 2,
|
|
* …). Combined with a target parent's depth it tells us whether a move keeps
|
|
* the whole subtree within the depth limit.
|
|
*/
|
|
export function subtreeHeight(items: TreeItem[], rootId: string): number {
|
|
const childrenOf = new Map<string, TreeItem[]>();
|
|
for (const item of items) {
|
|
if (!item.parentId) continue;
|
|
const siblings = childrenOf.get(item.parentId) ?? [];
|
|
siblings.push(item);
|
|
childrenOf.set(item.parentId, siblings);
|
|
}
|
|
|
|
const heightFrom = (id: string, seen: Set<string>): number => {
|
|
if (seen.has(id)) return 0;
|
|
seen.add(id);
|
|
const children = childrenOf.get(id) ?? [];
|
|
let max = 0;
|
|
for (const child of children) max = Math.max(max, heightFrom(child.id, seen));
|
|
return max + 1;
|
|
};
|
|
return heightFrom(rootId, new Set());
|
|
}
|