Compare commits

...

2 commits

Author SHA1 Message Date
a1f2ee9e0a 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>
2026-05-18 09:22:32 +00:00
fe75ea5c45 fix(database-view): send tableId to bridge and correct SSE path
useViewData omitted the tableId query param required by the bridge
GET /views/:id/data route -> 400 'tableId query param required' and a
blank 'Could not load view'. The SSE consumer hit /api/v1/events/sse
but the bridge mounts the stream at /api/events -> 404 reconnect loop.
Thread tableId through ViewDataParams and all five callers; point the
SSE URL at /api/events.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 09:20:38 +00:00
16 changed files with 170 additions and 11 deletions

View file

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

View file

@ -71,6 +71,14 @@ export function LinkedReferencesPanel({ pageId }: LinkedReferencesPanelProps) {
entries: data.database_embeds, 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; return result;
}, [data, t]); }, [data, t]);

View file

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

View file

@ -6,7 +6,7 @@ import { resolveBridgeUrl } from "../services/bridge-client";
/** /**
* SSE consumer for realtime row/view updates from the bridge. * SSE consumer for realtime row/view updates from the bridge.
* *
* Connects to `GET /api/v1/events/sse?tables=<tableId>&views=<viewId>` and * Connects to `GET /api/events/sse?tables=<tableId>&views=<viewId>` and
* listens for events whose type starts with `row.` or `view.`. On match, it * listens for events whose type starts with `row.` or `view.`. On match, it
* invalidates the React Query cache for the affected view so the table * invalidates the React Query cache for the affected view so the table
* re-fetches silently. * re-fetches silently.
@ -46,7 +46,10 @@ export function useDatabaseRealtimeUpdates(
if (!tableId || !viewId) return; if (!tableId || !viewId) return;
const url = resolveBridgeUrl(bridgeUrl); const url = resolveBridgeUrl(bridgeUrl);
const sseUrl = `${url}/api/v1/events/sse?tables=${encodeURIComponent(tableId)}&views=${encodeURIComponent(viewId)}`; // The bridge mounts the SSE router at /api/events (NOT under /api/v1) on
// purpose, to keep it out of the v1 mutation rate-limiter. See bridge
// src/index.ts: app.route('/api', eventsRouter) + eventsRoutes '/events'.
const sseUrl = `${url}/api/events/sse?tables=${encodeURIComponent(tableId)}&views=${encodeURIComponent(viewId)}`;
let retryTimeout: ReturnType<typeof setTimeout> | null = null; let retryTimeout: ReturnType<typeof setTimeout> | null = null;

View file

@ -34,6 +34,7 @@ export interface UseViewDataResult {
*/ */
export function useViewData({ export function useViewData({
viewId, viewId,
tableId,
bridgeUrl, bridgeUrl,
page = 1, page = 1,
size = 50, size = 50,
@ -42,11 +43,13 @@ export function useViewData({
return useQuery<UseViewDataResult>({ return useQuery<UseViewDataResult>({
queryKey: viewDataQueryKey(viewId, page, size, url), queryKey: viewDataQueryKey(viewId, page, size, url),
enabled: Boolean(viewId), enabled: Boolean(viewId) && Boolean(tableId),
queryFn: async () => { queryFn: async () => {
const client = getBridgeClient(url); const client = getBridgeClient(url);
// tableId is mandatory: the bridge route GET /views/:viewId/data returns
// 400 "tableId query param required" without it.
const res = await (client.get(`/api/v1/views/${viewId}/data`, { const res = await (client.get(`/api/v1/views/${viewId}/data`, {
params: { page, size }, params: { page, size, tableId },
}) as unknown as Promise<BridgeViewDataResponse>); }) as unknown as Promise<BridgeViewDataResponse>);
// Normalise: bridge may return top-level array or wrapped envelope. // Normalise: bridge may return top-level array or wrapped envelope.

View file

@ -102,6 +102,7 @@ export function CalendarRenderer({ tableId, viewId, bridgeUrl }: CalendarRendere
const { data, isLoading, isError, error, refetch } = useViewData({ const { data, isLoading, isError, error, refetch } = useViewData({
viewId, viewId,
tableId,
bridgeUrl, bridgeUrl,
page: 1, page: 1,
size: PAGE_SIZE, size: PAGE_SIZE,

View file

@ -280,6 +280,7 @@ export function KanbanRenderer({ tableId, viewId, bridgeUrl }: KanbanRendererPro
const { data, isLoading, isError, error, refetch } = useViewData({ const { data, isLoading, isError, error, refetch } = useViewData({
viewId, viewId,
tableId,
bridgeUrl, bridgeUrl,
page: 1, page: 1,
size: PAGE_SIZE, size: PAGE_SIZE,

View file

@ -173,6 +173,7 @@ export function TableRenderer({ tableId, viewId, bridgeUrl }: TableRendererProps
const { data, isLoading, isError, error, refetch } = useViewData({ const { data, isLoading, isError, error, refetch } = useViewData({
viewId, viewId,
tableId,
bridgeUrl, bridgeUrl,
page, page,
size: PAGE_SIZE, size: PAGE_SIZE,

View file

@ -261,6 +261,7 @@ export function TimelineRenderer({ tableId, viewId, bridgeUrl }: TimelineRendere
const { data, isLoading: dataLoading, isError: dataError, error, refetch } = useViewData({ const { data, isLoading: dataLoading, isError: dataError, error, refetch } = useViewData({
viewId, viewId,
tableId,
bridgeUrl, bridgeUrl,
page: 1, page: 1,
size: PAGE_SIZE, size: PAGE_SIZE,

View file

@ -118,6 +118,7 @@ export function InsertDatabaseModal({
isError: fieldsError, isError: fieldsError,
} = useViewData({ } = useViewData({
viewId: selectedView?.id ?? "", viewId: selectedView?.id ?? "",
tableId: selectedTable?.id ?? "",
bridgeUrl, bridgeUrl,
page: 1, page: 1,
size: 1, size: 1,

View file

@ -71,6 +71,9 @@ export interface BridgeViewDataResponse {
/** Paginated fetch params for the view-data hook. */ /** Paginated fetch params for the view-data hook. */
export interface ViewDataParams { export interface ViewDataParams {
viewId: string; viewId: string;
// Required by the bridge: GET /views/:viewId/data needs tableId to build
// Row instances (Baserow does not echo the tableId in listRows responses).
tableId: string;
bridgeUrl?: string | null; bridgeUrl?: string | null;
page?: number; page?: number;
size?: number; size?: number;

View file

@ -1,9 +1,10 @@
import { import {
Controller, Controller,
Get, Get,
NotFoundException, HttpCode,
Param, Param,
ParseUUIDPipe, ParseUUIDPipe,
Post,
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { import {
@ -18,6 +19,7 @@ import { AuthUser } from '../../../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../../../common/decorators/auth-workspace.decorator'; import { AuthWorkspace } from '../../../../common/decorators/auth-workspace.decorator';
import { User, Workspace } from '@docmost/db/types/entity.types'; import { User, Workspace } from '@docmost/db/types/entity.types';
import { BacklinkService, BacklinksResult } from '../services/backlink.service'; import { BacklinkService, BacklinksResult } from '../services/backlink.service';
import { BacklinkIndexerService } from '../services/backlink-indexer.service';
/** /**
* REST controller for the backlinks feature. * REST controller for the backlinks feature.
@ -34,7 +36,35 @@ import { BacklinkService, BacklinksResult } from '../services/backlink.service';
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Controller('v1/pages') @Controller('v1/pages')
export class BacklinksController { 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. * 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. * Delete all backlink rows where this page is the source.
* Called before reindexing to ensure idempotence. * Called before reindexing to ensure idempotence.

View file

@ -20,17 +20,24 @@ export interface PageSummary {
*/ */
export interface BacklinkEntry { export interface BacklinkEntry {
source: PageSummary; source: PageSummary;
linkType: 'wikilink' | 'mention' | 'database_embed'; linkType: 'wikilink' | 'mention' | 'database_embed' | 'parent_child';
contextExcerpt: string | null; contextExcerpt: string | null;
} }
/** /**
* Grouped backlinks: source pages entries per link type. * 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 { export interface BacklinksResult {
wikilinks: BacklinkEntry[]; wikilinks: BacklinkEntry[];
mentions: BacklinkEntry[]; mentions: BacklinkEntry[];
database_embeds: BacklinkEntry[]; database_embeds: BacklinkEntry[];
parentChild: BacklinkEntry[];
total: number; total: number;
} }
@ -127,17 +134,70 @@ export class BacklinkService {
else database_embeds.push(entry); 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 { return {
wikilinks, wikilinks,
mentions, mentions,
database_embeds, database_embeds,
total: rows.rows.length, parentChild,
total: rows.rows.length + parentChild.length,
}; };
} catch (err) { } catch (err) {
this.logger.error( this.logger.error(
`getBacklinksFor(${targetPageId}): ${err?.['message']}`, `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: [], wikilinks: [],
mentions: [], mentions: [],
database_embeds: [], database_embeds: [],
parentChild: [],
total: 0, total: 0,
}); });
@ -81,6 +82,7 @@ describe('BacklinkService', () => {
}, },
], ],
database_embeds: [], database_embeds: [],
parentChild: [],
total: 2, total: 2,
}); });
@ -112,6 +114,7 @@ describe('BacklinkService', () => {
wikilinks: [], wikilinks: [],
mentions: [], mentions: [],
database_embeds: [], database_embeds: [],
parentChild: [],
total: 0, total: 0,
}); });

View file

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