Compare commits
No commits in common. "a1f2ee9e0a0124ea4bf42e86c9c0afe8d5cf17c6" and "a23f836358942714386ba129d4a58d8224ae8e28" have entirely different histories.
a1f2ee9e0a
...
a23f836358
16 changed files with 11 additions and 170 deletions
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -118,7 +118,6 @@ export function InsertDatabaseModal({
|
|||
isError: fieldsError,
|
||||
} = useViewData({
|
||||
viewId: selectedView?.id ?? "",
|
||||
tableId: selectedTable?.id ?? "",
|
||||
bridgeUrl,
|
||||
page: 1,
|
||||
size: 1,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue