NodeVault is a personal knowledge vault: drop in files, submit URLs, or point it at an RSS feed, and it turns them into a searchable, private knowledge base you can ask questions of — grounded answers with citations back to your own documents, plus standing alerts when newly-ingested content matches something you care about. Content is ingested through durable background workflows — chunked, embedded, and stored as vectors in Postgres — so retrieval works by meaning, not just keywords.
How it works today:
- Sign in with a magic link (no passwords) — email delivery via Resend
- Create a vault and add content: upload a file, submit a URL, or point it at an RSS feed for ongoing ingestion
- An Inngest workflow picks the content up asynchronously: extracts text (PDF via
unpdf, DOCX viamammoth, articles via@mozilla/readability), chunks it, generates embeddings (Gemini or OpenAI, per account), and writes vectors to pgvector - Search the vault with hybrid retrieval — Reciprocal Rank Fusion over vector similarity and Postgres full-text search
- Ask your vault questions in a streamed, cited conversation — a hand-rolled RAG pipeline, fully managed grounding via Vertex AI Search / OpenAI file search, or (with your own OpenRouter key) the same local retrieval answered by any model on OpenRouter
- Save a topic and get emailed the moment newly-ingested content matches it — no polling, it rides the same ingestion pipeline every asset already goes through
What started as a roadmap is now the core of the product:
- Hybrid search — pgvector cosine similarity and Postgres
ts_rank_cdfull-text search run as independent candidate sets, fused with Reciprocal Rank Fusion (RRF, k=60) in a single chained-CTE Drizzle query. The vector leg doesn't use a fixed similarity cutoff: a chunk only qualifies if it clears the vault's own mean + 1.5×stddev, since a fixed threshold like 0.5 lets unrelated chunks through in some vaults and starves results in others. - Q&A with citations — streamed over Server-Sent Events, with three selectable modes per account:
- Local: a hand-rolled RAG pipeline — follow-up questions are condensed into standalone queries against conversation history, the top-8 chunks come from the same hybrid search used by the UI, and the model is asked to cite sources inline (
[1],[2]). - Managed: retrieval is handed off entirely to the provider's own grounding — Vertex AI Search for Gemini accounts, OpenAI
file_searchover a vector store otherwise — letting the model issue its own retrieval queries mid-conversation instead of one upfront fetch. Citations are reconstructed afterward from grounding metadata and resolved back to the source asset. - OpenRouter: the exact Local retrieval stack, but the final answer is generated by any model on OpenRouter that the user picks per conversation. Because OpenRouter has no embeddings, it's a pure generation override layered on top of the account's base provider (which still owns embeddings, ingestion and retrieval) rather than a peer provider — so it never touches the pgvector index or the one-way provider switch. The model catalogue is fetched once and cached, searchable, and sorted free-first; the chosen model is persisted with the conversation, and upstream provider errors (rate limits, etc.) are surfaced verbatim so the user can switch models and retry.
- Conversations and per-message citations are persisted, so a vault's Q&A history is itself browsable.
- Local: a hand-rolled RAG pipeline — follow-up questions are condensed into standalone queries against conversation history, the top-8 chunks come from the same hybrid search used by the UI, and the model is asked to cite sources inline (
- Saved topics & alerts — save a free-text topic, it's embedded asynchronously, and every asset ingestion runs a final match step against every account topic. Idempotency is enforced structurally (a unique index on
(topicId, assetId)+onConflictDoNothing) rather than in application logic, so a topic alerts at most once per asset no matter how many retries or qualifying chunks are involved. Matches batch into a single email per user via a React Email template. - RSS feed vaults — create a vault directly from a feed URL; a weekly Inngest cron fans out one sync per RSS-backed vault, throttled and deduped against existing asset URLs, feeding new items through the exact same ingestion pipeline as a manually-submitted URL.
- Multi-provider AI, with live migration — Gemini and OpenAI sit behind one
AiClientinterface (embeddings, streaming answers, managed grounding, index mirroring), selectable per account. OpenAI's embeddings are truncated to 768 dimensions (Matryoshka representation learning) to match Gemini's, so both providers share onepgvectorcolumn with zero schema changes. Switching provider triggers a concurrency-limited background workflow that re-embeds and re-indexes every asset and topic before cutting over — with an account-level flag gating AI access mid-migration so answers never generate against a half-migrated index. - Bring-your-own-cloud grounding — the Vertex AI Search data store is provisioned per account in the user's own GCP project, not a shared platform index, with an explicit verification step that turns "API not enabled" / "no permission" / "store missing" into actionable setup errors instead of opaque failures.
- SSRF-safe ingestion — every outbound fetch triggered by user input (RSS feeds, submitted URLs) is checked against
assertPublicHttpUrlbefore it hits the network.
Not yet built: per-vault synthesis/digests ("what do my saved articles say about X"), and an MCP server exposing vault retrieval to external AI tools.
| Layer | Tech |
|---|---|
| Frontend | Next.js 15 (App Router) · React 19 · HeroUI v3 · Tailwind CSS 4 |
| API | Node.js · Koa · tRPC v11 · zod contracts · Server-Sent Events for streamed Q&A |
| Background jobs | Inngest — durable, step-based workflows (including cron polling) |
| Database | Postgres (Neon in prod) · Drizzle ORM · pgvector |
| AI providers | Google Gemini (@google/genai) and OpenAI (openai), selectable per account, behind a shared provider interface; optional OpenRouter (openai SDK against its base URL) as a per-conversation generation override |
| Managed grounding | Google Vertex AI Search (@google-cloud/discoveryengine) · OpenAI file_search vector stores |
| Content extraction | unpdf (PDF) · mammoth (DOCX) · @mozilla/readability + jsdom (articles) · rss-parser (feeds) |
| Resend · React Email templates | |
| Hosting | Frontend on Cloudflare Workers (OpenNext) · API on Fly.io · file storage on R2 |
| Tooling | NX monorepo · pnpm · TypeScript · Vitest · tsx |
The API is a tRPC router hosted on Koa, but business logic lives in plain ApiHandlers that declare their contract types as generics:
export const authLogin: ApiHandler<LoginRequest, OkResponse> = async (context) => {
const body = context.event.payload // typed & zod-validated
// ...
return context.event.response.ok(authTokens) // checked against the response contract
}Handlers are wired onto procedures via an execute() bridge, with every procedure declaring .input() and .output() schemas from the shared contracts package — so requests are validated and responses are verified (and excess fields stripped) at runtime, while a handler whose types disagree with its schemas is a compile-time error at the wiring site:
export const authRouter = router({
verify: publicProcedure
.input(verifyRequestSchema)
.output(verifyLoginResponseSchema)
.mutation(execute(authVerify)),
})The frontend imports the router type only (@platform/apps.api) and gets a fully typed client with zero code generation:
await api.vaults.create.mutate({ name })Each mutating request runs inside a single Postgres transaction (unit of work) managed by middleware — handlers never touch transaction code; a thrown error or 4xx response rolls everything back.
File processing is too slow and failure-prone for a request/response cycle, so upload endpoints just persist the row and emit an event:
await inngest.send({ name: 'files/file.uploaded', data: { fileId } })The workflow (apps/api/inngest/functions/process-file.ts) runs as discrete, individually-retried step.run() units — fetch/extract text, chunk, embed, store vectors, mark ready. A step that fails retries from that step, not from the beginning, and file status (pending → processing → ready | failed) is visible to the frontend via polling. Locally, pnpm run inngest gives you a dev UI at http://localhost:8288 showing every run, step, and payload.
Embeddings are stored in the same Postgres schema as everything else (Drizzle vector columns + HNSW index). That means vector search composes with ordinary SQL — scoped by account, vault, date, or status in one WHERE clause, transactionally consistent, no separate vector database to sync.
apps/
api/ Koa + tRPC API server (routes/ per domain), SSE ask/ pipeline, Inngest functions
nodevault/ Next.js 15 frontend (Cloudflare Workers via OpenNext)
components/
api/ Koa host for tRPC, router/procedure helpers, execute() bridge
configuration/ Config builder + server config (NODEVAULT env var)
context/ Context, InboundEvent, Response, Session, structured logging
nodevault/
contracts/ Public API contracts — zod schemas + DTO types (client-safe)
domain/ Drizzle schema + inferred row types, AppError
postgres/ PgSession unit of work, pool factory, migrations
utils/ Pure utilities (date, string, math)
utils-server/ Node-only utilities (crypto, auth tokens, SSRF-safe URL fetching)
integrations/
cloudflare/ R2 object storage client (uploaded file blobs)
gemini/ Google Gemini client (embeddings, generation)
openai/ OpenAI client (embeddings, Responses API, vector stores)
openrouter/ OpenRouter client (model catalogue + streamed generation)
vertexsearch/ Google Vertex AI Search (Discovery Engine) client
resend/ Transactional email client
Three strict layers keep the boundaries clean: storage (Drizzle rows, server-only) → mappers (explicit field picking, Date → UTC ISO) → contracts (zod DTOs, the only thing the frontend imports).
pnpm install
# Local Postgres (pgvector/pgvector:pg18 container)
docker start nodevault-postgres
pnpm run db # apply migrations
# Separate terminals:
pnpm run api # API (tsx watch)
pnpm run app # Next.js frontend on :8001
pnpm run inngest # Inngest dev server UI on :8288
# Quality
npx vitest # tests
npx tsc --noEmit # type check
pnpm run lint # lint with autofixMigrations: edit the schema in components/nodevault/domain/models/, then pnpm run db:generate --name=<change> and pnpm run db. See docs/migrations.md.
Server config comes from the NODEVAULT environment variable (base64-encoded JSON); local overrides from the path in NODEVAULT_OVERRIDES. Database URL defaults to postgres://nodevault:nodevault@localhost:5432/nodevault in dev, DATABASE_URL (Neon) in prod.
| App | Platform | Command |
|---|---|---|
apps/api |
Fly.io (Docker) | fly deploy from apps/api/ |
apps/nodevault |
Cloudflare Workers | pnpm run app:build then wrangler deploy |