feat: document cross-references and relationship links#267
Conversation
Add explicit relationships between documents — see_also, prerequisite, supersedes, related. Complements automatic knowledge graph with human-curated links. New features: - document_links table (migration 13) with cascade deletes and indexes - Core module src/core/links.ts: createLink, getDocumentLinks, deleteLink, getPrerequisiteChain (with cycle detection), listLinks - MCP tools: link-documents, get-document-links, delete-link - CLI commands: link, links, unlink, prereqs - REST API: POST/GET /api/v1/documents/:id/links, DELETE /api/v1/links/:id - 20 unit tests covering CRUD, chains, cycles, cascade delete, validation - CLI reference docs updated Closes #169 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
Pull request overview
Adds first-class, human-curated document-to-document relationship links (cross-references) on top of the existing knowledge graph, exposing link management via core APIs, REST, MCP tools, CLI, and docs.
Changes:
- Add schema migration v13 introducing
document_linkswith uniqueness + indexes. - Introduce
src/core/links.tswith link CRUD, link listing, and prerequisite-chain traversal. - Expose link operations through MCP tools, REST routes, CLI commands, and add unit tests + CLI docs.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
src/db/schema.ts |
Bumps schema version to 13 and adds document_links migration + indexes. |
src/core/links.ts |
New core module implementing link creation/query/deletion and prerequisite traversal. |
src/core/index.ts |
Re-exports link APIs/types from the core package entrypoint. |
src/api/routes.ts |
Adds REST endpoints to create/list links per document and delete links by ID. |
src/mcp/server.ts |
Adds MCP tools for creating, listing, and deleting document links. |
src/cli/index.ts |
Adds CLI commands to link/unlink/list links and show prerequisite chains. |
tests/unit/schema.test.ts |
Updates expected schema version to 13. |
tests/unit/links.test.ts |
Adds unit tests for link CRUD, duplicate handling, prerequisites, and cascade deletes. |
docs/reference/cli.md |
Documents new CLI commands for document links. |
| function assertDocumentExists(db: Database.Database, docId: string, role: string): void { | ||
| const row = db.prepare("SELECT id FROM documents WHERE id = ?").get(docId) as | ||
| | { id: string } | ||
| | undefined; | ||
| if (!row) { | ||
| throw new DocumentNotFoundError(`${role} document not found: ${docId}`); |
There was a problem hiding this comment.
DocumentNotFoundError is being constructed with a full message (e.g. "Source document not found: ") rather than the missing document ID. This produces redundant/awkward messages like "Document not found: Source document not found: …" and diverges from how DocumentNotFoundError is used elsewhere. Pass the raw missing docId to DocumentNotFoundError and use a ValidationError (or a dedicated error type) if you need to communicate whether it was the source or target.
| function assertDocumentExists(db: Database.Database, docId: string, role: string): void { | |
| const row = db.prepare("SELECT id FROM documents WHERE id = ?").get(docId) as | |
| | { id: string } | |
| | undefined; | |
| if (!row) { | |
| throw new DocumentNotFoundError(`${role} document not found: ${docId}`); | |
| function assertDocumentExists(db: Database.Database, docId: string, _role: string): void { | |
| const row = db.prepare("SELECT id FROM documents WHERE id = ?").get(docId) as | |
| | { id: string } | |
| | undefined; | |
| if (!row) { | |
| throw new DocumentNotFoundError(docId); |
| `SELECT l.source_id, d.title | ||
| FROM document_links l | ||
| JOIN documents d ON d.id = l.source_id | ||
| WHERE l.target_id = ? AND l.link_type = 'prerequisite' |
There was a problem hiding this comment.
getPrerequisiteChain() selects a single prerequisite with LIMIT 1 but no ORDER BY. If multiple prerequisite links point to the same target, the chosen chain becomes non-deterministic (depends on SQLite query plan/insertion order). Add an explicit ordering (e.g., by created_at) or change the function to handle multiple prerequisites deterministically (or to reject multiple prerequisites).
| WHERE l.target_id = ? AND l.link_type = 'prerequisite' | |
| WHERE l.target_id = ? AND l.link_type = 'prerequisite' | |
| ORDER BY l.created_at ASC, l.id ASC |
| if (!body.targetId || !body.linkType) { | ||
| sendError(res, 400, "VALIDATION_ERROR", "targetId and linkType are required"); | ||
| return; | ||
| } | ||
| const link = createLink(db, linksDocId, body.targetId, body.linkType as LinkType, body.label); |
There was a problem hiding this comment.
POST /api/v1/documents/:id/links: the parsed body is cast but not type-validated. targetId/linkType/label can be non-strings (or label an object), which can lead to inconsistent data or SQLite binding errors. Add typeof checks (and reject invalid types) before calling createLink().
| if (!body.targetId || !body.linkType) { | |
| sendError(res, 400, "VALIDATION_ERROR", "targetId and linkType are required"); | |
| return; | |
| } | |
| const link = createLink(db, linksDocId, body.targetId, body.linkType as LinkType, body.label); | |
| if (typeof body.targetId !== "string" || body.targetId.trim() === "") { | |
| sendError(res, 400, "VALIDATION_ERROR", "targetId must be a non-empty string"); | |
| return; | |
| } | |
| if (typeof body.linkType !== "string" || body.linkType.trim() === "") { | |
| sendError(res, 400, "VALIDATION_ERROR", "linkType must be a non-empty string"); | |
| return; | |
| } | |
| if ("label" in body && typeof body.label !== "undefined" && typeof body.label !== "string") { | |
| sendError(res, 400, "VALIDATION_ERROR", "label, if provided, must be a string"); | |
| return; | |
| } | |
| const link = createLink( | |
| db, | |
| linksDocId, | |
| body.targetId, | |
| body.linkType as LinkType, | |
| body.label, | |
| ); |
Summary
Adds explicit document-to-document relationships (cross-references) that complement the automatic knowledge graph with human-curated links.
Link Types
see_alsoprerequisitesupersedesrelatedChanges
Database (
src/db/schema.ts)document_linkstable withUNIQUE(source_id, target_id, link_type), cascade deletes, indexes on source/targetCore (
src/core/links.ts) — new modulecreateLink()— with duplicate detection (returns existing on conflict)getDocumentLinks()— returns outgoing + incoming links with titlesdeleteLink()— with not-found validationgetPrerequisiteChain()— walks backwards through prerequisite links with cycle detectionlistLinks()— all links, optionally filtered by typeMCP Tools (
src/mcp/server.ts)link-documents— create relationship between two documentsget-document-links— retrieve all links for a documentdelete-link— remove a linkCLI (
src/cli/index.ts)libscope link <sourceId> <targetId> --type see_also --label "..."libscope links <documentId>libscope unlink <linkId>libscope prereqs <documentId>REST API (
src/api/routes.ts)POST /api/v1/documents/:id/links— create linkGET /api/v1/documents/:id/links— get linksDELETE /api/v1/links/:id— delete linkTests — 20 new unit tests
Closes #169