feat(mcpedia): Phase 1 MVP — monorepo, Core, Web UI, MCP server, Postgres FTS
- bun workspaces + Turborepo monorepo (apps/web, apps/mcp; packages/*, scripts) - @mcpedia/core single business-logic layer (Document/Content/Search services) - @mcpedia/db Drizzle schema: documents + weighted tsvector (GIN) for FTS - @mcpedia/parser frontmatter, @mcpedia/search Postgres FTS (ts_rank+ts_headline) - Next.js 16 Web UI (home/doc SSG, search dynamic) + react-markdown render - MCP server (stdio) with 4 tools + in-memory smoke test - scripts/indexer walks content/ -> upserts into Postgres - 4 seed docs; README + PHASES status
This commit is contained in:
+45
@@ -0,0 +1,45 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
**/node_modules/
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
**/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
.env*
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
# monorepo / turbo
|
||||||
|
.turbo/
|
||||||
|
dist/
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,9 @@
|
|||||||
|
<!-- BEGIN:nextjs-agent-rules -->
|
||||||
|
|
||||||
|
# This is NOT the Next.js you know
|
||||||
|
|
||||||
|
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
|
||||||
|
|
||||||
|
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
||||||
|
|
||||||
|
<!-- END:nextjs-agent-rules -->
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# MCPedia — Phase Status
|
||||||
|
|
||||||
|
Legend: ✅ built · 🟡 partial · ⬜ deferred
|
||||||
|
|
||||||
|
## Phase 1 — MVP (✅ DONE)
|
||||||
|
|
||||||
|
| Capability | Status | Notes |
|
||||||
|
| --------------------- | ------ | ----- |
|
||||||
|
| Monorepo (bun + Turbo)| ✅ | apps/{web,mcp}, packages/{types,config,db,parser,search,core}, scripts |
|
||||||
|
| Content as Markdown | ✅ | `content/{docs,writeups,research,notes}/`, Git-tracked |
|
||||||
|
| Frontmatter parsing | ✅ | `@mcpedia/parser` (gray-matter) |
|
||||||
|
| Postgres metadata | ✅ | `@mcpedia/db` Drizzle, `documents` table |
|
||||||
|
| Postgres FTS | ✅ | weighted `tsvector` (title A / body B), GIN index, `ts_rank`+`ts_headline` |
|
||||||
|
| Core services | ✅ | Document / Content / Search — single business-logic layer |
|
||||||
|
| Indexer | ✅ | `scripts/indexer.ts` walks content/ → upserts |
|
||||||
|
| Web UI (Next 16) | ✅ | home (list), doc view (SSG), search (dynamic). react-markdown render |
|
||||||
|
| MCP server (stdio) | ✅ | 4 tools; in-memory smoke test passing |
|
||||||
|
| Hybrid/semantic search| ⬜ | Phase 2 |
|
||||||
|
|
||||||
|
## Phase 2 — Semantic + API
|
||||||
|
|
||||||
|
- [ ] `pgvector` + embedding column on `document_chunks`
|
||||||
|
- [ ] Chunking + embedding provider abstraction (`EmbeddingProvider`: OpenAI/Gemini/Ollama/local)
|
||||||
|
- [ ] Hybrid search (FTS score + cosine, reciprocal-rank fusion)
|
||||||
|
- [ ] tRPC + Hono API (`apps/api`) sharing `@mcpedia/core`
|
||||||
|
- [ ] Tags / Categories / References as first-class tables
|
||||||
|
- [ ] Auth (Auth.js / OIDC) — public/private/unlisted/admin/owner
|
||||||
|
- [ ] shadcn/ui components + Shiki syntax highlighting (replace minimal markdown render)
|
||||||
|
|
||||||
|
## Phase 3 — Async + Scale
|
||||||
|
|
||||||
|
- [ ] Redis + BullMQ background indexing / embedding workers
|
||||||
|
- [ ] Git synchronization hook (auto-reindex on push)
|
||||||
|
- [ ] Document revision system (`document_revisions`)
|
||||||
|
- [ ] MCP Resources (`mcpedia://docs/...`) in addition to tools
|
||||||
|
|
||||||
|
## Phase 4 — Scale-out (only if needed)
|
||||||
|
|
||||||
|
- [ ] Dedicated search engine (OpenSearch/Elasticsearch) — YAGNI until FTS is insufficient
|
||||||
|
- [ ] Object storage for assets
|
||||||
|
- [ ] Advanced ranking, distributed workers, observability, multi-tenant
|
||||||
|
|
||||||
|
## Decisions locked (from initial planning)
|
||||||
|
|
||||||
|
- **Tooling:** bun workspaces + Turborepo (repo already used bun; pnpm rejected to minimize churn).
|
||||||
|
- **DB:** imrnes Postgres `100.121.180.82:6432/mcpedia` for both dev and deploy; driver `prepare:false` (PgBouncer). Docker Compose reserved for future prod.
|
||||||
|
- **Phase 1 scope:** Core + Web + MCP only. tRPC/Hono API, pgvector, auth, BullMQ deferred (YAGNI).
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# MCPedia
|
||||||
|
|
||||||
|
> A content-first knowledge base — readable as Markdown/MDX in Git, queryable by
|
||||||
|
> humans via a Web UI and by AI agents via the Model Context Protocol (MCP).
|
||||||
|
|
||||||
|
MCPedia keeps content as plain Markdown files under `content/`. A Git-tracked
|
||||||
|
source of truth, indexed into PostgreSQL (metadata + a `tsvector` full-text
|
||||||
|
column) and served through a single **Core** layer that every interface
|
||||||
|
(Web, MCP) shares — no business logic duplicated per surface.
|
||||||
|
|
||||||
|
## Monorepo layout
|
||||||
|
|
||||||
|
```
|
||||||
|
mcpedia/
|
||||||
|
├── apps/
|
||||||
|
│ ├── web/ # Next.js 16 (Turbopack) — human-facing docs UI + search
|
||||||
|
│ └── mcp/ # MCP server (stdio) — AI-agent interface
|
||||||
|
├── packages/
|
||||||
|
│ ├── types/ # shared domain types (DocSection, Document, SearchHit, ...)
|
||||||
|
│ ├── config/ # loads .env (repo root) as authoritative dev config
|
||||||
|
│ ├── db/ # Drizzle ORM schema + client + drizzle-kit config
|
||||||
|
│ ├── parser/ # frontmatter (gray-matter) parsing
|
||||||
|
│ ├── search/ # Postgres FTS query (ts_rank + ts_headline)
|
||||||
|
│ └── core/ # Document/Content/Search services — the only business logic
|
||||||
|
├── content/ # docs/ writeups/ research/ notes/ (the knowledge base)
|
||||||
|
└── scripts/ # indexer.ts (walks content/ -> upserts into Postgres)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture principle
|
||||||
|
|
||||||
|
```
|
||||||
|
Web ─┐
|
||||||
|
├──► Core ──► Repository (@mcpedia/db) ──► PostgreSQL
|
||||||
|
MCP ─┘
|
||||||
|
```
|
||||||
|
|
||||||
|
All interfaces go through `@mcpedia/core`. Nothing outside `packages/db` and
|
||||||
|
`packages/core` touches the database directly.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun install # install workspace deps
|
||||||
|
cp .env.example .env # set DATABASE_URL (dev uses imrnes Postgres :6432)
|
||||||
|
bunx turbo run build # typecheck + build every package
|
||||||
|
|
||||||
|
bun run index # walk content/ -> upsert into Postgres
|
||||||
|
bun --cwd apps/web run dev # Web UI on :3000
|
||||||
|
bun run mcp # MCP server on stdio (pipe to an MCP client)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database
|
||||||
|
|
||||||
|
Phase 1 uses Postgres FTS only. Schema is defined in `packages/db/src/schema.ts`
|
||||||
|
(a `documents` table with a `search_vector` generated `tsvector` column + GIN
|
||||||
|
index). Apply it with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bunx --cwd packages/db drizzle-kit push
|
||||||
|
```
|
||||||
|
|
||||||
|
> Note: on imrnes (PgBouncer `:6432`) a leaked `DATABASE_URL` shell var can
|
||||||
|
> shadow `.env`. `@mcpedia/config` loads `.env` **last** so the repo config
|
||||||
|
> always wins for local/dev.
|
||||||
|
|
||||||
|
## Content
|
||||||
|
|
||||||
|
Each Markdown file carries YAML frontmatter:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
id: websocket-contract
|
||||||
|
title: WebSocket Contract
|
||||||
|
type: documentation
|
||||||
|
tags: [typescript, websocket, rpc]
|
||||||
|
status: published
|
||||||
|
author: asep
|
||||||
|
created_at: 2026-08-19
|
||||||
|
updated_at: 2026-08-19
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
`slug` = relative path under `content/` (e.g. `docs/websocket/contract`). The
|
||||||
|
`body` shown in the UI is always read from the on-disk file (source of truth);
|
||||||
|
the DB stores metadata + the search vector.
|
||||||
|
|
||||||
|
## MCP tools (Phase 1)
|
||||||
|
|
||||||
|
| Tool | Purpose |
|
||||||
|
| --------------------- | ------------------------------------------------ |
|
||||||
|
| `search_documents` | Postgres FTS over the corpus (ranked + snippet) |
|
||||||
|
| `get_document` | Full markdown body by slug |
|
||||||
|
| `list_documents` | List, optionally filtered by section |
|
||||||
|
| `get_related_documents` | Docs sharing tags with a given slug |
|
||||||
|
|
||||||
|
Smoke test (in-memory transport, real JSON-RPC):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun --cwd apps/mcp run smoke
|
||||||
|
```
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
**Phase 1 — MVP (DONE):** monorepo, Core, Web UI (home/doc/search), MCP server,
|
||||||
|
Postgres FTS keyword search, content indexing.
|
||||||
|
|
||||||
|
See `PHASES.md` for Phase 2–4 (pgvector semantic/hybrid search, tRPC/Hono API,
|
||||||
|
auth, Redis/BullMQ background workers, revisions, scale-out).
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "@mcpedia/mcp",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"bin": {
|
||||||
|
"mcpedia-mcp": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "bun run src/index.ts",
|
||||||
|
"lint": "tsc --noEmit",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"smoke": "bun run src/smoke.test.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@mcpedia/config": "workspace:*",
|
||||||
|
"@mcpedia/core": "workspace:*",
|
||||||
|
"@mcpedia/search": "workspace:*",
|
||||||
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20",
|
||||||
|
"typescript": "^5.6.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { listDocuments, getDocument, getRelated } from "@mcpedia/core";
|
||||||
|
import { keywordSearch } from "@mcpedia/search";
|
||||||
|
|
||||||
|
export function createMcpServer(): McpServer {
|
||||||
|
const server = new McpServer({
|
||||||
|
name: "mcpedia",
|
||||||
|
version: "0.1.0",
|
||||||
|
});
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"search_documents",
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
"Full-text search across the MCPedia knowledge base (Postgres FTS). Returns ranked documents with a headline snippet.",
|
||||||
|
inputSchema: z.object({
|
||||||
|
query: z.string().describe("Free-text search query"),
|
||||||
|
limit: z.number().int().positive().max(50).optional(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async ({ query, limit }) => {
|
||||||
|
const hits = await keywordSearch(query, limit ?? 20);
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: JSON.stringify(hits, null, 2) }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"get_document",
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
"Fetch the full markdown body of a document by its slug (e.g. 'docs/websocket/contract').",
|
||||||
|
inputSchema: z.object({
|
||||||
|
slug: z.string().describe("Document slug, e.g. 'docs/websocket/contract'"),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async ({ slug }) => {
|
||||||
|
const doc = await getDocument(slug);
|
||||||
|
if (!doc) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `Document not found: ${slug}` }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { content: [{ type: "text", text: doc.body }] };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"list_documents",
|
||||||
|
{
|
||||||
|
description: "List documents, optionally filtered by section.",
|
||||||
|
inputSchema: z.object({
|
||||||
|
section: z
|
||||||
|
.enum(["docs", "writeups", "research", "notes"])
|
||||||
|
.optional(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async ({ section }) => {
|
||||||
|
const docs = await listDocuments({ section });
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: JSON.stringify(docs, null, 2) }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"get_related_documents",
|
||||||
|
{
|
||||||
|
description: "Return documents that share tags with the given slug.",
|
||||||
|
inputSchema: z.object({
|
||||||
|
slug: z.string(),
|
||||||
|
limit: z.number().int().positive().max(20).optional(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async ({ slug, limit }) => {
|
||||||
|
const related = await getRelated(slug, limit ?? 5);
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: JSON.stringify(related, null, 2) }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
// When run directly (bun run src/index.ts), serve over stdio.
|
||||||
|
const isMain =
|
||||||
|
process.argv[1] && import.meta.url === `file://${process.argv[1]}`;
|
||||||
|
if (isMain) {
|
||||||
|
const transport = new StdioServerTransport();
|
||||||
|
await createMcpServer().connect(transport);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||||
|
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
||||||
|
import { createMcpServer } from "../src/index";
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const server = createMcpServer();
|
||||||
|
const [clientT, serverT] = InMemoryTransport.createLinkedPair();
|
||||||
|
await server.connect(serverT);
|
||||||
|
|
||||||
|
const client = new Client({ name: "smoke", version: "0.0.1" });
|
||||||
|
await client.connect(clientT);
|
||||||
|
|
||||||
|
// 1) tool discovery
|
||||||
|
const tools = await client.listTools();
|
||||||
|
const names = tools.tools.map((t) => t.name).sort();
|
||||||
|
console.log("tools:", names.join(", "));
|
||||||
|
const expected = [
|
||||||
|
"get_document",
|
||||||
|
"get_related_documents",
|
||||||
|
"list_documents",
|
||||||
|
"search_documents",
|
||||||
|
].sort();
|
||||||
|
if (JSON.stringify(names) !== JSON.stringify(expected)) {
|
||||||
|
throw new Error(`tool set mismatch: ${names.join(",")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) search_documents
|
||||||
|
const search = await client.callTool({
|
||||||
|
name: "search_documents",
|
||||||
|
arguments: { query: "websocket", limit: 10 },
|
||||||
|
});
|
||||||
|
const hits = JSON.parse((search.content as any)[0].text);
|
||||||
|
if (!Array.isArray(hits) || hits.length < 1) {
|
||||||
|
throw new Error("search_documents returned no hits");
|
||||||
|
}
|
||||||
|
console.log(`search_documents("websocket") => ${hits.length} hits`);
|
||||||
|
console.log(" top:", hits[0].doc.slug, hits[0].doc.title);
|
||||||
|
|
||||||
|
// 3) get_document
|
||||||
|
const get = await client.callTool({
|
||||||
|
name: "get_document",
|
||||||
|
arguments: { slug: "docs/websocket/contract" },
|
||||||
|
});
|
||||||
|
const body = (get.content as any)[0].text;
|
||||||
|
if (!body.includes("WebSocket Contract")) {
|
||||||
|
throw new Error("get_document returned unexpected body");
|
||||||
|
}
|
||||||
|
console.log("get_document('docs/websocket/contract') => body ok (len", body.length, ")");
|
||||||
|
|
||||||
|
// 4) get_document not found
|
||||||
|
const missing = await client.callTool({
|
||||||
|
name: "get_document",
|
||||||
|
arguments: { slug: "nope/missing" },
|
||||||
|
});
|
||||||
|
if (!(missing as any).isError) {
|
||||||
|
throw new Error("get_document should report isError for missing doc");
|
||||||
|
}
|
||||||
|
console.log("get_document('nope/missing') => isError ok");
|
||||||
|
|
||||||
|
// 5) list_documents
|
||||||
|
const list = await client.callTool({
|
||||||
|
name: "list_documents",
|
||||||
|
arguments: { section: "docs" },
|
||||||
|
});
|
||||||
|
const docs = JSON.parse((list.content as any)[0].text);
|
||||||
|
if (docs.length !== 1) throw new Error("list_documents docs != 1");
|
||||||
|
console.log("list_documents(section=docs) =>", docs.length, "doc");
|
||||||
|
|
||||||
|
await client.close();
|
||||||
|
await server.close();
|
||||||
|
console.log("\nSMOKE OK");
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.then(() => process.exit(0))
|
||||||
|
.catch((e) => {
|
||||||
|
console.error("SMOKE FAIL:", e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"paths": {
|
||||||
|
"@mcpedia/*": ["../../packages/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { getDocument, listDocuments, getRelated } from "@mcpedia/core";
|
||||||
|
import Markdown from "@/components/Markdown";
|
||||||
|
|
||||||
|
export default async function DocPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ section: string; slug: string[] }>;
|
||||||
|
}) {
|
||||||
|
const { section, slug } = await params;
|
||||||
|
const fullSlug = `${section}/${slug.join("/")}`;
|
||||||
|
const doc = await getDocument(fullSlug);
|
||||||
|
if (!doc) notFound();
|
||||||
|
|
||||||
|
const related = await getRelated(fullSlug, 5);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Link href="/" className="text-sm text-zinc-500 hover:underline">
|
||||||
|
← Back
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight mt-2">
|
||||||
|
{doc.title}
|
||||||
|
</h1>
|
||||||
|
<div className="text-xs text-zinc-500 mt-1">
|
||||||
|
{doc.tags.map((t) => `#${t}`).join(" ")} · {doc.author || "unknown"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Markdown source={doc.body} />
|
||||||
|
|
||||||
|
{related.length > 0 && (
|
||||||
|
<aside className="mt-8 border-t border-zinc-200 dark:border-zinc-800 pt-4">
|
||||||
|
<h2 className="text-sm font-medium mb-2">Related</h2>
|
||||||
|
<ul className="text-sm space-y-1">
|
||||||
|
{related.map((r) => (
|
||||||
|
<li key={r.slug}>
|
||||||
|
<Link
|
||||||
|
href={`/${r.section}/${r.slug}`}
|
||||||
|
className="hover:underline"
|
||||||
|
>
|
||||||
|
{r.title}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateStaticParams() {
|
||||||
|
const docs = await listDocuments();
|
||||||
|
return docs.map((d) => ({
|
||||||
|
section: d.section,
|
||||||
|
slug: d.slug.split("/").slice(1),
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import ReactMarkdown from "react-markdown";
|
||||||
|
|
||||||
|
export default function Markdown({ source }: { source: string }) {
|
||||||
|
return (
|
||||||
|
<div className="prose prose-zinc dark:prose-invert max-w-none [&_pre]:bg-zinc-100 dark:[&_pre]:bg-zinc-900 [&_pre]:p-3 [&_pre]:rounded [&_code]:font-mono [&_a]:underline">
|
||||||
|
<ReactMarkdown>{source}</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: #ffffff;
|
||||||
|
--foreground: #171717;
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--font-sans: var(--font-geist-sans);
|
||||||
|
--font-mono: var(--font-geist-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--background: #0a0a0a;
|
||||||
|
--foreground: #ededed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "MCPedia",
|
||||||
|
description: "Knowledge base for humans and AI agents via MCP.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang="en" className="h-full antialiased">
|
||||||
|
<body className="min-h-full flex flex-col bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100">
|
||||||
|
<header className="border-b border-zinc-200 dark:border-zinc-800">
|
||||||
|
<div className="mx-auto max-w-5xl px-4 py-3 flex items-center justify-between">
|
||||||
|
<Link href="/" className="font-semibold tracking-tight">
|
||||||
|
MCPedia
|
||||||
|
</Link>
|
||||||
|
<nav className="flex gap-4 text-sm">
|
||||||
|
<Link href="/" className="hover:underline">Home</Link>
|
||||||
|
<Link href="/search" className="hover:underline">Search</Link>
|
||||||
|
<Link
|
||||||
|
href="/docs"
|
||||||
|
className="hover:underline"
|
||||||
|
>
|
||||||
|
Docs
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main className="mx-auto max-w-5xl w-full flex-1 px-4 py-8">{children}</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { listDocuments } from "@mcpedia/core";
|
||||||
|
import type { DocumentMeta } from "@mcpedia/core";
|
||||||
|
|
||||||
|
const SECTIONS = ["docs", "writeups", "research", "notes"] as const;
|
||||||
|
|
||||||
|
export default async function Home() {
|
||||||
|
const all = await listDocuments();
|
||||||
|
const bySection = SECTIONS.map((section) => ({
|
||||||
|
section,
|
||||||
|
docs: all.filter((d) => d.section === section),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<section>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">MCPedia</h1>
|
||||||
|
<p className="mt-2 text-zinc-600 dark:text-zinc-400">
|
||||||
|
A content-first knowledge base. Humans read the Web UI; AI agents use
|
||||||
|
the MCP server. Both share one Core.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{bySection.map(({ section, docs }) => (
|
||||||
|
<section key={section}>
|
||||||
|
<h2 className="text-lg font-medium capitalize mb-2">{section}</h2>
|
||||||
|
{docs.length === 0 ? (
|
||||||
|
<p className="text-sm text-zinc-500">No documents yet.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-zinc-200 dark:divide-zinc-800">
|
||||||
|
{docs.map((d: DocumentMeta) => (
|
||||||
|
<li key={d.slug} className="py-2">
|
||||||
|
<Link
|
||||||
|
href={`/${d.section}/${d.slug}`}
|
||||||
|
className="hover:underline font-medium"
|
||||||
|
>
|
||||||
|
{d.title}
|
||||||
|
</Link>
|
||||||
|
<div className="text-xs text-zinc-500 mt-0.5">
|
||||||
|
{d.tags.map((t) => `#${t}`).join(" ")}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { keywordSearch } from "@mcpedia/core";
|
||||||
|
|
||||||
|
export default async function SearchPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ q?: string }>;
|
||||||
|
}) {
|
||||||
|
const { q } = await searchParams;
|
||||||
|
const query = q?.trim() ?? "";
|
||||||
|
const hits = query ? await keywordSearch(query, 30) : [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Search</h1>
|
||||||
|
<form method="get" className="flex gap-2">
|
||||||
|
<input
|
||||||
|
name="q"
|
||||||
|
defaultValue={query}
|
||||||
|
placeholder="e.g. websocket contract typescript"
|
||||||
|
className="flex-1 rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900 px-4 py-2 text-sm font-medium"
|
||||||
|
>
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{query && hits.length === 0 && (
|
||||||
|
<p className="text-sm text-zinc-500">No results for “{query}”.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{hits.map((h) => (
|
||||||
|
<li
|
||||||
|
key={h.doc.slug}
|
||||||
|
className="rounded border border-zinc-200 dark:border-zinc-800 p-3"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={`/${h.doc.section}/${h.doc.slug}`}
|
||||||
|
className="font-medium hover:underline"
|
||||||
|
>
|
||||||
|
{h.doc.title}
|
||||||
|
</Link>
|
||||||
|
<p
|
||||||
|
className="text-sm text-zinc-600 dark:text-zinc-400 mt-1"
|
||||||
|
dangerouslySetInnerHTML={{ __html: h.snippet }}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import nextTs from "eslint-config-next/typescript";
|
||||||
|
|
||||||
|
const eslintConfig = defineConfig([
|
||||||
|
...nextVitals,
|
||||||
|
...nextTs,
|
||||||
|
// Override default ignores of eslint-config-next.
|
||||||
|
globalIgnores([
|
||||||
|
// Default ignores of eslint-config-next:
|
||||||
|
".next/**",
|
||||||
|
"out/**",
|
||||||
|
"build/**",
|
||||||
|
"next-env.d.ts",
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,29 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
const geistSans = Geist({
|
||||||
|
variable: "--font-geist-sans",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const geistMono = Geist_Mono({
|
||||||
|
variable: "--font-geist-mono",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Create Next App",
|
||||||
|
description: "Generated by create next app",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: LayoutProps<"/">) {
|
||||||
|
return (
|
||||||
|
<html
|
||||||
|
lang="en"
|
||||||
|
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||||
|
>
|
||||||
|
<body className="min-h-full flex flex-col">{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
// @mcpedia/db pulls in the `postgres` driver + node:fs; keep it external to
|
||||||
|
// the server bundle so it runs in the Node runtime as-is.
|
||||||
|
serverExternalPackages: ["@mcpedia/db", "@mcpedia/config", "@mcpedia/parser"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "@mcpedia/web",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@mcpedia/config": "workspace:*",
|
||||||
|
"@mcpedia/core": "workspace:*",
|
||||||
|
"next": "16.3.1",
|
||||||
|
"react": "19.2.8",
|
||||||
|
"react-dom": "19.2.8",
|
||||||
|
"react-markdown": "^9.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "16.3.1",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"typescript": "^5.6.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||||
|
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||||
|
<Image
|
||||||
|
className="dark:invert h-5 w-[100px]"
|
||||||
|
src="/next.svg"
|
||||||
|
alt="Next.js logo"
|
||||||
|
width={100}
|
||||||
|
height={20}
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||||
|
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||||
|
To get started, edit the{" "}
|
||||||
|
<code className="rounded bg-black/[.06] px-1.5 py-0.5 font-mono text-[0.9em] dark:bg-white/[.08]">
|
||||||
|
page.tsx
|
||||||
|
</code>{" "}
|
||||||
|
file.
|
||||||
|
</h1>
|
||||||
|
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||||
|
Looking for a starting point or more instructions? Head over to{" "}
|
||||||
|
<a
|
||||||
|
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||||
|
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||||
|
>
|
||||||
|
Templates
|
||||||
|
</a>{" "}
|
||||||
|
or the{" "}
|
||||||
|
<a
|
||||||
|
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||||
|
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||||
|
>
|
||||||
|
Learning
|
||||||
|
</a>{" "}
|
||||||
|
center.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||||
|
<a
|
||||||
|
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||||
|
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
className="dark:invert h-[14px] w-4"
|
||||||
|
src="/vercel.svg"
|
||||||
|
alt="Vercel logomark"
|
||||||
|
width={16}
|
||||||
|
height={14}
|
||||||
|
/>
|
||||||
|
Deploy Now
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||||
|
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
Documentation
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 391 B |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 1.0 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 128 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||||
|
After Width: | Height: | Size: 385 B |
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"plugins": [{ "name": "next" }],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./app/*"],
|
||||||
|
"@mcpedia/*": ["../../packages/*"]
|
||||||
|
},
|
||||||
|
"allowJs": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
---
|
||||||
|
id: websocket-contract
|
||||||
|
title: WebSocket Contract
|
||||||
|
type: documentation
|
||||||
|
tags:
|
||||||
|
- typescript
|
||||||
|
- websocket
|
||||||
|
- rpc
|
||||||
|
status: published
|
||||||
|
author: asep
|
||||||
|
created_at: 2026-08-19
|
||||||
|
updated_at: 2026-08-19
|
||||||
|
---
|
||||||
|
|
||||||
|
# WebSocket Contract
|
||||||
|
|
||||||
|
The WebSocket contract defines how clients establish a bidirectional connection
|
||||||
|
and exchange RPC-style messages with the server. It is the foundation for the
|
||||||
|
type-safe API described in the tRPC integration notes.
|
||||||
|
|
||||||
|
## Handshake
|
||||||
|
|
||||||
|
A client opens a single WebSocket connection and sends an `init` frame携带 an
|
||||||
|
auth token. The server answers with `ready` or closes the socket with code 4401
|
||||||
|
if the token is invalid.
|
||||||
|
|
||||||
|
## Message envelope
|
||||||
|
|
||||||
|
Every frame uses a JSON envelope:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "id": "req-1", "method": "echo", "params": { "text": "hi" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
The server replies with a matching `id` and either a `result` or an `error`
|
||||||
|
field. This request/response correlation is what makes the protocol feel like
|
||||||
|
RPC even though it rides on a single socket.
|
||||||
|
|
||||||
|
## Timeouts
|
||||||
|
|
||||||
|
If the server does not answer within the negotiated timeout, the client should
|
||||||
|
re-send with the same `id` rather than opening a new connection. See the
|
||||||
|
debugging writeup for a common timeout pitfall when proxies buffer frames.
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
id: typescript-patterns
|
||||||
|
title: TypeScript Patterns
|
||||||
|
type: note
|
||||||
|
tags:
|
||||||
|
- typescript
|
||||||
|
- patterns
|
||||||
|
status: published
|
||||||
|
author: asep
|
||||||
|
created_at: 2026-08-19
|
||||||
|
updated_at: 2026-08-19
|
||||||
|
---
|
||||||
|
|
||||||
|
# TypeScript Patterns
|
||||||
|
|
||||||
|
A notebook of small, reusable TypeScript patterns that keep code honest.
|
||||||
|
|
||||||
|
## Branded types for ids
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type DocId = string & { readonly __brand: "DocId" };
|
||||||
|
const asDocId = (s: string) => s as DocId;
|
||||||
|
```
|
||||||
|
|
||||||
|
Branding prevents passing an arbitrary string where a document id is expected.
|
||||||
|
|
||||||
|
## Discriminated unions over inheritance
|
||||||
|
|
||||||
|
Prefer a closed set of variants with a `kind` field. Exhaustiveness checks with
|
||||||
|
`switch` catch missing cases at compile time instead of at runtime.
|
||||||
|
|
||||||
|
## Prefer composition
|
||||||
|
|
||||||
|
When two modules share behavior, extract a function. Avoid inheritance trees
|
||||||
|
that couple unrelated code. This matches the MCPedia Core principle: one layer
|
||||||
|
owns the logic, interfaces only call into it.
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
id: mcp-architecture
|
||||||
|
title: MCP Architecture Notes
|
||||||
|
type: research
|
||||||
|
tags:
|
||||||
|
- mcp
|
||||||
|
- architecture
|
||||||
|
- ai
|
||||||
|
status: published
|
||||||
|
author: asep
|
||||||
|
created_at: 2026-08-19
|
||||||
|
updated_at: 2026-08-19
|
||||||
|
---
|
||||||
|
|
||||||
|
# MCP Architecture Notes
|
||||||
|
|
||||||
|
The Model Context Protocol (MCP) lets an AI client treat a knowledge base as a
|
||||||
|
first-class context source instead of yet another REST API. The server exposes
|
||||||
|
tools and resources; the client decides what to read.
|
||||||
|
|
||||||
|
## Tools vs resources
|
||||||
|
|
||||||
|
- Tools are actions the model calls (`search_documents`, `get_document`).
|
||||||
|
- Resources are addressable content the model can pull (`mcpedia://docs/...`).
|
||||||
|
|
||||||
|
## Why it matters here
|
||||||
|
|
||||||
|
MCPedia exposes both. The Web UI is for humans; the MCP server is for agents.
|
||||||
|
Both go through the same Core layer, so there is exactly one copy of the
|
||||||
|
business logic and one search implementation.
|
||||||
|
|
||||||
|
## Reference
|
||||||
|
|
||||||
|
Related reading: the WebSocket contract and the tRPC type-safe API notes.
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
id: websocket-timeout
|
||||||
|
title: Debugging a WebSocket Timeout Behind a Proxy
|
||||||
|
type: writeup
|
||||||
|
tags:
|
||||||
|
- websocket
|
||||||
|
- debugging
|
||||||
|
- proxy
|
||||||
|
status: published
|
||||||
|
author: asep
|
||||||
|
created_at: 2026-08-19
|
||||||
|
updated_at: 2026-08-19
|
||||||
|
---
|
||||||
|
|
||||||
|
# Debugging a WebSocket Timeout Behind a Proxy
|
||||||
|
|
||||||
|
A recurring incident: the browser WebSocket connects, sends one frame, then
|
||||||
|
silently times out. The server logs show no error. Root cause: the reverse
|
||||||
|
proxy was buffering frames and only flushing on connection close.
|
||||||
|
|
||||||
|
## Symptoms
|
||||||
|
|
||||||
|
- Connection opens (101 Switching Protocols).
|
||||||
|
- First message never reaches the upstream.
|
||||||
|
- Client hits its own 30s timeout and reconnects, creating a storm.
|
||||||
|
|
||||||
|
## Fix
|
||||||
|
|
||||||
|
Disable proxy buffering for the WebSocket upgrade route and ensure the proxy
|
||||||
|
does not apply an idle timeout shorter than the application's heartbeat
|
||||||
|
interval. After that, frames flowed immediately and the timeout disappeared.
|
||||||
|
|
||||||
|
## Lesson
|
||||||
|
|
||||||
|
Always confirm at the proxy layer whether frames are buffered before assuming
|
||||||
|
the application server is at fault.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "mcpedia",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"packageManager": "bun@1.3.14",
|
||||||
|
"workspaces": [
|
||||||
|
"apps/*",
|
||||||
|
"packages/*",
|
||||||
|
"scripts"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"dev": "turbo run dev",
|
||||||
|
"build": "turbo run build",
|
||||||
|
"lint": "turbo run lint",
|
||||||
|
"typecheck": "turbo run typecheck",
|
||||||
|
"index": "bun run scripts/indexer.ts",
|
||||||
|
"mcp": "bun --cwd apps/mcp run start"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"turbo": "^2.5.0",
|
||||||
|
"typescript": "^5.6.0",
|
||||||
|
"prettier": "^3.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "@mcpedia/config",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@mcpedia/types": "workspace:*"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
// packages/config -> repo root (../../..)
|
||||||
|
export const REPO_ROOT = resolve(here, "../../..");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load .env (repo root) as the authoritative dev config and apply it to
|
||||||
|
* process.env. We intentionally OVERRIDE any inherited DATABASE_URL so a stray
|
||||||
|
* shell env var can never point the app at the wrong database. .env is
|
||||||
|
* gitignored; for deploy, set the real vars in the environment and omit .env.
|
||||||
|
*/
|
||||||
|
function loadDotEnv() {
|
||||||
|
const dotEnv = resolve(REPO_ROOT, ".env");
|
||||||
|
if (!existsSync(dotEnv)) return;
|
||||||
|
for (const line of readFileSync(dotEnv, "utf8").split("\n")) {
|
||||||
|
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
|
||||||
|
if (!m) continue;
|
||||||
|
const key = m[1];
|
||||||
|
let val = m[2];
|
||||||
|
if (
|
||||||
|
(val.startsWith('"') && val.endsWith('"')) ||
|
||||||
|
(val.startsWith("'") && val.endsWith("'"))
|
||||||
|
) {
|
||||||
|
val = val.slice(1, -1);
|
||||||
|
}
|
||||||
|
process.env[key] = val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadDotEnv();
|
||||||
|
|
||||||
|
export const CONTENT_ROOT =
|
||||||
|
process.env.CONTENT_ROOT ?? resolve(REPO_ROOT, "content");
|
||||||
|
|
||||||
|
export const DATABASE_URL = process.env.DATABASE_URL ?? "";
|
||||||
|
|
||||||
|
if (!DATABASE_URL) {
|
||||||
|
// Fail fast with an explicit message instead of a cryptic driver error.
|
||||||
|
throw new Error(
|
||||||
|
"DATABASE_URL is not set. Copy .env.example to .env and set it (dev uses imrnes Postgres :6432).",
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "@mcpedia/core",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts",
|
||||||
|
"./row-map": "./src/row-map.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@mcpedia/config": "workspace:*",
|
||||||
|
"@mcpedia/db": "workspace:*",
|
||||||
|
"@mcpedia/parser": "workspace:*",
|
||||||
|
"@mcpedia/search": "workspace:*",
|
||||||
|
"@mcpedia/types": "workspace:*",
|
||||||
|
"drizzle-orm": "^0.38.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||||
|
import { join, relative } from "node:path";
|
||||||
|
import { CONTENT_ROOT } from "@mcpedia/config";
|
||||||
|
|
||||||
|
/** List all markdown/mdx content files relative to CONTENT_ROOT. */
|
||||||
|
export function listContentFiles(): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
const walk = (dir: string) => {
|
||||||
|
for (const entry of readdirSync(dir)) {
|
||||||
|
const full = join(dir, entry);
|
||||||
|
const st = statSync(full);
|
||||||
|
if (st.isDirectory()) walk(full);
|
||||||
|
else if (/\.mdx?$/.test(entry)) out.push(relative(CONTENT_ROOT, full));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(CONTENT_ROOT);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read a content file's raw text by relative path. */
|
||||||
|
export function readContentFile(relPath: string): string {
|
||||||
|
return readFileSync(join(CONTENT_ROOT, relPath), "utf8");
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { and, eq, sql } from "drizzle-orm";
|
||||||
|
import { db } from "@mcpedia/db";
|
||||||
|
import { documents } from "@mcpedia/db/schema";
|
||||||
|
import { CONTENT_ROOT } from "@mcpedia/config";
|
||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import type {
|
||||||
|
Document,
|
||||||
|
DocumentMeta,
|
||||||
|
} from "@mcpedia/types";
|
||||||
|
import { readContentFile } from "./content.service";
|
||||||
|
import { toMeta } from "./row-map";
|
||||||
|
|
||||||
|
export async function listDocuments(opts: {
|
||||||
|
section?: string;
|
||||||
|
status?: string;
|
||||||
|
} = {}): Promise<DocumentMeta[]> {
|
||||||
|
const status = opts.status ?? "published";
|
||||||
|
const where = [eq(documents.status, status)];
|
||||||
|
if (opts.section) where.push(eq(documents.section, opts.section));
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(documents)
|
||||||
|
.where(and(...where))
|
||||||
|
.orderBy(documents.updatedAt);
|
||||||
|
return rows.map(toMeta);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDocument(slug: string): Promise<Document | null> {
|
||||||
|
const [row] = await db.select().from(documents).where(eq(documents.slug, slug));
|
||||||
|
if (!row) return null;
|
||||||
|
// Prefer the on-disk file (source of truth); fall back to stored body.
|
||||||
|
const abs = join(CONTENT_ROOT, row.path);
|
||||||
|
const body = existsSync(abs) ? readFileSync(abs, "utf8") : row.body;
|
||||||
|
return { ...toMeta(row), body };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRelated(slug: string, limit = 5): Promise<DocumentMeta[]> {
|
||||||
|
const [row] = await db
|
||||||
|
.select({ tags: documents.tags })
|
||||||
|
.from(documents)
|
||||||
|
.where(eq(documents.slug, slug));
|
||||||
|
if (!row || row.tags.length === 0) return [];
|
||||||
|
// Build a text[] array literal for the && (overlap) operator, binding each
|
||||||
|
// tag as a parameter to avoid SQL injection from frontmatter content.
|
||||||
|
const arrLit = sql`ARRAY[${sql.join(
|
||||||
|
row.tags.map((t) => sql.param(t)),
|
||||||
|
sql`, `,
|
||||||
|
)}]::text[]`;
|
||||||
|
const related = await db
|
||||||
|
.select()
|
||||||
|
.from(documents)
|
||||||
|
.where(and(eq(documents.status, "published"), sql`${documents.tags} && ${arrLit}`))
|
||||||
|
.limit(limit + 1);
|
||||||
|
return related.filter((d) => d.slug !== slug).map(toMeta).slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { readContentFile };
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export * from "./content.service";
|
||||||
|
export * from "./document.service";
|
||||||
|
export * from "./search.service";
|
||||||
|
export { toMeta } from "./row-map";
|
||||||
|
|
||||||
|
export type {
|
||||||
|
DocSection,
|
||||||
|
DocStatus,
|
||||||
|
DocType,
|
||||||
|
Document,
|
||||||
|
DocumentMeta,
|
||||||
|
SearchHit,
|
||||||
|
} from "@mcpedia/types";
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// Single source of truth for row→domain mapping lives in @mcpedia/search.
|
||||||
|
export { toMeta } from "@mcpedia/search";
|
||||||
|
export type { DocumentRow } from "@mcpedia/db/schema";
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { keywordSearch, toTsQuery } from "@mcpedia/search";
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig } from "drizzle-kit";
|
||||||
|
import { DATABASE_URL } from "@mcpedia/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
dialect: "postgresql",
|
||||||
|
schema: "./src/schema.ts",
|
||||||
|
out: "./drizzle",
|
||||||
|
dbCredentials: { url: DATABASE_URL },
|
||||||
|
verbose: true,
|
||||||
|
strict: true,
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
CREATE TABLE "documents" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"slug" text NOT NULL,
|
||||||
|
"title" text NOT NULL,
|
||||||
|
"type" text NOT NULL,
|
||||||
|
"section" text NOT NULL,
|
||||||
|
"status" text DEFAULT 'published' NOT NULL,
|
||||||
|
"author" text DEFAULT '' NOT NULL,
|
||||||
|
"tags" text[] DEFAULT '{}' NOT NULL,
|
||||||
|
"path" text NOT NULL,
|
||||||
|
"body" text DEFAULT '' NOT NULL,
|
||||||
|
"search_vector" "tsvector" GENERATED ALWAYS AS (setweight(to_tsvector('simple', coalesce("documents"."title", '')), 'A') || setweight(to_tsvector('simple', coalesce("documents"."body", '')), 'B')) STORED NOT NULL,
|
||||||
|
"created_at" timestamp with time zone NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone NOT NULL,
|
||||||
|
CONSTRAINT "documents_slug_unique" UNIQUE("slug")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX "documents_search_idx" ON "documents" USING gin ("search_vector");--> statement-breakpoint
|
||||||
|
CREATE INDEX "documents_section_idx" ON "documents" USING btree ("section");
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
{
|
||||||
|
"id": "249f80c0-d953-42f2-a98b-6701e2115856",
|
||||||
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.documents": {
|
||||||
|
"name": "documents",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"slug": {
|
||||||
|
"name": "slug",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "title",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"section": {
|
||||||
|
"name": "section",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'published'"
|
||||||
|
},
|
||||||
|
"author": {
|
||||||
|
"name": "author",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "''"
|
||||||
|
},
|
||||||
|
"tags": {
|
||||||
|
"name": "tags",
|
||||||
|
"type": "text[]",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'{}'"
|
||||||
|
},
|
||||||
|
"path": {
|
||||||
|
"name": "path",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"body": {
|
||||||
|
"name": "body",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "''"
|
||||||
|
},
|
||||||
|
"search_vector": {
|
||||||
|
"name": "search_vector",
|
||||||
|
"type": "tsvector",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"generated": {
|
||||||
|
"as": "setweight(to_tsvector('simple', coalesce(\"documents\".\"title\", '')), 'A') || setweight(to_tsvector('simple', coalesce(\"documents\".\"body\", '')), 'B')",
|
||||||
|
"type": "stored"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"documents_search_idx": {
|
||||||
|
"name": "documents_search_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "search_vector",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "gin",
|
||||||
|
"with": {}
|
||||||
|
},
|
||||||
|
"documents_section_idx": {
|
||||||
|
"name": "documents_section_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "section",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"documents_slug_unique": {
|
||||||
|
"name": "documents_slug_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"slug"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"roles": {},
|
||||||
|
"policies": {},
|
||||||
|
"views": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787133375079,
|
||||||
|
"tag": "0000_grey_toro",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "@mcpedia/db",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/client.ts",
|
||||||
|
"./schema": "./src/schema.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@mcpedia/config": "workspace:*",
|
||||||
|
"drizzle-orm": "^0.38.0",
|
||||||
|
"postgres": "^3.4.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"drizzle-kit": "^0.30.0",
|
||||||
|
"@types/node": "^20"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { drizzle } from "drizzle-orm/postgres-js";
|
||||||
|
import postgres from "postgres";
|
||||||
|
import { DATABASE_URL } from "@mcpedia/config";
|
||||||
|
import * as schema from "./schema";
|
||||||
|
|
||||||
|
// PgBouncer (imrnes :6432) uses transaction pooling, which rejects protocol
|
||||||
|
//-level prepared statements. prepare:false makes postgres-js use simple queries.
|
||||||
|
const client = postgres(DATABASE_URL, {
|
||||||
|
prepare: false,
|
||||||
|
max: 5,
|
||||||
|
onnotice: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const db = drizzle(client, { schema });
|
||||||
|
export { schema, client };
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { sql, SQL } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
customType,
|
||||||
|
index,
|
||||||
|
integer,
|
||||||
|
pgTable,
|
||||||
|
text,
|
||||||
|
timestamp,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
|
// tsvector isn't a first-class drizzle type; wrap the raw Postgres type.
|
||||||
|
const tsvector = customType<{ data: string }>({
|
||||||
|
dataType() {
|
||||||
|
return "tsvector";
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const documents = pgTable(
|
||||||
|
"documents",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(), // slug
|
||||||
|
slug: text("slug").notNull().unique(),
|
||||||
|
title: text("title").notNull(),
|
||||||
|
type: text("type").notNull(),
|
||||||
|
section: text("section").notNull(),
|
||||||
|
status: text("status").notNull().default("published"),
|
||||||
|
author: text("author").notNull().default(""),
|
||||||
|
tags: text("tags").array().notNull().default(sql`'{}'`),
|
||||||
|
path: text("path").notNull(),
|
||||||
|
body: text("body").notNull().default(""),
|
||||||
|
// Weighted search vector: title (A) + body (B), using the 'simple' config so
|
||||||
|
// mixed ID/EN queries match literally without stemming. Lazy closure over
|
||||||
|
// `documents` (must NOT reference the table eagerly — it is in TDZ here).
|
||||||
|
searchVector: tsvector("search_vector")
|
||||||
|
.notNull()
|
||||||
|
.generatedAlwaysAs(
|
||||||
|
(): SQL =>
|
||||||
|
sql`setweight(to_tsvector('simple', coalesce(${documents.title}, '')), 'A') || setweight(to_tsvector('simple', coalesce(${documents.body}, '')), 'B')`,
|
||||||
|
),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
searchIdx: index("documents_search_idx").using("gin", t.searchVector),
|
||||||
|
sectionIdx: index("documents_section_idx").on(t.section),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Phase 2 (semantic search) — defined here for reference, NOT created yet:
|
||||||
|
// export const documentChunks = pgTable("document_chunks", {
|
||||||
|
// id: text("id").primaryKey(),
|
||||||
|
// documentId: text("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }),
|
||||||
|
// content: text("content").notNull(),
|
||||||
|
// position: integer("position").notNull(),
|
||||||
|
// embedding: customType<{ data: number[] }>({ dataType: () => "vector(1536)" })("embedding"),
|
||||||
|
// });
|
||||||
|
|
||||||
|
export type DocumentRow = typeof documents.$inferSelect;
|
||||||
|
export type NewDocumentRow = typeof documents.$inferInsert;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user