dorfteich/packages/shared/src/tree.ts
Claude Fable 5 eb6b0d5d02
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
Page hierarchy: parentId, create-under-parent, reparent (#106)
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>
2026-07-14 09:57:47 +02:00

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());
}