Add wikilink index, backlinks API, and phantom resolution (#47)
All checks were successful
CD / Build and push images (push) Successful in 3m2s
CI / Lint, typecheck, test (push) Successful in 2m16s
CI / Auth e2e pack (push) Successful in 2m44s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m16s
CD / Promote to Int (push) Successful in 11s

Maintain a server-side `page_links` index on every content change so
backlinks and missing-target ("phantom") links can be queried.

- prisma: `PageLink` (from_page_id, nullable to_page_id, target_slug;
  unique per (from, slug); cascade on source purge, set-null on target
  purge); migration.
- shared: `extractWikilinkSlugs(doc)` (distinct target slugs) and the
  `BacklinkView` / `PhantomLinkView` read shapes.
- collab: the persistence hook (#35) now rewrites the source page's outgoing
  links in the same transaction as the content cache — one row per distinct
  wikilink slug, resolved to a page in the same pond (null = phantom).
- api: `GET /pages/:id/backlinks` (permission-filtered — wikilinks resolve
  within a pond, so seeing the pond is the read right) and
  `GET /ponds/:id/phantom-links` (missing targets grouped with their
  referrers). Creating or renaming a page to a slug that pages already link
  to resolves those phantom rows; because links store the target's id,
  backlinks survive a later rename of the target's slug.
- tests: shared extraction unit test; collab persistence db test (store
  writes resolved + phantom rows and rewrites the index); api LinksService
  db test (backlinks, permission filter, phantom aggregation, create/rename
  resolution, id-based backlinks survive target rename).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
This commit is contained in:
Claude Opus 4.8 2026-07-09 12:54:10 +02:00
parent 7244b89215
commit 14e69b399c
16 changed files with 444 additions and 0 deletions

View File

@ -0,0 +1,24 @@
-- CreateTable
CREATE TABLE "page_links" (
"id" TEXT NOT NULL,
"from_page_id" TEXT NOT NULL,
"to_page_id" TEXT,
"target_slug" TEXT NOT NULL,
CONSTRAINT "page_links_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "page_links_to_page_id_idx" ON "page_links"("to_page_id");
-- CreateIndex
CREATE INDEX "page_links_target_slug_idx" ON "page_links"("target_slug");
-- CreateIndex
CREATE UNIQUE INDEX "page_links_from_page_id_target_slug_key" ON "page_links"("from_page_id", "target_slug");
-- AddForeignKey
ALTER TABLE "page_links" ADD CONSTRAINT "page_links_from_page_id_fkey" FOREIGN KEY ("from_page_id") REFERENCES "pages"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "page_links" ADD CONSTRAINT "page_links_to_page_id_fkey" FOREIGN KEY ("to_page_id") REFERENCES "pages"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -110,6 +110,8 @@ model Page {
versions PageVersion[]
pendingContributors PagePendingContributor[]
labels PageLabel[]
outgoingLinks PageLink[] @relation("outgoingLinks")
incomingLinks PageLink[] @relation("incomingLinks")
@@unique([pondId, slug])
@@index([pondId])
@ -234,6 +236,27 @@ model Label {
@@map("labels")
}
/// Wikilink index maintained on every content change (data-model.md §page_links,
/// issue #47). One row per (source page, distinct target slug). `toPageId` is
/// the resolved target within the same pond, or null for a "phantom" link whose
/// target does not exist yet; creating/renaming a page to that slug resolves the
/// row. Backlinks query by `toPageId`. Collab (the content writer) maintains the
/// outgoing rows over raw SQL; it does not use Prisma.
model PageLink {
id String @id @default(uuid())
fromPageId String @map("from_page_id")
toPageId String? @map("to_page_id")
targetSlug String @map("target_slug")
fromPage Page @relation("outgoingLinks", fields: [fromPageId], references: [id], onDelete: Cascade)
toPage Page? @relation("incomingLinks", fields: [toPageId], references: [id], onDelete: SetNull)
@@unique([fromPageId, targetSlug])
@@index([toPageId])
@@index([targetSlug])
@@map("page_links")
}
/// Assignment of a label to a page (data-model.md §labels). Cascades on both
/// sides: purging a page or deleting a label removes the assignment.
model PageLabel {

View File

@ -11,6 +11,7 @@ import { ConfigModule } from './config/config.module';
import { FilesModule } from './files/files.module';
import { HealthModule } from './health/health.module';
import { LabelsModule } from './labels/labels.module';
import { LinksModule } from './links/links.module';
import { MailModule } from './mail/mail.module';
import { PagesModule } from './pages/pages.module';
import { PondsModule } from './ponds/ponds.module';
@ -36,6 +37,7 @@ import { VersionsModule } from './versions/versions.module';
CompactionModule,
VersionsModule,
LabelsModule,
LinksModule,
AuthModule,
AdminModule,
LoggerModule.forRootAsync({

View File

@ -0,0 +1,26 @@
import { Controller, Get, Param, Req } from '@nestjs/common';
import { BacklinkView, PhantomLinkView } from '@dorfteich/shared';
import { AuthedRequest } from '../auth/auth.guard';
import { LinksService } from './links.service';
/** Wikilink index reads (issue #47): backlinks and a pond's phantom links. */
@Controller()
export class LinksController {
constructor(private readonly links: LinksService) {}
/** Pages that link to this page (permission-filtered). */
@Get('pages/:id/backlinks')
async backlinks(@Param('id') id: string, @Req() request: AuthedRequest): Promise<BacklinkView[]> {
return this.links.backlinks(request.user!, id);
}
/** Referenced-but-missing pages in the pond, with their referrers. */
@Get('ponds/:pondId/phantom-links')
async phantomLinks(
@Param('pondId') pondId: string,
@Req() request: AuthedRequest,
): Promise<PhantomLinkView[]> {
return this.links.phantomLinks(request.user!, pondId);
}
}

View File

@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { PondsModule } from '../ponds/ponds.module';
import { LinksController } from './links.controller';
import { LinksService } from './links.service';
@Module({
imports: [PondsModule],
controllers: [LinksController],
providers: [LinksService],
exports: [LinksService],
})
export class LinksModule {}

View File

@ -0,0 +1,142 @@
import { randomUUID } from 'node:crypto';
import { INestApplication, NotFoundException } from '@nestjs/common';
import { PrismaClient, User } from '@prisma/client';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import * as Y from 'yjs';
import { PagesService } from '../pages/pages.service';
import { createTestApp } from '../testing/test-app';
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { LinksService } from './links.service';
describe.skipIf(!hasTestDb)('LinksService (db, issue #47)', () => {
let app: INestApplication;
let prisma: PrismaClient;
let links: LinksService;
let pages: PagesService;
const suffix = uniqueSuffix();
let owner: User;
let outsider: User;
let pondId: string;
const pageIds: string[] = [];
/** Creates a page with an explicit slug (bypassing slug generation). */
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: `a${pageIds.length}`,
createdBy: owner.id,
},
});
pageIds.push(id);
return id;
}
/** Simulates what the collab persistence writes for one outgoing link. */
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);
pages = app.get(PagesService);
owner = await prisma.user.create({
data: {
username: `lnk-owner-${suffix}`,
email: `lnk-owner-${suffix}@example.test`,
displayName: 'Link Owner',
},
});
outsider = await prisma.user.create({
data: {
username: `lnk-out-${suffix}`,
email: `lnk-out-${suffix}@example.test`,
displayName: 'Link Outsider',
},
});
const pond = await prisma.pond.create({
data: { slug: `lnk-pond-${suffix}`, name: 'Link Pond', type: 'PERSONAL', ownerId: owner.id },
});
pondId = pond.id;
// Headroom for the pages the create test makes.
await prisma.quotaOverride.create({
data: { subjectType: 'USER', subjectId: owner.id, quotaKey: 'additional_ponds', value: 100 },
});
});
afterAll(async () => {
await prisma.pageLink.deleteMany({ where: { fromPage: { pondId } } });
await prisma.page.deleteMany({ where: { pondId } });
await prisma.quotaOverride.deleteMany({ where: { subjectId: owner.id } });
await prisma.pond.deleteMany({ where: { id: pondId } });
await prisma.user.deleteMany({ where: { id: { in: [owner.id, outsider.id] } } });
await prisma.$disconnect();
await app.close();
});
it('lists backlinks to a page', async () => {
const target = await makePage(`target-${suffix}`);
const source = await makePage(`source-${suffix}`, 'Source Page');
await link(source, `target-${suffix}`, target);
const backlinks = await links.backlinks(owner, target);
expect(backlinks).toEqual([{ pageId: source, title: 'Source Page', slug: `source-${suffix}` }]);
});
it('hides backlinks from users who cannot see the pond', async () => {
const target = await makePage(`hidden-${suffix}`);
await expect(links.backlinks(outsider, target)).rejects.toBeInstanceOf(NotFoundException);
});
it('aggregates phantom links by target slug', async () => {
const a = await makePage(`ph-a-${suffix}`, 'A');
const b = await makePage(`ph-b-${suffix}`, 'B');
await link(a, `ghost-${suffix}`, null);
await link(b, `ghost-${suffix}`, null);
const phantoms = await links.phantomLinks(owner, pondId);
const ghost = phantoms.find((p) => p.targetSlug === `ghost-${suffix}`);
expect(ghost?.referencedBy.map((r) => r.title).sort()).toEqual(['A', 'B']);
});
it('resolves phantom links when a page with the target slug is created', async () => {
const source = await makePage(`res-src-${suffix}`, 'Res Source');
await link(source, 'brand-new-page', null);
const created = await pages.create(owner, pondId, { title: 'Brand New Page' });
pageIds.push(created.id);
expect(created.slug).toBe('brand-new-page');
// The phantom now points at the created page — it is a backlink, not phantom.
const backlinks = await links.backlinks(owner, created.id);
expect(backlinks.map((b) => b.pageId)).toContain(source);
const phantoms = await links.phantomLinks(owner, pondId);
expect(phantoms.some((p) => p.targetSlug === 'brand-new-page')).toBe(false);
});
it('re-points phantom links on rename, and id-based backlinks survive a later target rename', async () => {
const source = await makePage(`rn-src-${suffix}`, 'Rename Source');
await link(source, 'wanted-slug', null);
const target = await makePage(`rn-tgt-${suffix}`, 'Rename Target');
// Rename the target to the wanted slug → the phantom resolves.
await pages.update(owner, target, { slug: 'wanted-slug' });
expect((await links.backlinks(owner, target)).map((b) => b.pageId)).toContain(source);
// Rename the target's slug again: the backlink stores the id, so it survives.
await pages.update(owner, target, { slug: 'wanted-slug-renamed' });
expect((await links.backlinks(owner, target)).map((b) => b.pageId)).toContain(source);
});
});

