Compare commits

..

No commits in common. "a1f2ee9e0a0124ea4bf42e86c9c0afe8d5cf17c6" and "a23f836358942714386ba129d4a58d8224ae8e28" have entirely different histories.

16 changed files with 11 additions and 170 deletions

View file

@ -55,7 +55,6 @@ const mockResult = {
],
mentions: [],
database_embeds: [],
parentChild: [],
total: 1,
};
@ -97,7 +96,7 @@ describe('LinkedReferencesPanel', () => {
(useBacklinks as ReturnType<typeof vi.fn>).mockReturnValue({
isLoading: false,
isError: false,
data: { wikilinks: [], mentions: [], database_embeds: [], parentChild: [], total: 0 },
data: { wikilinks: [], mentions: [], database_embeds: [], total: 0 },
refetch: vi.fn(),
});
@ -162,7 +161,6 @@ 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,14 +71,6 @@ 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' | 'parent_child';
linkType: 'wikilink' | 'mention' | 'database_embed';
contextExcerpt: string | null;
}
@ -23,8 +23,6 @@ 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

@ -6,7 +6,7 @@ import { resolveBridgeUrl } from "../services/bridge-client";
/**
* SSE consumer for realtime row/view updates from the bridge.
*
* Connects to `GET /api/events/sse?tables=<tableId>&views=<viewId>` and
* Connects to `GET /api/v1/events/sse?tables=<tableId>&views=<viewId>` and
* 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
* re-fetches silently.
@ -46,10 +46,7 @@ export function useDatabaseRealtimeUpdates(
if (!tableId || !viewId) return;
const url = resolveBridgeUrl(bridgeUrl);
// 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)}`;
const sseUrl = `${url}/api/v1/events/sse?tables=${encodeURIComponent(tableId)}&views=${encodeURIComponent(viewId)}`;
let retryTimeout: ReturnType<typeof setTimeout> | null = null;

View file

@ -34,7 +34,6 @@ export interface UseViewDataResult {
*/
export function useViewData({
viewId,
tableId,
bridgeUrl,
page = 1,
size = 50,
@ -43,13 +42,11 @@ export function useViewData({
return useQuery<UseViewDataResult>({
queryKey: viewDataQueryKey(viewId, page, size, url),
enabled: Boolean(viewId) && Boolean(tableId),
enabled: Boolean(viewId),
queryFn: async () => {
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`, {
params: { page, size, tableId },
params: { page, size },
}) as unknown as Promise<BridgeViewDataResponse>);
// Normalise: bridge may return top-level array or wrapped envelope.

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -71,9 +71,6 @@ export interface BridgeViewDataResponse {
/** Paginated fetch params for the view-data hook. */
export interface ViewDataParams {
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;
page?: number;
size?: number;

View file

@ -1,10 +1,9 @@
import {
Controller,
Get,
HttpCode,
NotFoundException,
Param,
ParseUUIDPipe,
Post,
UseGuards,
} from '@nestjs/common';
import {
@ -19,7 +18,6 @@ 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.
@ -36,35 +34,7 @@ import { BacklinkIndexerService } from '../services/backlink-indexer.service';
@UseGuards(JwtAuthGuard)
@Controller('v1/pages')
export class BacklinksController {
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);
}
constructor(private readonly backlinkService: BacklinkService) {}
/**
* Returns all backlinks pointing to the given page, grouped by link type.

View file

@ -137,37 +137,6 @@ 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,24 +20,17 @@ export interface PageSummary {
*/
export interface BacklinkEntry {
source: PageSummary;
linkType: 'wikilink' | 'mention' | 'database_embed' | 'parent_child';
linkType: 'wikilink' | 'mention' | 'database_embed';
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;
}
@ -134,70 +127,17 @@ 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,
parentChild,
total: rows.rows.length + parentChild.length,
total: rows.rows.length,
};
} catch (err) {
this.logger.error(
`getBacklinksFor(${targetPageId}): ${err?.['message']}`,
);
return {
wikilinks: [],
mentions: [],
database_embeds: [],
parentChild: [],
total: 0,
};
return { wikilinks: [], mentions: [], database_embeds: [], total: 0 };
}
}
}

View file

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

View file

@ -2,7 +2,6 @@
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';
/**
@ -24,7 +23,6 @@ const mockBacklinksResult = {
],
mentions: [],
database_embeds: [],
parentChild: [],
total: 1,
};
@ -42,12 +40,6 @@ describe('BacklinksController', () => {
getBacklinksFor: jest.fn().mockResolvedValue(mockBacklinksResult),
},
},
{
provide: BacklinkIndexerService,
useValue: {
reindexWorkspace: jest.fn().mockResolvedValue({ pages: 0 }),
},
},
],
})
.overrideGuard(JwtAuthGuard)
@ -70,7 +62,6 @@ describe('BacklinksController', () => {
wikilinks: [],
mentions: [],
database_embeds: [],
parentChild: [],
total: 0,
});