refactor(graph): camelCase row keys and drop dead try/catch lazy import

Server: rows from kysely with camelCase plugin already arrive as
sourcePageId / targetPageId / spaceId / spaceName. Drop the snake_case
indexing and update the spec accordingly.

Client: remove the unreachable try/catch around React.lazy for
react-force-graph-2d — lazy() never throws synchronously, the catch
was dead code from an earlier wip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Corentin JOGUET 2026-05-11 09:54:39 +00:00
parent 47dee1eb12
commit 11e003e71e
3 changed files with 54 additions and 65 deletions

View file

@ -114,25 +114,12 @@ function spaceColor(spaceId: string): string {
/* -------------------------------------------------------------------------- */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let ForceGraph2DLazy: React.LazyExoticComponent<React.ComponentType<any>> | null = null;
try {
ForceGraph2DLazy = React.lazy(
() =>
// The cast via unknown is required because the module shape is not known
// at compile time (lib not installed). Replace with a typed import once
// `pnpm add react-force-graph-2d` is run.
import(
/* @vite-ignore */
"react-force-graph-2d"
) as Promise<{
// eslint-disable-next-line @typescript-eslint/no-explicit-any
default: React.ComponentType<any>;
}>,
);
} catch {
ForceGraph2DLazy = null;
}
const ForceGraph2DLazy: React.LazyExoticComponent<React.ComponentType<any>> | null =
// eslint-disable-next-line @typescript-eslint/no-explicit-any
React.lazy(() => import("react-force-graph-2d") as Promise<{
// eslint-disable-next-line @typescript-eslint/no-explicit-any
default: React.ComponentType<any>;
}>);
/* -------------------------------------------------------------------------- */
/* Fallback placeholder */

View file

@ -34,9 +34,9 @@ const PAGE_CONTENT_UPDATED_EVENT = 'acadenice.page.content.updated';
// ---------------------------------------------------------------------------
interface BacklinkAggRow {
source_page_id: string;
target_page_id: string;
link_type: LinkType;
sourcePageId: string;
targetPageId: string;
linkType: LinkType;
weight: number;
}
@ -44,8 +44,8 @@ interface PageMetaRow {
id: string;
title: string | null;
slug: string | null;
space_id: string;
space_name: string | null;
spaceId: string;
spaceName: string | null;
icon: string | null;
}
@ -206,8 +206,8 @@ export class GraphService {
reachablePageIds = this.bfsReachable(rawEdges, pageId, depth);
filteredEdges = rawEdges.filter(
(e) =>
reachablePageIds!.has(e.source_page_id) &&
reachablePageIds!.has(e.target_page_id),
reachablePageIds!.has(e.sourcePageId) &&
reachablePageIds!.has(e.targetPageId),
);
} else {
filteredEdges = rawEdges;
@ -216,8 +216,8 @@ export class GraphService {
// 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);
connectedPageIds.add(e.sourcePageId);
connectedPageIds.add(e.targetPageId);
}
// Step 4: Load page metadata for all referenced pages.
@ -230,12 +230,12 @@ export class GraphService {
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,
e.sourcePageId,
(outDegreeMap.get(e.sourcePageId) ?? 0) + 1,
);
inDegreeMap.set(
e.target_page_id,
(inDegreeMap.get(e.target_page_id) ?? 0) + 1,
e.targetPageId,
(inDegreeMap.get(e.targetPageId) ?? 0) + 1,
);
}
@ -254,19 +254,19 @@ export class GraphService {
// Re-filter edges and re-compute degrees.
filteredEdges = filteredEdges.filter(
(e) =>
finalPageIds.has(e.source_page_id) &&
finalPageIds.has(e.target_page_id),
finalPageIds.has(e.sourcePageId) &&
finalPageIds.has(e.targetPageId),
);
inDegreeMap.clear();
outDegreeMap.clear();
for (const e of filteredEdges) {
outDegreeMap.set(
e.source_page_id,
(outDegreeMap.get(e.source_page_id) ?? 0) + 1,
e.sourcePageId,
(outDegreeMap.get(e.sourcePageId) ?? 0) + 1,
);
inDegreeMap.set(
e.target_page_id,
(inDegreeMap.get(e.target_page_id) ?? 0) + 1,
e.targetPageId,
(inDegreeMap.get(e.targetPageId) ?? 0) + 1,
);
}
}
@ -311,8 +311,8 @@ export class GraphService {
label: meta.title,
slug: meta.slug,
type: 'page',
spaceId: meta.space_id,
spaceName: meta.space_name,
spaceId: meta.spaceId,
spaceName: meta.spaceName,
icon: meta.icon,
isOrphan,
metrics: { inDegree: inDeg, outDegree: outDeg },
@ -320,10 +320,10 @@ export class GraphService {
}
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,
id: `${e.sourcePageId}:${e.targetPageId}:${e.linkType}`,
source: e.sourcePageId,
target: e.targetPageId,
type: e.linkType,
weight: e.weight,
}));
@ -369,12 +369,14 @@ export class GraphService {
? sql`AND src_sp.id = ${spaceId}`
: sql``;
// Aliases en camelCase: les requêtes sql`...` brutes ne passent pas
// par le CamelCasePlugin Kysely (seul le query builder typé le fait).
const rows = await sql<BacklinkAggRow>`
SELECT
bl.source_page_id,
bl.target_page_id,
bl.link_type,
COUNT(*)::int AS weight
bl.source_page_id AS "sourcePageId",
bl.target_page_id AS "targetPageId",
bl.link_type AS "linkType",
COUNT(*)::int AS weight
FROM acadenice_backlink bl
-- Source page permission check
JOIN pages src_p ON src_p.id = bl.source_page_id
@ -442,8 +444,8 @@ export class GraphService {
p.id,
p.title,
p.slug_id AS slug,
p.space_id,
sp.name AS space_name,
p.space_id AS "spaceId",
sp.name AS "spaceName",
p.icon
FROM pages p
JOIN spaces sp ON sp.id = p.space_id
@ -487,8 +489,8 @@ export class GraphService {
p.id,
p.title,
p.slug_id AS slug,
p.space_id,
sp.name AS space_name,
p.space_id AS "spaceId",
sp.name AS "spaceName",
p.icon
FROM pages p
JOIN spaces sp ON sp.id = p.space_id
@ -537,9 +539,9 @@ export class GraphService {
const rows = await sql<BacklinkAggRow>`
SELECT
child.parent_page_id AS source_page_id,
child.id AS target_page_id,
'parent_child'::text AS link_type,
child.parent_page_id AS "sourcePageId",
child.id AS "targetPageId",
'parent_child'::text AS "linkType",
1::int AS weight
FROM pages child
-- Child page space
@ -599,8 +601,8 @@ export class GraphService {
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);
addEdge(e.sourcePageId, e.targetPageId);
addEdge(e.targetPageId, e.sourcePageId);
}
const visited = new Set<string>([rootId]);