View File

@ -0,0 +1,76 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { BacklinkView, PhantomLinkView } from '@dorfteich/shared';
import { User } from '@prisma/client';
import { InterimAccessService } from '../ponds/interim-access.service';
import { PrismaService } from '../prisma/prisma.service';
/**
* Reads over the wikilink index (`page_links`, issue #47). The index is written
* by the collab server on every content change; here it is queried for a page's
* backlinks and a pond's phantom (missing-target) links. Wikilinks resolve only
* within a pond, so every backlink lives in the same pond as its target
* seeing the pond (InterimAccessService) is therefore the read permission.
*/
@Injectable()
export class LinksService {
constructor(
private readonly prisma: PrismaService,
private readonly access: InterimAccessService,
) {}
/** Pages linking to `pageId`, filtered to what the user may read (issue #47). */
async backlinks(user: User, pageId: string): Promise<BacklinkView[]> {
const page = await this.prisma.page.findFirst({
where: { id: pageId, deletedAt: null },
include: { pond: true },
});
if (!page) throw new NotFoundException();
this.access.assertCanSee(user, page.pond);
const links = await this.prisma.pageLink.findMany({
where: { toPageId: pageId, fromPage: { deletedAt: null } },
include: { fromPage: { select: { id: true, title: true, slug: true } } },
orderBy: { fromPage: { title: 'asc' } },
});
const seen = new Set<string>();
const backlinks: BacklinkView[] = [];
for (const link of links) {
if (seen.has(link.fromPage.id)) continue;
seen.add(link.fromPage.id);
backlinks.push({
pageId: link.fromPage.id,
title: link.fromPage.title,
slug: link.fromPage.slug,
});
}
return backlinks;
}
/** Referenced-but-missing targets in a pond, with the pages referencing them. */
async phantomLinks(user: User, pondId: string): Promise<PhantomLinkView[]> {
const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } });
this.access.assertCanSee(user, pond);
const rows = await this.prisma.pageLink.findMany({
where: { toPageId: null, fromPage: { pondId, deletedAt: null } },
include: { fromPage: { select: { id: true, title: true, slug: true } } },
orderBy: [{ targetSlug: 'asc' }, { fromPage: { title: 'asc' } }],
});
const grouped = new Map<string, PhantomLinkView>();
for (const row of rows) {
let entry = grouped.get(row.targetSlug);
if (!entry) {
entry = { targetSlug: row.targetSlug, referencedBy: [] };
grouped.set(row.targetSlug, entry);
}
entry.referencedBy.push({
pageId: row.fromPage.id,
title: row.fromPage.title,
slug: row.fromPage.slug,
});
}
return [...grouped.values()];
}
}

