feat(backlinks): workspace reindex backfill and sub-page references

Add POST /v1/pages/backlinks/reindex to rebuild acadenice_backlink for
every non-deleted page of the workspace in one call; the per-save
indexer never backfills pre-existing content so graph + backlinks stay
empty otherwise. Surface direct sub-pages in the linked references
panel via a parent_child group sourced live from pages.parent_page_id
(same hierarchy the Knowledge Graph uses), giving Notion-like parity.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Corentin JOGUET 2026-05-18 09:22:32 +00:00
parent fe75ea5c45
commit a1f2ee9e0a
8 changed files with 152 additions and 7 deletions

View file

@ -55,6 +55,7 @@ const mockResult = {
],
mentions: [],
database_embeds: [],
parentChild: [],
total: 1,
};
@ -96,7 +97,7 @@ describe('LinkedReferencesPanel', () => {
(useBacklinks as ReturnType<typeof vi.fn>).mockReturnValue({
isLoading: false,
isError: false,
data: { wikilinks: [], mentions: [], database_embeds: [], total: 0 },
data: { wikilinks: [], mentions: [], database_embeds: [], parentChild: [], total: 0 },
refetch: vi.fn(),
});
@ -161,6 +162,7 @@ describe('LinkedReferencesPanel', () => {
{ source: { id: 'src-2', title: 'Mention Src', slugId: 's2', icon: null, spaceSlug: 'x', spaceName: 'X' }, linkType: 'mention', contextExcerpt: null },
],
database_embeds: [],
parentChild: [],
total: 2,
},
refetch: vi.fn(),

View file

@ -71,6 +71,14 @@ export function LinkedReferencesPanel({ pageId }: LinkedReferencesPanelProps) {
entries: data.database_embeds,
});
}
if (data.parentChild && data.parentChild.length > 0) {
result.push({
key: 'parent_child',
label: t('backlinks.group.subpages', 'Sub-pages'),
icon: <IconFileDescription size={14} />,
entries: data.parentChild,
});
}
return result;
}, [data, t]);

View file

