Architecture

Prem is an Electron desktop app plus an optional team server. A vault is a folder of markdown notes and attachments; everything Prem adds lives in a hidden .prem folder inside it, so a vault is always just files you can back up, keep in git or open in another editor.

This page is for people changing Prem's code. DESIGN_SYSTEM.md covers how the interface looks, and SECURITY.md how to report a vulnerability.

Overview

flowchart LR
  subgraph App["Prem (Electron)"]
    R["Renderer<br/>React + CodeMirror"] -- "window.api (typed IPC)" --> P[Preload]
    P --> M["Main process<br/>ipc/handlers.ts"]
    M --> VM[VaultManager]
    VM --> LP[LocalFsProvider]
    VM --> RP[RemoteProvider]
    M --> AM[AnalysisManager]
    AM --> PY["prem_runner.py<br/>one per note"]
  end
  LP --> Disk[("Vault folder<br/>notes + .prem/")]
  RP -- "HTTPS + server-sent events" --> S["Team server<br/>src/server"]
  S --> SDisk[("Lab vault folder")]
  PKG["prem package<br/>scripts, Jupyter"] -. files .-> Disk
  PKG -. "HTTP API" .-> S

The renderer never touches files, the network or Python. It asks main, through a narrow typed API, and main checks every argument again before acting.

Layers

src/
  shared/      Plain TypeScript with no Electron, Node-only or React code, used by every process
    vault/       paths, per-folder access rules, error codes, IPC and HTTP contracts, core types
    notes/       templates, the daily notebook, frontmatter, wikilinks, tables, link index and resolution
    records/     history entries, signing status, protocol runs, workflows and jobs, line diff
    search/      the in-memory full-text index
    attachments/ attachment naming and links, CSV parsing, notebook (.ipynb) previews
    analysis/    analysis cells and their outputs, environment lists, reproducing, what needs approval
    settings/    the settings schema and validation
    commands.ts, keybindings.ts   every command, its default shortcut, and keybindings.json
    diagnostics.ts, updates.ts    problem reports, and when auto-update may run
  main/        Electron main process
    index.ts     window and app lifecycle, wiring
    ipc/         validates renderer requests and routes them to the vault layer
    vault/       VaultProvider, LocalFsProvider, RemoteProvider, NoteHistory, VaultManager, backup, format
    analysis/    finding Python, per-vault environments, the cell runner, approvals (trust.ts)
    export/      PDF export
    menu.ts, settings.ts, keybindings.ts, state.ts, log.ts, updates.ts
  preload/     exposes a narrow, typed `window.api` to the renderer through contextBridge
  renderer/    React UI: file tree, CodeMirror editor and extensions, board, panels, dialogs
  server/      the team server: config and tokens, HTTP API, change stream, access checks, lab admin CLI
python/        the prem-notebook package, for writing to a vault from Python (its own pytest suite)
e2e/           Playwright tests that drive the built app

The rule that keeps this maintainable: logic goes in shared/ as pure functions with tests; main/ and server/ wire it to storage; renderer/ only calls window.api. If you find yourself writing business logic in a React component or an IPC handler, it probably belongs in shared/.

Where new code goes

Data on disk

Where What Written by Safe to delete?
vault: notes, attachments/ the notebook the app, other editors, the prem package no: they are the notebook
vault: .prem/history/<note>.jsonl each note's hash-chained history log NoteHistory only no: history and signatures are lost
vault: .prem/objects/ every saved version, compressed, by hash NoteHistory only no
vault: .prem/format.json the vault format version checkVaultFormat, on first open yes: it is written again
vault: .trash/ deleted files on the team server (the app uses the system trash) the server yes, once nothing in it is needed
userData: settings.json, keybindings.json the person's settings and shortcuts the person, or the Settings screen yes: defaults return
userData: state.json the last vault or server opened main/state.ts yes
userData: analysis-trust.json code and environment lists this computer approved TrustStore yes: Prem asks again
userData: envs/, runner/ Python environments and the runner script AnalysisManager yes: rebuilt when needed
userData: logs/ main.log, rotated at 1 MB, three older files kept LogFile yes

userData is ~/Library/Application Support/Prem on macOS, %APPDATA%\Prem on Windows and ~/.config/Prem on Linux.

The storage seam

VaultProvider is the one interface everything above storage uses. Paths are vault-relative POSIX strings. Two implementations:

VaultManager owns the open provider plus the link index and search index, and exposes higher-level operations (open daily entry, create from template, attach a file, sign, history, back up). On open it checks the vault's format (format.ts): a vault written by a newer Prem, with a higher VAULT_FORMAT, is refused with a message rather than half understood. The team server makes the same check when it starts.

Because the renderer never knows which provider is in use, every feature works identically on a local folder and on a team vault.

Versioning and conflicts

Every read returns a version (derived from mtime and size); every write may carry expectedVersion. If the file changed in between, the write fails with CONFLICT and the editor shows "keep mine / reload". The team server serialises writes to the same note under a per-path lock so version checks cannot interleave.

History and signing

NoteHistory records every version of every note in .prem/:

Saves, outside edits (from the watcher, or caught up before the next operation), renames, deletions, signatures, witnesses and amendments are all entries in this log. shared/records/signatures.ts folds a log into a RecordStatus (draft / signed / witnessed / amended, locked or not); the provider refuses writes and deletes to locked notes unless the save carries an amendment reason.