View File

@ -155,10 +155,26 @@ export class PagesService {
contentCache: { create: contentCacheData(content) },
},
});
// A new page may satisfy phantom wikilinks that referenced its slug (#47).
await this.resolvePhantomLinks(pond.id, slug, page.id);
this.logger.info({ pageId: page.id, pondId: pond.id, userId: user.id }, 'audit: page created');
return this.viewOf(page);
}
/**
* Point phantom `page_links` (unresolved, `to_page_id` null) whose
* `target_slug` matches `slug` within the pond at `pageId` (issue #47).
* Called when a page is created or renamed to a slug that pages already link
* to. Backlinks store the target's id, so once resolved a later target rename
* keeps them connected.
*/
private async resolvePhantomLinks(pondId: string, slug: string, pageId: string): Promise<void> {
await this.prisma.$executeRaw`
UPDATE page_links SET to_page_id = ${pageId}
WHERE to_page_id IS NULL AND target_slug = ${slug}
AND from_page_id IN (SELECT id FROM pages WHERE pond_id = ${pondId})`;
}
async getState(user: User, id: string): Promise<PageStateView> {
const page = await this.findVisiblePage(user, id);
return this.stateViewOf(page);
@ -229,6 +245,8 @@ export class PagesService {
where: { id: page.id },
data: { title: input.title, slug },
});
// Renaming to a slug pages already link to resolves those phantom links (#47).
if (slug !== page.slug) await this.resolvePhantomLinks(page.pondId, slug, page.id);
return this.viewOf(updated);
}

