The system, layer by layer
Every layer sits behind an interface, so the UI, sync engine, and search index don't know or care where data physically lives.
- Client
- React 19 + TypeScript + Vite, state in Zustand
- Ships as a web app on Cloudflare and as a Tauri 2 desktop app. Only 3 Rust IPC commands; all business logic stays in TypeScript.
- Local store
RecipeStoreinterface- IndexedDB in the browser and plain Markdown files on desktop, chosen automatically at startup.
- Search
- sql.js (SQLite in WASM)
- Full-text and tag search runs entirely in-process, with no server round trip.
- Edge
- nginx · Let's Encrypt
- TLS termination in front of the API, running alongside it in Docker Compose.
- API
- Express 5
/sync/push,/sync/changes,/bootstrap,/extract-*, and/taxonomy.- Data
- PostgreSQL 18
- Recipes, mutations, taxonomy, and revisions. A file-backed store stands in during local development.
- Auth
- AWS Cognito · OIDC
- The server verifies JWTs with
aws-jwt-verify. Sync is optional; signed-out use stays local. - AI
- Claude Haiku
- Called only from the server, so API keys never reach the client.
Offline-first sync
Every local write lands in storage immediately and is queued as a named mutation (upsertRecipe, deleteRecipe). Clients push the queue when they're online and poll /sync/changes?cursor=… every 10 seconds and on window focus. The server deduplicates by clientMutationId, so a client that reconnects and replays its whole queue never creates duplicates.
Step through what happens when two devices edit the same recipe while both are offline:
- title
- Pasta
- rating
- 4 (changed this step)
- revision
- 4
- queue
- upsert #a1
- title
- Pasta 2.0 (changed this step)
- rating
- 3
- revision
- 4
- queue
- upsert #b1
- title
- Pasta
- rating
- 3
- revision
- 4
- queue
- —
Both devices go offline and edit "Pasta". A changes the rating; B renames it. Each write is saved locally and queued.
A second guard covers deletes: a synced recipe can't be deleted while its device is offline, so an offline device can't remove a recipe other devices may have changed in the meantime.
Four ways to add a recipe
Every path ends in the same draft editor. The three automated paths send content to Claude Haiku on the server and can pull several recipes out of one source. Website imports fall back to the page's own structured data if the model call fails.
On desktop, the Tauri shell fetches the page itself through the fetch_recipe_page IPC command.
Testing and shipping
This is the part that maps most directly to my day job. Every push to main runs the full check gate before any installer is built.
biome lint · formatLint and formatting enforced across the repo.tsc --noEmitStrict type check on both the client and the server.vitest + Testing LibraryClient tests covering the API client, auth boundaries, extraction, persistence, the editor store, and taxonomy sync.vitest + supertestServer tests against the Express app and the store layer.GitHub ActionsRunsnpm run check, then builds and publishes Tauri installers for macOS, Windows, and Linux on every push to main.
Trade-offs I chose on purpose
- Last write wins, not CRDTs. Two people on one account editing the same recipe at the same moment is rare, so ordered upserts plus a delete guard give convergence without the complexity of lossless merging.
- Polling, not websockets. A 10-second cursor poll is simple to reason about, survives flaky connections, and is plenty for a recipe app.
- Self-hosted server, managed auth. I run Postgres and the API myself to learn them, but leave identity to Cognito rather than rolling my own.
Toby McGuire