Numscratch is a local-first text calculator. Expressions are written on the left and evaluated results appear immediately on the right. Documents work without an account or network connection and are persisted in the browser with RxDB. Signing in adds optional multi-device synchronization through a NestJS and MariaDB backend.
- Arithmetic, variables, functions, constants, dates, and continuation lines
- Metric and imperial units, currencies, and
toconversions - Lists, named objects, property access, and collection helpers
- Syntax highlighting, contextual autocompletion, and slash commands
- Incremental calculation based on a dependency graph
- Local document persistence in IndexedDB through RxDB
- Optional OAuth2 login with InteraApps Accounts
- Conflict-aware, checkpoint-based cloud replication
- Revocable, read-only share links with live and presentation views
- A single production container that serves the API and built frontend
Browser
Vue MathEditor
RxDB + IndexedDB Always available, including offline
│
├── HTTP push/pull /api/v1/sync/*
├── SSE invalidation reconnect + RESYNC
└── Public share SSE read-only document snapshots
│
NestJS API
InteraApps OAuth2 guard
Drizzle ORM
│
MariaDB
WebSockets are intentionally not used. Changes already travel from the browser to the API through batched HTTP pushes, so the server only needs a one-way signal that tells clients to pull again. Server-Sent Events provide that signal with native browser reconnection and lower operational complexity. Each reconnect emits RESYNC, while a monotonic MariaDB sequence makes pull checkpoints deterministic.
The backend never stores passwords and does not implement its own identity system. It exchanges the OAuth2 code with InteraApps Accounts, stores the resulting access token in a signed, HTTP-only, same-site cookie, and validates the user through the Accounts API.
packages/
backend/ NestJS API, OAuth integration, Drizzle schema, and sync protocol
frontend/ Vue application, RxDB database, document UI, and MathEditor
math-engine/ Parser, evaluator, formatter, and dependency graph
- Node.js
^22.18.0or>=24.12.0 - pnpm
^11.1.2 - Docker with Compose for the local MariaDB instance
Install dependencies:
pnpm installStart MariaDB:
docker compose up -d mariadbCreate the backend environment file:
cp packages/backend/.env.example packages/backend/.envAnonymous local use works without OAuth credentials. To test login and synchronization, create an OAuth2 app in InteraApps Accounts and set INTERAAPPS_CLIENT_ID and INTERAAPPS_CLIENT_SECRET. Register this development callback URL:
http://localhost:5173/api/v1/auth/callback
Start the frontend and backend together:
pnpm devThe frontend runs on http://localhost:5173 and proxies /api to the backend on port 3000. Drizzle migrations run automatically when the backend starts.
- Without an account, documents remain in the browser and no sync connection is opened.
- Documents created while signed in are scoped to that account and replicated automatically.
- Account documents remain cached locally for offline use but are hidden after signing out.
- Anonymous documents remain local unless the user explicitly migrates them.
- If a user signs in with local documents and the cloud account is empty, Numscratch asks whether those documents should be uploaded.
- Choosing Keep local leaves them device-only. New account documents still sync normally.
Concurrent edits use deterministic last-write-wins resolution based on updatedAt and a revision UUID. The server validates RxDB's assumed master state before accepting a write and returns the current master document on conflict. Deletes are replicated as tombstones instead of being physically removed.
All API routes start with /api/v1.
| Method | Route | Purpose |
|---|---|---|
GET |
/api/v1/health |
Container health check |
GET |
/api/v1/auth/login |
Start InteraApps OAuth2 login |
GET |
/api/v1/auth/callback |
Complete the OAuth2 code flow |
GET |
/api/v1/auth/me |
Return the current account or null |
POST |
/api/v1/auth/logout |
Clear the OAuth access-token cookie |
GET |
/api/v1/exchange-rates |
Return cached Frankfurter rates normalized to USD |
GET |
/api/v1/sync/summary |
Return the remote document count |
GET |
/api/v1/sync/pull |
Pull a checkpointed document batch |
POST |
/api/v1/sync/push |
Push RxDB write rows and return conflicts |
GET |
/api/v1/sync/stream |
Receive SSE resync notifications |
GET |
/api/v1/shares/document/:documentId |
Return a document's share link |
POST |
/api/v1/shares |
Create an authenticated read-only share link |
DELETE |
/api/v1/shares/:token |
Revoke an owned share link |
GET |
/api/v1/shares/:token |
Read a public shared document snapshot |
GET |
/api/v1/shares/:token/stream |
Receive public read-only live updates over SSE |
The sync routes and share-management routes require a valid InteraApps session. Public share reads are authorized by an unguessable 192-bit token and never expose account data. Push batches, field lengths, ownership, request origins, and document identifiers are validated by the backend.
Exchange rates come from https://api.frankfurter.dev/v2/rates. The backend deduplicates concurrent refreshes, honors upstream cache headers and ETags, and serves the last successful response for up to seven days when the provider is unavailable. Historical chart requests are fetched in bounded chunks and cached separately so long time ranges do not overload the provider. The frontend refreshes current rates hourly and keeps its last rate set in browser storage for offline use. Cache durations, stale windows, request timeouts, and the upstream URL can be configured through the EXCHANGE_RATE_* and FRANKFURTER_RATES_URL variables documented in packages/backend/.env.example.
pnpm lint
pnpm type-check
pnpm test
pnpm buildGenerate a new Drizzle migration after changing the database schema:
pnpm --filter backend db:generateBuild the single-container image:
docker build -t numscratch .Run it with a MariaDB connection and OAuth configuration:
docker run --rm -p 3000:3000 \
-e DATABASE_URL='mysql://user:password@database:3306/numscratch' \
-e PUBLIC_URL='https://numscratch.example.com' \
-e FRONTEND_URL='https://numscratch.example.com' \
-e INTERAAPPS_CLIENT_ID='your-client-id' \
-e INTERAAPPS_CLIENT_SECRET='your-client-secret' \
-e COOKIE_SECRET='a-long-random-production-secret' \
numscratchRegister https://numscratch.example.com/api/v1/auth/callback as the production OAuth2 callback. TLS must terminate at the container or a trusted reverse proxy so the secure authentication cookie can be used.
The image builds the Vue frontend first, copies it into the NestJS runtime, applies Drizzle migrations on startup, and serves both the SPA and /api/v1 from port 3000.
a = 100
b = 50
a - b
* 5
distance = 100 km
distance to miles
accounts {
checking = 1000 EUR
brokerage = 500 EUR
}
sum accounts
expenses = [10, 20, 30]
avg expenses
today + 30 days
More details are available in the math engine documentation.