View File

@ -21,6 +21,19 @@ function makeDoc(text: string): Y.Doc {
return doc;
}
/** A Y.Doc whose paragraph contains a `[[slug]]` wikilink per slug (issue #47). */
function makeDocWithWikilinks(...slugs: string[]): Y.Doc {
const doc = new Y.Doc();
const inline = [
editorSchema.text('See '),
...slugs.map((slug) => editorSchema.node('wikilink', { targetSlug: slug, displayText: null })),
];
const paragraph = editorSchema.node('paragraph', null, inline);
const pmDoc = editorSchema.node('doc', null, [paragraph]);
prosemirrorJSONToYXmlFragment(editorSchema, pmDoc.toJSON(), doc.getXmlFragment('default'));
return doc;
}
/**
* A minimal valid Yjs state with no document content. Seeding pages with an
* empty state (rather than an empty paragraph) keeps these SQL round-trip tests
@ -149,4 +162,33 @@ describe.skipIf(!url)('PostgresPagePersistence (DB-backed)', () => {
const loaded = await persistence.loadInto(missingId, new Y.Doc());
expect(loaded).toBe(false);
});
it('maintains the page_links index on store — resolved and phantom (issue #47)', async () => {
const target = await createPage();
const targetSlug = `wl-target-${target.slice(0, 8)}`;
await pool.query('UPDATE pages SET slug = $2 WHERE id = $1', [target, targetSlug]);
const source = await createPage();
const persistence = new PostgresPagePersistence(pool);
// The doc links an existing page and a missing one.
const doc = makeDocWithWikilinks(targetSlug, 'ghost-slug');
const result = await persistence.store(source, doc);
expect(result.outcome).toBe('stored');
const rows = await pool.query<{ target_slug: string; to_page_id: string | null }>(
'SELECT target_slug, to_page_id FROM page_links WHERE from_page_id = $1 ORDER BY target_slug',
[source],
);
const bySlug = new Map(rows.rows.map((r) => [r.target_slug, r.to_page_id]));
expect(bySlug.get(targetSlug)).toBe(target); // resolved
expect(bySlug.has('ghost-slug')).toBe(true);
expect(bySlug.get('ghost-slug')).toBeNull(); // phantom
// Re-storing without the ghost link removes its row (index is rewritten).
await persistence.store(source, makeDocWithWikilinks(targetSlug));
const after = await pool.query('SELECT target_slug FROM page_links WHERE from_page_id = $1', [
source,
]);
expect(after.rows.map((r) => r.target_slug)).toEqual([targetSlug]);
});
});

View File

@ -165,6 +165,21 @@ export class PostgresPagePersistence implements PagePersistence {
);
}
// Rewrite this page's outgoing wikilink index (issue #47): replace all its
// rows with one per distinct target slug, resolved to a page in the same
// pond (null `to_page_id` = phantom, target does not exist yet).
await client.query('DELETE FROM page_links WHERE from_page_id = $1', [pageId]);
if (derived.wikilinkSlugs.length > 0) {
await client.query(
`INSERT INTO page_links (id, from_page_id, to_page_id, target_slug)
SELECT gen_random_uuid(), $1, target.id, s.link_slug
FROM unnest($2::text[]) AS s(link_slug)
LEFT JOIN pages AS target
ON target.pond_id = $3 AND target.slug = s.link_slug AND target.deleted_at IS NULL`,
[pageId, derived.wikilinkSlugs, pondId],
);
}
await client.query('COMMIT');
this.lastStoredVector.set(pageId, nextVector);
return { outcome: 'stored', bytes: full.byteLength, durationMs: durationOf(), merged };

View File

@ -4,6 +4,7 @@ import {
docToPlainText,
editorSchema,
extractOutline,
extractWikilinkSlugs,
type OutlineEntry,
} from '@dorfteich/shared';
import { Node } from 'prosemirror-model';
@ -40,6 +41,9 @@ export interface DerivedPageContent {
* `Attachment.pageId` pointed at the page that embeds the file (issue #31),
* mirroring the api's REST save path. */
imageFileIds: string[];
/** Distinct target slugs of every `[[wikilink]]`, for the `page_links`
* index (issue #47). */
wikilinkSlugs: string[];
}
function imageFileIdsOf(doc: Node): string[] {
@ -65,5 +69,6 @@ export function deriveContentFromDoc(ydoc: Y.Doc): DerivedPageContent {
html: docToHtml(doc),
outline: extractOutline(doc),
imageFileIds: imageFileIdsOf(doc),
wikilinkSlugs: extractWikilinkSlugs(doc),
};
}

