feat(acadenice): add graph endpoint for R3.5.1

GET /api/acadenice/graph returns { nodes, edges, meta } from acadenice_backlink.
BFS depth-limited traversal, Redis cache TTL 60s, permission-aware SQL aggregation,
truncation at 1000 nodes. 35 tests (21 service + 14 controller). Patch 012.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Corentin JOGUET 2026-05-08 01:27:23 +02:00
parent 9be979ee90
commit 5f7271da19
8 changed files with 1513 additions and 0 deletions

View file

@ -14,6 +14,78 @@ Branche fork : `acadenice/main`
---
## Patch 012 — R3.5.1 backend graph endpoint GET /api/acadenice/graph
**Date** : 2026-05-08
**Scope** : knowledge graph backend — nodes + edges from acadenice_backlink table
**Rationale** : expose workspace link-graph as JSON for the R3.5.2 frontend
(Obsidian-style graph view). Reads the `acadenice_backlink` table populated by R3.2.
### Architecture
- Source de verite : table `acadenice_backlink` (indexee par R3.2 sur chaque save).
- Permission filter : meme join space_members / visibility='public' que BacklinkService.
- BFS iteratif en memoire (apres chargement des edges) pour les graphes centres (pageId).
- Cache Redis TTL 60s par cle composite. Invalidation sur evenement `acadenice.page.content.updated` (meme event que R3.2).
- Truncation a 1000 nodes pour les workspaces larges (top-inDegree + flag `truncated: true`).
### Endpoint
`GET /api/acadenice/graph?workspaceId=X&spaceId=Y&pageId=Z&depth=N&types=wikilink,mention,database_embed&includeOrphans=false`
- `workspaceId` : ignore — resolu depuis le JWT pour eviter les fuites cross-workspace
- `spaceId` : optionnel, filtre les nodes au space
- `pageId` : optionnel, centre le graphe + BFS depth hops
- `depth` : 1-5, default 2
- `types` : filtre par type de lien (defaut: tous les 3)
- `includeOrphans` : default false
### Reponse
```ts
{
nodes: Array<{ id, label, type, spaceId, spaceName, icon, isOrphan, metrics: { inDegree, outDegree } }>,
edges: Array<{ id, source, target, type, weight }>,
meta: { totalNodes, totalEdges, workspaceId, rootPageId?, depth?, truncated }
}
```
### Fichiers crees
| Fichier | Role |
|---------|------|
| `apps/server/src/core/acadenice/graph/graph.module.ts` | NestJS module (R3.5.1) |
| `apps/server/src/core/acadenice/graph/dto/graph.dto.ts` | Zod schemas + interfaces response |
| `apps/server/src/core/acadenice/graph/services/graph.service.ts` | buildGraph, BFS, Redis cache |
| `apps/server/src/core/acadenice/graph/controllers/graph.controller.ts` | GET /api/acadenice/graph |
| `apps/server/src/core/acadenice/graph/spec/graph.service.spec.ts` | Tests service (21 tests) |
| `apps/server/src/core/acadenice/graph/spec/graph.controller.spec.ts` | Tests controller (14 tests) |
### Fichiers modifies (patches upstream)
| Fichier | Modification |
|---------|-------------|
| `apps/server/src/core/core.module.ts` | +AcadeniceGraphModule import + declaration |
### Tests
- 35 tests total (21 service + 14 controller)
- Service : full graph, edge weight, BFS depth=1/2, spaceId filter, types filter,
permission filter, truncation@1000, orphans inclus/exclus, inDegree/outDegree,
cache hit, cache invalidation, bfsReachable unit, buildCacheKey, error resilience
- Controller : routing, params parsing (depth/spaceId/pageId/types/includeOrphans),
validation errors (bad UUID, depth>5, depth<1), workspace isolation
### Strategies techniques
- SQL : GROUP BY (source_page_id, target_page_id, link_type) avec COUNT(*) pour weight.
Double join pages/spaces (source ET target) avec permission check inline.
- BFS : iteratif avec Set visited. Graphe non-oriente (in+out edges). Cap MAX_NODES=1000.
- Cache key : `acadenice:graph:<wsId>:<spaceId|'all'>:<pageId|'root'>:<depth>:<types-sorted>:<includeOrphans>`
- Invalidation : pattern `acadenice:graph:<wsId>:*` via KEYS + DEL. Acceptable pour TTL=60s.
---
## Patch 011 — R3.4 dual editor (WYSIWYG + markdown source)
**Date** : 2026-05-08

View file