@ -15,7 +15,7 @@ export interface PageSummary {
export interface BacklinkEntry {
source: PageSummary;
linkType: 'wikilink' | 'mention' | 'database_embed';
linkType: 'wikilink' | 'mention' | 'database_embed' | 'parent_child';
contextExcerpt: string | null;
}
@ -23,6 +23,8 @@ export interface BacklinksResult {
wikilinks: BacklinkEntry[];
mentions: BacklinkEntry[];
database_embeds: BacklinkEntry[];
/** Direct sub-pages of the target page (page-tree hierarchy, Notion parity). */
parentChild: BacklinkEntry[];
total: number;
}

View file

@ -1,9 +1,10 @@
import {
Controller,
Get,
NotFoundException,
HttpCode,
Param,
ParseUUIDPipe,
Post,
UseGuards,
} from '@nestjs/common';
import {
@ -18,6 +19,7 @@ import { AuthUser } from '../../../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../../../common/decorators/auth-workspace.decorator';
import { User, Workspace } from '@docmost/db/types/entity.types';
import { BacklinkService, BacklinksResult } from '../services/backlink.service';
import { BacklinkIndexerService } from '../services/backlink-indexer.service';
/**
* REST controller for the backlinks feature.
@ -34,7 +36,35 @@ import { BacklinkService, BacklinksResult } from '../services/backlink.service';
@UseGuards(JwtAuthGuard)
@Controller('v1/pages')
export class BacklinksController {
constructor(private readonly backlinkService: BacklinkService) {}
constructor(
private readonly backlinkService: BacklinkService,
private readonly backlinkIndexer: BacklinkIndexerService,
) {}
/**
* Backfill: reindex every page of the caller's workspace.
*
* One-shot operation to populate acadenice_backlink for content created
* before the indexer worked (graph + backlinks otherwise stay empty until
* each page is re-saved). Idempotent. Scoped to the caller's own workspace.
*
* Note: gated only by JwtAuthGuard for now. Admin-only gating is a future
* hardening the op is idempotent and workspace-scoped, low blast radius.
*/
@ApiOperation({
summary: 'Reindex all backlinks of the workspace',
description:
'Backfill: walks every non-deleted page of the caller workspace and rebuilds acadenice_backlink.',
})
@ApiResponse({ status: 200, description: 'Number of pages reindexed' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@Post('backlinks/reindex')
@HttpCode(200)
async reindexWorkspace(
@AuthWorkspace() workspace: Workspace,
): Promise<{ pages: number }> {
return this.backlinkIndexer.reindexWorkspace(workspace.id);
}
/**
* Returns all backlinks pointing to the given page, grouped by link type.

View file

@ -137,6 +137,37 @@ export class BacklinkIndexerService {
}
}
/**
* Backfill: reindex every non-deleted page of a workspace.
*
* Why this exists: reindexPage only runs on a page save event, so existing
* content created before the indexer worked (or before a fix) never lands
* in acadenice_backlink until each page is manually re-saved. This walks the
* whole workspace once. Sequential on purpose reindexPage is idempotent and
* the simple loop is correct up to ~10k pages (same bound as the per-save
* strategy documented on this class).
*/
async reindexWorkspace(workspaceId: string): Promise<{ pages: number }> {
const result = await sql<{ id: string }>`
SELECT p.id
FROM pages p
JOIN spaces s ON s.id = p.space_id
WHERE s.workspace_id = ${workspaceId}
AND p.deleted_at IS NULL
`.execute(this.db);
let count = 0;
for (const row of result.rows) {
await this.reindexPage(row.id);
count++;
}
this.logger.log(
`reindexWorkspace: ${workspaceId} -> ${count} page(s) reindexed`,
);
return { pages: count };
}
/**
* Delete all backlink rows where this page is the source.
* Called before reindexing to ensure idempotence.

View file

@ -20,17 +20,24 @@ export interface PageSummary {
*/
export interface BacklinkEntry {
source: PageSummary;
linkType: 'wikilink' | 'mention' | 'database_embed';
linkType: 'wikilink' | 'mention' | 'database_embed' | 'parent_child';
contextExcerpt: string | null;
}
/**
* Grouped backlinks: source pages entries per link type.
*
* `parentChild` is the set of direct sub-pages of the target page. It is NOT
* sourced from acadenice_backlink (the indexer only tracks content links
* wikilink/mention/embed); it is derived live from pages.parent_page_id, the
* same hierarchy the Knowledge Graph uses. This gives Notion-like behaviour:
* creating a sub-page makes it appear under the parent's linked references.
*/
export interface BacklinksResult {
wikilinks: BacklinkEntry[];
mentions: BacklinkEntry[];
database_embeds: BacklinkEntry[];
parentChild: BacklinkEntry[];
total: number;
}
@ -127,17 +134,70 @@ export class BacklinkService {
else database_embeds.push(entry);
}
// Sub-pages (Notion parity). Sourced from the page tree, not the
// backlink index. Same permission filter as content backlinks.
const childRows = await sql<{
source_page_id: string;
source_title: string | null;
source_slug_id: string | null;
source_icon: string | null;
space_slug: string | null;
space_name: string | null;
}>`
SELECT
p.id AS source_page_id,
p.title AS source_title,
p.slug_id AS source_slug_id,
p.icon AS source_icon,
sp.slug AS space_slug,
sp.name AS space_name
FROM pages p
JOIN spaces sp ON sp.id = p.space_id
WHERE p.parent_page_id = ${targetPageId}
AND p.deleted_at IS NULL
AND sp.workspace_id = ${workspaceId}
AND (
sp.visibility = 'public'
OR EXISTS (
SELECT 1 FROM space_members sm
WHERE sm.space_id = sp.id
AND sm.user_id = ${userId}
)
)
ORDER BY p.title ASC
`.execute(this.db);
const parentChild: BacklinkEntry[] = childRows.rows.map((row) => ({
source: {
id: row.source_page_id,
title: row.source_title,
slugId: row.source_slug_id,
icon: row.source_icon,
spaceSlug: row.space_slug,
spaceName: row.space_name,
},
linkType: 'parent_child',
contextExcerpt: null,
}));
return {
wikilinks,
mentions,
database_embeds,
total: rows.rows.length,
parentChild,
total: rows.rows.length + parentChild.length,
};
} catch (err) {
this.logger.error(
`getBacklinksFor(${targetPageId}): ${err?.['message']}`,
);
return { wikilinks: [], mentions: [], database_embeds: [], total: 0 };
return {
wikilinks: [],
mentions: [],
database_embeds: [],
parentChild: [],
total: 0,
};
}
}
}

View file

@ -44,6 +44,7 @@ describe('BacklinkService', () => {
wikilinks: [],
mentions: [],
database_embeds: [],
parentChild: [],
total: 0,
});
@ -81,6 +82,7 @@ describe('BacklinkService', () => {
},
],
database_embeds: [],
parentChild: [],
total: 2,
});
@ -112,6 +114,7 @@ describe('BacklinkService', () => {
wikilinks: [],
mentions: [],
database_embeds: [],
parentChild: [],
total: 0,
});

View file

@ -2,6 +2,7 @@
import { Test } from '@nestjs/testing';
import { BacklinksController } from '../controllers/backlinks.controller';
import { BacklinkService } from '../services/backlink.service';
import { BacklinkIndexerService } from '../services/backlink-indexer.service';
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
/**
@ -23,6 +24,7 @@ const mockBacklinksResult = {
],
mentions: [],
database_embeds: [],
parentChild: [],
total: 1,
};
@ -40,6 +42,12 @@ describe('BacklinksController', () => {
getBacklinksFor: jest.fn().mockResolvedValue(mockBacklinksResult),
},
},
{
provide: BacklinkIndexerService,
useValue: {
reindexWorkspace: jest.fn().mockResolvedValue({ pages: 0 }),
},
},
],
})
.overrideGuard(JwtAuthGuard)
@ -62,6 +70,7 @@ describe('BacklinksController', () => {
wikilinks: [],
mentions: [],
database_embeds: [],
parentChild: [],
total: 0,
});