Basalt docs
GitHub

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

  1. 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).
  2. Uniform fields. Typed fields are defined once per workspace and used identically by tree pages and databases — no per-database property wildwuchs; Status means the same thing everywhere (ADR-0013).
  3. 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).
  4. Fast is a feature. Perceived speed is Notion's biggest weakness. We define numeric budgets (below) and fail CI when they regress (ADR-0011).
  5. Local feel, cloud truth. Every interaction is optimistic and applies locally first; the network is never on the input path (ADR-0002).
  6. 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.
  7. Self-host is not a second-class citizen. Minimal footprint is app + Postgres. The cloud runs the same image (ADR-0008).
  8. 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.
  9. Calm linking. Wiki-style [[...]] linking with fuzzy search is the primary connective tissue (Obsidian culture), not a maze of @-menus. @ stays reserved for people.
  10. 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 in infra/ (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:

  1. Vertical first — the architecture is allocation-light by design.
  2. Split jobs/materializer into a worker process (same image, ROLE=worker).
  3. 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.
  4. Read replicas for the projection (search, database views).
  5. Per-tenant databases are not planned; workspace_id sharding 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