From eb6b0d5d0295d112e13617b97056a126bceffab2 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Tue, 14 Jul 2026 09:57:47 +0200 Subject: [PATCH] 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 --- .../migration.sql | 8 + apps/api/prisma/schema.prisma | 10 + apps/api/src/pages/hierarchy.db.test.ts | 233 ++++++++++++++++++ apps/api/src/pages/pages.service.ts | 89 ++++++- packages/shared/i18n/de/errors.json | 2 + packages/shared/i18n/en/errors.json | 2 + packages/shared/src/index.ts | 1 + packages/shared/src/labels.ts | 97 +------- packages/shared/src/pages.ts | 24 ++ packages/shared/src/tree.ts | 128 ++++++++++ 10 files changed, 495 insertions(+), 99 deletions(-) create mode 100644 apps/api/prisma/migrations/20260714000000_page_hierarchy/migration.sql create mode 100644 apps/api/src/pages/hierarchy.db.test.ts create mode 100644 packages/shared/src/tree.ts diff --git a/apps/api/prisma/migrations/20260714000000_page_hierarchy/migration.sql b/apps/api/prisma/migrations/20260714000000_page_hierarchy/migration.sql new file mode 100644 index 0000000..79bccb5 --- /dev/null +++ b/apps/api/prisma/migrations/20260714000000_page_hierarchy/migration.sql @@ -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; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 9be3039..fbfc8ef 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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") } diff --git a/apps/api/src/pages/hierarchy.db.test.ts b/apps/api/src/pages/hierarchy.db.test.ts new file mode 100644 index 0000000..c6933ec --- /dev/null +++ b/apps/api/src/pages/hierarchy.db.test.ts @@ -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 { + 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(); + }); +}); diff --git a/apps/api/src/pages/pages.service.ts b/apps/api/src/pages/pages.service.ts index ddc3df6..04a7506 100644 --- a/apps/api/src/pages/pages.service.ts +++ b/apps/api/src/pages/pages.service.ts @@ -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 { + 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 = { 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 { - 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, + parentId: string | null = null, ): Promise { 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 { 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 { 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 } })); diff --git a/packages/shared/i18n/de/errors.json b/packages/shared/i18n/de/errors.json index fd449f9..152d142 100644 --- a/packages/shared/i18n/de/errors.json +++ b/packages/shared/i18n/de/errors.json @@ -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.", diff --git a/packages/shared/i18n/en/errors.json b/packages/shared/i18n/en/errors.json index 732291e..c8b9b2b 100644 --- a/packages/shared/i18n/en/errors.json +++ b/packages/shared/i18n/en/errors.json @@ -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.", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index efe36d2..c7dd071 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -30,4 +30,5 @@ export * from './ponds'; export * from './public-api'; export * from './quotas'; export * from './text-diff'; +export * from './tree'; export * from './watches'; diff --git a/packages/shared/src/labels.ts b/packages/shared/src/labels.ts index bf7a3c9..afeee7f 100644 --- a/packages/shared/src/labels.ts +++ b/packages/shared/src/labels.ts @@ -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(); - 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 { - const index = new Map(); - 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 { - const childrenOf = new Map(); - 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(); - 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([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(); - 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): 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); } diff --git a/packages/shared/src/pages.ts b/packages/shared/src/pages.ts index bb69d6f..3112787 100644 --- a/packages/shared/src/pages.ts +++ b/packages/shared/src/pages.ts @@ -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; @@ -40,6 +50,13 @@ export type UpdatePageInput = z.infer; 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; @@ -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; diff --git a/packages/shared/src/tree.ts b/packages/shared/src/tree.ts new file mode 100644 index 0000000..8087e02 --- /dev/null +++ b/packages/shared/src/tree.ts @@ -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 & { children: TreeNode[] }; + +/** + * 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( + items: T[], + compare?: (a: T, b: T) => number, +): TreeNode[] { + const nodes = new Map>(); + for (const item of items) nodes.set(item.id, { ...item, children: [] }); + + const roots: TreeNode[] = []; + 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 { + const index = new Map(); + 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 { + const childrenOf = new Map(); + 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(); + 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([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(); + 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): 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()); +}