32 KiB
Knowledge Module - Developer Guide
Comprehensive developer documentation for the Knowledge Module implementation, architecture, and contribution guidelines.
Table of Contents
- Architecture Overview
- Database Schema
- Service Layer
- Caching Strategy
- Wiki-Link System
- Testing Guide
- Contributing
Architecture Overview
The Knowledge Module follows a layered architecture pattern:
┌─────────────────────────────────────────┐
│ Controllers (REST) │
│ knowledge | search | tags | import │
└────────────────┬────────────────────────┘
│
┌────────────────▼────────────────────────┐
│ Service Layer │
│ KnowledgeService | SearchService │
│ LinkSyncService | GraphService │
│ TagsService | ImportExportService │
│ StatsService | CacheService │
└────────────────┬────────────────────────┘
│
┌────────────────▼────────────────────────┐
│ Data Access (Prisma ORM) │
│ KnowledgeEntry | KnowledgeLink │
│ KnowledgeTag | KnowledgeEntryVersion │
│ KnowledgeEmbedding │
└────────────────┬────────────────────────┘
│
┌────────────────▼────────────────────────┐
│ PostgreSQL 17 + pgvector │
└─────────────────────────────────────────┘
Module Structure
apps/api/src/knowledge/
├── controllers/
│ ├── knowledge.controller.ts # Entry CRUD endpoints
│ ├── search.controller.ts # Search endpoints
│ ├── tags.controller.ts # Tag management
│ ├── import-export.controller.ts # Import/export
│ └── stats.controller.ts # Statistics
├── services/
│ ├── cache.service.ts # Valkey caching
│ ├── graph.service.ts # Graph traversal
│ ├── import-export.service.ts # File import/export
│ ├── link-resolution.service.ts # Link resolution
│ ├── link-sync.service.ts # Link synchronization
│ ├── search.service.ts # Full-text search
│ └── stats.service.ts # Statistics aggregation
├── entities/
│ ├── knowledge-entry.entity.ts # Entry DTOs
│ ├── knowledge-entry-version.entity.ts
│ ├── graph.entity.ts # Graph DTOs
│ └── stats.entity.ts # Stats DTOs
├── dto/
│ ├── create-entry.dto.ts
│ ├── update-entry.dto.ts
│ ├── entry-query.dto.ts
│ ├── search-query.dto.ts
│ └── ...
├── utils/
│ ├── wiki-link-parser.ts # Wiki-link parsing
│ └── markdown.ts # Markdown rendering
├── knowledge.service.ts # Core entry service
├── tags.service.ts # Tag service
└── knowledge.module.ts # NestJS module
Key Responsibilities
Controllers
- HTTP request/response handling
- Input validation (DTOs)
- Permission enforcement (guards)
- Error handling
Services
- Business logic
- Data transformation
- Transaction management
- Cache invalidation
Repositories (Prisma)
- Database queries
- Relation loading
- Type-safe data access
Database Schema
Core Models
KnowledgeEntry
Main entity for knowledge base entries.
model KnowledgeEntry {
id String @id @default(uuid()) @db.Uuid
workspaceId String @map("workspace_id") @db.Uuid
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
// Identity
slug String // URL-friendly identifier
title String // Display name
// Content
content String @db.Text // Raw markdown
contentHtml String? @map("content_html") @db.Text // Rendered HTML
summary String? // Optional brief description
// Status
status EntryStatus @default(DRAFT) // DRAFT | PUBLISHED | ARCHIVED
visibility Visibility @default(PRIVATE) // PRIVATE | WORKSPACE | PUBLIC
// Audit
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
createdBy String @map("created_by") @db.Uuid
updatedBy String @map("updated_by") @db.Uuid
// Relations
tags KnowledgeEntryTag[]
outgoingLinks KnowledgeLink[] @relation("SourceEntry")
incomingLinks KnowledgeLink[] @relation("TargetEntry")
versions KnowledgeEntryVersion[]
embedding KnowledgeEmbedding?
@@unique([workspaceId, slug])
@@index([workspaceId, status])
@@index([workspaceId, updatedAt])
@@map("knowledge_entries")
}
Indexes:
workspaceId, slug(unique constraint)workspaceId, status(filtering)workspaceId, updatedAt(recent entries)
KnowledgeLink
Represents wiki-links between entries.
model KnowledgeLink {
id String @id @default(uuid()) @db.Uuid
sourceId String @map("source_id") @db.Uuid
source KnowledgeEntry @relation("SourceEntry", fields: [sourceId], references: [id], onDelete: Cascade)
targetId String @map("target_id") @db.Uuid
target KnowledgeEntry @relation("TargetEntry", fields: [targetId], references: [id], onDelete: Cascade)
// Link metadata
linkText String @map("link_text") // Original link text from markdown
context String? // Surrounding text (future feature)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
@@unique([sourceId, targetId])
@@index([sourceId])
@@index([targetId])
@@map("knowledge_links")
}
Unique constraint:
sourceId, targetId(prevents duplicate links)
Indexes:
sourceId(outgoing links lookup)targetId(backlinks lookup)
KnowledgeEntryVersion
Version history for entries.
model KnowledgeEntryVersion {
id String @id @default(uuid()) @db.Uuid
entryId String @map("entry_id") @db.Uuid
entry KnowledgeEntry @relation(fields: [entryId], references: [id], onDelete: Cascade)
version Int // Version number (1, 2, 3, ...)
title String
content String @db.Text
summary String?
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
createdBy String @map("created_by") @db.Uuid
author User @relation("EntryVersionAuthor", fields: [createdBy], references: [id])
changeNote String? @map("change_note") // Optional change description
@@unique([entryId, version])
@@index([entryId, version])
@@map("knowledge_entry_versions")
}
Versioning strategy:
- Auto-incrementing version numbers
- Immutable history (no updates or deletes)
- Snapshot of title, content, summary at time of save
KnowledgeTag
Tags for categorization.
model KnowledgeTag {
id String @id @default(uuid()) @db.Uuid
workspaceId String @map("workspace_id") @db.Uuid
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
name String // Display name
slug String // URL-friendly identifier
color String? // Hex color (e.g., "#3b82f6")
description String?
entries KnowledgeEntryTag[]
@@unique([workspaceId, slug])
@@index([workspaceId])
@@map("knowledge_tags")
}
KnowledgeEntryTag
Many-to-many junction table for entries and tags.
model KnowledgeEntryTag {
entryId String @map("entry_id") @db.Uuid
entry KnowledgeEntry @relation(fields: [entryId], references: [id], onDelete: Cascade)
tagId String @map("tag_id") @db.Uuid
tag KnowledgeTag @relation(fields: [tagId], references: [id], onDelete: Cascade)
@@id([entryId, tagId])
@@index([entryId])
@@index([tagId])
@@map("knowledge_entry_tags")
}
KnowledgeEmbedding
Semantic search embeddings (future feature).
model KnowledgeEmbedding {
id String @id @default(uuid()) @db.Uuid
entryId String @unique @map("entry_id") @db.Uuid
entry KnowledgeEntry @relation(fields: [entryId], references: [id], onDelete: Cascade)
embedding Unsupported("vector(1536)") // pgvector type
model String // Model used (e.g., "text-embedding-ada-002")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
@@index([entryId])
@@map("knowledge_embeddings")
}
Service Layer
KnowledgeService
Core service for entry management.
Key methods:
class KnowledgeService {
// CRUD operations
async findAll(workspaceId, query): Promise<PaginatedEntries>;
async findOne(workspaceId, slug): Promise<KnowledgeEntryWithTags>;
async create(workspaceId, userId, dto): Promise<KnowledgeEntryWithTags>;
async update(workspaceId, slug, userId, dto): Promise<KnowledgeEntryWithTags>;
async remove(workspaceId, slug, userId): Promise<void>;
// Version management
async findVersions(workspaceId, slug, page, limit): Promise<PaginatedVersions>;
async findVersion(workspaceId, slug, version): Promise<KnowledgeEntryVersion>;
async restoreVersion(
workspaceId,
slug,
version,
userId,
changeNote
): Promise<KnowledgeEntryWithTags>;
}
Create flow:
async create(workspaceId, userId, dto) {
// 1. Generate slug from title
const slug = this.generateUniqueSlug(dto.title, workspaceId);
// 2. Render markdown to HTML
const contentHtml = renderMarkdown(dto.content);
// 3. Create entry in transaction
const entry = await this.prisma.$transaction(async (tx) => {
// Create entry
const newEntry = await tx.knowledgeEntry.create({
data: {
workspaceId,
slug,
title: dto.title,
content: dto.content,
contentHtml,
summary: dto.summary,
status: dto.status || 'DRAFT',
visibility: dto.visibility || 'PRIVATE',
createdBy: userId,
updatedBy: userId,
},
});
// Create initial version (v1)
await tx.knowledgeEntryVersion.create({
data: {
entryId: newEntry.id,
version: 1,
title: newEntry.title,
content: newEntry.content,
summary: newEntry.summary,
createdBy: userId,
changeNote: dto.changeNote || 'Initial version',
},
});
// Handle tags (create or link)
if (dto.tags && dto.tags.length > 0) {
await this.linkTags(tx, newEntry.id, workspaceId, dto.tags);
}
return newEntry;
});
// 4. Parse and sync wiki-links (outside transaction)
await this.linkSync.syncLinks(entry.id, dto.content);
// 5. Invalidate caches
await this.cache.invalidateSearchCaches(workspaceId);
// 6. Return with tags
return this.findOne(workspaceId, slug);
}
Update flow:
async update(workspaceId, slug, userId, dto) {
// 1. Find existing entry
const existing = await this.findOne(workspaceId, slug);
// 2. Get next version number
const latestVersion = await this.getLatestVersion(existing.id);
const nextVersion = latestVersion.version + 1;
// 3. Render new HTML if content changed
const contentHtml = dto.content
? renderMarkdown(dto.content)
: existing.contentHtml;
// 4. Update in transaction
const updated = await this.prisma.$transaction(async (tx) => {
// Update entry
const updatedEntry = await tx.knowledgeEntry.update({
where: { id: existing.id },
data: {
title: dto.title ?? existing.title,
content: dto.content ?? existing.content,
contentHtml,
summary: dto.summary ?? existing.summary,
status: dto.status ?? existing.status,
visibility: dto.visibility ?? existing.visibility,
updatedBy: userId,
},
});
// Create version snapshot
await tx.knowledgeEntryVersion.create({
data: {
entryId: updatedEntry.id,
version: nextVersion,
title: updatedEntry.title,
content: updatedEntry.content,
summary: updatedEntry.summary,
createdBy: userId,
changeNote: dto.changeNote || `Update to version ${nextVersion}`,
},
});
// Update tags if provided
if (dto.tags !== undefined) {
await this.replaceTags(tx, updatedEntry.id, workspaceId, dto.tags);
}
return updatedEntry;
});
// 5. Re-sync links if content changed
if (dto.content) {
await this.linkSync.syncLinks(updated.id, dto.content);
}
// 6. Invalidate caches
await this.cache.invalidateEntry(workspaceId, slug);
await this.cache.invalidateSearchCaches(workspaceId);
await this.cache.invalidateGraphCachesForEntry(updated.id);
// 7. Return updated entry
return this.findOne(workspaceId, slug);
}
LinkSyncService
Manages wiki-link parsing and synchronization.
Key methods:
class LinkSyncService {
async syncLinks(entryId: string, content: string): Promise<void>;
async getBacklinks(entryId: string): Promise<BacklinkWithSource[]>;
}
Link sync flow:
async syncLinks(entryId, content) {
// 1. Parse wiki-links from content
const parsedLinks = parseWikiLinks(content);
// 2. Get source entry details
const entry = await this.prisma.knowledgeEntry.findUnique({
where: { id: entryId },
select: { workspaceId: true },
});
// 3. Delete existing links from this entry
await this.prisma.knowledgeLink.deleteMany({
where: { sourceId: entryId },
});
// 4. For each parsed link:
for (const link of parsedLinks) {
// Try to resolve target entry
const target = await this.linkResolver.resolve(
link.target,
entry.workspaceId
);
if (target) {
// Create resolved link
await this.prisma.knowledgeLink.create({
data: {
sourceId: entryId,
targetId: target.id,
linkText: link.displayText,
},
});
}
// Note: Unresolved links are simply not created
// They may resolve later when target entry is created
}
// 5. Invalidate graph caches
await this.cache.invalidateGraphCachesForEntry(entryId);
}
LinkResolutionService
Resolves wiki-link targets to actual entries.
Resolution strategy:
async resolve(target: string, workspaceId: string) {
// Strategy 1: Match by exact slug
let entry = await this.prisma.knowledgeEntry.findUnique({
where: {
workspaceId_slug: { workspaceId, slug: target },
},
});
if (entry) return entry;
// Strategy 2: Generate slug from target and try again
const slugified = slugify(target, { lower: true, strict: true });
entry = await this.prisma.knowledgeEntry.findUnique({
where: {
workspaceId_slug: { workspaceId, slug: slugified },
},
});
if (entry) return entry;
// Strategy 3: Match by title (case-insensitive)
entry = await this.prisma.knowledgeEntry.findFirst({
where: {
workspaceId,
title: {
equals: target,
mode: 'insensitive',
},
},
});
return entry || null;
}
Resolution examples:
| Link Target | Resolution |
|---|---|
react-hooks |
Exact slug match |
React Hooks |
Slugify to react-hooks, then match |
REACT HOOKS |
Case-insensitive title match → React Hooks |
SearchService
Full-text search and filtering.
Key methods:
class SearchService {
async search(query, workspaceId, options): Promise<PaginatedSearchResults>;
async searchByTags(tags, workspaceId, options): Promise<PaginatedEntries>;
async recentEntries(workspaceId, limit, status?): Promise<KnowledgeEntryWithTags[]>;
}
Search implementation:
async search(query, workspaceId, options) {
// Check cache first
const cached = await this.cache.getSearch(workspaceId, query, options);
if (cached) return cached;
// Build where clause
const where: Prisma.KnowledgeEntryWhereInput = {
workspaceId,
OR: [
{ title: { contains: query, mode: 'insensitive' } },
{ content: { contains: query, mode: 'insensitive' } },
],
};
if (options.status) {
where.status = options.status;
}
// Execute search with pagination
const [entries, total] = await Promise.all([
this.prisma.knowledgeEntry.findMany({
where,
include: { tags: { include: { tag: true } } },
orderBy: { updatedAt: 'desc' },
skip: (options.page - 1) * options.limit,
take: options.limit,
}),
this.prisma.knowledgeEntry.count({ where }),
]);
const result = {
data: entries,
pagination: {
page: options.page,
limit: options.limit,
total,
totalPages: Math.ceil(total / options.limit),
},
};
// Cache the result
await this.cache.setSearch(workspaceId, query, options, result);
return result;
}
GraphService
Knowledge graph traversal.
Key methods:
class GraphService {
async getEntryGraph(
workspaceId: string,
entryId: string,
maxDepth: number = 1
): Promise<EntryGraphResponse>;
}
BFS graph traversal:
async getEntryGraph(workspaceId, entryId, maxDepth) {
// Check cache
const cached = await this.cache.getGraph(workspaceId, entryId, maxDepth);
if (cached) return cached;
const nodes: GraphNode[] = [];
const edges: GraphEdge[] = [];
const visited = new Set<string>();
const queue: Array<[string, number]> = [[entryId, 0]];
visited.add(entryId);
while (queue.length > 0) {
const [currentId, depth] = queue.shift()!;
// Fetch entry with links
const entry = await this.prisma.knowledgeEntry.findUnique({
where: { id: currentId },
include: {
tags: { include: { tag: true } },
outgoingLinks: { include: { target: true } },
incomingLinks: { include: { source: true } },
},
});
if (!entry) continue;
// Add node
nodes.push({
id: entry.id,
slug: entry.slug,
title: entry.title,
summary: entry.summary,
tags: entry.tags.map(et => ({
id: et.tag.id,
name: et.tag.name,
slug: et.tag.slug,
color: et.tag.color,
})),
depth,
});
// Continue BFS if not at max depth
if (depth < maxDepth) {
// Process outgoing links
for (const link of entry.outgoingLinks) {
edges.push({
id: link.id,
sourceId: link.sourceId,
targetId: link.targetId,
linkText: link.linkText,
});
if (!visited.has(link.targetId)) {
visited.add(link.targetId);
queue.push([link.targetId, depth + 1]);
}
}
// Process incoming links
for (const link of entry.incomingLinks) {
const edgeExists = edges.some(
e => e.sourceId === link.sourceId && e.targetId === link.targetId
);
if (!edgeExists) {
edges.push({
id: link.id,
sourceId: link.sourceId,
targetId: link.targetId,
linkText: link.linkText,
});
}
if (!visited.has(link.sourceId)) {
visited.add(link.sourceId);
queue.push([link.sourceId, depth + 1]);
}
}
}
}
const result = {
centerNode: nodes.find(n => n.id === entryId)!,
nodes,
edges,
stats: {
totalNodes: nodes.length,
totalEdges: edges.length,
maxDepth,
},
};
// Cache result
await this.cache.setGraph(workspaceId, entryId, maxDepth, result);
return result;
}
Caching Strategy
The Knowledge Module uses Valkey (Redis-compatible) for high-performance caching.
Cache Keys
knowledge:entry:{workspaceId}:{slug}
knowledge:search:{workspaceId}:{query}:{options_hash}
knowledge:search-tags:{workspaceId}:{tags}:{options_hash}
knowledge:graph:{workspaceId}:{entryId}:{depth}
Cache Configuration
class KnowledgeCacheService {
private readonly ENTRY_PREFIX = "knowledge:entry:";
private readonly SEARCH_PREFIX = "knowledge:search:";
private readonly SEARCH_TAGS_PREFIX = "knowledge:search-tags:";
private readonly GRAPH_PREFIX = "knowledge:graph:";
private readonly DEFAULT_TTL = 300; // 5 minutes
private readonly isEnabled: boolean;
constructor() {
this.isEnabled = process.env.KNOWLEDGE_CACHE_ENABLED !== "false";
this.DEFAULT_TTL = parseInt(process.env.KNOWLEDGE_CACHE_TTL || "300");
}
}
Invalidation Strategy
Entry changes:
- Create: Invalidate search caches
- Update: Invalidate entry cache, search caches, graph caches
- Delete: Invalidate entry cache, search caches, graph caches
Link changes:
- Invalidate graph caches for source and target entries
Tag changes:
- Invalidate tag-based search caches
async invalidateEntry(workspaceId: string, slug: string) {
const key = `${this.ENTRY_PREFIX}${workspaceId}:${slug}`;
await this.valkey.del(key);
this.stats.deletes++;
}
async invalidateSearchCaches(workspaceId: string) {
const pattern = `${this.SEARCH_PREFIX}${workspaceId}:*`;
const keys = await this.valkey.keys(pattern);
if (keys.length > 0) {
await this.valkey.del(...keys);
this.stats.deletes += keys.length;
}
}
async invalidateGraphCachesForEntry(entryId: string) {
// Graph caches include entryId in the key
const pattern = `${this.GRAPH_PREFIX}*:${entryId}:*`;
const keys = await this.valkey.keys(pattern);
if (keys.length > 0) {
await this.valkey.del(...keys);
this.stats.deletes += keys.length;
}
}
Performance Metrics
Track cache effectiveness:
interface CacheStats {
hits: number;
misses: number;
sets: number;
deletes: number;
hitRate: number;
}
getStats(): CacheStats {
const total = this.stats.hits + this.stats.misses;
return {
...this.stats,
hitRate: total > 0 ? this.stats.hits / total : 0,
};
}
Wiki-Link System
Parsing Algorithm
The wiki-link parser handles complex edge cases:
Supported syntax:
[[Page Name]] → Link to "Page Name"
[[page-slug]] → Link by slug
[[Page Name|Display Text]] → Custom display text
Edge cases handled:
- Nested brackets:
[[Link with [brackets] inside]] - Code blocks:
`[[not a link]]` - Fenced code:
```[[not a link]]``` - Escaped brackets:
\[[not a link]] - Triple brackets:
[[[not a link]]]
Parsing flow:
- Find excluded regions (code blocks, inline code)
- Scan for
[[patterns - Find matching
]] - Validate link target
- Parse pipe separator for display text
- Return array of WikiLink objects
interface WikiLink {
raw: string; // "[[Page Name]]"
target: string; // "Page Name" or "page-slug"
displayText: string; // "Page Name" or custom text
start: number; // Position in content
end: number; // Position in content
}
Link Resolution
Three-step resolution:
1. Exact slug match: "react-hooks" → entry with slug "react-hooks"
2. Slugified match: "React Hooks" → slugify → "react-hooks" → match
3. Title match: Case-insensitive title search
Unresolved links:
- Not stored in database
- Will auto-resolve when target entry is created
- Future: UI indication for broken links
Link Synchronization
On entry create/update:
1. Parse wiki-links from content
2. Delete existing links from this entry
3. For each parsed link:
a. Try to resolve target
b. If resolved, create KnowledgeLink record
c. If unresolved, skip (may resolve later)
4. Invalidate graph caches
Why delete-and-recreate?
- Simpler than diffing changes
- Ensures consistency with current content
- Links are cheap to recreate
Testing Guide
Test Structure
apps/api/src/knowledge/
├── knowledge.service.spec.ts
├── tags.service.spec.ts
├── search.controller.spec.ts
├── tags.controller.spec.ts
├── services/
│ ├── cache.service.spec.ts
│ ├── graph.service.spec.ts
│ ├── link-resolution.service.spec.ts
│ ├── link-sync.service.spec.ts
│ └── search.service.spec.ts
└── utils/
├── markdown.spec.ts
└── wiki-link-parser.spec.ts
Running Tests
# All knowledge module tests
pnpm test knowledge
# Specific test file
pnpm test knowledge.service.spec.ts
# Watch mode
pnpm test:watch knowledge
# Coverage
pnpm test:coverage
Test Coverage Requirements
- Minimum: 85% overall coverage
- Critical paths: 100% coverage required
- Entry CRUD operations
- Version management
- Link resolution
- Wiki-link parsing
Writing Tests
Service tests (unit):
describe("KnowledgeService", () => {
let service: KnowledgeService;
let prisma: PrismaService;
let linkSync: LinkSyncService;
let cache: KnowledgeCacheService;
beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [
KnowledgeService,
{
provide: PrismaService,
useValue: mockDeep<PrismaService>(),
},
{
provide: LinkSyncService,
useValue: mockDeep<LinkSyncService>(),
},
{
provide: KnowledgeCacheService,
useValue: mockDeep<KnowledgeCacheService>(),
},
],
}).compile();
service = module.get(KnowledgeService);
prisma = module.get(PrismaService);
linkSync = module.get(LinkSyncService);
cache = module.get(KnowledgeCacheService);
});
describe("create", () => {
it("should create entry with unique slug", async () => {
const dto = {
title: "Test Entry",
content: "# Test",
};
prisma.knowledgeEntry.create.mockResolvedValue({
id: "entry-id",
slug: "test-entry",
...dto,
});
const result = await service.create("workspace-id", "user-id", dto);
expect(result.slug).toBe("test-entry");
expect(linkSync.syncLinks).toHaveBeenCalledWith("entry-id", dto.content);
expect(cache.invalidateSearchCaches).toHaveBeenCalled();
});
});
});
Controller tests (integration):
describe("KnowledgeController (e2e)", () => {
let app: INestApplication;
let prisma: PrismaService;
beforeAll(async () => {
const module = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = module.createNestApplication();
await app.init();
prisma = module.get(PrismaService);
});
afterAll(async () => {
await prisma.$disconnect();
await app.close();
});
describe("POST /knowledge/entries", () => {
it("should create entry and return 201", () => {
return request(app.getHttpServer())
.post("/knowledge/entries")
.set("Authorization", `Bearer ${authToken}`)
.set("x-workspace-id", workspaceId)
.send({
title: "Test Entry",
content: "# Test Content",
status: "DRAFT",
})
.expect(201)
.expect((res) => {
expect(res.body.slug).toBe("test-entry");
expect(res.body.status).toBe("DRAFT");
});
});
});
});
Utility tests:
describe("parseWikiLinks", () => {
it("should parse simple wiki link", () => {
const content = "See [[My Page]] for details.";
const links = parseWikiLinks(content);
expect(links).toHaveLength(1);
expect(links[0]).toMatchObject({
target: "My Page",
displayText: "My Page",
raw: "[[My Page]]",
});
});
it("should parse link with custom display text", () => {
const content = "See [[page-slug|custom text]] here.";
const links = parseWikiLinks(content);
expect(links[0]).toMatchObject({
target: "page-slug",
displayText: "custom text",
});
});
it("should ignore links in code blocks", () => {
const content = "```\n[[Not A Link]]\n```";
const links = parseWikiLinks(content);
expect(links).toHaveLength(0);
});
});
Test Data Setup
Create reusable fixtures:
// test/fixtures/knowledge.fixtures.ts
export const createMockEntry = (overrides = {}) => ({
id: "entry-uuid",
workspaceId: "workspace-uuid",
slug: "test-entry",
title: "Test Entry",
content: "# Test",
contentHtml: "<h1>Test</h1>",
summary: null,
status: "DRAFT",
visibility: "PRIVATE",
createdAt: new Date(),
updatedAt: new Date(),
createdBy: "user-uuid",
updatedBy: "user-uuid",
...overrides,
});
export const createMockVersion = (entryId: string, version: number) => ({
id: `version-${version}-uuid`,
entryId,
version,
title: "Test Entry",
content: `# Version ${version}`,
summary: null,
changeNote: `Update ${version}`,
createdAt: new Date(),
createdBy: "user-uuid",
});
Contributing
Development Workflow
-
Create feature branch
git checkout -b feature/your-feature develop -
Write tests first (TDD approach)
pnpm test:watch knowledge -
Implement feature
- Follow TypeScript strict mode
- Use existing patterns
- Add JSDoc comments
-
Run tests and linting
pnpm test knowledge pnpm lint pnpm format -
Commit with conventional format
git commit -m "feat(knowledge): add semantic search endpoint" -
Create pull request to
develop
Code Style
TypeScript:
- Strict mode enabled
- No
anytypes - Explicit return types
- Interface over type when possible
NestJS conventions:
- Services are
@Injectable() - Controllers use
@Controller(),@Get(), etc. - DTOs with class-validator decorators
- Dependency injection via constructor
Naming:
camelCasefor variables and functionsPascalCasefor classes and interfacesUPPER_SNAKE_CASEfor constantskebab-casefor file names
Adding New Features
New endpoint:
- Create DTO in
dto/ - Add controller method with proper guards
- Implement service method
- Write tests (unit + integration)
- Update API documentation
New service:
- Create service class in
services/ - Add
@Injectable()decorator - Register in
knowledge.module.ts - Write comprehensive tests
- Document public API with JSDoc
Database changes:
- Update
schema.prisma - Create migration:
pnpm prisma migrate dev --name your_migration_name - Update entity interfaces in
entities/ - Update services to use new schema
- Write migration tests
Performance Considerations
Always consider:
- Database query efficiency (use indexes)
- N+1 query problems (use
includewisely) - Cache invalidation strategy
- Transaction boundaries
- Large content handling
Optimization checklist:
- Proper indexes on database columns
- Caching for expensive operations
- Pagination for list endpoints
- Lazy loading for relations
- Bulk operations where possible
Security Checklist
- Input validation (DTOs)
- Permission guards on endpoints
- Workspace isolation (never cross workspaces)
- SQL injection prevention (Prisma handles this)
- No sensitive data in logs
- Rate limiting (future)
Additional Resources
- User Guide — End-user documentation
- API Documentation — Complete API reference
- Main README — Project overview
- NestJS Docs — Framework documentation
- Prisma Docs — ORM documentation
Happy coding! 🚀