Skip to content

Repository files navigation

Numscratch

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.

Features

  • Arithmetic, variables, functions, constants, dates, and continuation lines
  • Metric and imperial units, currencies, and to conversions
  • 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

Architecture

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.

Workspace

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

Requirements

  • Node.js ^22.18.0 or >=24.12.0
  • pnpm ^11.1.2
  • Docker with Compose for the local MariaDB instance

Local development

Install dependencies:

pnpm install

Start MariaDB:

docker compose up -d mariadb

Create the backend environment file:

cp packages/backend/.env.example packages/backend/.env

Anonymous 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 dev

The frontend runs on http://localhost:5173 and proxies /api to the backend on port 3000. Drizzle migrations run automatically when the backend starts.

Local-first behavior

  • 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.

API

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.

Validation

pnpm lint
pnpm type-check
pnpm test
pnpm build

Generate a new Drizzle migration after changing the database schema:

pnpm --filter backend db:generate

Production image

Build 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' \
  numscratch

Register 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.

Calculator syntax

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.

Releases

Packages

Contributors

Languages