Files
stack/KNOWLEDGE_DEV.md
Jason Woltje 955bed91ed
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
ci/woodpecker/pr/woodpecker Pipeline failed
docs: add knowledge module documentation (closes #80)
- Created KNOWLEDGE_USER_GUIDE.md with comprehensive user documentation
  - Getting started, creating entries, wiki-links
  - Tags and organization, search capabilities
  - Import/export, version history, graph visualization
  - Tips, best practices, and permissions

- Created KNOWLEDGE_API.md with complete REST API reference
  - All endpoints with request/response formats
  - Authentication and permissions
  - Detailed examples with curl and JavaScript
  - Error responses and validation

- Created KNOWLEDGE_DEV.md with developer documentation
  - Architecture overview and module structure
  - Database schema with all models
  - Service layer implementation details
  - Caching strategy and performance
  - Wiki-link parsing and resolution system
  - Testing guide and contribution guidelines

- Updated README.md with Knowledge Module section
  - Feature overview and quick examples
  - Links to detailed documentation
  - Performance metrics
  - Added knowledge management to overview

All documentation includes:
- Real examples from codebase
- Code snippets and API calls
- Best practices and workflows
- Cross-references between docs
2026-01-30 15:18:35 -06:00

32 KiB

Knowledge Module - Developer Guide

Comprehensive developer documentation for the Knowledge Module implementation, architecture, and contribution guidelines.

Table of Contents

  1. Architecture Overview
  2. Database Schema
  3. Service Layer
  4. Caching Strategy
  5. Wiki-Link System
  6. Testing Guide
  7. 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)

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,
  };
}

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:

  1. Find excluded regions (code blocks, inline code)
  2. Scan for [[ patterns
  3. Find matching ]]
  4. Validate link target
  5. Parse pipe separator for display text
  6. 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
}

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

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

  1. Create feature branch

    git checkout -b feature/your-feature develop
    
  2. Write tests first (TDD approach)

    pnpm test:watch knowledge
    
  3. Implement feature

    • Follow TypeScript strict mode
    • Use existing patterns
    • Add JSDoc comments
  4. Run tests and linting

    pnpm test knowledge
    pnpm lint
    pnpm format
    
  5. Commit with conventional format

    git commit -m "feat(knowledge): add semantic search endpoint"
    
  6. Create pull request to develop

Code Style

TypeScript:

  • Strict mode enabled
  • No any types
  • 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:

  • camelCase for variables and functions
  • PascalCase for classes and interfaces
  • UPPER_SNAKE_CASE for constants
  • kebab-case for file names

Adding New Features

New endpoint:

  1. Create DTO in dto/
  2. Add controller method with proper guards
  3. Implement service method
  4. Write tests (unit + integration)
  5. Update API documentation

New service:

  1. Create service class in services/
  2. Add @Injectable() decorator
  3. Register in knowledge.module.ts
  4. Write comprehensive tests
  5. Document public API with JSDoc

Database changes:

  1. Update schema.prisma
  2. Create migration: pnpm prisma migrate dev --name your_migration_name
  3. Update entity interfaces in entities/
  4. Update services to use new schema
  5. Write migration tests

Performance Considerations

Always consider:

  • Database query efficiency (use indexes)
  • N+1 query problems (use include wisely)
  • 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


Happy coding! 🚀