Postgres, Drizzle and Hono
One schema, typed end to end — and a server that does not care which runtime it lands in.
Hono is a small router built on Web standard Request and Response, which means the same app object runs on Cloudflare Workers, on Bun, on Node and inside an Electron process without being rewritten. That is not a convenience — it is what lets one server be mounted by two completely different shells.
The self-hosted NotebookLM is the clearest case: a Next.js deployment mounts the Hono app under a catch-all route, and the Electron desktop build mounts the same app through its dev server and then as a bundle. Same routes, same handlers, same behaviour.
Hosted or entirely local, from one codebase.
Behaviour is identical because the only thing that differs is an adapter holding the database, the storage and the auth — Neon and S3 on the hosted side, an embedded Postgres and the local filesystem on the desktop. That is why the desktop build works with no account and no network: nothing above the adapter knows which one it got.
Tables are declared in code, so the types the application uses are derived from the schema rather than written twice and kept in sync by hand.
Generated as SQL, reviewed in the pull request like anything else. An ORM that hides the migration is an ORM that surprises you during a deploy.
Queries look like the SQL they become, which matters the moment one is slow — you can read the query and the plan without translating between two mental models.
One per vector dimension, so changing embedding model is non-destructive: retrieval reads only rows written by the model currently configured, and the old rows stay put.
The question is not scale. It is how many machines need the same row.
Tether’s API uses bun:sqlite with no ORM at all, because its data belongs to one machine and a file is the correct shape for that. Mercala uses Postgres with row-level security because many tenants share one database and the isolation has to be enforced below the application. Neither is a compromise — they are answers to different questions, and using the wrong one shows up as either needless operational weight or a data race you cannot fix in the application layer.
An ORM that hides the migration is an ORM that surprises you during a deploy.
the working rule across these reposThe web app, the phone app and the server that feeds them share one set of types.
Coursified is five apps — web, mobile, server, relay, fetcher — over thirteen packages. Voxtar is six apps over three packages plus a Python service. Tether is two apps over one. The counts differ; the shape never does: deployables in apps/, anything two of them share in packages/. The reason is narrow and it is about when you find out you were wrong. A shared type in a package means changing it breaks the build of every app that imports it, in CI, before anything ships. The same type copied into a web repo and a mobile repo means you find out from a user.
schemas and api-client. The server validates with the same schema the client sends against, so the wire format has one definition rather than two that agree today.
Colour and type, read by web and mobile alike — which is the only reason the phone build still looks like the product.
db as a package means the schema, the migrations and the query helpers travel together, and nothing reaches past them into raw SQL by accident.
One implementation of who-you-are, because two is how a mobile app ends up trusting a token the web app would have rejected.
A helper used once. A package with one consumer is a directory with extra ceremony, and I have deleted more of those than I have kept.
React, React Native and CodeMirror are pinned in root overrides. Workspaces resolve compatible-but-different versions perfectly happily, and for these that is a bug.
Two copies of @codemirror/state are two nominal types, because its classes carry private fields. Every EditorView passed between them failed to typecheck. That broke production on 16 September 2026.
Not the lockfile — because the incident was Vercel's bun failing to parse the lockfile and resolving fresh. A fix that only exists in a lockfile is a fix that assumes the lockfile is read.
The overrides block carries a paragraph explaining why. A pin without its reason is the next person's bug, and the next person is usually me.
Self-hosted NotebookLM
One Hono app, eighteen tables through Drizzle, and a platform adapter that lets the same server run hosted or entirely on your machine.
Typed from the schema outward, and portable by default.
Drizzle means the types come from the tables rather than beside them; Hono means the server is not married to the runtime it happens to start in.