View File

@ -3,3 +3,4 @@ export * from './markdown';
export * from './html';
export * from './plain-text';
export * from './outline';
export * from './wikilinks';

View File

@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest';
import { markdownToDoc } from './markdown';
import { extractWikilinkSlugs } from './wikilinks';
describe('extractWikilinkSlugs (issue #47)', () => {
it('collects distinct target slugs in order of first appearance', () => {
const doc = markdownToDoc('Link to [[alpha]] and [[beta|Bee]] and again [[alpha]].');
expect(extractWikilinkSlugs(doc)).toEqual(['alpha', 'beta']);
});
it('returns an empty list when there are no wikilinks', () => {
expect(extractWikilinkSlugs(markdownToDoc('Just plain text, no links.'))).toEqual([]);
});
});

View File

@ -0,0 +1,22 @@
import { Node } from 'prosemirror-model';
/**
* The distinct target slugs of every `[[wikilink]]` in a document (issue #47).
* Used server-side to maintain the `page_links` index on every content change,
* so backlinks and phantom (missing-target) links can be queried. Order of
* first appearance is preserved; duplicates are collapsed.
*/
export function extractWikilinkSlugs(doc: Node): string[] {
const slugs: string[] = [];
const seen = new Set<string>();
doc.descendants((node) => {
if (node.type.name === 'wikilink') {
const slug = node.attrs.targetSlug as string;
if (slug && !seen.has(slug)) {
seen.add(slug);
slugs.push(slug);
}
}
});
return slugs;
}

View File

@ -7,6 +7,7 @@ export * from './files';
export * from './health';
export * from './i18n-tools';
export * from './labels';
export * from './links';
export * from './pages';
export * from './ponds';
export * from './quotas';

View File

@ -0,0 +1,18 @@
/**
* Wikilink index views shared between api and web (issue #47/#48). The index
* (`page_links`) is maintained on every content change; these are the read
* shapes for the backlinks panel and the pond's missing-pages view.
*/
/** A page that links to the page being viewed (its backlink). */
export interface BacklinkView {
pageId: string;
title: string;
slug: string;
}
/** A referenced-but-missing target and the pages that link to it. */
export interface PhantomLinkView {
targetSlug: string;
referencedBy: BacklinkView[];
}