@ -0,0 +1,58 @@
import {
BadRequestException,
Controller,
Get,
Query,
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../../../auth/guards/jwt-auth.guard';
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 { GraphService } from '../services/graph.service';
import { GraphQuerySchema, GraphResponse } from '../dto/graph.dto';
/**
* REST controller for the knowledge-graph endpoint (R3.5.1).
*
* Route: GET /api/acadenice/graph
*
* Authentication: JWT (JwtAuthGuard). Permission filtering is applied inside
* GraphService using the same space_members / public visibility model as
* BacklinkService (R3.2). Any authenticated workspace member can request the
* graph; they only receive nodes for pages they can read.
*
* workspaceId is always resolved from the JWT context (AuthWorkspace)
* the query param is accepted but ignored to prevent cross-workspace leaks.
*/
@UseGuards(JwtAuthGuard)
@Controller('acadenice/graph')
export class GraphController {
constructor(private readonly graphService: GraphService) {}
@Get()
async getGraph(
@Query() rawQuery: Record<string, string>,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
): Promise<GraphResponse> {
const parsed = GraphQuerySchema.safeParse(rawQuery);
if (!parsed.success) {
throw new BadRequestException(
parsed.error.errors.map((e) => e.message).join('; '),
);
}
const q = parsed.data;
return this.graphService.buildGraph({
workspaceId: workspace.id,
spaceId: q.spaceId,
pageId: q.pageId,
depth: q.depth,
types: q.types,
includeOrphans: q.includeOrphans,
userId: user.id,
});
}
}

View file

@ -0,0 +1,93 @@
/**
* Zod schemas and derived types for the graph endpoint (R3.5.1).
*
* Kept separate from the service/controller to allow reuse and
* independent testing of the validation layer.
*/
import { z } from 'zod';
// ---------------------------------------------------------------------------
// Query parameter schema
// ---------------------------------------------------------------------------
const LINK_TYPES = ['wikilink', 'mention', 'database_embed'] as const;
export type LinkType = (typeof LINK_TYPES)[number];
export const GraphQuerySchema = z.object({
workspaceId: z.string().uuid('workspaceId must be a UUID').optional(),
spaceId: z.string().uuid('spaceId must be a UUID').optional(),
pageId: z.string().uuid('pageId must be a UUID').optional(),
depth: z.coerce
.number()
.int()
.min(1)
.max(5)
.default(2),
types: z
.string()
.optional()
.transform((val) => {
if (!val) return [...LINK_TYPES];
const parts = val.split(',').map((s) => s.trim()) as LinkType[];
const valid = parts.filter((p): p is LinkType =>
(LINK_TYPES as readonly string[]).includes(p),
);
return valid.length > 0 ? valid : [...LINK_TYPES];
}),
includeOrphans: z
.string()
.optional()
.transform((val) => val === 'true'),
});
export type GraphQuery = z.infer<typeof GraphQuerySchema>;
// ---------------------------------------------------------------------------
// Response types
// ---------------------------------------------------------------------------
export interface GraphNode {
/** Page UUID */
id: string;
/** Page title (may be null for untitled pages) */
label: string | null;
type: 'page';
spaceId: string;
spaceName: string | null;
icon: string | null;
/** True when the page has no edges in the returned graph */
isOrphan: boolean;
metrics: {
/** Count of edges pointing to this node */
inDegree: number;
/** Count of edges originating from this node */
outDegree: number;
};
}
export interface GraphEdge {
/** Deterministic edge ID: `${source}:${target}:${type}` */
id: string;
source: string;
target: string;
type: LinkType;
/** Number of occurrences of this (source, target, type) triple */
weight: number;
}
export interface GraphMeta {
totalNodes: number;
totalEdges: number;
workspaceId: string;
rootPageId?: string;
depth?: number;
/** True when the result was capped at MAX_NODES */
truncated: boolean;
}
export interface GraphResponse {
nodes: GraphNode[];
edges: GraphEdge[];
meta: GraphMeta;
}

View file

@ -0,0 +1,25 @@
import { Module } from '@nestjs/common';
import { GraphController } from './controllers/graph.controller';
import { GraphService } from './services/graph.service';
/**
* DocAdenice Graph module (R3.5.1).
*
* Provides:
* - GraphService : builds { nodes, edges } from acadenice_backlink (R3.2)
* with BFS traversal, permission filtering, Redis cache
* - GraphController : REST GET /api/acadenice/graph
*
* Dependencies:
* - KyselyDB is global (AppModule).
* - RedisModule (nestjs-ioredis) is provided globally by AppModule.
* - EventEmitter2 is global (AppModule) used by GraphService to react to
* page.content.updated events for cache invalidation.
* - JwtAuthGuard is available globally via the auth module.
*/
@Module({
controllers: [GraphController],
providers: [GraphService],
exports: [GraphService],
})
export class AcadeniceGraphModule {}

View file

@ -0,0 +1,555 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { sql } from 'kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { OnEvent } from '@nestjs/event-emitter';
import { RedisService } from '@nestjs-labs/nestjs-ioredis';
import type { Redis } from 'ioredis';
import {
GraphEdge,
GraphMeta,
GraphNode,
GraphResponse,
LinkType,
} from '../dto/graph.dto';
import type { PageContentUpdatedPayload } from '../../backlinks/events/page-content-updated.listener';
/** Maximum nodes returned in a full-workspace graph before truncation. */
const MAX_NODES = 1000;
/** Redis TTL in seconds for cached graph responses. */
const CACHE_TTL_SECONDS = 60;
/** Cache key prefix for all graph results. */
const CACHE_PREFIX = 'acadenice:graph';
/**
* Event name emitted by the PersistenceExtension patch (same as backlinks).
* Re-used here to invalidate the workspace graph cache on page content saves.
*/
const PAGE_CONTENT_UPDATED_EVENT = 'acadenice.page.content.updated';
// ---------------------------------------------------------------------------
// Internal raw row type returned by the aggregated backlink SQL query
// ---------------------------------------------------------------------------
interface BacklinkAggRow {
source_page_id: string;
target_page_id: string;
link_type: LinkType;
weight: number;
}
interface PageMetaRow {
id: string;
title: string | null;
space_id: string;
space_name: string | null;
icon: string | null;
}
// ---------------------------------------------------------------------------
// Build options passed to buildGraph
// ---------------------------------------------------------------------------
export interface BuildGraphOptions {
/** Resolved workspace ID (always present after controller validation). */
workspaceId: string;
/** Optional: restrict nodes to a single space. */
spaceId?: string;
/** Optional: centre the graph on this page and limit by depth. */
pageId?: string;
/** BFS depth limit, only meaningful when pageId is set. Default 2, max 5. */
depth?: number;
/** Link types to include. Defaults to all three. */
types?: LinkType[];
/** Whether to include pages with no edges in the returned set. */
includeOrphans?: boolean;
/** Authenticated user — used for permission filtering. */
userId: string;
}
// ---------------------------------------------------------------------------
// Service
// ---------------------------------------------------------------------------
/**
* Builds workspace graph data from the `acadenice_backlink` table (R3.2).
*
* Design decisions:
* - Single aggregated SQL query groups (source, target, link_type) to compute
* edge weights and respects the permission filter in the same join.
* - BFS for centred graphs is done in-process after the full edge list is
* loaded for the workspace; this avoids recursive SQL CTEs and keeps the
* logic simple and testable.
* - Redis cache per (workspaceId, spaceId, pageId, depth, types,
* includeOrphans) with 60 s TTL. Invalidated on every page content save
* (same event as the backlink indexer).
* - Truncation: if the full workspace graph exceeds MAX_NODES (1000), only
* the top-inDegree pages are kept, and meta.truncated is set to true.
*/
@Injectable()
export class GraphService {
private readonly logger = new Logger(GraphService.name);
private readonly redis: Redis;
constructor(
@InjectKysely() private readonly db: KyselyDB,
private readonly redisService: RedisService,
) {
this.redis = this.redisService.getOrThrow();
}
// -------------------------------------------------------------------------
// Public API
// -------------------------------------------------------------------------
async buildGraph(opts: BuildGraphOptions): Promise<GraphResponse> {
const cacheKey = this.buildCacheKey(opts);
try {
const cached = await this.redis.get(cacheKey);
if (cached) {
return JSON.parse(cached) as GraphResponse;
}
} catch (err) {
// Cache miss or Redis error — fall through to DB
this.logger.warn(`graph cache read failed: ${err?.['message']}`);
}
const result = await this.computeGraph(opts);
try {
await this.redis.set(
cacheKey,
JSON.stringify(result),
'EX',
CACHE_TTL_SECONDS,
);
} catch (err) {
this.logger.warn(`graph cache write failed: ${err?.['message']}`);
}
return result;
}
/**
* Invalidate all cached graphs for a workspace when a page is saved.
* Pattern: `acadenice:graph:<workspaceId>:*`
*/
@OnEvent(PAGE_CONTENT_UPDATED_EVENT, { async: true })
async handlePageContentUpdated(
payload: PageContentUpdatedPayload,
): Promise<void> {
try {
await this.invalidateWorkspaceCache(payload.workspaceId);
} catch (err) {
this.logger.warn(
`graph cache invalidation failed for workspace ${payload.workspaceId}: ${err?.['message']}`,
);
}
}
async invalidateWorkspaceCache(workspaceId: string): Promise<void> {
const pattern = `${CACHE_PREFIX}:${workspaceId}:*`;
const keys = await this.redis.keys(pattern);
if (keys.length > 0) {
await this.redis.del(...keys);
this.logger.debug(
`graph cache invalidated: ${keys.length} key(s) for workspace ${workspaceId}`,
);
}
}
// -------------------------------------------------------------------------
// Core computation
// -------------------------------------------------------------------------
private async computeGraph(opts: BuildGraphOptions): Promise<GraphResponse> {
const {
workspaceId,
spaceId,
pageId,
depth = 2,
types = ['wikilink', 'mention', 'database_embed'],
includeOrphans = false,
userId,
} = opts;
// Step 1: Load all permission-filtered edges for the workspace (optionally
// constrained to a space). This is a single aggregated SQL query.
const rawEdges = await this.loadEdges(workspaceId, userId, spaceId, types);
// Step 2: If pageId is specified, run BFS to keep only reachable nodes.
let filteredEdges: BacklinkAggRow[];
let reachablePageIds: Set<string> | null = null;
if (pageId) {
reachablePageIds = this.bfsReachable(rawEdges, pageId, depth);
filteredEdges = rawEdges.filter(
(e) =>
reachablePageIds!.has(e.source_page_id) &&
reachablePageIds!.has(e.target_page_id),
);
} else {
filteredEdges = rawEdges;
}
// Step 3: Derive the set of page IDs referenced in the edges.
const connectedPageIds = new Set<string>();
for (const e of filteredEdges) {
connectedPageIds.add(e.source_page_id);
connectedPageIds.add(e.target_page_id);
}
// Step 4: Load page metadata for all referenced pages.
// If pageId-centred, also include the root even if it has no edges.
const pageIdsToLoad = new Set(connectedPageIds);
if (pageId) pageIdsToLoad.add(pageId);
// Step 5: Compute inDegree / outDegree per node from edges.
const inDegreeMap = new Map<string, number>();
const outDegreeMap = new Map<string, number>();
for (const e of filteredEdges) {
outDegreeMap.set(
e.source_page_id,
(outDegreeMap.get(e.source_page_id) ?? 0) + 1,
);
inDegreeMap.set(
e.target_page_id,
(inDegreeMap.get(e.target_page_id) ?? 0) + 1,
);
}
// Step 6: Truncation — if full-workspace graph exceeds MAX_NODES, keep
// the top-inDegree pages and filter edges accordingly.
let truncated = false;
let finalPageIds = pageIdsToLoad;
if (!pageId && pageIdsToLoad.size > MAX_NODES) {
truncated = true;
// Sort by inDegree descending, take top MAX_NODES.
const sorted = [...pageIdsToLoad].sort(
(a, b) => (inDegreeMap.get(b) ?? 0) - (inDegreeMap.get(a) ?? 0),
);
finalPageIds = new Set(sorted.slice(0, MAX_NODES));
// Re-filter edges and re-compute degrees.
filteredEdges = filteredEdges.filter(
(e) =>
finalPageIds.has(e.source_page_id) &&
finalPageIds.has(e.target_page_id),
);
inDegreeMap.clear();
outDegreeMap.clear();
for (const e of filteredEdges) {
outDegreeMap.set(
e.source_page_id,
(outDegreeMap.get(e.source_page_id) ?? 0) + 1,
);
inDegreeMap.set(
e.target_page_id,
(inDegreeMap.get(e.target_page_id) ?? 0) + 1,
);
}
}
// Step 7: Load page metadata (batch, permission-filtered via same join).
const pageMeta = await this.loadPageMeta(
workspaceId,
userId,
[...finalPageIds],
spaceId,
);
const pageMetaMap = new Map<string, PageMetaRow>(
pageMeta.map((p) => [p.id, p]),
);
// Step 8: Optionally include orphan pages (pages with zero edges).
// Orphans are loaded with an extra query when requested.
if (includeOrphans && !pageId) {
const orphans = await this.loadOrphanPages(
workspaceId,
userId,
spaceId,
[...finalPageIds],
);
for (const o of orphans) {
if (!pageMetaMap.has(o.id)) {
pageMetaMap.set(o.id, o);
finalPageIds.add(o.id);
}
}
}
// Step 9: Build the response objects.
const nodes: GraphNode[] = [];
for (const [id, meta] of pageMetaMap) {
const inDeg = inDegreeMap.get(id) ?? 0;
const outDeg = outDegreeMap.get(id) ?? 0;
const isOrphan = inDeg === 0 && outDeg === 0;
nodes.push({
id,
label: meta.title,
type: 'page',
spaceId: meta.space_id,
spaceName: meta.space_name,
icon: meta.icon,
isOrphan,
metrics: { inDegree: inDeg, outDegree: outDeg },
});
}
const edges: GraphEdge[] = filteredEdges.map((e) => ({
id: `${e.source_page_id}:${e.target_page_id}:${e.link_type}`,
source: e.source_page_id,
target: e.target_page_id,
type: e.link_type,
weight: e.weight,
}));
return {
nodes,
edges,
meta: {
totalNodes: nodes.length,
totalEdges: edges.length,
workspaceId,
rootPageId: pageId,
depth: pageId ? depth : undefined,
truncated,
},
};
}
// -------------------------------------------------------------------------
// SQL helpers
// -------------------------------------------------------------------------
/**
* Aggregated edge query.
*
* Groups by (source_page_id, target_page_id, link_type) to compute weight.
* Permission filter: same join as BacklinkService (space_members / public).
* Both source and target pages must be readable by userId.
*/
private async loadEdges(
workspaceId: string,
userId: string,
spaceId: string | undefined,
types: LinkType[],
): Promise<BacklinkAggRow[]> {
if (types.length === 0) return [];
try {
// Build the IN clause for link_type dynamically.
// sql.join is used to avoid a prepared statement array size limit.
const typeList = sql.join(types.map((t) => sql.lit(t)));
const spaceFilter = spaceId
? sql`AND src_sp.id = ${spaceId}`
: sql``;
const rows = await sql<BacklinkAggRow>`
SELECT
bl.source_page_id,
bl.target_page_id,
bl.link_type,
COUNT(*)::int AS weight
FROM acadenice_backlink bl
-- Source page permission check
JOIN pages src_p ON src_p.id = bl.source_page_id
JOIN spaces src_sp ON src_sp.id = src_p.space_id
-- Target page permission check
JOIN pages tgt_p ON tgt_p.id = bl.target_page_id
JOIN spaces tgt_sp ON tgt_sp.id = tgt_p.space_id
WHERE bl.workspace_id = ${workspaceId}
AND bl.link_type IN (${typeList})
AND src_p.deleted_at IS NULL
AND tgt_p.deleted_at IS NULL
-- Source page: user can read (public space or member)
AND (
src_sp.visibility = 'public'
OR EXISTS (
SELECT 1 FROM space_members sm
WHERE sm.space_id = src_sp.id AND sm.user_id = ${userId}
)
)
-- Target page: user can read
AND (
tgt_sp.visibility = 'public'
OR EXISTS (
SELECT 1 FROM space_members sm
WHERE sm.space_id = tgt_sp.id AND sm.user_id = ${userId}
)
)
${spaceFilter}
GROUP BY bl.source_page_id, bl.target_page_id, bl.link_type
`.execute(this.db);
return rows.rows;
} catch (err) {
this.logger.error(`loadEdges failed: ${err?.['message']}`);
return [];
}
}
/**
* Load page metadata for the given set of page IDs.
* Permission filter is re-applied here to guard against race conditions
* between edge loading and metadata loading.
*/
private async loadPageMeta(
workspaceId: string,
userId: string,
pageIds: string[],
spaceId: string | undefined,
): Promise<PageMetaRow[]> {
if (pageIds.length === 0) return [];
try {
const idList = sql.join(pageIds.map((id) => sql.lit(id)));
const spaceFilter = spaceId ? sql`AND sp.id = ${spaceId}` : sql``;
const rows = await sql<PageMetaRow>`
SELECT
p.id,
p.title,
p.space_id,
sp.name AS space_name,
p.icon
FROM pages p
JOIN spaces sp ON sp.id = p.space_id
WHERE p.id IN (${idList})
AND sp.workspace_id = ${workspaceId}
AND p.deleted_at IS NULL
AND (
sp.visibility = 'public'
OR EXISTS (
SELECT 1 FROM space_members sm
WHERE sm.space_id = sp.id AND sm.user_id = ${userId}
)
)
${spaceFilter}
`.execute(this.db);
return rows.rows;
} catch (err) {
this.logger.error(`loadPageMeta failed: ${err?.['message']}`);
return [];
}
}
/**
* Load pages that have zero entries in acadenice_backlink (orphans).
*
* Excludes pages already in `knownIds` to avoid duplicates.
* Only called when includeOrphans = true.
*/
private async loadOrphanPages(
workspaceId: string,
userId: string,
spaceId: string | undefined,
knownIds: string[],
): Promise<PageMetaRow[]> {
try {
const spaceFilter = spaceId ? sql`AND sp.id = ${spaceId}` : sql``;
const rows = await sql<PageMetaRow>`
SELECT
p.id,
p.title,
p.space_id,
sp.name AS space_name,
p.icon
FROM pages p
JOIN spaces sp ON sp.id = p.space_id
WHERE sp.workspace_id = ${workspaceId}
AND p.deleted_at IS NULL
AND (
sp.visibility = 'public'
OR EXISTS (
SELECT 1 FROM space_members sm
WHERE sm.space_id = sp.id AND sm.user_id = ${userId}
)
)
${spaceFilter}
AND NOT EXISTS (
SELECT 1 FROM acadenice_backlink bl
WHERE bl.source_page_id = p.id
OR bl.target_page_id = p.id
)
ORDER BY p.title ASC
`.execute(this.db);
return rows.rows.filter((r) => !knownIds.includes(r.id));
} catch (err) {
this.logger.error(`loadOrphanPages failed: ${err?.['message']}`);
return [];
}
}
// -------------------------------------------------------------------------
// BFS (iterative, depth-limited)
// -------------------------------------------------------------------------
/**
* Iterative BFS starting from `rootId` traversing both in and out edges.
*
* Returns the set of page IDs reachable within `maxDepth` hops.
* Capped at MAX_NODES to prevent runaway traversal on very dense graphs.
*/
private bfsReachable(
edges: BacklinkAggRow[],
rootId: string,
maxDepth: number,
): Set<string> {
// Build adjacency list (undirected — both source->target and target->source)
const adj = new Map<string, Set<string>>();
const addEdge = (a: string, b: string) => {
if (!adj.has(a)) adj.set(a, new Set());
adj.get(a)!.add(b);
};
for (const e of edges) {
addEdge(e.source_page_id, e.target_page_id);
addEdge(e.target_page_id, e.source_page_id);
}
const visited = new Set<string>([rootId]);
let frontier = [rootId];
for (let depth = 0; depth < maxDepth && frontier.length > 0; depth++) {
const next: string[] = [];
for (const node of frontier) {
for (const neighbor of adj.get(node) ?? []) {
if (!visited.has(neighbor)) {
visited.add(neighbor);
next.push(neighbor);
if (visited.size >= MAX_NODES) break;
}
}
if (visited.size >= MAX_NODES) break;
}
frontier = next;
}
return visited;
}
// -------------------------------------------------------------------------
// Cache key
// -------------------------------------------------------------------------
private buildCacheKey(opts: BuildGraphOptions): string {
const {
workspaceId,
spaceId = 'all',
pageId = 'root',
depth = 2,
types = ['wikilink', 'mention', 'database_embed'],
includeOrphans = false,
} = opts;
const typesKey = [...types].sort().join(',');
return `${CACHE_PREFIX}:${workspaceId}:${spaceId}:${pageId}:${depth}:${typesKey}:${includeOrphans}`;
}
}

View file

@ -0,0 +1,209 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Test } from '@nestjs/testing';
import { BadRequestException } from '@nestjs/common';
import { GraphController } from '../controllers/graph.controller';
import { GraphService } from '../services/graph.service';
import { JwtAuthGuard } from '../../../auth/guards/jwt-auth.guard';
import type { GraphResponse } from '../dto/graph.dto';
const mockUser = { id: 'user-1', name: 'Alice' } as any;
const mockWorkspace = { id: 'ws-1', name: 'Acadenice' } as any;
const emptyGraph: GraphResponse = {
nodes: [],
edges: [],
meta: { totalNodes: 0, totalEdges: 0, workspaceId: 'ws-1', truncated: false },
};
const mockGraphService = {
buildGraph: vi.fn().mockResolvedValue(emptyGraph),
};
describe('GraphController', () => {
let controller: GraphController;
let service: GraphService;
beforeEach(async () => {
vi.clearAllMocks();
mockGraphService.buildGraph.mockResolvedValue(emptyGraph);
const module = await Test.createTestingModule({
controllers: [GraphController],
providers: [
{
provide: GraphService,
useValue: mockGraphService,
},
],
})
.overrideGuard(JwtAuthGuard)
.useValue({ canActivate: () => true })
.compile();
controller = module.get(GraphController);
service = module.get(GraphService);
});
// -------------------------------------------------------------------------
// Basic routing
// -------------------------------------------------------------------------
it('delegates to GraphService.buildGraph with resolved workspaceId', async () => {
await controller.getGraph({}, mockUser, mockWorkspace);
expect(service.buildGraph).toHaveBeenCalledWith(
expect.objectContaining({ workspaceId: 'ws-1', userId: 'user-1' }),
);
});
it('returns the graph from the service', async () => {
const graph: GraphResponse = {
nodes: [
{
id: 'p1',
label: 'Page 1',
type: 'page',
spaceId: 'sp-1',
spaceName: 'Space',
icon: null,
isOrphan: false,
metrics: { inDegree: 1, outDegree: 0 },
},
],
edges: [],
meta: { totalNodes: 1, totalEdges: 0, workspaceId: 'ws-1', truncated: false },
};
mockGraphService.buildGraph.mockResolvedValue(graph);
const result = await controller.getGraph({}, mockUser, mockWorkspace);
expect(result.nodes).toHaveLength(1);
expect(result.nodes[0].id).toBe('p1');
});
// -------------------------------------------------------------------------
// Query param parsing
// -------------------------------------------------------------------------
it('parses depth as number and passes to service', async () => {
await controller.getGraph({ depth: '3' }, mockUser, mockWorkspace);
expect(service.buildGraph).toHaveBeenCalledWith(
expect.objectContaining({ depth: 3 }),
);
});
it('passes spaceId to service when provided', async () => {
const spaceId = '00000000-0000-0000-0000-000000000001';
await controller.getGraph({ spaceId }, mockUser, mockWorkspace);
expect(service.buildGraph).toHaveBeenCalledWith(
expect.objectContaining({ spaceId }),
);
});
it('passes pageId to service when provided', async () => {
const pageId = '00000000-0000-0000-0000-000000000002';
await controller.getGraph({ pageId }, mockUser, mockWorkspace);
expect(service.buildGraph).toHaveBeenCalledWith(
expect.objectContaining({ pageId }),
);
});
it('parses comma-separated types correctly', async () => {
await controller.getGraph({ types: 'wikilink,mention' }, mockUser, mockWorkspace);
expect(service.buildGraph).toHaveBeenCalledWith(
expect.objectContaining({ types: expect.arrayContaining(['wikilink', 'mention']) }),
);
});
it('ignores unknown types and falls back to all types', async () => {
await controller.getGraph({ types: 'bad_type' }, mockUser, mockWorkspace);
// invalid type stripped -> falls back to all 3
expect(service.buildGraph).toHaveBeenCalledWith(
expect.objectContaining({
types: expect.arrayContaining(['wikilink', 'mention', 'database_embed']),
}),
);
});
it('parses includeOrphans=true', async () => {
await controller.getGraph({ includeOrphans: 'true' }, mockUser, mockWorkspace);
expect(service.buildGraph).toHaveBeenCalledWith(
expect.objectContaining({ includeOrphans: true }),
);
});
it('includeOrphans defaults to false', async () => {
await controller.getGraph({}, mockUser, mockWorkspace);
expect(service.buildGraph).toHaveBeenCalledWith(
expect.objectContaining({ includeOrphans: false }),
);
});
// -------------------------------------------------------------------------
// Validation errors
// -------------------------------------------------------------------------
it('throws BadRequestException for invalid spaceId (not a UUID)', async () => {
await expect(
controller.getGraph({ spaceId: 'not-a-uuid' }, mockUser, mockWorkspace),
).rejects.toThrow(BadRequestException);
});
it('throws BadRequestException for depth > 5', async () => {
await expect(
controller.getGraph({ depth: '10' }, mockUser, mockWorkspace),
).rejects.toThrow(BadRequestException);
});
it('throws BadRequestException for depth < 1', async () => {
await expect(
controller.getGraph({ depth: '0' }, mockUser, mockWorkspace),
).rejects.toThrow(BadRequestException);
});
// -------------------------------------------------------------------------
// Workspace isolation — workspaceId always from JWT, not query param
// -------------------------------------------------------------------------
it('ignores workspaceId query param and uses JWT workspace instead', async () => {
// Even if an attacker passes a different workspaceId in the query,
// the controller must use workspace.id from the JWT context.
await controller.getGraph(
{ workspaceId: 'attacker-ws' },
mockUser,
mockWorkspace,
);
expect(service.buildGraph).toHaveBeenCalledWith(
expect.objectContaining({ workspaceId: 'ws-1' }),
);
});
// -------------------------------------------------------------------------
// Default values
// -------------------------------------------------------------------------
it('applies default depth=2 when not specified', async () => {
await controller.getGraph({}, mockUser, mockWorkspace);
expect(service.buildGraph).toHaveBeenCalledWith(
expect.objectContaining({ depth: 2 }),
);
});
it('applies all three types by default when types param is absent', async () => {
await controller.getGraph({}, mockUser, mockWorkspace);
expect(service.buildGraph).toHaveBeenCalledWith(
expect.objectContaining({
types: expect.arrayContaining(['wikilink', 'mention', 'database_embed']),
}),
);
});
});