A log that can't be read (after a sync conflict or a disk error) is never treated as empty. NoteHistory.list throws HistoryUnreadable, and recordStatus reports the note as locked with chainOk: false. The note then shows a warning and stays read-only until its log is restored from a backup.

Signatures are attestations tied to the authenticated user (team-server token, or the OS user locally); they are tamper-evident but not cryptographic yet.

Records: runs, workflows, jobs and samples

Runs, workflows, jobs, samples and boxes are notes told apart by their type field, with the logic in pure functions.

A new record type follows the same pattern: a type value, a pure module in shared/records/ with tests, a summary in the link index if a view lists them, and a branch in the bar.

PDF export (src/main/export) prints a note, or every note in a folder, from a self-contained HTML page: printable.ts turns markdown, metadata, the signing events and the history check into HTML (pure, unit-tested), and pdf.ts embeds vault images and prints it in a hidden window with scripts off. Raw HTML in a note is printed as text and the page's content security policy blocks every network load.

Index, search and large vaults

On open, VaultManager reads every note once into the link index and the search index. After that only changed notes are read again.

The window is never sent the whole index. After each change main pushes a small IndexSummary (a version number plus the job and workflow summaries), and the renderer asks for the rest only when it is on screen: the Links panel calls index:links for the open note, the graph calls index:graph, and the Samples view calls index:samples (useLinkIndex, useNoteLinks, useLinkGraph and useSampleIndex in LinkIndexContext.tsx). LinkIndex.snapshot() is cached per version.

npm run e2e:scale checks this holds with 10,000 notes (scripts/bench-vault.mjs, e2e/scale.spec.ts): open within 8 s, search within 1 s, an outside edit shown within 3 s, and main under 900 MB. Run it when you change how notes are read, indexed or sent to the window.

Security model

What someone else could control is note text, files in a synced or shared vault, and anything on the network. The design keeps each of those away from running code.

Not covered yet: signatures are attestations rather than cryptographic signatures, and installers are unsigned until the certificates are added (see Releases).

Commands and shortcuts

shared/commands.ts lists every command with its default shortcut, written once with Mod (⌘ on macOS, Ctrl elsewhere) and matched on the physical key. The keyboard dispatcher, the application menu (main/menu.ts), the command palette, the shortcuts sheet and the README table all read it; a test fails if the README drifts. Components register what a command does while they're on screen with useCommand(id, run, enabled) (renderer/src/commands/registry.ts): the shell handles navigation, the open note handles formatting and signing. A command that can't run right now leaves its key to the editor, so ⌘[ still indents when there's nothing to go back to.

People's own shortcuts are in keybindings.json. shared/keybindings.ts validates it, applies its entries on top of the defaults (resolveBindings) and edits it so it lists only changes (withBinding). Main watches it the same way as settings.json (both are a WatchedJsonFile) and rebuilds the menu when it changes; the renderer passes the result to setBindings. A key bound to two commands runs whichever can run at the time (commandsForKey).

Settings

shared/settings/schema.ts lists every setting with its type, default and allowed values; the Settings screen is built from it and settings.json is checked against it. validate keeps every good value and reports each bad one, falling back to its default, and withSetting writes only what differs from the default while keeping entries Prem doesn't know. main/settings.ts watches the file's folder (so editors that save by replacing the file are seen), and keeps the last good settings while the file has a syntax error. Main applies the theme through nativeTheme, so the renderer's prefers-color-scheme styles follow it, and sends every change to the renderer, which sets CSS variables (SettingsContext.tsx) and reconfigures the editor in place. What Prem remembers between launches (the last vault or server) is in main/state.ts and state.json, out of the way of people's settings.

Python analysis

main/analysis runs Python for notes; docs/analysis.md describes it from the outside.

python/ is the prem-notebook package (docs/python.md). It has no dependencies and two backends:

_markdown.py ports the few note rules it needs: frontmatter, sections, tables, attachment names and links. Change the TypeScript and Python versions together. e2e/python.spec.ts checks that a write from Python reaches the open editor and shows as an outside change.

shared/notes/relink.ts is a pure function from a note's text to its text after a move: it resolves each wikilink against the vault as it was before (the same resolver the editor uses) and rewrites the ones that pointed at something that moved, and recomputes relative markdown links. VaultManager.rename runs it over every note after the provider moves the files, writing with the version it read so a concurrent edit isn't overwritten. Locked and read-only notes are reported, not changed, so a rename never alters a signed record or bypasses permissions. On a team vault this happens in the renaming person's app, through the same permission checks as any other save.

Team server

src/server wraps a LocalFsProvider in a small Node HTTP server (no framework, no database):

The server bundles to a single file (out/server/index.js) with only Node built-ins, so deploying it is copying one file. Backing up a team vault is the server's job: see Backups.

Diagnostics and backup

Releases

Renderer

Testing and CI

ci.yml runs on every pull request:

Job Runs on What
Typecheck, test and build Linux format, lint, typecheck, unit tests, both builds, third-party notices, the site
Unit and smoke tests macOS, Windows typecheck, unit tests, both builds, the @smoke e2e tests
Large vault Linux npm run e2e:scale
Python package Linux, Python 3.9 and 3.13 pytest, then building and installing the wheel
End-to-end tests Linux the whole e2e suite

CodeQL scans every pull request and runs weekly, and Dependabot keeps dependencies current.

This page is docs/ARCHITECTURE.md in the repository. Corrections are welcome.