View file

@ -22,7 +22,7 @@ function row(
type: 'wikilink' | 'mention' | 'database_embed' = 'wikilink',
weight = 1,
) {
return { source_page_id: source, target_page_id: target, link_type: type, weight };
return { sourcePageId: source, targetPageId: target, linkType: type, weight };
}
/** Factory for a mock PageMetaRow. */
@ -32,7 +32,7 @@ function pageMeta(
spaceId = 'space-1',
spaceName = 'Space 1',
) {
return { id, title, space_id: spaceId, space_name: spaceName, icon: null };
return { id, title, slug: null, spaceId, spaceName, icon: null };
}
// ---------------------------------------------------------------------------
@ -507,8 +507,8 @@ describe('GraphService', () => {
// Backlink table is empty — only parent-child edges exist
jest.spyOn(service as any, 'loadEdges').mockResolvedValue([]);
jest.spyOn(service as any, 'loadParentChildEdges').mockResolvedValue([
{ source_page_id: 'parent-1', target_page_id: 'child-1', link_type: 'parent_child', weight: 1 },
{ source_page_id: 'parent-1', target_page_id: 'child-2', link_type: 'parent_child', weight: 1 },
{ sourcePageId: 'parent-1', targetPageId: 'child-1', linkType: 'parent_child', weight: 1 },
{ sourcePageId: 'parent-1', targetPageId: 'child-2', linkType: 'parent_child', weight: 1 },
]);
jest.spyOn(service as any, 'loadPageMeta').mockResolvedValue([
pageMeta('parent-1', 'Parent'),
@ -529,7 +529,7 @@ describe('GraphService', () => {
it('parent_child edges carry correct source -> target direction', async () => {
jest.spyOn(service as any, 'loadEdges').mockResolvedValue([]);
jest.spyOn(service as any, 'loadParentChildEdges').mockResolvedValue([
{ source_page_id: 'parent-1', target_page_id: 'child-1', link_type: 'parent_child', weight: 1 },
{ sourcePageId: 'parent-1', targetPageId: 'child-1', linkType: 'parent_child', weight: 1 },
]);
jest.spyOn(service as any, 'loadPageMeta').mockResolvedValue([
pageMeta('parent-1', 'Parent'),
@ -583,7 +583,7 @@ describe('GraphService', () => {
row('p1', 'p2', 'wikilink'),
]);
jest.spyOn(service as any, 'loadParentChildEdges').mockResolvedValue([
{ source_page_id: 'p2', target_page_id: 'p3', link_type: 'parent_child', weight: 1 },
{ sourcePageId: 'p2', targetPageId: 'p3', linkType: 'parent_child', weight: 1 },
]);
jest.spyOn(service as any, 'loadPageMeta').mockResolvedValue([
pageMeta('p1'),
@ -607,8 +607,8 @@ describe('GraphService', () => {
jest.spyOn(service as any, 'loadParentChildEdges').mockResolvedValue([]);
jest.spyOn(service as any, 'loadPageMeta').mockImplementation(
async (..._args: any[]) => [
{ id: 'p1', title: 'Page 1', slug: 'page-1-slug', space_id: 'sp-1', space_name: 'S', icon: null },
{ id: 'p2', title: 'Page 2', slug: null, space_id: 'sp-1', space_name: 'S', icon: null },
{ id: 'p1', title: 'Page 1', slug: 'page-1-slug', spaceId: 'sp-1', spaceName: 'S', icon: null },
{ id: 'p2', title: 'Page 2', slug: null, spaceId: 'sp-1', spaceName: 'S', icon: null },
],
);