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
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:
parent
a33b37f1a4
commit
eb6b0d5d02
@ -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;
|
||||
@ -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")
|
||||
}
|
||||
|
||||
|
||||
233
apps/api/src/pages/hierarchy.db.test.ts
Normal file
233
apps/api/src/pages/hierarchy.db.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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 } }));
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -30,4 +30,5 @@ export * from './ponds';
|
||||
export * from './public-api';
|
||||
export * from './quotas';
|
||||
export * from './text-diff';
|
||||
export * from './tree';
|
||||
export * from './watches';
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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
128
packages/shared/src/tree.ts
Normal 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());
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user