Overview
What Basalt is
Basalt is a knowledge workspace: block-based documents, typed databases with views, and real-time multi-user collaboration. It serves two first-class shapes of knowledge — structured (the page tree) and linear (flat, typed lists of like pages) — both carrying the same uniform fields. It is one codebase that runs self-hosted (two containers) and as a managed cloud.
Positioning: Notion's structural power without its weight, lock-in, and AI pushiness; Obsidian's speed and linking culture without its single-user, local-file ceiling.
Product principles that shape the architecture
- One truth, many references. Every block has exactly one canonical location. It can be referenced anywhere — rendered live and editable at every reference site — and a page can be a member of many databases without moving. Metadata follows the same rule: field values live on the page, one truth (ADR-0001, ADR-0006, ADR-0013).
- Uniform fields. Typed fields are defined once per workspace and used
identically by tree pages and databases — no per-database property
wildwuchs;
Statusmeans the same thing everywhere (ADR-0013). - Markdown-native. The primary audience is people who work with AI agents. Every page reads and writes as canonical Markdown — round-trip guaranteed, CI-enforced; special blocks (databases, views) have defined Markdown forms. Storage is blocks + CRDT; Markdown is the contract (ADR-0012).
- Fast is a feature. Perceived speed is Notion's biggest weakness. We define numeric budgets (below) and fail CI when they regress (ADR-0011).
- Local feel, cloud truth. Every interaction is optimistic and applies locally first; the network is never on the input path (ADR-0002).
- AI is a plugin, not a foundation. The core has zero AI dependencies. Basalt is built to be worked on by your agents (see Markdown-native), not to sell you ours: AI features live in an optional module — bring your own key, point at your own endpoint, or disable entirely.
- Self-host is not a second-class citizen. Minimal footprint is app + Postgres. The cloud runs the same image (ADR-0008).
- API-first. The web client consumes the same public, versioned API that integrators get (ADR-0009). No private fast paths that let the public API rot.
- Calm linking. Wiki-style
[[...]]linking with fuzzy search is the primary connective tissue (Obsidian culture), not a maze of@-menus.@stays reserved for people. - Your data is portable. With Markdown as the contract, export is just
GET— plus JSON for structure. Portability is a property of the format, not a checkbox.
Non-goals for v1
Explicitly out of scope until after P5 (see roadmap.md):
- Project-management surfaces (kanban/board views, sprints, timelines, per-board field values). Databases serve knowledge lists first — document registers, glossaries, note collections. PM views are a later, additive layer (ADR-0013 documents the deferred per-membership override option).
- Full offline workspace (whole-workspace replication à la Anytype). v1 is online-first with offline tolerance for open documents.
- Plugin marketplace / third-party runtime. The block-type registry is kept modular so this stays possible, but no public plugin API yet.
- End-to-end encryption. Incompatible with server-side search/projection; would need a fundamentally different architecture.
- Native mobile/desktop apps. Responsive web first; desktop shell (Tauri) later if demand justifies it.
- Whiteboards, forms, email and other adjacent surfaces.
System overview
Browser (React SPA)
│ ├── REST /api/v1 (OpenAPI) ── structure, queries, auth
│ ├── WS: Yjs document sync ── collaborative text editing
│ └── WS: event stream ── cache invalidation, presence
▼
Basalt Server ── one Node.js deployable
├── API layer Fastify, zod schemas → OpenAPI
├── Collab layer Hocuspocus (Yjs docs), doc mutator service
├── Materializer Y.Doc → block rows, links, search index
└── Jobs pg-boss (compaction, imports, webhooks)
│
├────────────► Postgres 16+ (truth: docs + structure; projection;
│ search via FTS; queue via pg-boss)
└────────────► S3-compatible (file attachments; MinIO when self-hosted)
Key structural facts:
- One deployable. API, collab, materializer, and jobs run in one process in v1. This is deliberate: the doc mutator and Hocuspocus must share hot document state (see sync.md); self-hosting stays trivial. The scaling path (below) splits it later without changing the model.
- Two sync planes. Document text is CRDT-synced (Yjs). Everything structural — the page tree, database rows, fields, permissions — is server-authoritative REST with optimistic clients and event-driven cache invalidation. The full reasoning lives in sync.md and ADR-0002.
- Projection, not dual truth. Postgres holds Yjs binaries (the truth for doc content) and a queryable projection of every block. The projection is a read model: rebuildable, never written to except by the materializer.
Technology choices
| Layer | Choice | ADR / rationale |
|---|---|---|
| Language | TypeScript everywhere, Node 22 LTS | 0004 |
| Monorepo | pnpm workspaces + Turborepo | standard, boring |
| API server | Fastify + zod → OpenAPI 3.1 → generated client | 0009 |
| Realtime docs | Yjs + Hocuspocus (shared WebSocket) | 0002 |
| Editor | TipTap v3 (ProseMirror) + y-prosemirror | 0003 |
| Markdown | own packages/markdown: unified/remark parser + canonical serializer |
0012 |
| Database | Postgres 16+ (JSONB + relational + FTS) | 0005 |
| Jobs/queue | pg-boss (Postgres-backed, no Redis required) | 0005 |
| Blob storage | S3 API (MinIO self-host, R2/S3 cloud), presigned uploads | boring |
| Frontend | React 19 + Vite, TanStack Router/Query/Virtual | ecosystem depth |
| Design system | own packages/ui on Radix primitives + Tailwind v4 |
"better UI" needs owned components; Radix supplies a11y |
| Auth | Better-Auth (email+password, OIDC); SAML/SCIM later | self-host friendly, TS-native |
| Search | Postgres FTS behind a SearchProvider interface; Meilisearch as later option |
0005 |
| Observability | Prometheus scrape endpoint served by the app (/metrics) + liveness/readiness probes; tracing deferred until it can be inert-unless-configured |
0017 |
| Desktop | (later) Tauri, not Electron | non-goal v1 |
Exact versions are pinned at P0 kickoff; this table records majors and intent.
Independence rule: no runtime dependency on paid/hosted tiers of our tooling (TipTap Pro/Cloud, Liveblocks, etc.). Everything in the critical path is permissively licensed or written by us — otherwise "self-hostable" is a false promise, because a customer's instance would stop at somebody else's paywall or outage.
Licence rule (ADR-0010): no copyleft in the dependency tree — no GPL, AGPL, LGPL, SSPL or BUSL, at any depth. Basalt is proprietary, and a single copyleft library would attach its terms to the product. This is not a preference to weigh against a dependency's merits: it is a hard filter applied before the merits are considered, because discovering it after release is a rewrite rather than a licence problem.
Repository layout
basalt/
apps/
server/ # Fastify API + Hocuspocus + materializer + jobs (one deployable)
web/ # React SPA
packages/
core/ # domain types, block schema, zod validation — shared vocabulary
editor/ # TipTap extensions: block nodes, refs, slash menu, drag layer
sync/ # client sync glue: doc manager, subdoc mounting, offline buffer
markdown/ # canonical Markdown parser + serializer (the ADR-0012 contract)
ui/ # design system (Radix-based components, tokens, themes)
api-client/ # generated TS client from OpenAPI (published, MIT)
infra/
docker/ # Dockerfile, docker-compose.selfhost.yml
helm/ # (later) Kubernetes chart for cloud
docs/
architecture/ # this document set
e2e/ # Playwright end-to-end + performance budget tests
Dependency direction: apps/* → packages/*; packages/core depends on
nothing internal; editor/sync/markdown/ui may depend on core only.
No app code in packages, ever.
Performance budgets
These are product requirements, verified in CI against a seeded self-host instance (fixtures: workspace with 1 000 pages, one database with 10 000 rows). See ADR-0011 for enforcement mechanics.
| Metric | Budget |
|---|---|
| Cold load → first interactive page (P75) | < 1.5 s |
| Warm navigation page → page (P75) | < 150 ms |
| Document open → editable (warm, P75) | < 200 ms |
| Keystroke → paint (P95) | < 50 ms, zero network on input path |
| 10k-row table: first 50 rows painted | < 300 ms |
| 10k-row table scroll | no long task > 50 ms |
| Initial JS (app shell, gzip) | < 300 KB |
| API simple reads (P99, server-side) | < 100 ms |
Engineering practices & quality gates
- CI gates (all red = no merge): typecheck, lint (ESLint + Prettier), unit (Vitest), e2e (Playwright against the self-host compose), performance budgets, Markdown round-trip property suite (ADR-0012), OpenAPI-diff (breaking-change detection on the public API).
- Dev environment: DDEV (
ddev start; Node 22 + Postgres 16 in containers — see ../development.md). The tested self-host artifact remains the Docker compose ininfra/(ADR-0008); CI runs against compose, not DDEV. - Testing focus: the materializer (Y.Doc → projection) and the permission resolver get exhaustive unit suites — they are the two components where silent bugs corrupt trust. Editor behavior is covered by e2e.
- Migrations: forward-only SQL migrations, run automatically on server boot (self-host friendliness), gated by advisory lock.
- Conventional Commits, trunk-based development, feature flags over long-lived branches.
Security & tenancy summary
- Every row carries
workspace_id; every query is workspace-scoped at the repository layer, and since P5 Postgres RLS enforces the same scoping under it: the app connects as an unprivileged role, every tenant table is FORCEd, and a statement with no workspace context sees nothing. It is a tenancy backstop, not a second permission model (ADR-0007, "RLS as shipped"). - Sessions: httpOnly cookies, CSRF protection on mutating routes; PATs (P5) are hashed at rest and scopeable.
- References never leak content across permission boundaries: a transclusion renders a placeholder unless the viewer can read the canonical block (ADR-0007).
- Public share links are read-only token routes with cacheable rendering.
Scaling path (documented, not built)
v1 is a single node and that is fine for a long time. When it is not:
- Vertical first — the architecture is allocation-light by design.
- Split jobs/materializer into a worker process (same image,
ROLE=worker). - Doc sharding: Hocuspocus instances own docs by consistent hash of
doc_id; a thin WS router provides sticky routing. The doc mutator moves with the doc owner. Requires a Redis pub/sub backplane for the event WS — the first moment Redis becomes a dependency, and only for the cloud. - Read replicas for the projection (search, database views).
- Per-tenant databases are not planned;
workspace_idsharding is the escape hatch if a single cluster ever saturates.
Open decisions
| Topic | State |
|---|---|
| License (AGPL vs. permissive, CLA vs. DCO) | ADR-0010 Proposed — founder decision before first public release |
| Product name | "Basalt" confirmed as development name (owner, 2026-07-23); final product name due before public release |
| Formula language surface (v1 scope) | decided during P3, spec'd in data-model.md at AST level |
| Board/calendar view priority order | product call during P3 |