Plan: Unify Monorepo into a Single Nuxt 3 Project
Historical ADR. Records the multi-app layout (NestJS API, separate playground) and the intent to merge into one Nuxt repo. The current tree has evolved since (e.g. no
apps/*,/learnquest removed from the unified app — see Learning quest). Keep this document for why past decisions were made, not as a map of every file today.
Context
The repo currently splits across four projects:
apps/rag-ui— Nuxt 3 (port 3000) — main RAG frontend (search, documents, upload).apps/rag-api— NestJS (port 3001) — Prisma + pgvector backend, multi-provider embeddings/LLM.packages/rag-learning— Pure TS library (@rag/learning) — levels, validators, wizard data.packages/rag-playground— Nuxt 3 (port 3002) — gamified learning UI, imports@rag/learning.
Goal: collapse all four into ONE Nuxt 3 project at the repo root. NestJS becomes Nuxt server/api (h3) routes; rag-ui stays at /; rag-playground moves under /learn/*; rag-learning is inlined as utils/learning/. Single Dockerfile, single package.json, no workspace.
Outcome: simpler dev story (one pnpm dev, port 3000 only), no CORS, fewer build artifacts, easier deploy.
A. Target Directory Structure
from-zero-rag/
├── nuxt.config.ts # merged
├── app.config.ts
├── app.vue
├── package.json # single, all deps merged
├── tsconfig.json
├── prisma/ # moved from apps/rag-api/prisma/
│ ├── schema.prisma
│ └── migrations/
├── prisma.config.ts
├── assets/css/main.css
├── public/
├── layouts/
│ ├── default.vue # rag-ui shell
│ └── learn.vue # playground shell
├── pages/
│ ├── index.vue # rag-ui home
│ ├── upload.vue
│ ├── documents/{index,[id]}.vue
│ └── learn/
│ ├── index.vue
│ ├── onboarding.vue
│ ├── level/[id].vue
│ └── challenge/[id].vue
├── components/
│ ├── AppHeader.vue # top-level (rag-ui)
│ ├── BottomNav.vue
│ └── learn/ # auto-prefixed <Learn*>
├── composables/
├── stores/{documents,progress}.ts
├── plugins/i18n.ts
├── i18n/locales/{en,es}.json # namespaced { ui:..., learn:... }
├── utils/learning/ # inlined @rag/learning
│ ├── levels/ validators/ wizard/ types/ index.ts
└── server/
├── api/
│ ├── documents/{index.get, index.post, upload.post, [id].get, [id].delete, [id]/reprocess.post}.ts
│ ├── search/{index, rag, inspect, converse}.post.ts
│ ├── agent/chat.post.ts
│ └── admin/{queries,stats}.get.ts
├── plugins/prisma.ts # disconnect on close
└── utils/
├── prisma.ts # singleton
├── chunking.ts embedding.ts ollama.ts
├── documents.service.ts search.service.ts agent.service.ts
├── validation.ts # zod schemas
└── errors.tsB. NestJS → h3 Migration Mapping
Apply uniformly:
@Controller + @Post/@Get→ file underserver/api/...exportingdefineEventHandler.@Body() dto(class-validator) →await readValidatedBody(event, schema.parse)with zod inserver/utils/validation.ts.@Param('id')→getRouterParam(event, 'id').@Query()+ pipes →getQuery(event)+ manualNumber()/clamp helpers.@Injectable()services → plain modules inserver/utils/*.service.ts; replace constructor DI with direct imports of the prisma singleton + utility modules.PrismaService extends PrismaClient→server/utils/prisma.tssingleton (globalThisguard for HMR). Disconnect viaserver/plugins/prisma.tson Nitroclosehook.NotFoundException/BadRequestException→throw createError({ statusCode, statusMessage }).@HttpCode(204)→setResponseStatus(event, 204).- CORS /
enableCors→ delete (same-origin). Logger→consola/console.
Module-by-module:
- documents — port
documents.controller.tsto 6 route files. Movedocuments/chunking/embedding.service.tstoserver/utils/. ReplaceFileInterceptor + UploadedFilewithawait readMultipartFormData(event); manually enforce 10 MB cap and normalize to{ buffer, originalname, mimetype, size }forcreateFromFile.pdf-parsestays as-is. - search — port
search.controller.tsto 4 routes;SearchService→server/utils/search.service.tswithsearch/rag/inspect/converseexports. No SSE today (converse returns final JSON); addrouteRulesonly if Nitro timeout shortens it. - agent —
agent.controller.ts→server/api/agent/chat.post.ts; service →server/utils/agent.service.ts(imports docs + search services + prisma directly). - admin —
queries.get.ts,stats.get.ts, parsinglimit/offsetfrom query string with clamps. - ollama —
apps/rag-api/src/ollama/create-ollama.ts→server/utils/ollama.ts(pure factory). - prisma — schema + migrations to root
prisma/; updateprisma.config.tspaths; adddb:migrate,db:generate,db:studio,postinstallscripts.
C. Frontend Merge
- Stores: copy both Pinia stores into
stores/(distinct ids — no collision). StripapiBaseUrlfromdocuments.ts; switch to relative$fetch('/api/...'). - Components:
AppHeader.vue,BottomNav.vuestay top-level. Playground-only components move undercomponents/learn/(auto-prefix<Learn*>). - Layouts: keep
layouts/default.vuefrom rag-ui. Addlayouts/learn.vue(extracted from playgroundapp.vue). SetdefinePageMeta({ layout: 'learn' })onpages/learn/*— or use a singlepages/learn.vueparent with<NuxtPage />. - i18n: one
plugins/i18n.ts. Merge locales as{ ui: { ... }, learn: { ... } }. Updatet()calls in playground pages/components to thelearn.*prefix. - Monaco: add
nuxt-monaco-editortomodules; only imported frompages/learn/challenge/[id].vue(route-level code-split keeps it off/,/documents,/upload). OptionallydefineAsyncComponentfor extra safety. - Nuxt UI version conflict: rag-ui uses
@nuxt/ui v2, playground usesv3. Standardize on v3. Audit all<U*>components in rag-ui —<UFormGroup>→<UFormField>,<UDropdown>→<UDropdownMenu>, prop renames, color/size scales. - Tailwind v3 → v4: rewrite
assets/css/main.cssentry to@import "tailwindcss"; replacetailwind.config.jswith@themeblocks or drop it; recheck@applyusage.
D. Routing for /learn/*
File-based, mechanical:
pages/learn/index.vue→/learnpages/learn/onboarding.vue→/learn/onboardingpages/learn/level/[id].vue→/learn/level/:idpages/learn/challenge/[id].vue→/learn/challenge/:id
Optional pages/learn.vue containing <NuxtPage /> for a persistent learn-only sub-shell. Update internal <NuxtLink to="/level/1"> → /learn/level/1.
E. Inline rag-learning into utils/learning/
All rag-learning content is currently consumed in the browser, so it lives client-side:
src/levels/*→utils/learning/levels/src/types/*→utils/learning/types/src/wizard/*(incl.buildEnvFile) →utils/learning/wizard/src/validators/*→utils/learning/validators/src/index.ts→utils/learning/index.ts(re-exportsgetAllLevels,getLevel,getChallenge,wizardSteps, types, validators)
Update imports: from '@rag/learning' → from '~/utils/learning'. Promote zod to a top-level dep. Delete packages/rag-learning/ after verification.
F. Config Consolidation
nuxt.config.ts:
modules: ['@nuxt/ui', '@pinia/nuxt', '@vueuse/nuxt', 'nuxt-monaco-editor']css: ['~/assets/css/main.css']runtimeConfig: droppublic.apiBaseUrl. Add private:databaseUrl,googleApiKey,openaiApiKey,ollamaUrl,ollamaModel,ollamaLlmModel,embeddingDimensions, chat/planner timeouts.- Merged
app.head(one title, deduped fonts). colorMode: { preference: 'dark' }.monacoEditor: { locale: 'en' }.- Optional
routeRulesfor/api/search/converse,/api/agent/chatif timeouts need tuning.
package.json (single):
- Runtime: union of all four (Nuxt UI v3, Pinia, VueUse, vue-i18n, vue-router, nuxt-monaco-editor, monaco-editor, marked, zod,
@google/generative-ai,@prisma/client,@prisma/adapter-pg, ollama, openai, pdf-parse, pg, uuid). - Dev: nuxt, typescript, vue-tsc, tailwindcss v4, @tailwindcss/vite, prisma, @types/node, @types/pdf-parse, @types/uuid.
- Drop: all
@nestjs/*,class-validator,class-transformer,rxjs,reflect-metadata,ts-jest,ts-loader. - Scripts:
dev,build,start,db:migrate,db:generate,db:studio,postinstall: nuxt prepare && prisma generate.
Workspace: delete pnpm-workspace.yaml. Run a clean pnpm install at root.
tsconfig.json: extends ./.nuxt/tsconfig.json. Strict on.
G. Docker / docker-compose
Single root Dockerfile — multi-stage:
deps—pnpm install --frozen-lockfilebuild—pnpm build→.output/runtime— Node 20-alpine, copy.output/+prisma/, entrypoint runsprisma migrate deploythennode .output/server/index.mjs. Expose 3000.
docker-compose.yml collapsed:
- Keep
postgres(pgvector) andollamaunchanged. - Replace
backend,frontend,playgroundwith oneappservice: build root, env (DATABASE_URL,OLLAMA_*,GOOGLE_API_KEY,OPENAI_API_KEY,EMBEDDING_DIMENSIONS,NUXT_HOST=0.0.0.0,NUXT_PORT=3000),ports: ["3000:3000"],depends_on: postgres. - Drop
NUXT_PUBLIC_API_BASE_URLbuild arg and theprofilesmatrix.
H. Env Var Consolidation
Single .env at root. Remove NUXT_PUBLIC_API_BASE_URL, BACKEND_PORT, FRONTEND_PORT, PLAYGROUND_PORT (one PORT=3000). Keep DB + provider keys + Ollama config + timeouts. Read via useRuntimeConfig(), not process.env direct. grep -r apiBaseUrl and grep -r NUXT_PUBLIC_API_BASE_URL to confirm nothing left.
I. Migration Order (with Verification Gates)
- New branch + tag current commit.
- Scaffold root Nuxt project (root
nuxt.config.ts,package.json,tsconfig.json,app.vue, blankpages/index.vue). Gate:pnpm devboots blank :3000. - Move Prisma to root + add
server/utils/prisma.tssingleton. Gate:prisma migrate devconnects to existing DB. - Port one trivial route (
server/api/admin/stats.get.ts) end-to-end as pattern proof. Gate:curl /api/admin/statsreturns expected JSON. - Port documents module (utils first, then 6 routes incl.
upload.post.ts). Gate: PDF upload via curl works;GET /api/documentslists it with chunks. - Port search module (4 routes). Gate: semantic + RAG + converse + inspect endpoints respond.
- Port agent module. Gate:
/api/agent/chatreturns reply. - Port remaining admin route (
queries). - Migrate rag-ui frontend (pages, components, composables, layout, store, css, i18n, plugin, app.config, app.vue). Strip
apiBaseUrl. Apply Nuxt UI v2→v3 + Tailwind v3→v4 changes. Gate:/,/documents,/documents/[id],/uploadall functional via the new server routes. - Inline rag-learning under
utils/learning/; rewrite imports. Gate:pnpm buildsucceeds with no@rag/learningresolution errors. - Migrate playground under
/learn/*(pages,components/learn/,progressstore,learn.vuelayout, namespaced i18n, monaco module). Gate: all/learn/*routes work; Monaco only loads on challenge page. - Consolidate Dockerfile + compose. Gate:
docker compose upend-to-end. - Delete legacy:
apps/rag-api/,apps/rag-ui/,packages/rag-learning/,packages/rag-playground/,pnpm-workspace.yaml. UpdateREADME.md,DOCKER.md,CONTRIBUTING.md. - Final E2E pass (section J).
J. End-to-End Verification
- Fresh
pnpm install && pnpm prisma migrate deployclean. pnpm devon :3000, no console errors.- DB: pgvector extension + tables present.
/upload→ small PDF → appears on/documentswith chunk count > 0./documents/[id]shows chunks.- POST
/api/search→ results;/api/search/rag→ reply with sources;/api/search/conversereturns within timeout (no CORS preflight);/api/search/inspect→ embedding preview + latency. - POST
/api/agent/chat→ planner output, optionalused_kb: truewith sources. /api/admin/statsand/api/admin/querieswork./learn→ level map./learn/onboarding→ wizard produces env file./learn/level/1lists challenges./learn/challenge/<id>Monaco loads, validator runs, progress persists across reload.- Bundle audit:
/does NOT load Monaco chunks; challenge page does. pnpm build && node .output/server/index.mjsworks.docker compose upreproduces all of the above.
K. Critical Files to Read & Risks
Read before each step:
- Prisma:
apps/rag-api/prisma/schema.prisma,prisma.config.ts,src/prisma/prisma.service.ts. - Documents:
src/documents/{documents.controller,documents.service,chunking.service,embedding.service}.ts,dto/create-document.dto.ts. - Search:
src/search/{search.controller,search.service}.ts,dto/{search,converse}.dto.ts. - Agent:
src/agent/{agent.controller,agent.service}.ts,dto/agent-chat.dto.ts. - UI merge:
apps/rag-ui/{nuxt.config.ts,app.vue,app.config.ts},stores/documents.ts,components/AppHeader.vue,i18n/locales/en.json. - Learning:
packages/rag-learning/src/{index.ts,wizard-types.ts}, allvalidators/. - Playground:
packages/rag-playground/{nuxt.config.ts,app.vue},stores/progress.ts, allpages/*.vueandcomponents/*.vue,i18n/locales/en.json.
Risks / gotchas:
- DI → top-level imports may surface circular deps between docs/search/agent services — break with explicit interface separation if it appears.
class-validator→ zod (or valibot); preservewhitelist: truesemantics with.strict().FileInterceptorsize limits NOT enforced byreadMultipartFormData— manual 10 MB check required.converseclient uses 210 s timeout — verify Nitro/Node defaults don't kill earlier; if streaming added later use h3eventStream/sendStream(don't portSse()/Observable patterns).- CORS removed — drop
enableCors, dropNUXT_PUBLIC_API_BASE_URLeverywhere. - Nuxt UI v2→v3: high-effort breaking change, audit every
<U*>component. - Tailwind v3→v4: CSS entry syntax changes,
@applyrules need recheck. - i18n key collisions (
home,nav.back) — namespace underui.*,learn.*. - Monaco SSR —
nuxt-monaco-editoris client-only, but verify; wrap in<ClientOnly>if needed. - Prisma 7 + ESM: ensure
@prisma/adapter-pgconfig matches NestJS bootstrap inprisma.config.ts. - Pinia
progressstore'snew Date()defaults cause SSR hydration mismatch — guard withimport.meta.clientor move to a hydration action. - Pages calling
$fetch('/api/...')insetup()should useuseAsyncDatafor proper SSR hydration. - Workspace removal:
rm -rf node_modules **/node_modules pnpm-lock.yamlbefore first rootpnpm install. - Tests: rag-api has Jest specs (
chunking.service.spec.ts,search.service.spec.ts) — port to Vitest, don't drop coverage silently.