An open-source, Notion‑inspired application.
Thoth aims to provide a fast, flexible note‑taking and knowledge management experience with a familiar hierarchy of pages and blocks, focusing on simplicity and speed.
- Open source: community‑driven, MIT‑licensed.
- Notion‑inspired UX: pages, hierarchy, and a clean editor experience.
- Modern stack: TypeScript, Next.js 16 (App Router) with Turbopack, React 19, and Mantine UI.
Thoth is a pnpm monorepo. The Next.js application lives in apps/web, with the repository
root acting as a thin workspace orchestrator (root commands delegate to apps/web under the
hood — you don't need to learn new commands or cd into apps/web):
apps/web/src/app: Next.js App Router pages and API routes (apps/web/src/app/api).apps/web/src/components: UI components organized using Atomic Design (atoms,molecules,organisms,templates).apps/web/src/lib: shared libraries — auth (better-auth), API client, environment validation, and Nanostores-based state. Thin adapters around@thoth/database/@thoth/storagealso live here (apps/web/src/lib/database,apps/web/src/lib/storage) so existing import paths and web-specific concerns (sessions, cookies, request auth) stay unchanged.apps/web/src/types: shared TypeScript types and Zod schemas for API request/response validation (entity schemas are re-exported from@thoth/database/schemas).apps/web/tests/e2e: Playwright end-to-end tests.packages/database(@thoth/database): framework-agnostic entity schemas, SuperSave entity definitions/repositories, migrations, and DB-pure services shared by the web app (and future services). Has no dependency on Next.js, sessions, or cookies. Itssrc/cli/migrate.tsis the only place schema sync/migrations are ever run — seepnpm db:migratebelow.packages/storage(@thoth/storage): the storage adapter contract and local-filesystem implementation, with no dependency on the web environment validator or auth/session concerns.
Prerequisites: Node.js 24.x and pnpm 10+.
pnpm install
pnpm devThe dev server runs Next.js with Turbopack and hot-reload at http://localhost:3000.
The app validates required environment variables at startup (see apps/web/src/lib/environment.ts
and apps/web/src/lib/environment/app-url.ts). Every key is listed below (variable names copied
verbatim from environmentSchema to avoid drift):
| Variable | Required? | Default | Description |
|---|---|---|---|
NODE_ENV |
Yes | — | development, production, or test. |
DB |
Yes | — | Database connection string (e.g. sqlite:///path/to/db.sqlite or a MySQL connection string). |
LOG_LEVEL |
No | info |
Logging verbosity: error, warn, info, http, debug, trace. |
BETTER_AUTH_SECRET |
Yes | — | Secret used by better-auth for session/auth handling. |
APP_URL |
No | http://localhost:${PORT ?? 3000} |
The public, absolute base URL Thoth is served at (e.g. https://thoth.example.com). Explicitly wired into better-auth's baseURL/trustedOrigins instead of relying on request-header inference. Must be set in production — see "Production deployment" below. No trailing slash. |
OIDC_CLIENT_ID |
No | — | OIDC client id. If all four OIDC_* vars are set, OIDC login is used; otherwise credentials (email/password) auth is used. |
OIDC_CLIENT_SECRET |
No | — | OIDC client secret. |
OIDC_DISCOVERY_URL |
No | — | OIDC discovery document URL. |
OIDC_AUTHORIZATION_URL |
No | — | OIDC authorization endpoint URL. |
WORKSPACE_DELETE_GRACE_PERIOD_DAYS |
No | 30 |
Days a soft-deleted workspace is retained before pnpm workspaces:purge permanently removes it. |
PAGE_DELETE_GRACE_PERIOD_DAYS |
No | 30 |
Days a soft-deleted page is retained before permanent purge. |
STORAGE_TYPE |
No | local |
File-storage backend for uploaded files. Only local is currently supported. |
STORAGE_LOCAL_FOLDER |
No | data/uploads |
Folder the local storage adapter writes uploaded file bytes to (relative to the process's cwd). |
MAX_UPLOAD_SIZE_BYTES |
No | 10485760 (10 MB) |
Per-file upload size cap, in bytes. |
FILES_PURGE_GRACE_PERIOD_HOURS |
No | 24 |
Hours an orphaned uploaded file is retained before pnpm files:purge permanently removes it. |
PORT |
No | 3000 |
Runtime/Node var, not validated by envalid — consumed directly by the Node HTTP server (next start/Docker entrypoint) before the app's env schema is even checked. Only used indirectly here as the default port in APP_URL's fallback. |
@thoth/jobs (see "Process topology" below) has its own, separate environment schema
(apps/jobs/src/environment.ts) — it never reads apps/web's BETTER_AUTH_SECRET, but since
THOTH-061 it opens its own database connection, so DB must be set for both processes (the same
connection string works for a single-SQLite-file deployment):
| Variable | Required? | Default | Description |
|---|---|---|---|
NODE_ENV |
Yes | — | development, production, or test. |
DB |
Yes | — | Database connection string, same value as apps/web's DB above. |
JOB_SOCKET_PATH |
No | a per-UID private temp path | Absolute path to the Unix domain socket @thoth/jobs listens on. Also read directly by apps/web's /api/health route (apps/web/src/lib/jobs/health.ts) to reach the same socket — set it once and pass it to both processes (PM2/Docker/dev harness/tests all do this already). |
JOB_POLL_INTERVAL_MS |
No | 1000 |
How often the runner polls for due jobs when not woken by an enqueue/retry. |
JOB_SHUTDOWN_TIMEOUT_MS |
No | 10000 |
How long the process waits for active handlers to finish/abort on SIGTERM/SIGINT before exiting. PM2's kill_timeout for thoth-jobs (pm2.config.js) is deliberately longer than this. |
JOB_CONCURRENCY |
No | 4 |
Maximum number of jobs executed concurrently. |
JOB_RETENTION_MS |
No | 900000 (15 min) |
How long terminal (completed/dead) job records are retained in memory before eviction. |
JOB_RETENTION_MAX |
No | 500 |
Maximum number of terminal job records retained in memory regardless of age. |
JOB_SCHEDULER_TICK_MS |
No | 5000 |
How often the scheduler ticks to ensure the current interval bucket has been enqueued. |
WEBHOOK_DELIVERY_TIMEOUT_MS |
No | 5000 |
Per-attempt network timeout (ms) for outbound webhook delivery fetches (THOTH-061). |
WEBHOOK_DELIVERY_BACKOFF_BASE_MS |
No | 500 |
Base delay (ms) for full-jitter exponential backoff between webhook delivery retry attempts (THOTH-061). |
STORAGE_TYPE |
No | local |
File-storage backend used by the maintenance.purge-files job (THOTH-063). Must match apps/web's STORAGE_TYPE in any real deployment — both processes act on the same uploaded-file bytes. |
STORAGE_LOCAL_FOLDER |
No | data/uploads |
Local storage folder used by maintenance.purge-files and as the parent of the search index tree (<STORAGE_LOCAL_FOLDER>/_search/<workspaceId>). Must match apps/web's STORAGE_LOCAL_FOLDER. |
SEARCH_MODEL_ID |
No | Xenova/all-MiniLM-L6-v2 |
Local embeddings model used by @thoth/jobs workspace search. Pre-download it into SEARCH_MODEL_CACHE_DIR; runtime search startup disables remote model fetches. |
SEARCH_MODEL_CACHE_DIR |
No | data/models/search |
Cache directory passed to @huggingface/transformers before the search model is first loaded. Relative paths resolve from the @thoth/jobs process cwd. |
SEARCH_INDEX_VERSION |
No | 2 |
Search index schema/version tag. Bump this whenever the search document formatter, chunking, embedding model/options, or other on-disk search-index assumptions change; the next open rebuilds the workspace index automatically. |
SEARCH_QUERY_TIMEOUT_MS |
No | 120000 |
Maximum wall-clock time for one synchronous socket kind:'search' request before the jobs process returns SEARCH_UNAVAILABLE. |
SEARCH_RECONCILE_INTERVAL_MS |
No | 3600000 |
Interval for the scheduled search.scan-workspaces reconciliation pass (default hourly). |
WORKSPACE_DELETE_GRACE_PERIOD_DAYS |
No | 30 |
Grace period read by the scheduled maintenance.purge-workspaces job and by pnpm workspaces:purge. Must match apps/web's value of the same name. |
PAGE_DELETE_GRACE_PERIOD_DAYS |
No | 30 |
Grace period read by the scheduled maintenance.purge-pages job and by pnpm pages:purge. Must match apps/web's value of the same name. |
FILES_PURGE_GRACE_PERIOD_HOURS |
No | 24 |
Grace period read by the scheduled maintenance.purge-files job and by pnpm files:purge. Must match apps/web's value of the same name. |
MAINTENANCE_PURGE_BATCH_SIZE |
No | 100 |
Bounded item count per maintenance handler execution/continuation step (all three purge jobs and their CLI wrappers). |
JOB_COMPLETED_RETENTION_DAYS |
No | 7 |
Days a completed in-memory job record is retained before maintenance.prune-jobs removes it from the queue. |
JOB_DEAD_RETENTION_DAYS |
No | 30 |
Days a dead in-memory job record is retained before maintenance.prune-jobs removes it from the queue — long enough to diagnose a failure before evidence disappears. |
A local MySQL database can be started with Docker Compose:
docker compose up -dThoth runs two long-running Node processes side by side, supervised by PM2
in production/preview images (pm2.config.js at the repository root) and by a lightweight
harness locally/in tests (scripts/dev.mjs, apps/web/tests/integration/global-setup.ts,
apps/web/playwright.config.ts):
thoth-web— the Next.js app. The only process that binds an HTTP port (3000by default). Runs with schema sync always disabled (skipSync: true).thoth-jobs(@thoth/jobs) — an in-memory job queue/scheduler served over a Unix domain socket (never TCP/HTTP). Opens its own database connection (schema sync/migrations disabled), no exposed port. Never administered via HTTP/UI by design (see THOTH-060 non-goals).
Both are PM2 fork-mode, single-instance apps (instances: 1) — clustering is not supported:
the current queue is a single in-process instance, and the web/session/SQLite test topology has
not been designed for multiple concurrent instances of either process. @thoth/jobs also remains a
required singleton for THOTH-086 search specifically: the Vectra filesystem index under
<STORAGE_LOCAL_FOLDER>/_search/<workspaceId> is a single-writer data structure, so only one jobs
process may mutate it at a time. Each restarts
independently on crash (PM2's bounded restart policy); a jobs crash/restart does not disrupt
web (or vice versa) — in-flight leased jobs are recovered by @thoth/jobs on its own restart.
Startup ordering: a one-shot migration (pnpm db:migrate, the @thoth/database migration
CLI) must complete before PM2 starts either process — see scripts/start-production.mjs,
the sole production/preview entrypoint (pnpm start/pnpm start:production, and both
Dockerfiles' CMD). PM2 child restarts never re-run migrations. Within PM2, jobs is declared
first and uses wait_ready/listen_timeout (jobs signals process.send('ready') only after
startup completes — opening its database connection, lease recovery, scheduler init, and a
secure 0600-mode Unix socket bind — see apps/jobs/src/index.ts), but web may start listening before jobs finishes binding; this is
safe because /api/health stays 503 until jobs responds to a real ping.
@thoth/jobs now also owns semantic workspace search. On non-test startup it warms the configured
SEARCH_MODEL_ID once before reporting readiness; if warmup fails, the process exits non-zero
rather than serving a half-initialised socket. Page mutations enqueue search.sync-page for
incremental timestamp-based index updates, while the scheduled hourly search.scan-workspaces →
search.reconcile-workspace pass repairs drift, removes stale/missing/private/deleted pages using
isPageSearchEligible, and rebuilds version-mismatched/corrupt indexes automatically. Changing the
page-document formatter, embedding model/options, chunking config, or any other index assumption
requires bumping SEARCH_INDEX_VERSION.
Health (GET /api/health, public, unauthenticated): returns 200 { status: 'ok', components: { web: 'ok', jobs: 'ok' } } only once a short-timeout
(apps/web/src/lib/jobs/health.ts, 500ms connect/response) protocol ping over
JOB_SOCKET_PATH succeeds; otherwise 503 { status: 'unavailable', components: { web: 'ok', jobs: 'unavailable' } }. Never exposes DB state, queue depth, the socket path, process ids, job
payloads, or exception text — coarse component status only.
Local targeted commands (all delegate to root scripts):
| Script | Command | Purpose |
|---|---|---|
| Full stack dev | pnpm dev |
Migrate → start @thoth/jobs on an isolated socket → wait for a real ping → start next dev --turbopack. Fail-fast: either child exiting stops the other and forwards the failure; Ctrl-C/SIGTERM are forwarded to both and only the harness-owned temp socket dir is removed. |
| Web only | pnpm dev:web |
Starts only next dev, for UI-only debugging. /api/health correctly reports jobs: 'unavailable' unless you separately start @thoth/jobs and export the matching JOB_SOCKET_PATH yourself. |
| Jobs only | pnpm dev:jobs |
Starts only @thoth/jobs (tsx watch), e.g. to pair with dev:web above. |
| Build web only | pnpm build:web |
next build --turbopack for apps/web. |
| Build jobs only | pnpm build:jobs |
Compiles @thoth/jobs to apps/jobs/dist. |
Logs: PM2 merges each app's stdout/stderr into logs/thoth-jobs-*.log /
logs/thoth-web-*.log under the working directory (pm2.config.js); use pm2 logs/pm2 logs thoth-jobs/pm2 logs thoth-web inside the container (docker exec) to tail them live.
Socket security: the socket's parent directory is created mode 0700, and the socket file
itself is chmod 0600 immediately after bind (both owned by the non-root nextjs user in
Docker) — it is ephemeral runtime state, not part of any persisted volume.
- Always set
APP_URLto the exact, absolute public URL of the deployment (e.g.https://thoth.example.com, no trailing slash). Thoth passes this explicitly intobetter-auth'sbaseURLandtrustedOriginsrather than inferring it from the incoming request'sHostheader, which prevents host-header-based origin spoofing from being trusted for cookie/redirect purposes. - Because the Docker image is built once and can be deployed at several different URLs,
APP_URLis intentionally a runtime-only, server-side env var — never aNEXT_PUBLIC_*build-time variable. The browser-side auth client talks to the same origin serving the page, so it needs no URL-related env var at all. - Schema is never synced automatically by the running web process. Before starting/upgrading
the server, run
pnpm db:migrate(delegates to the standalone@thoth/databasemigration CLI) to create/upgrade the schema; the Docker images run this automatically before starting the server. - Set
BETTER_AUTH_SECRETto a strong, unique secret (never reuse the development value).
Run these from the repository root:
| Script | Command | Purpose |
|---|---|---|
| Dev server | pnpm dev |
Start the full stack (migrate → jobs → web) with Turbopack hot-reload |
| Dev server (web only) | pnpm dev:web |
Start only next dev, for UI-only debugging |
| Dev server (jobs only) | pnpm dev:jobs |
Start only @thoth/jobs |
| Build | pnpm build |
Production build of @thoth/jobs and apps/web (next build --turbopack) |
| Start | pnpm start / pnpm start:production |
Migrate once, then run PM2 (thoth-web + thoth-jobs) via scripts/start-production.mjs |
| Lint (all) | pnpm lint |
Run ESLint + Prettier + TypeScript checks concurrently |
| Format | pnpm format |
Auto-fix Prettier and ESLint issues in apps/web/src/ |
| Unit tests | pnpm test:unit |
Run Vitest unit tests |
| Integration tests | pnpm test:integration |
Run API integration tests against a live server |
| All fast tests | pnpm test |
Run unit + integration tests |
| E2E tests | pnpm test:e2e |
Run Playwright end-to-end tests |
| E2E tests (UI) | pnpm test:e2e:ui |
Run Playwright tests in interactive UI mode |
| E2E report | pnpm test:e2e:report |
Show the last Playwright HTML report |
| Seed database | pnpm db:seed |
Seed the database with sample data |
| Migrate database | pnpm db:migrate |
Create/upgrade the schema via the standalone @thoth/database migration CLI |
- Notion → Thoth import script — a standalone CLI that syncs
content from a Notion workspace into a Thoth workspace over the public
/api/v1/*API. See its own README for setup, configuration, and usage. pnpm workspaces:purge/pnpm pages:purge/pnpm files:purge— manual maintenance CLIs (scripts/purge-deleted-{workspaces,pages,files}.ts). Seedocs/JOBS_AND_MAINTENANCE.mdfor the full operations reference: what each command does, its relationship to the scheduled@thoth/jobsmaintenance handlers, migration ownership, and dead-job diagnosis.
Thoth uses three test suites with clear boundaries:
- Unit tests (
apps/web/src/**/*.test.ts, Vitest) — fast, isolated checks with no server required. - API integration tests (
apps/web/tests/integration/api/**/*.test.ts, Vitest) — real HTTP against a spawned Next.js dev server backed by a seeded SQLite database. - E2E tests (
apps/web/tests/e2e/**/*.spec.ts, Playwright) — browser-based UI interaction tests.
Shared seeded data constants live in apps/web/tests/fixtures/seed.ts and are re-exported from apps/web/tests/e2e/constants.ts for Playwright. See .agents/commands/e2e-test.md for E2E conventions.
Issues and PRs are welcome. Please follow TypeScript best practices (prefer type over interface) and the existing Atomic Design conventions in apps/web/src/components. See AGENTS.md for detailed guidance on the codebase's patterns and conventions.
Releases and production Docker images are published automatically via the Release GitHub Actions workflow whenever a vX.Y.Z (stable) or vX.Y.Z-betaN (pre-release) tag is pushed, from main or any other branch (e.g. a hotfix/* branch):
validate-tagchecks the tag matches the expected format and fails fast otherwise.build-and-push-imagebuilds the production image from the rootDockerfileand pushes it to GitHub Container Registry (ghcr.io/<owner>/<repo>:<tag>), additionally tagging:latestfor stable (non-beta) releases.create-releasecreates a GitHub Release with auto-generated, label-categorized release notes; this only runs once the image has built and pushed successfully.
To cut a release, tag the commit you want to ship and push the tag, e.g. git tag v1.4.0 && git push origin v1.4.0 (use -betaN suffix for pre-releases). See docs/RELEASING.md for the full process, including redoing a release.
MIT