Pond-wide wikilink graph endpoint (#111)
All checks were successful
CD / Build and push images (push) Successful in 4m0s
CD / Deploy to Test (push) Successful in 9s
CI / Lint, typecheck, test (push) Successful in 4m21s
CI / Build container images (push) Has been skipped
CD / Smoke tests against Test (push) Successful in 1m16s
CD / Promote to Int (push) Successful in 11s
CI / Auth e2e pack (push) Successful in 5m41s
CI / Import/export fidelity gate (push) Successful in 47s
All checks were successful
CD / Build and push images (push) Successful in 4m0s
CD / Deploy to Test (push) Successful in 9s
CI / Lint, typecheck, test (push) Successful in 4m21s
CI / Build container images (push) Has been skipped
CD / Smoke tests against Test (push) Successful in 1m16s
CD / Promote to Int (push) Successful in 11s
CI / Auth e2e pack (push) Successful in 5m41s
CI / Import/export fidelity gate (push) Successful in 47s
GET /ponds/:pondId/links returns the caller's readable slice of the wikilink graph in one read: nodes (id, title, slug, labelIds for the coloring), resolved edges deduplicated per direction (a rename can leave several slugs pointing at one target), and phantom targets with their referrer ids. An edge survives only when both endpoints are readable; a phantom disappears entirely once its last readable referrer is filtered — a hidden page's existence never leaks through any of the three collections. Trashed pages and their links are excluded. Shared PondGraphView types feed the knowledge-graph views (#112/#113). DB tests cover the owner's full graph, the label-DENY reader slice, edge dedup, trash exclusion, and labelIds on nodes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
15184876bd
commit
e957c2a28f
@ -1,5 +1,5 @@
|
||||
import { Controller, Get, Param, Req } from '@nestjs/common';
|
||||
import { BacklinkView, PhantomLinkView } from '@dorfteich/shared';
|
||||
import { BacklinkView, PhantomLinkView, PondGraphView } from '@dorfteich/shared';
|
||||
|
||||
import { AuthedRequest } from '../auth/auth.guard';
|
||||
import { RequiresPagePermission, RequiresPondRole } from '../permissions/permission.decorators';
|
||||
@ -17,6 +17,18 @@ export class LinksController {
|
||||
return this.links.backlinks(request.user!, id);
|
||||
}
|
||||
|
||||
/** The pond's readable wikilink graph for the knowledge-graph views
|
||||
* (issue #111): nodes, resolved edges, and phantom targets — all sliced
|
||||
* to the caller's read permissions. */
|
||||
@Get('ponds/:pondId/links')
|
||||
@RequiresPondRole('reader', { idParam: 'pondId' }) // every collection is filtered per page
|
||||
async pondGraph(
|
||||
@Param('pondId') pondId: string,
|
||||
@Req() request: AuthedRequest,
|
||||
): Promise<PondGraphView> {
|
||||
return this.links.pondGraph(request.user!, pondId);
|
||||
}
|
||||
|
||||
/** Referenced-but-missing pages in the pond, with their referrers. */
|
||||
@Get('ponds/:pondId/phantom-links')
|
||||
@RequiresPondRole('reader', { idParam: 'pondId' }) // referrers are filtered per page
|
||||
|
||||
@ -147,3 +147,155 @@ describe.skipIf(!hasTestDb)('LinksService (db, issue #47)', () => {
|
||||
expect((await links.backlinks(owner, target)).map((b) => b.pageId)).toContain(source);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!hasTestDb)('LinksService.pondGraph (db, issue #111)', () => {
|
||||
let app: INestApplication;
|
||||
let prisma: PrismaClient;
|
||||
let links: LinksService;
|
||||
const suffix = uniqueSuffix();
|
||||
let owner: User;
|
||||
let reader: User;
|
||||
let pondId: string;
|
||||
let a: string;
|
||||
let b: string;
|
||||
let secret: string;
|
||||
let trashed: string;
|
||||
|
||||
async function makePage(slug: string, title = slug): Promise<string> {
|
||||
const id = randomUUID();
|
||||
await prisma.page.create({
|
||||
data: {
|
||||
id,
|
||||
pondId,
|
||||
title,
|
||||
slug,
|
||||
ydocState: new Uint8Array(Y.encodeStateAsUpdate(new Y.Doc())),
|
||||
sortKey: `g${slug}`,
|
||||
createdBy: owner.id,
|
||||
},
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
async function link(fromId: string, targetSlug: string, toId: string | null): Promise<void> {
|
||||
await prisma.pageLink.create({ data: { fromPageId: fromId, toPageId: toId, targetSlug } });
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
prisma = createTestPrisma();
|
||||
app = await createTestApp();
|
||||
links = app.get(LinksService);
|
||||
|
||||
owner = await prisma.user.create({
|
||||
data: {
|
||||
username: `gr-owner-${suffix}`,
|
||||
email: `gr-owner-${suffix}@example.test`,
|
||||
displayName: 'Graph Owner',
|
||||
},
|
||||
});
|
||||
reader = await prisma.user.create({
|
||||
data: {
|
||||
username: `gr-reader-${suffix}`,
|
||||
email: `gr-reader-${suffix}@example.test`,
|
||||
displayName: 'Graph Reader',
|
||||
},
|
||||
});
|
||||
const pond = await prisma.pond.create({
|
||||
data: { slug: `gr-pond-${suffix}`, name: 'Graph Pond', type: 'SHARED', ownerId: owner.id },
|
||||
});
|
||||
pondId = pond.id;
|
||||
await grantOwnerAdmin(prisma, pondId, owner.id);
|
||||
// Grants before any permission resolution warms the pond's cache.
|
||||
const secretLabel = await prisma.label.create({
|
||||
data: { pondId, name: 'secret', color: '#334455' },
|
||||
});
|
||||
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: secretLabel.id,
|
||||
effect: 'DENY',
|
||||
createdBy: owner.id,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// A → B (resolved), A → S (resolved, S is secret-labeled), A → T
|
||||
// (resolved, T gets trashed), B → ghost (phantom), S → ghost-secret
|
||||
// (phantom whose only referrer is hidden from the reader), plus a
|
||||
// duplicate A → B row under a second slug (rename leftover).
|
||||
a = await makePage(`gr-a-${suffix}`, 'GA');
|
||||
b = await makePage(`gr-b-${suffix}`, 'GB');
|
||||
secret = await makePage(`gr-s-${suffix}`, 'GS');
|
||||
trashed = await makePage(`gr-t-${suffix}`, 'GT');
|
||||
await prisma.pageLabel.create({ data: { pageId: secret, labelId: secretLabel.id } });
|
||||
await link(a, `gr-b-${suffix}`, b);
|
||||
await link(a, `gr-b-old-${suffix}`, b);
|
||||
await link(a, `gr-s-${suffix}`, secret);
|
||||
await link(a, `gr-t-${suffix}`, trashed);
|
||||
await link(b, `ghost-${suffix}`, null);
|
||||
await link(secret, `ghost-secret-${suffix}`, null);
|
||||
await prisma.page.update({
|
||||
where: { id: trashed },
|
||||
data: { deletedAt: new Date(), deletedBy: owner.id },
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.pageLink.deleteMany({ where: { fromPage: { pondId } } });
|
||||
await prisma.roleGrant.deleteMany({ where: { pondId } });
|
||||
await prisma.pageLabel.deleteMany({ where: { page: { 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('returns the full readable graph for the owner, deduplicated and without trash', async () => {
|
||||
const graph = await links.pondGraph(owner, pondId);
|
||||
expect(graph.nodes.map((n) => n.title).sort()).toEqual(['GA', 'GB', 'GS']);
|
||||
// The duplicate A→B row (second slug) collapses into one edge; the edge
|
||||
// to the trashed page is gone with the node.
|
||||
expect(graph.edges.sort((x, y) => x.to.localeCompare(y.to))).toEqual(
|
||||
[
|
||||
{ from: a, to: b },
|
||||
{ from: a, to: secret },
|
||||
].sort((x, y) => x.to.localeCompare(y.to)),
|
||||
);
|
||||
expect(graph.phantoms.map((p) => p.targetSlug).sort()).toEqual([
|
||||
`ghost-${suffix}`,
|
||||
`ghost-secret-${suffix}`,
|
||||
]);
|
||||
});
|
||||
|
||||
it('slices every collection to the reader — no hidden page leaks anywhere', async () => {
|
||||
const graph = await links.pondGraph(reader, pondId);
|
||||
// The secret node is gone …
|
||||
expect(graph.nodes.map((n) => n.title).sort()).toEqual(['GA', 'GB']);
|
||||
// … the edge into it is dropped even though its source is readable …
|
||||
expect(graph.edges).toEqual([{ from: a, to: b }]);
|
||||
// … and the phantom whose only referrer is hidden disappears entirely.
|
||||
expect(graph.phantoms).toEqual([{ targetSlug: `ghost-${suffix}`, referencedBy: [b] }]);
|
||||
});
|
||||
|
||||
it('carries labelIds on nodes for the graph coloring', async () => {
|
||||
const graph = await links.pondGraph(owner, pondId);
|
||||
const secretNode = graph.nodes.find((n) => n.id === secret);
|
||||
expect(secretNode?.labelIds).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { BacklinkView, PhantomLinkView } from '@dorfteich/shared';
|
||||
import { BacklinkView, GraphPhantomView, PhantomLinkView, PondGraphView } from '@dorfteich/shared';
|
||||
import { User } from '@prisma/client';
|
||||
|
||||
import { PermissionService } from '../permissions/permission.service';
|
||||
@ -114,4 +114,71 @@ export class LinksService {
|
||||
}
|
||||
return [...grouped.values()];
|
||||
}
|
||||
|
||||
/**
|
||||
* The pond's readable wikilink graph (issue #111): every collection is
|
||||
* sliced to the caller — nodes are the readable pages, an edge survives
|
||||
* only when BOTH endpoints are readable, and a phantom only with its
|
||||
* readable referrers (none left → the phantom disappears entirely). So a
|
||||
* label-scoped reader can never infer a hidden page's existence from the
|
||||
* graph. Trashed pages and their links are excluded throughout.
|
||||
*/
|
||||
async pondGraph(user: User, pondId: string): Promise<PondGraphView> {
|
||||
const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } });
|
||||
if (!pond) throw new NotFoundException();
|
||||
|
||||
const pages = await this.prisma.page.findMany({
|
||||
where: { pondId, deletedAt: null },
|
||||
select: { id: true, title: true, slug: true, labels: { select: { labelId: true } } },
|
||||
orderBy: { title: 'asc' },
|
||||
});
|
||||
const readable = await this.permissions.filterPages(
|
||||
user,
|
||||
pondId,
|
||||
pages.map((page) => ({ id: page.id, labelIds: page.labels.map((l) => l.labelId) })),
|
||||
'read',
|
||||
);
|
||||
const nodes = pages
|
||||
.filter((page) => readable.has(page.id))
|
||||
.map((page) => ({
|
||||
id: page.id,
|
||||
title: page.title,
|
||||
slug: page.slug,
|
||||
labelIds: page.labels.map((l) => l.labelId),
|
||||
}));
|
||||
|
||||
const links = await this.prisma.pageLink.findMany({
|
||||
where: { fromPage: { pondId, deletedAt: null } },
|
||||
select: {
|
||||
fromPageId: true,
|
||||
toPageId: true,
|
||||
targetSlug: true,
|
||||
toPage: { select: { deletedAt: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// Two rows may resolve to the same target (a rename can leave several
|
||||
// slugs pointing at one page) — deduplicate per direction.
|
||||
const edgeKeys = new Set<string>();
|
||||
const edges: { from: string; to: string }[] = [];
|
||||
const phantomMap = new Map<string, GraphPhantomView>();
|
||||
for (const link of links) {
|
||||
if (!readable.has(link.fromPageId)) continue;
|
||||
if (link.toPageId !== null) {
|
||||
if (link.toPage?.deletedAt !== null || !readable.has(link.toPageId)) continue;
|
||||
const key = `${link.fromPageId}→${link.toPageId}`;
|
||||
if (edgeKeys.has(key)) continue;
|
||||
edgeKeys.add(key);
|
||||
edges.push({ from: link.fromPageId, to: link.toPageId });
|
||||
} else {
|
||||
let entry = phantomMap.get(link.targetSlug);
|
||||
if (!entry) {
|
||||
entry = { targetSlug: link.targetSlug, referencedBy: [] };
|
||||
phantomMap.set(link.targetSlug, entry);
|
||||
}
|
||||
entry.referencedBy.push(link.fromPageId);
|
||||
}
|
||||
}
|
||||
return { nodes, edges, phantoms: [...phantomMap.values()] };
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,3 +18,37 @@ export interface PhantomLinkView {
|
||||
targetSlug: string;
|
||||
referencedBy: BacklinkView[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The pond's readable wikilink graph (issue #111, `GET /ponds/:id/links`):
|
||||
* nodes are the caller-readable pages, edges the resolved wikilinks whose
|
||||
* BOTH endpoints are readable, and phantoms the missing targets with their
|
||||
* readable referrers — an unreadable page's existence never leaks through
|
||||
* any of the three collections. Consumed by the knowledge-graph views
|
||||
* (#112/#113).
|
||||
*/
|
||||
export interface GraphNodeView {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
/** For label-based node coloring; resolved via the pond's label tree. */
|
||||
labelIds: string[];
|
||||
}
|
||||
|
||||
/** One resolved wikilink, by page ids (deduplicated per direction). */
|
||||
export interface GraphEdgeView {
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
/** A phantom target with the node ids that reference it. */
|
||||
export interface GraphPhantomView {
|
||||
targetSlug: string;
|
||||
referencedBy: string[];
|
||||
}
|
||||
|
||||
export interface PondGraphView {
|
||||
nodes: GraphNodeView[];
|
||||
edges: GraphEdgeView[];
|
||||
phantoms: GraphPhantomView[];
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user