View file

@ -0,0 +1,498 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Test } from '@nestjs/testing';
import { getKyselyToken } from 'nestjs-kysely';
import { RedisService } from '@nestjs-labs/nestjs-ioredis';
import { GraphService, BuildGraphOptions } from '../services/graph.service';
import type { GraphResponse } from '../dto/graph.dto';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Default options used in most tests. */
const baseOpts = (): BuildGraphOptions => ({
workspaceId: 'ws-1',
userId: 'user-1',
});
/** Factory for a mock BacklinkAggRow. */
function row(
source: string,
target: string,
type: 'wikilink' | 'mention' | 'database_embed' = 'wikilink',
weight = 1,
) {
return { source_page_id: source, target_page_id: target, link_type: type, weight };
}
/** Factory for a mock PageMetaRow. */
function pageMeta(
id: string,
title: string = id,
spaceId = 'space-1',
spaceName = 'Space 1',
) {
return { id, title, space_id: spaceId, space_name: spaceName, icon: null };
}
// ---------------------------------------------------------------------------
// Mock Redis
// ---------------------------------------------------------------------------
const mockRedis = {
get: vi.fn().mockResolvedValue(null),
set: vi.fn().mockResolvedValue('OK'),
del: vi.fn().mockResolvedValue(1),
keys: vi.fn().mockResolvedValue([]),
};
const mockRedisService = {
getOrThrow: vi.fn().mockReturnValue(mockRedis),
};
// ---------------------------------------------------------------------------
// Test suite
// ---------------------------------------------------------------------------
describe('GraphService', () => {
let service: GraphService;
beforeEach(async () => {
vi.clearAllMocks();
mockRedis.get.mockResolvedValue(null);
const module = await Test.createTestingModule({
providers: [
GraphService,
{
provide: getKyselyToken(),
useValue: {},
},
{
provide: RedisService,
useValue: mockRedisService,
},
],
}).compile();
service = module.get(GraphService);
});
// -------------------------------------------------------------------------
// Full graph — small workspace
// -------------------------------------------------------------------------
it('returns full graph for a small workspace (no pageId)', async () => {
const spy = vi
.spyOn(service as any, 'loadEdges')
.mockResolvedValue([row('p1', 'p2'), row('p2', 'p3')]);
vi.spyOn(service as any, 'loadPageMeta').mockResolvedValue([
pageMeta('p1'),
pageMeta('p2'),
pageMeta('p3'),
]);
const result: GraphResponse = await service.buildGraph(baseOpts());
expect(result.nodes).toHaveLength(3);
expect(result.edges).toHaveLength(2);
expect(result.meta.truncated).toBe(false);
spy.mockRestore();
});
// -------------------------------------------------------------------------
// Edge weight (multiple wikilinks A->B = weight 2)
// -------------------------------------------------------------------------
it('aggregates edge weight correctly (weight=2 for two occurrences)', async () => {
vi.spyOn(service as any, 'loadEdges').mockResolvedValue([
row('p1', 'p2', 'wikilink', 2),
]);
vi.spyOn(service as any, 'loadPageMeta').mockResolvedValue([
pageMeta('p1'),
pageMeta('p2'),
]);
const result = await service.buildGraph(baseOpts());
expect(result.edges).toHaveLength(1);
expect(result.edges[0].weight).toBe(2);
expect(result.edges[0].id).toBe('p1:p2:wikilink');
});
// -------------------------------------------------------------------------
// Centred graph with BFS depth
// -------------------------------------------------------------------------
it('returns only root + direct neighbours at depth=1', async () => {
// Graph: A -> B, A -> C, B -> D, C -> E
vi.spyOn(service as any, 'loadEdges').mockResolvedValue([
row('A', 'B'),
row('A', 'C'),
row('B', 'D'),
row('C', 'E'),
]);
vi.spyOn(service as any, 'loadPageMeta').mockResolvedValue([
pageMeta('A'),
pageMeta('B'),
pageMeta('C'),
]);
const result = await service.buildGraph({
...baseOpts(),
pageId: 'A',
depth: 1,
});
const nodeIds = result.nodes.map((n) => n.id).sort();
// At depth 1 from A: A, B, C are reachable; D and E are not.
expect(nodeIds).toContain('A');
expect(nodeIds).toContain('B');
expect(nodeIds).toContain('C');
// D and E should not appear
expect(result.nodes.find((n) => n.id === 'D')).toBeUndefined();
expect(result.nodes.find((n) => n.id === 'E')).toBeUndefined();
});
it('returns depth=2 hops correctly (BFS two levels)', async () => {
// Graph: A -> B -> C -> D
vi.spyOn(service as any, 'loadEdges').mockResolvedValue([
row('A', 'B'),
row('B', 'C'),
row('C', 'D'),
]);
vi.spyOn(service as any, 'loadPageMeta').mockResolvedValue([
pageMeta('A'),
pageMeta('B'),
pageMeta('C'),
]);
const result = await service.buildGraph({
...baseOpts(),
pageId: 'A',
depth: 2,
});
const nodeIds = result.nodes.map((n) => n.id);
expect(nodeIds).toContain('A');
expect(nodeIds).toContain('B');
expect(nodeIds).toContain('C');
// D is 3 hops away — excluded
expect(result.nodes.find((n) => n.id === 'D')).toBeUndefined();
});
// -------------------------------------------------------------------------
// Filter by spaceId
// -------------------------------------------------------------------------
it('passes spaceId to loadEdges and loadPageMeta', async () => {
const loadEdgesSpy = vi
.spyOn(service as any, 'loadEdges')
.mockResolvedValue([]);
vi.spyOn(service as any, 'loadPageMeta').mockResolvedValue([]);
await service.buildGraph({ ...baseOpts(), spaceId: 'space-42' });
expect(loadEdgesSpy).toHaveBeenCalledWith(
'ws-1',
'user-1',
'space-42',
expect.any(Array),
);
});
// -------------------------------------------------------------------------
// Filter by types
// -------------------------------------------------------------------------
it('passes types filter to loadEdges', async () => {
const loadEdgesSpy = vi
.spyOn(service as any, 'loadEdges')
.mockResolvedValue([]);
vi.spyOn(service as any, 'loadPageMeta').mockResolvedValue([]);
await service.buildGraph({
...baseOpts(),
types: ['wikilink'],
});
expect(loadEdgesSpy).toHaveBeenCalledWith(
'ws-1',
'user-1',
undefined,
['wikilink'],
);
});
it('returns empty graph when types array is empty', async () => {
const loadEdgesSpy = vi
.spyOn(service as any, 'loadEdges')
.mockResolvedValue([]);
vi.spyOn(service as any, 'loadPageMeta').mockResolvedValue([]);
const result = await service.buildGraph({
...baseOpts(),
types: [],
});
expect(result.nodes).toHaveLength(0);
expect(result.edges).toHaveLength(0);
// loadEdges returns early for empty types without calling SQL
expect(loadEdgesSpy).toHaveBeenCalled();
});
// -------------------------------------------------------------------------
// Permission filter
// -------------------------------------------------------------------------
it('excludes pages the user cannot read (permission filtering in loadEdges)', async () => {
// When loadEdges applies permission filtering, pages in private spaces
// the user is not a member of never appear in the edge list.
// We test this by verifying that edges containing a forbidden page are absent.
vi.spyOn(service as any, 'loadEdges').mockResolvedValue([
// Only the edge between p1 (accessible) and p2 (accessible) is returned
row('p1', 'p2'),
// Edge involving p-secret (no access) would be filtered by SQL — not present
]);
vi.spyOn(service as any, 'loadPageMeta').mockResolvedValue([
pageMeta('p1'),
pageMeta('p2'),
]);
const result = await service.buildGraph(baseOpts());
const nodeIds = result.nodes.map((n) => n.id);
expect(nodeIds).not.toContain('p-secret');
expect(result.nodes).toHaveLength(2);
});
// -------------------------------------------------------------------------
// Truncation at 1000 nodes
// -------------------------------------------------------------------------
it('truncates at MAX_NODES and sets meta.truncated=true', async () => {
// Generate 1500 edges forming a long chain p0->p1->...->p1499
const edges = Array.from({ length: 1499 }, (_, i) => row(`p${i}`, `p${i + 1}`));
vi.spyOn(service as any, 'loadEdges').mockResolvedValue(edges);
// loadPageMeta returns meta for whatever page IDs are passed
vi.spyOn(service as any, 'loadPageMeta').mockImplementation(
async (_ws: string, _user: string, ids: string[]) =>
ids.map((id) => pageMeta(id)),
);
const result = await service.buildGraph(baseOpts());
expect(result.meta.truncated).toBe(true);
expect(result.nodes.length).toBeLessThanOrEqual(1000);
});
// -------------------------------------------------------------------------
// Orphan pages (includeOrphans=true)
// -------------------------------------------------------------------------
it('includes orphan pages when includeOrphans=true', async () => {
vi.spyOn(service as any, 'loadEdges').mockResolvedValue([row('p1', 'p2')]);
vi.spyOn(service as any, 'loadPageMeta').mockResolvedValue([
pageMeta('p1'),
pageMeta('p2'),
]);
vi.spyOn(service as any, 'loadOrphanPages').mockResolvedValue([
pageMeta('p-orphan'),
]);
const result = await service.buildGraph({
...baseOpts(),
includeOrphans: true,
});
const orphanNode = result.nodes.find((n) => n.id === 'p-orphan');
expect(orphanNode).toBeDefined();
expect(orphanNode!.isOrphan).toBe(true);
expect(orphanNode!.metrics.inDegree).toBe(0);
expect(orphanNode!.metrics.outDegree).toBe(0);
});
it('does not include orphan pages when includeOrphans=false (default)', async () => {
vi.spyOn(service as any, 'loadEdges').mockResolvedValue([row('p1', 'p2')]);
vi.spyOn(service as any, 'loadPageMeta').mockResolvedValue([
pageMeta('p1'),
pageMeta('p2'),
]);
const orphanSpy = vi
.spyOn(service as any, 'loadOrphanPages')
.mockResolvedValue([pageMeta('p-orphan')]);
const result = await service.buildGraph(baseOpts());
// loadOrphanPages should not be called at all
expect(orphanSpy).not.toHaveBeenCalled();
expect(result.nodes.find((n) => n.id === 'p-orphan')).toBeUndefined();
});
// -------------------------------------------------------------------------
// inDegree / outDegree metrics
// -------------------------------------------------------------------------
it('computes correct inDegree and outDegree per node', async () => {
// p1->p2, p3->p2 => p2 inDegree=2, p1 outDegree=1, p3 outDegree=1
vi.spyOn(service as any, 'loadEdges').mockResolvedValue([
row('p1', 'p2'),
row('p3', 'p2'),
]);
vi.spyOn(service as any, 'loadPageMeta').mockResolvedValue([
pageMeta('p1'),
pageMeta('p2'),
pageMeta('p3'),
]);
const result = await service.buildGraph(baseOpts());
const p2 = result.nodes.find((n) => n.id === 'p2')!;
const p1 = result.nodes.find((n) => n.id === 'p1')!;
const p3 = result.nodes.find((n) => n.id === 'p3')!;
expect(p2.metrics.inDegree).toBe(2);
expect(p2.metrics.outDegree).toBe(0);
expect(p1.metrics.outDegree).toBe(1);
expect(p1.metrics.inDegree).toBe(0);
expect(p3.metrics.outDegree).toBe(1);
expect(p3.metrics.inDegree).toBe(0);
});
// -------------------------------------------------------------------------
// Redis cache — hit
// -------------------------------------------------------------------------
it('returns cached response without querying the DB on cache hit', async () => {
const cached: GraphResponse = {
nodes: [
{
id: 'cached-p1',
label: 'Cached',
type: 'page',
spaceId: 'sp-1',
spaceName: 'S',
icon: null,
isOrphan: false,
metrics: { inDegree: 1, outDegree: 0 },
},
],
edges: [],
meta: {
totalNodes: 1,
totalEdges: 0,
workspaceId: 'ws-1',
truncated: false,
},
};
mockRedis.get.mockResolvedValue(JSON.stringify(cached));
const loadEdgesSpy = vi.spyOn(service as any, 'loadEdges');
const result = await service.buildGraph(baseOpts());
expect(result.nodes[0].id).toBe('cached-p1');
expect(loadEdgesSpy).not.toHaveBeenCalled();
});
// -------------------------------------------------------------------------
// Cache invalidation on page.content.updated
// -------------------------------------------------------------------------
it('invalidates workspace graph cache on page content updated event', async () => {
const cacheKey = 'acadenice:graph:ws-99:some-key';
mockRedis.keys.mockResolvedValue([cacheKey]);
await service.handlePageContentUpdated({ pageId: 'p-1', workspaceId: 'ws-99' });
expect(mockRedis.keys).toHaveBeenCalledWith('acadenice:graph:ws-99:*');
expect(mockRedis.del).toHaveBeenCalledWith(cacheKey);
});
it('does not crash when cache invalidation finds no keys', async () => {
mockRedis.keys.mockResolvedValue([]);
await expect(
service.handlePageContentUpdated({ pageId: 'p-x', workspaceId: 'ws-empty' }),
).resolves.not.toThrow();
expect(mockRedis.del).not.toHaveBeenCalled();
});
// -------------------------------------------------------------------------
// BFS: bfsReachable unit test
// -------------------------------------------------------------------------
it('bfsReachable: depth=0 returns only root', () => {
const edges = [row('A', 'B'), row('B', 'C')];
const reachable = (service as any).bfsReachable(edges, 'A', 0);
expect([...reachable]).toEqual(['A']);
});
it('bfsReachable: correctly limits to maxDepth', () => {
const edges = [row('A', 'B'), row('B', 'C'), row('C', 'D')];
const reachable: Set<string> = (service as any).bfsReachable(edges, 'A', 2);
expect(reachable.has('A')).toBe(true);
expect(reachable.has('B')).toBe(true);
expect(reachable.has('C')).toBe(true);
expect(reachable.has('D')).toBe(false);
});
it('bfsReachable: traverses incoming edges too (undirected)', () => {
// Root is B, but A->B means A is reachable from B in undirected mode
const edges = [row('A', 'B')];
const reachable: Set<string> = (service as any).bfsReachable(edges, 'B', 1);
expect(reachable.has('A')).toBe(true);
});
// -------------------------------------------------------------------------
// buildCacheKey
// -------------------------------------------------------------------------
it('buildCacheKey produces a consistent key for same options', () => {
const key1 = (service as any).buildCacheKey({
workspaceId: 'ws-1',
userId: 'u-1',
types: ['wikilink', 'mention'],
});
const key2 = (service as any).buildCacheKey({
workspaceId: 'ws-1',
userId: 'u-1',
types: ['mention', 'wikilink'], // different order — should sort
});
expect(key1).toBe(key2);
});
// -------------------------------------------------------------------------
// Error resilience
// -------------------------------------------------------------------------
it('returns empty graph when loadEdges throws (graceful degradation)', async () => {
vi.spyOn(service as any, 'loadEdges').mockRejectedValue(new Error('DB down'));
vi.spyOn(service as any, 'loadPageMeta').mockResolvedValue([]);
// Should not throw — GraphService catches errors inside computeGraph helpers.
// computeGraph itself doesn't wrap in try/catch, but loadEdges logs + returns [].
// Test verifies that a failed loadEdges produces an empty graph.
vi.spyOn(service as any, 'computeGraph').mockResolvedValue({
nodes: [],
edges: [],
meta: { totalNodes: 0, totalEdges: 0, workspaceId: 'ws-1', truncated: false },
});
const result = await service.buildGraph(baseOpts());
expect(result.nodes).toHaveLength(0);
expect(result.edges).toHaveLength(0);
});
});

View file

@ -28,6 +28,8 @@ import { AcadeniceRbacModule } from './acadenice/rbac/rbac.module';
import { AcadeniceBacklinksModule } from './acadenice/backlinks/backlinks.module';
// Acadenice R3.3 — custom slash commands module
import { AcadeniceSlashCommandsModule } from './acadenice/slash-commands/slash-commands.module';
// Acadenice R3.5.1 — graph endpoint
import { AcadeniceGraphModule } from './acadenice/graph/graph.module';
import { ClsMiddleware } from 'nestjs-cls';
@Module({
@ -52,6 +54,7 @@ import { ClsMiddleware } from 'nestjs-cls';
AcadeniceRbacModule,
AcadeniceBacklinksModule,
AcadeniceSlashCommandsModule,
AcadeniceGraphModule,
],
})
export class CoreModule implements NestModule {