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:
parent
fe75ea5c45
commit
a1f2ee9e0a
8 changed files with 152 additions and 7 deletions
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue