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:
asepharyana
2026-08-19 17:40:14 +07:00
commit ec3a2867d4
60 changed files with 3723 additions and 0 deletions
+11
View File
@@ -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,
});
+19
View File
@@ -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");
+157
View File
@@ -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": {}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1787133375079,
"tag": "0000_grey_toro",
"breakpoints": true
}
]
}
+19
View File
@@ -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"
}
}
+15
View File
@@ -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 };
+59
View File
@@ -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;