feat: add PostgreSQL persistence for permanent shareable links
- Add PostgreSQL service to docker-compose with health checks
- Add migrate service that runs prisma migrate deploy on startup
- Integrate Prisma client in worker: checks for existing generations
(same repo+commit) before regenerating, writes to Postgres on completion
- Update /api/docs/[id] with Redis → PostgreSQL fallback + cache repopulation
- Update Dockerfile: prisma generate in build, copy Prisma engine to worker/web
- Add @codeboard/database dependency to web and worker packages
- Add initial SQL migration for Generation and User tables
- Change removeOnComplete to { age: 3600 } for job retention
This commit is contained in:
@@ -16,6 +16,7 @@ RUN npm install --production=false
|
|||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
|
RUN npx prisma generate --schema=packages/database/prisma/schema.prisma
|
||||||
RUN npx turbo build
|
RUN npx turbo build
|
||||||
|
|
||||||
FROM base AS web
|
FROM base AS web
|
||||||
@@ -24,6 +25,7 @@ RUN addgroup --system --gid 1001 nodejs && \
|
|||||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
|
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
|
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public
|
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/packages/database/prisma ./packages/database/prisma
|
||||||
USER nextjs
|
USER nextjs
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
ENV PORT=3000 HOSTNAME="0.0.0.0"
|
ENV PORT=3000 HOSTNAME="0.0.0.0"
|
||||||
@@ -38,6 +40,10 @@ COPY --from=builder /app/packages/shared/dist ./packages/shared/dist
|
|||||||
COPY --from=builder /app/packages/parser/dist ./packages/parser/dist
|
COPY --from=builder /app/packages/parser/dist ./packages/parser/dist
|
||||||
COPY --from=builder /app/packages/llm/dist ./packages/llm/dist
|
COPY --from=builder /app/packages/llm/dist ./packages/llm/dist
|
||||||
COPY --from=builder /app/packages/diagrams/dist ./packages/diagrams/dist
|
COPY --from=builder /app/packages/diagrams/dist ./packages/diagrams/dist
|
||||||
|
COPY --from=builder /app/packages/database/package.json ./packages/database/
|
||||||
|
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
||||||
|
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
|
||||||
|
COPY --from=builder /app/packages/database/prisma ./packages/database/prisma
|
||||||
COPY --from=builder /app/package.json ./
|
COPY --from=builder /app/package.json ./
|
||||||
COPY --from=builder /app/apps/worker/package.json ./apps/worker/
|
COPY --from=builder /app/apps/worker/package.json ./apps/worker/
|
||||||
COPY --from=builder /app/packages/shared/package.json ./packages/shared/
|
COPY --from=builder /app/packages/shared/package.json ./packages/shared/
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const config = {
|
const config = {
|
||||||
transpilePackages: ["@codeboard/shared"],
|
transpilePackages: ["@codeboard/shared", "@codeboard/database"],
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codeboard/shared": "*",
|
"@codeboard/shared": "*",
|
||||||
|
"@codeboard/database": "*",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"bullmq": "^5.34.0",
|
"bullmq": "^5.34.0",
|
||||||
"ioredis": "^5.4.0",
|
"ioredis": "^5.4.0",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { getRedis } from "@/lib/redis";
|
import { getRedis } from "@/lib/redis";
|
||||||
|
import { prisma } from "@codeboard/database";
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_request: Request,
|
_request: Request,
|
||||||
@@ -8,13 +9,34 @@ export async function GET(
|
|||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const redis = getRedis();
|
const redis = getRedis();
|
||||||
|
|
||||||
const result = await redis.get(`codeboard:result:${id}`);
|
let result = await redis.get(`codeboard:result:${id}`);
|
||||||
if (!result) {
|
|
||||||
|
if (result) {
|
||||||
|
return NextResponse.json(JSON.parse(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
const generation = await prisma.generation.findFirst({
|
||||||
|
where: { id }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!generation || !generation.result) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Documentation not found" },
|
{ error: "Documentation not found" },
|
||||||
{ status: 404 }
|
{ status: 404 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json(JSON.parse(result));
|
const docs = generation.result as any;
|
||||||
|
docs.id = id;
|
||||||
|
docs.repoUrl = generation.repoUrl;
|
||||||
|
docs.repoName = generation.repoName;
|
||||||
|
|
||||||
|
await redis.set(
|
||||||
|
`codeboard:result:${id}`,
|
||||||
|
JSON.stringify(docs),
|
||||||
|
"EX",
|
||||||
|
86400
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json(docs);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export async function POST(request: Request) {
|
|||||||
const queue = getQueue();
|
const queue = getQueue();
|
||||||
await queue.add("generate", { repoUrl, generationId }, {
|
await queue.add("generate", { repoUrl, generationId }, {
|
||||||
jobId: generationId,
|
jobId: generationId,
|
||||||
removeOnComplete: true,
|
removeOnComplete: { age: 3600 },
|
||||||
removeOnFail: false,
|
removeOnFail: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"@codeboard/parser": "*",
|
"@codeboard/parser": "*",
|
||||||
"@codeboard/llm": "*",
|
"@codeboard/llm": "*",
|
||||||
"@codeboard/diagrams": "*",
|
"@codeboard/diagrams": "*",
|
||||||
|
"@codeboard/database": "*",
|
||||||
"bullmq": "^5.34.0",
|
"bullmq": "^5.34.0",
|
||||||
"ioredis": "^5.4.0",
|
"ioredis": "^5.4.0",
|
||||||
"simple-git": "^3.27.0"
|
"simple-git": "^3.27.0"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Job } from "bullmq";
|
import type { Job } from "bullmq";
|
||||||
import IORedis from "ioredis";
|
import IORedis from "ioredis";
|
||||||
|
import { prisma } from "@codeboard/database";
|
||||||
import { cloneRepository } from "./jobs/clone.js";
|
import { cloneRepository } from "./jobs/clone.js";
|
||||||
import { parseRepository } from "./jobs/parse.js";
|
import { parseRepository } from "./jobs/parse.js";
|
||||||
import { generateDocs } from "./jobs/generate.js";
|
import { generateDocs } from "./jobs/generate.js";
|
||||||
@@ -38,6 +39,33 @@ export async function processGenerationJob(
|
|||||||
try {
|
try {
|
||||||
await updateProgress(generationId, "CLONING", 10, "Cloning repository...");
|
await updateProgress(generationId, "CLONING", 10, "Cloning repository...");
|
||||||
const cloneResult = await cloneRepository(repoUrl);
|
const cloneResult = await cloneRepository(repoUrl);
|
||||||
|
const commitHash = cloneResult.metadata.lastCommit;
|
||||||
|
|
||||||
|
const existingGeneration = await prisma.generation.findUnique({
|
||||||
|
where: {
|
||||||
|
repoUrl_commitHash: {
|
||||||
|
repoUrl,
|
||||||
|
commitHash
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingGeneration && existingGeneration.result) {
|
||||||
|
const docs = existingGeneration.result as any;
|
||||||
|
docs.id = generationId;
|
||||||
|
docs.repoUrl = repoUrl;
|
||||||
|
docs.repoName = existingGeneration.repoName;
|
||||||
|
|
||||||
|
await redis.set(
|
||||||
|
`codeboard:result:${generationId}`,
|
||||||
|
JSON.stringify(docs),
|
||||||
|
"EX",
|
||||||
|
86400
|
||||||
|
);
|
||||||
|
|
||||||
|
await updateProgress(generationId, "COMPLETED", 100, "Using cached documentation!");
|
||||||
|
return { generationId, duration: 0, repoName: existingGeneration.repoName, cached: true };
|
||||||
|
}
|
||||||
|
|
||||||
await updateProgress(
|
await updateProgress(
|
||||||
generationId,
|
generationId,
|
||||||
@@ -72,9 +100,22 @@ export async function processGenerationJob(
|
|||||||
86400
|
86400
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await prisma.generation.create({
|
||||||
|
data: {
|
||||||
|
id: generationId,
|
||||||
|
repoUrl,
|
||||||
|
repoName: cloneResult.metadata.name,
|
||||||
|
commitHash,
|
||||||
|
status: "COMPLETED",
|
||||||
|
progress: 100,
|
||||||
|
result: docs as any,
|
||||||
|
duration
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
await updateProgress(generationId, "COMPLETED", 100, "Done!");
|
await updateProgress(generationId, "COMPLETED", 100, "Done!");
|
||||||
|
|
||||||
return { generationId, duration, repoName: cloneResult.metadata.name };
|
return { generationId, duration, repoName: cloneResult.metadata.name, cached: false };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : "Unknown error";
|
const message = err instanceof Error ? err.message : "Unknown error";
|
||||||
await updateProgress(generationId, "FAILED", 0, message);
|
await updateProgress(generationId, "FAILED", 0, message);
|
||||||
|
|||||||
@@ -7,9 +7,14 @@ services:
|
|||||||
- "4100:3000"
|
- "4100:3000"
|
||||||
environment:
|
environment:
|
||||||
- REDIS_URL=redis://redis:6379
|
- REDIS_URL=redis://redis:6379
|
||||||
|
- DATABASE_URL=postgresql://codeboard:codeboard@postgres:5432/codeboard
|
||||||
depends_on:
|
depends_on:
|
||||||
redis:
|
redis:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
migrate:
|
||||||
|
condition: service_completed_successfully
|
||||||
restart: always
|
restart: always
|
||||||
|
|
||||||
worker:
|
worker:
|
||||||
@@ -18,6 +23,7 @@ services:
|
|||||||
target: worker
|
target: worker
|
||||||
environment:
|
environment:
|
||||||
- REDIS_URL=redis://redis:6379
|
- REDIS_URL=redis://redis:6379
|
||||||
|
- DATABASE_URL=postgresql://codeboard:codeboard@postgres:5432/codeboard
|
||||||
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
|
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
|
||||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
||||||
- LLM_MODEL=${LLM_MODEL:-kimi-k2-turbo-preview}
|
- LLM_MODEL=${LLM_MODEL:-kimi-k2-turbo-preview}
|
||||||
@@ -25,8 +31,39 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
redis:
|
redis:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
migrate:
|
||||||
|
condition: service_completed_successfully
|
||||||
restart: always
|
restart: always
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=codeboard
|
||||||
|
- POSTGRES_PASSWORD=codeboard
|
||||||
|
- POSTGRES_DB=codeboard
|
||||||
|
volumes:
|
||||||
|
- codeboard_postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U codeboard"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
restart: always
|
||||||
|
|
||||||
|
migrate:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
target: builder
|
||||||
|
command: npx prisma migrate deploy --schema=packages/database/prisma/schema.prisma
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=postgresql://codeboard:codeboard@postgres:5432/codeboard
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
volumes:
|
volumes:
|
||||||
@@ -34,4 +71,5 @@ services:
|
|||||||
restart: always
|
restart: always
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
codeboard_postgres_data:
|
||||||
codeboard_redis_data:
|
codeboard_redis_data:
|
||||||
|
|||||||
49
packages/database/prisma/migrations/0001_init/migration.sql
Normal file
49
packages/database/prisma/migrations/0001_init/migration.sql
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "Status" AS ENUM ('QUEUED', 'CLONING', 'PARSING', 'GENERATING', 'RENDERING', 'COMPLETED', 'FAILED');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Generation" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"repoUrl" TEXT NOT NULL,
|
||||||
|
"repoName" TEXT NOT NULL,
|
||||||
|
"commitHash" TEXT NOT NULL,
|
||||||
|
"status" "Status" NOT NULL DEFAULT 'QUEUED',
|
||||||
|
"progress" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"result" JSONB,
|
||||||
|
"error" TEXT,
|
||||||
|
"costUsd" DOUBLE PRECISION,
|
||||||
|
"duration" INTEGER,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"userId" TEXT,
|
||||||
|
"viewCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
CONSTRAINT "Generation_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "User" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"githubId" TEXT NOT NULL,
|
||||||
|
"login" TEXT NOT NULL,
|
||||||
|
"email" TEXT,
|
||||||
|
"avatarUrl" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Generation_repoUrl_idx" ON "Generation"("repoUrl");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Generation_status_idx" ON "Generation"("status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Generation_repoUrl_commitHash_key" ON "Generation"("repoUrl", "commitHash");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_githubId_key" ON "User"("githubId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Generation" ADD CONSTRAINT "Generation_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
3
packages/database/prisma/migrations/migration_lock.toml
Normal file
3
packages/database/prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (i.e. Git)
|
||||||
|
provider = "postgresql"
|
||||||
@@ -18,6 +18,9 @@
|
|||||||
"db:generate": {
|
"db:generate": {
|
||||||
"cache": false
|
"cache": false
|
||||||
},
|
},
|
||||||
|
"db:migrate": {
|
||||||
|
"cache": false
|
||||||
|
},
|
||||||
"db:push": {
|
"db:push": {
|
||||||
"cache": false
|
"cache": false
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user