Page hierarchy: parentId, create-under-parent, reparent (#106)
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>
This commit is contained in:
Claude Fable 5 2026-07-14 09:57:47 +02:00
parent a33b37f1a4
commit eb6b0d5d02
10 changed files with 495 additions and 99 deletions

View File

@ -0,0 +1,8 @@
-- AlterTable
ALTER TABLE "pages" ADD COLUMN "parent_id" TEXT;
-- CreateIndex
CREATE INDEX "pages_parent_id_idx" ON "pages"("parent_id");
-- AddForeignKey
ALTER TABLE "pages" ADD CONSTRAINT "pages_parent_id_fkey" FOREIGN KEY ("parent_id") REFERENCES "pages"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -251,9 +251,16 @@ model RoleGrant {
/// the merged state Y.Doc, decoded by the API to derive `PageContentCache`
/// on every save (issue #23). `sortKey` uses fractional indexing so pages
/// can be reordered without rewriting siblings (sidebar reorder is #26).
/// `parentId` nests pages into a tree (issue #106), mirroring the label
/// hierarchy (max 6 levels, enforced in the service; cycles rejected at write
/// time). Purely organizational: slugs stay flat and pond-unique, so moving a
/// page never changes its URL or breaks wikilinks. Trashed pages keep their
/// `parentId` (restore re-attaches to the nearest live ancestor, issue #107);
/// `SetNull` is only the FK backstop — purge promotes children explicitly.
model Page {
id String @id @default(uuid())
pondId String @map("pond_id")
parentId String? @map("parent_id")
title String
slug String
ydocState Bytes @map("ydoc_state")
@ -265,6 +272,8 @@ model Page {
deletedBy String? @map("deleted_by")
pond Pond @relation(fields: [pondId], references: [id])
parent Page? @relation("PageHierarchy", fields: [parentId], references: [id], onDelete: SetNull)
children Page[] @relation("PageHierarchy")
creator User @relation(fields: [createdBy], references: [id])
updates PageUpdate[]
contentCache PageContentCache?
@ -279,6 +288,7 @@ model Page {
@@unique([pondId, slug])
@@index([pondId])
@@index([parentId])
@@map("pages")
}

View File

@ -0,0 +1,233 @@
import { ConflictException, INestApplication, NotFoundException } from '@nestjs/common';
import { MAX_PAGE_DEPTH } from '@dorfteich/shared';
import { PrismaClient, User } from '@prisma/client';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createTestApp } from '../testing/test-app';
import { createTestPrisma, grantOwnerAdmin, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { PagesService } from './pages.service';
/**
* Page hierarchy (issue #106): create-under-parent, reparent via reposition,
* cycle/depth rejection, and the permission rule that a child of an unreadable
* parent lists with `parentId: null` (no hidden-page id ever leaks).
*/
describe.skipIf(!hasTestDb)('Page hierarchy (db, issue #106)', () => {
let app: INestApplication;
let prisma: PrismaClient;
let pages: PagesService;
const suffix = uniqueSuffix();
let owner: User;
let reader: User;
let pondId: string;
let secretLabelId: string;
async function createChain(titles: string[]): Promise<string[]> {
const ids: string[] = [];
for (const [index, title] of titles.entries()) {
const page = await pages.create(owner, pondId, {
title,
parentId: index === 0 ? null : ids[index - 1],
});
ids.push(page.id);
}
return ids;
}
beforeAll(async () => {
prisma = createTestPrisma();
app = await createTestApp();
pages = app.get(PagesService);
owner = await prisma.user.create({
data: {
username: `tree-owner-${suffix}`,
email: `tree-owner-${suffix}@example.test`,
displayName: 'Tree Owner',
},
});
reader = await prisma.user.create({
data: {
username: `tree-reader-${suffix}`,
email: `tree-reader-${suffix}@example.test`,
displayName: 'Tree Reader',
},
});
const pond = await prisma.pond.create({
data: {
slug: `tree-pond-${suffix}`,
name: 'Tree Pond',
type: 'SHARED',
ownerId: owner.id,
},
});
pondId = pond.id;
await grantOwnerAdmin(prisma, pondId, owner.id);
// Reader grants BEFORE any permission resolution touches the pond — the
// PondPermissionCache would otherwise serve the pre-grant state.
const secret = await prisma.label.create({
data: { pondId, name: 'secret', color: '#334455' },
});
secretLabelId = secret.id;
await prisma.roleGrant.createMany({
data: [
{
pondId,
subjectType: 'USER',
subjectId: reader.id,
role: 'READER',
scopeType: 'POND',
effect: 'ALLOW',
createdBy: owner.id,
},
{
pondId,
subjectType: 'USER',
subjectId: reader.id,
role: 'READER',
scopeType: 'LABEL',
scopeId: secret.id,
effect: 'DENY',
createdBy: owner.id,
},
],
});
});
afterAll(async () => {
await prisma.roleGrant.deleteMany({ where: { pondId } });
await prisma.label.deleteMany({ where: { pondId } });
await prisma.page.deleteMany({ where: { pondId } });
await prisma.pond.deleteMany({ where: { id: pondId } });
await prisma.user.deleteMany({ where: { id: { in: [owner.id, reader.id] } } });
await prisma.$disconnect();
await app.close();
});
it('creates a page under a parent and at the root', async () => {
const root = await pages.create(owner, pondId, { title: 'Root' });
expect(root.parentId).toBeNull();
const child = await pages.create(owner, pondId, { title: 'Child', parentId: root.id });
expect(child.parentId).toBe(root.id);
const list = await pages.list(owner, pondId);
expect(list.find((p) => p.id === child.id)?.parentId).toBe(root.id);
expect(list.find((p) => p.id === root.id)?.parentId).toBeNull();
});
it('rejects an unknown, foreign, or trashed parent as 404', async () => {
await expect(
pages.create(owner, pondId, { title: 'Orphan', parentId: 'no-such-page' }),
).rejects.toBeInstanceOf(NotFoundException);
const foreignPond = await prisma.pond.create({
data: {
slug: `tree-foreign-${suffix}`,
name: 'Foreign',
type: 'PERSONAL',
ownerId: owner.id,
},
});
await grantOwnerAdmin(prisma, foreignPond.id, owner.id);
const foreignPage = await pages.create(owner, foreignPond.id, { title: 'Elsewhere' });
await expect(
pages.create(owner, pondId, { title: 'Crossing', parentId: foreignPage.id }),
).rejects.toBeInstanceOf(NotFoundException);
const doomed = await pages.create(owner, pondId, { title: 'Doomed' });
await pages.softDelete(owner, doomed.id);
await expect(
pages.create(owner, pondId, { title: 'Under trash', parentId: doomed.id }),
).rejects.toBeInstanceOf(NotFoundException);
await prisma.page.deleteMany({ where: { pondId: foreignPond.id } });
await prisma.roleGrant.deleteMany({ where: { pondId: foreignPond.id } });
await prisma.pond.delete({ where: { id: foreignPond.id } });
});
it(`rejects nesting beyond ${MAX_PAGE_DEPTH} levels on create`, async () => {
const chain = await createChain(
Array.from({ length: MAX_PAGE_DEPTH }, (_, i) => `Deep ${i + 1}`),
);
await expect(
pages.create(owner, pondId, { title: 'Too deep', parentId: chain[MAX_PAGE_DEPTH - 1] }),
).rejects.toBeInstanceOf(ConflictException);
});
it('reparents atomically through the position endpoint', async () => {
const a = await pages.create(owner, pondId, { title: 'Move A' });
const b = await pages.create(owner, pondId, { title: 'Move B' });
const child = await pages.create(owner, pondId, { title: 'Move child', parentId: a.id });
// Drag onto B: reparent + append, one call.
const moved = await pages.reposition(owner, child.id, {
afterId: b.id,
beforeId: null,
parentId: b.id,
});
expect(moved.parentId).toBe(b.id);
// Back to the root with `parentId: null`.
const rooted = await pages.reposition(owner, child.id, {
afterId: null,
beforeId: a.id,
parentId: null,
});
expect(rooted.parentId).toBeNull();
// An absent `parentId` keeps the current parent (plain reorder).
const reordered = await pages.reposition(owner, child.id, { afterId: b.id, beforeId: null });
expect(reordered.parentId).toBeNull();
});
it('rejects a move into the page own subtree as page_cycle', async () => {
const [top, , grandchild] = await createChain(['Cycle 1', 'Cycle 2', 'Cycle 3']);
let caught: unknown;
await pages
.reposition(owner, top!, { afterId: null, beforeId: null, parentId: grandchild! })
.catch((error: unknown) => {
caught = error;
});
expect(caught).toBeInstanceOf(ConflictException);
expect((caught as ConflictException).getResponse()).toMatchObject({ code: 'page_cycle' });
// Self-parenting is the trivial cycle.
await expect(
pages.reposition(owner, top!, { afterId: null, beforeId: null, parentId: top! }),
).rejects.toBeInstanceOf(ConflictException);
});
it('rejects a move that pushes the subtree past the depth limit', async () => {
const deep = await createChain(['Limit 1', 'Limit 2', 'Limit 3', 'Limit 4']);
const [subtreeTop] = await createChain(['Tall 1', 'Tall 2', 'Tall 3']);
let caught: unknown;
await pages
.reposition(owner, subtreeTop!, { afterId: null, beforeId: null, parentId: deep[3]! })
.catch((error: unknown) => {
caught = error;
});
expect(caught).toBeInstanceOf(ConflictException);
expect((caught as ConflictException).getResponse()).toMatchObject({
code: 'page_depth_exceeded',
});
});
it('nulls the parentId of a child whose parent the caller may not read', async () => {
const hidden = await pages.create(owner, pondId, { title: 'Hidden parent' });
await prisma.pageLabel.create({ data: { pageId: hidden.id, labelId: secretLabelId } });
const child = await pages.create(owner, pondId, {
title: 'Visible child',
parentId: hidden.id,
});
const ownerList = await pages.list(owner, pondId);
expect(ownerList.find((p) => p.id === child.id)?.parentId).toBe(hidden.id);
const readerList = await pages.list(reader, pondId);
expect(readerList.some((p) => p.id === hidden.id)).toBe(false);
expect(readerList.find((p) => p.id === child.id)?.parentId).toBeNull();
});
});

View File

@ -2,6 +2,7 @@ import { ConflictException, Injectable, NotFoundException } from '@nestjs/common
import {
CollabTokenResponse,
CreatePageInput,
MAX_PAGE_DEPTH,
OutlineEntry,
PageListItemView,
PageStateView,
@ -9,9 +10,13 @@ import {
PluginPageSummary,
RepositionPageInput,
SidebarSortMode,
TreeItem,
UpdatePageInput,
collectSubtreeIds,
nodeDepth,
pondSettingsSchema,
slugify,
subtreeHeight,
} from '@dorfteich/shared';
import { signCollabToken } from '@dorfteich/shared/token-crypto';
import { Page, Prisma, User } from '@prisma/client';
@ -59,6 +64,7 @@ export class PagesService {
return {
id: page.id,
pondId: page.pondId,
parentId: page.parentId,
title: page.title,
slug: page.slug,
sortKey: page.sortKey,
@ -97,6 +103,36 @@ export class PagesService {
return page;
}
/** The live `{id, parentId}` skeleton of a pond input to the tree walks
* (issue #106). Trashed pages keep their `parentId` but never count here. */
private async livePageTree(pondId: string): Promise<TreeItem[]> {
return this.prisma.page.findMany({
where: { pondId, deletedAt: null },
select: { id: true, parentId: true },
});
}
/**
* Validates a page's new parent (issue #106, mirroring `LabelsService`): it
* must be a live page of the pond (unknown/foreign ids read as 404), may not
* sit inside the moved page's own subtree (`page_cycle`), and the moved
* subtree must stay within {@link MAX_PAGE_DEPTH} (`page_depth_exceeded`).
* `movedId` is null when creating the new page is a leaf of height 1.
*/
private assertValidParent(tree: TreeItem[], parentId: string, movedId: string | null): void {
if (!tree.some((p) => p.id === parentId)) throw new NotFoundException();
if (movedId && collectSubtreeIds(tree, movedId).has(parentId)) {
throw new ConflictException({ code: 'page_cycle' });
}
const height = movedId ? subtreeHeight(tree, movedId) : 1;
if (nodeDepth(tree, parentId) + height > MAX_PAGE_DEPTH) {
throw new ConflictException({
code: 'page_depth_exceeded',
details: { max: [String(MAX_PAGE_DEPTH)] },
});
}
}
private static readonly SORT_ORDER: Record<SidebarSortMode, Prisma.PageOrderByWithRelationInput> =
{
alpha: { title: 'asc' },
@ -129,6 +165,9 @@ export class PagesService {
.filter((page) => readable.has(page.id))
.map((page) => ({
...this.viewOf(page),
// A parent the caller may not read is nulled (issue #106): the child
// shows at the root and the hidden page's id never leaks.
parentId: page.parentId && readable.has(page.parentId) ? page.parentId : null,
labelIds: page.labels.map((l) => l.labelId),
}));
}
@ -154,7 +193,13 @@ export class PagesService {
}
async create(user: User, pondId: string, input: CreatePageInput): Promise<PageView> {
const page = await this.insertPage(user, pondId, input.title, emptyPageState());
const page = await this.insertPage(
user,
pondId,
input.title,
emptyPageState(),
input.parentId ?? null,
);
// Auto-watch own pages (issue #93) — preference-gated, never fatal.
await this.watches.autoWatchPage(user, page.id, 'ownPage').catch(() => {});
return this.viewOf(page);
@ -182,9 +227,11 @@ export class PagesService {
pondId: string,
title: string,
state: Uint8Array<ArrayBuffer>,
parentId: string | null = null,
): Promise<Page> {
const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } });
if (!pond) throw new NotFoundException();
if (parentId) this.assertValidParent(await this.livePageTree(pond.id), parentId, null);
const slug = await this.generateUniqueSlugInPond(pond.id, title);
const last = await this.prisma.page.findFirst({
@ -198,6 +245,7 @@ export class PagesService {
const page = await this.prisma.page.create({
data: {
pondId: pond.id,
parentId,
title,
slug,
sortKey,
@ -296,13 +344,16 @@ export class PagesService {
}
/**
* Reposition a page in the manual sidebar order (issue #45). Recomputes only
* the moved page's `sort_key` to a value between its two new neighbours; when
* that key would grow too long (or the client's neighbours are stale) the
* whole pond is rebalanced to evenly-spaced keys with the page dropped at the
* target slot. The order is server-authoritative, so every viewer sees the
* same sequence. Requires write access; the sort mode does not have to be
* `manual` (the key is stored regardless, just not applied in other modes).
* Reposition a page in the manual sidebar order (issue #45) and/or move it to
* a new parent in the page tree (issue #106). Recomputes only the moved
* page's `sort_key` to a value between its two new neighbours; when that key
* would grow too long (or the client's neighbours are stale) the whole pond
* is rebalanced to evenly-spaced keys with the page dropped at the target
* slot. Per-sibling-group order needs no extra machinery: a key between two
* siblings keeps the group's relative order under the pond-wide sequence.
* The order is server-authoritative, so every viewer sees the same sequence.
* Requires write access; the sort mode does not have to be `manual` (the key
* is stored regardless, just not applied in other modes).
*/
async reposition(_user: User, id: string, input: RepositionPageInput): Promise<PageView> {
const page = await this.findLivePage(id);
@ -311,6 +362,13 @@ export class PagesService {
throw new ConflictException({ code: 'bad_request' });
}
// An absent `parentId` leaves the parent untouched; a present one (page id
// or null-for-root) reparents atomically with the placement (issue #106).
const parentId = input.parentId === page.parentId ? undefined : input.parentId;
if (parentId != null) {
this.assertValidParent(await this.livePageTree(page.pondId), parentId, id);
}
const [afterPage, beforePage] = await Promise.all([
afterId
? this.prisma.page.findFirst({
@ -330,10 +388,13 @@ export class PagesService {
const key = nextKeyOrRebalance(afterPage?.sortKey ?? null, beforePage?.sortKey ?? null);
if (key !== null) {
const updated = await this.prisma.page.update({ where: { id }, data: { sortKey: key } });
const updated = await this.prisma.page.update({
where: { id },
data: { sortKey: key, parentId },
});
return this.viewOf(updated);
}
return this.rebalanceAndPlace(page.pondId, id, afterId, beforeId);
return this.rebalanceAndPlace(page.pondId, id, afterId, beforeId, parentId);
}
/**
@ -346,6 +407,7 @@ export class PagesService {
movedId: string,
afterId: string | null,
beforeId: string | null,
parentId?: string | null,
): Promise<PageView> {
return this.prisma.$transaction(async (tx) => {
const pages = await tx.page.findMany({
@ -361,7 +423,12 @@ export class PagesService {
const keys = evenlySpacedKeys(order.length);
await Promise.all(
order.map((pid, i) => tx.page.update({ where: { id: pid }, data: { sortKey: keys[i]! } })),
order.map((pid, i) =>
tx.page.update({
where: { id: pid },
data: { sortKey: keys[i]!, ...(pid === movedId ? { parentId } : {}) },
}),
),
);
this.logger.info({ pondId, movedId, pages: order.length }, 'audit: sort keys rebalanced');
return this.viewOf(await tx.page.findUniqueOrThrow({ where: { id: movedId } }));

View File

@ -23,6 +23,8 @@
"page_document_too_large": "Die Seite ist zu groß (Limit: {{limitBytes}} Bytes).",
"invalid_page_state": "Der übermittelte Seiteninhalt ist ungültig.",
"page_trashed": "Diese Seite wurde in den Papierkorb verschoben.",
"page_cycle": "Eine Seite kann nicht in ihren eigenen Teilbaum verschoben werden.",
"page_depth_exceeded": "Seiten lassen sich höchstens {{max}} Ebenen tief verschachteln.",
"label_name_taken": "Ein Label mit diesem Namen existiert auf dieser Ebene bereits.",
"label_cycle": "Ein Label kann nicht in seinen eigenen Teilbaum verschoben werden.",
"label_depth_exceeded": "Labels lassen sich höchstens {{max}} Ebenen tief verschachteln.",

View File

@ -23,6 +23,8 @@
"page_document_too_large": "The page is too large (limit: {{limitBytes}} bytes).",
"invalid_page_state": "The submitted page content is invalid.",
"page_trashed": "This page has been moved to the trash.",
"page_cycle": "A page cannot be moved into its own subtree.",
"page_depth_exceeded": "Pages can be nested at most {{max}} levels deep.",
"label_name_taken": "A label with this name already exists at this level.",
"label_cycle": "A label cannot be moved into its own subtree.",
"label_depth_exceeded": "Labels can be nested at most {{max}} levels deep.",

View File

@ -30,4 +30,5 @@ export * from './ponds';
export * from './public-api';
export * from './quotas';
export * from './text-diff';
export * from './tree';
export * from './watches';

View File

@ -1,5 +1,7 @@
import { z } from 'zod';
import { buildTree, nodeDepth } from './tree';
/**
* Label schemas, views, and hierarchy helpers shared between api and web
* (issue #43). Labels organize pages within a pond and form a tree via
@ -90,19 +92,7 @@ function byName(a: LabelView, b: LabelView): number {
* resolver, which all need the same hierarchy.
*/
export function buildLabelTree(labels: LabelView[]): LabelTreeNode[] {
const nodes = new Map<string, LabelTreeNode>();
for (const label of labels) nodes.set(label.id, { ...label, children: [] });
const roots: LabelTreeNode[] = [];
for (const node of nodes.values()) {
const parent = node.parentId ? nodes.get(node.parentId) : undefined;
if (parent) parent.children.push(node);
else roots.push(node);
}
for (const node of nodes.values()) node.children.sort(byName);
roots.sort(byName);
return roots;
return buildTree(labels, byName);
}
/**
@ -122,87 +112,18 @@ export function flattenLabelTree(nodes: LabelTreeNode[]): LabelView[] {
return flat;
}
/** Indexes labels by id → parentId for the ancestor/descendant walks below. */
function parentIndex(labels: LabelView[]): Map<string, string | null> {
const index = new Map<string, string | null>();
for (const label of labels) index.set(label.id, label.parentId);
return index;
}
/**
* Ids of a label 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 subtree)
* and to gather the labels a delete removes. Robust against malformed cycles in
* the input: every id is visited at most once.
* The subtree/ancestor/depth walks moved to the generic tree helpers (issue
* #106) labels and pages share one implementation. Re-exported here so the
* label API keeps its established import surface; `labelDepth` keeps its name
* because permission resolution documents it (permissions.md §label scope).
*/
export function collectSubtreeIds(labels: LabelView[], rootId: string): Set<string> {
const childrenOf = new Map<string, string[]>();
for (const label of labels) {
if (!label.parentId) continue;
const siblings = childrenOf.get(label.parentId) ?? [];
siblings.push(label.id);
childrenOf.set(label.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 a label, nearest first (its parent, grandparent, ). This is
* what permission resolution needs: a grant on any ancestor of a page's label
* applies to the label too (permissions.md §label scope). Stops on a missing
* parent or a cycle, so it always terminates.
*/
export function collectAncestorIds(labels: LabelView[], labelId: string): string[] {
const parents = parentIndex(labels);
const ancestors: string[] = [];
const seen = new Set<string>([labelId]);
let current = parents.get(labelId) ?? null;
while (current && !seen.has(current)) {
ancestors.push(current);
seen.add(current);
current = parents.get(current) ?? null;
}
return ancestors;
}
export { collectAncestorIds, collectSubtreeIds, subtreeHeight } from './tree';
/**
* Depth of a label as a 1-based level (a root label is 1). Derived from the
* ancestor chain, so it is bounded even if the input is malformed.
*/
export function labelDepth(labels: LabelView[], labelId: string): number {
return collectAncestorIds(labels, labelId).length + 1;
}
/**
* Height of a label's subtree in levels (a leaf is 1, a label with children 2,
* ). Combined with a target parent's depth it tells us whether a move keeps
* the whole subtree within {@link MAX_LABEL_DEPTH}.
*/
export function subtreeHeight(labels: LabelView[], rootId: string): number {
const childrenOf = new Map<string, LabelView[]>();
for (const label of labels) {
if (!label.parentId) continue;
const siblings = childrenOf.get(label.parentId) ?? [];
siblings.push(label);
childrenOf.set(label.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());
return nodeDepth(labels, labelId);
}

View File

@ -18,8 +18,18 @@ export const pageSlugSchema = z
.min(1, 'validation.required')
.max(60, 'validation.tooLong');
/**
* Maximum nesting depth of the page tree (issue #106), counted as levels like
* {@link MAX_LABEL_DEPTH}: a root page is level 1. Creating or moving a page
* whose deepest descendant would exceed this is rejected.
*/
export const MAX_PAGE_DEPTH = 6;
export const createPageInputSchema = z.object({
title: pageTitleSchema,
/** Parent page id for a nested page (issue #106); omitted/null creates at
* the root level. Must be a live page of the same pond. */
parentId: z.string().min(1).nullish(),
});
export type CreatePageInput = z.infer<typeof createPageInputSchema>;
@ -40,6 +50,13 @@ export type UpdatePageInput = z.infer<typeof updatePageInputSchema>;
export const repositionPageInputSchema = z.object({
afterId: z.string().min(1).nullable(),
beforeId: z.string().min(1).nullable(),
/**
* New parent for the page (issue #106): a page id nests it, `null` moves it
* to the root level, and an absent field keeps the current parent so the
* one endpoint covers plain reordering, drag-onto-a-page, and the "Move
* to" dialog atomically. Cycles and depth violations are rejected.
*/
parentId: z.string().min(1).nullable().optional(),
});
export type RepositionPageInput = z.infer<typeof repositionPageInputSchema>;
@ -56,6 +73,13 @@ export const MAX_PAGE_DOCUMENT_BYTES = 5 * 1024 * 1024;
export interface PageView {
id: string;
pondId: string;
/**
* Parent page in the tree (issue #106), or `null` at the root. In list
* responses the server nulls this when the caller may not read the parent,
* so a permission-sliced view never leaks a hidden page's id the child
* then simply renders at the root level.
*/
parentId: string | null;
title: string;
slug: string;
sortKey: string;

128
packages/shared/src/tree.ts Normal file
View File

@ -0,0 +1,128 @@
/**
* 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());
}