Mango Developer Documentation

Everything you need to understand, extend, and contribute to Mango — a native MongoDB GUI compiled from TypeScript to native code with Perry.

Overview

Mango is a cross-platform MongoDB GUI that compiles TypeScript directly to native machine code. There is no Electron, no JVM, and no web view at runtime. The result is a ~7 MB binary that starts in under a second and uses less than 100 MB of RAM.

The key technologies behind Mango:

Architecture

Mango follows a simple layered architecture with three distinct concerns:

┌─────────────────────────────────────────────┐
  UI Layer         src/app.ts                
  Perry widgets, screens, navigation, state   
├─────────────────────────────────────────────┤
  Theme Layer      src/theme/                
  Colors (light/dark), typography, spacing    
├─────────────────────────────────────────────┤
  Data Layer       src/data/                 
  ConnectionStore, MongoClient, Preferences   
├─────────────────────────────────────────────┤
  Perry Runtime    perry/ui, perry/system    
  Native widgets, keychain, platform APIs     
└─────────────────────────────────────────────┘

On native platforms (macOS, iOS, Android, Linux, Windows), the app talks directly to MongoDB via the driver. On the web, a Fastify server proxies MongoDB operations over WebSocket:

// Native platforms
App.tsMongoClient → mongodb driver → TCP → MongoDB

// Web platform
Browser → WebSocket → Fastify (serve.ts) → MongoClient → MongoDB

Project Structure

src/
  app.ts — Main UI: all screens, navigation, state (1770 lines)
  serve.ts — Fastify web server + WebSocket proxy
  data/
    connection-store.ts — SQLite CRUD for connection profiles
    mongo-client.ts — MongoDB driver wrapper
    web-mongo-client.ts — WebSocket client for web mode
    preferences.ts — User preferences with SQLite + in-memory cache
    database.ts — SQLite singleton factory
    telemetry.ts — Optional analytics events
  theme/
    colors.ts — Light & dark theme color definitions
    typography.ts — Platform-specific font families & sizes
tests/
  preload.ts — Bun test mocks for Perry and MongoDB
  connection-store.test.ts
  mongo-client.test.ts
  preferences.test.ts
perry.toml — Perry build config: targets, icons, entitlements
package.json — Dependencies and scripts
tsconfig.json — TypeScript config (ES2022, strict)
bunfig.toml — Bun test runner config

Building & Running

Prerequisites

Install dependencies

npm install

Development (compile & run with hot reload)

perry compile src/app.ts -o Mango

Production build

# Build for the current platform
perry build

# Build for a specific target
perry build --target macos
perry build --target ios
perry build --target android
perry build --target linux
perry build --target windows
perry build --target web

Type checking

perry check

Run tests

# All tests
bun test

# Single test file
bun test tests/connection-store.test.ts

Web mode

For the web target, Mango runs as a Fastify HTTP server that serves static assets and proxies MongoDB operations over WebSocket:

# Start the web server
perry compile src/serve.ts -o MangoWeb
./MangoWeb --host 0.0.0.0 --port 3000

# Transient mode (demo, no persistence)
./MangoWeb --transient

What is Perry?

Perry is a TypeScript-to-native compiler. It parses TypeScript using SWC (Speedy Web Compiler), then generates native machine code via Cranelift. The result is a self-contained binary for each platform — no runtime, no interpreter, no bundled V8.

Perry handles:

Key insight: Perry is not a JavaScript runtime. Your TypeScript becomes native code — there is no event loop, no garbage collector, and no JIT compilation at runtime.

perry.toml configuration

The perry.toml file at the project root controls all build settings:

[project]
name = "Mango"
entry = "src/app.ts"
build_number = 57

[targets]
platforms = ["macos", "ios", "android", "linux", "windows", "web"]

[macos]
distribution = ["appstore", "direct"]
entitlements = ["sandbox", "network-client"]

[build]
opt_level = 2

UI Widgets

Perry provides an imperative widget API. There is no JSX, no virtual DOM, and no declarative binding. You create widgets, configure them with function calls, and add them to layout stacks.

Available widgets

WidgetImportDescription
VStackperry/uiVertical stack layout
HStackperry/uiHorizontal stack layout
Textperry/uiLabel / text display
TextFieldperry/uiSingle-line text input
TextAreaperry/uiMulti-line text input
Buttonperry/uiClickable button
ScrollViewperry/uiScrollable container
ImageFileperry/uiImage display
Dividerperry/uiVisual separator
Spacerperry/uiFlexible empty space

Creating widgets

import { VStack, HStack, Text, Button, TextField } from "perry/ui";

// Create a vertical layout
const container = new VStack();

// Add a text label
const title = new Text("Hello, Mango");
container.addArrangedSubview(title);

// Add a button with a click handler
const btn = new Button("Connect", () => {
  handleConnect();
});
container.addArrangedSubview(btn);

// Add a text field
const input = new TextField("Enter hostname...");
container.addArrangedSubview(input);

Styling & Layout

All styling is done through imperative function calls — there is no CSS. Colors are RGBA values (0.0–1.0 range), and layout is controlled through stack distribution and alignment.

Styling functions

import {
  textSetFontSize,
  widgetSetBackgroundColor,
  setCornerRadius,
  setPadding,
  stackSetDistribution,
  stackSetAlignment,
  setSpacing
} from "perry/ui";

// Font sizing
textSetFontSize(title, 24);

// Background color (RGBA, 0.0–1.0)
widgetSetBackgroundColor(card, 1.0, 1.0, 1.0, 1.0);

// Rounded corners
setCornerRadius(card, 12);

// Padding (top, right, bottom, left)
setPadding(card, 16, 20, 16, 20);

// Stack layout
stackSetDistribution(container, 0); // 0 = fill
stackSetAlignment(container, 0);    // 0 = leading
setSpacing(container, 8);
No CSS, no DOM. If you're coming from a web background, think of Perry's styling as SwiftUI-style modifiers applied imperatively. Every visual property is set through a direct function call.

Platform Detection

Perry exposes a compile-time constant __platform__ that resolves to an integer. Since this is a compile-time constant, Cranelift eliminates dead branches — native builds never include web-only code and vice versa.

// __platform__ values
0 = macOS
1 = iOS
2 = Android
3 = Windows
4 = Linux
5 = Web

// Usage in code
declare const __platform__: number;

if (__platform__ === 5) {
  // Web-only code — stripped from native builds
  import { WebMongoClient } from "./data/web-mongo-client";
} else {
  // Native-only code — stripped from web builds
  import { MongoClient } from "./data/mongo-client";
}

The perry-styling module also provides named constants:

import { isMacOS, isIOS, isAndroid, isWindows, isLinux, isWeb } from "perry-styling";

System APIs

Perry provides platform-native system APIs through the perry/system module:

APIDescription
isDarkMode()Returns true if the OS is in dark mode
keychainSave(key, value)Store a secret in the platform keychain (macOS Keychain, Windows Credential Manager, Linux Secret Service)
keychainGet(key)Retrieve a secret from the keychain
getDeviceIdiom()Returns the device type (phone, tablet, desktop)
import { isDarkMode, keychainSave, keychainGet } from "perry/system";

// Determine theme
const dark = isDarkMode();
const bg = dark ? colors.dark.background : colors.light.background;

// Store password securely
await keychainSave("mango-conn-abc123", password);

// Retrieve password
const pw = await keychainGet("mango-conn-abc123");

app.ts — The Main File

src/app.ts is the single-file entry point for the entire UI. At ~1770 lines, it contains all screens, navigation logic, widget construction, and event handling. This is intentional — Perry apps favor flat, imperative code over deep component hierarchies.

High-level structure

// 1. Imports
import { VStack, HStack, ... } from "perry/ui";
import { ConnectionStore } from "./data/connection-store";
import { MongoClient } from "./data/mongo-client";

// 2. Global state variables
let connectionIds: string[] = [];
let connectionNames: string[] = [];
let currentScreen = 0;

// 3. Screen builders
function buildConnectionList() { ... }
function buildBrowser() { ... }
function buildEditor() { ... }

// 4. Event handlers
function handleConnect() { ... }
function runQuery() { ... }

// 5. App initialization
buildConnectionList();

Screens & Navigation

Mango uses indexed screen-based navigation. Each screen is a distinct view built by a dedicated function:

ScreenFunctionDescription
Connection ListbuildConnectionList()Manage saved connections — create, edit, delete, connect
Database BrowserbuildBrowser()Tree navigation of databases and collections, query bar, results
Document EditorbuildEditor()View/edit document JSON with syntax highlighting
Edit ModalshowEditView()Full-screen modal for creating, editing, or duplicating documents

Navigation between screens is done by toggling visibility of top-level containers and updating the currentScreen index.

State Management

Mango uses module-level variables for all state. There is no Redux, no signals, and no observable patterns — just plain TypeScript variables that are read and mutated directly.

// Connection list state
let connectionIds: string[] = [];
let connectionNames: string[] = [];
let connectionUris: string[] = [];

// Form state for connection editing
let formName = "";
let formHost = "localhost";
let formPort = "27017";

// Query state
let currentFilter = "{}";
let currentSort = "{}";
let currentProjection = "{}";

When state changes, you manually call widget update functions to refresh the UI:

// Update a text widget after state changes
title.setText(`Documents (${results.length})`);

// Rebuild a list by removing and re-adding children
listContainer.removeAllArrangedSubviews();
for (const doc of results) {
  listContainer.addArrangedSubview(buildDocRow(doc));
}
Why no reactive framework? Perry compiles to native code, so the overhead of a virtual DOM or reactive system would add complexity without benefit. Direct widget manipulation is predictable and fast.

Theme System

Mango's theme is defined in src/theme/colors.ts as two color palettes (light and dark) and in src/theme/typography.ts for platform-specific fonts.

Color palette

Colors use an RGBA interface with values from 0.0 to 1.0:

interface RGBA {
  r: number; g: number; b: number; a: number;
}

const light = {
  background: { r: 1.0, g: 0.973, b: 0.941, a: 1.0 },  // #FFF8F0 Cream
  surface:    { r: 1.0, g: 1.0,   b: 1.0,   a: 1.0 },  // White
  text:       { r: 0.169, g: 0.176, b: 0.259, a: 1.0 },  // #2B2D42 Charcoal
  accent:     { r: 1.0, g: 0.624, b: 0.110, a: 1.0 },  // #FF9F1C Mango Orange
};

const dark = {
  background: { r: 0.169, g: 0.176, b: 0.259, a: 1.0 },  // #2B2D42 Charcoal
  surface:    { r: 0.22,  g: 0.23,  b: 0.31,  a: 1.0 },  // Dark Gray
  text:       { r: 0.91,  g: 0.91,  b: 0.93,  a: 1.0 },  // Light Gray
  accent:     { r: 1.0, g: 0.624, b: 0.110, a: 1.0 },  // Mango Orange (same)
};

Typography

Fonts are selected per platform at compile time:

PlatformUI FontMono Font
macOS / iOS.AppleSystemUIFontSF Mono
WindowsSegoe UICascadia Code
AndroidRobotoRoboto Mono
LinuxUbuntuJetBrains Mono
Websystem-uiui-monospace

Font sizes are defined as named constants:

const FontSize = {
  XS:    11,
  Small: 13,
  Body:  15,
  Large: 18,
  XL:    32,
};

ConnectionStore

src/data/connection-store.ts manages saved connection profiles in SQLite. It handles creation, retrieval, updates, and deletion of connection configs, with secure password storage through the platform keychain.

Connection model

Each connection profile stores:

Password storage

Passwords are never stored in SQLite. On native platforms, they go into the OS keychain. On web, localStorage is used as a fallback.

// Native: save to platform keychain
await keychainSave(`mango-${connectionId}`, password);

// Web fallback: localStorage
localStorage.setItem(`mango-pw-${connectionId}`, password);

SQLite schema

CREATE TABLE connections (
  id          TEXT PRIMARY KEY,
  name        TEXT NOT NULL,
  mode        TEXT DEFAULT 'host',
  host        TEXT DEFAULT 'localhost',
  port        INTEGER DEFAULT 27017,
  uri         TEXT,
  auth        TEXT,
  tls         INTEGER DEFAULT 0,
  ca_cert     TEXT,
  default_db  TEXT,
  created_at  TEXT,
  updated_at  TEXT
);

CREATE TABLE app_state (
  key   TEXT PRIMARY KEY,
  value TEXT
);

Core API

import { ConnectionStore } from "./data/connection-store";

const store = new ConnectionStore();

// Create
const id = store.createConnection({ name, host, port, ... });

// Read
const all = store.getAllConnections();
const one = store.getConnection(id);

// Update
store.updateConnection(id, { name: "Production" });

// Delete
store.deleteConnection(id);

// Build a connection URI from a saved profile
const uri = store.buildConnectionUri(id);

MongoClient

src/data/mongo-client.ts is a thin wrapper around the official MongoDB Node.js driver. It provides the core operations that Mango needs and tracks connection state and latency.

Core methods

MethodDescription
connect(uri)Establish connection, validate with ping
disconnect()Close the connection
listDatabases()List all databases with sizes
listCollections(db)List all collections in a database
query(db, coll, opts)Find documents with filter/sort/projection/limit/skip
insertDocument(db, coll, doc)Insert a new document
updateDocument(db, coll, filter, update)Update a document with $set
deleteDocument(db, coll, filter)Delete a single document by filter
duplicateDocument(db, coll, doc)Clone a document (strips _id)
getCollectionStats(db, coll)Collection metadata (count, size, avg doc size)
listIndexes(db, coll)Index list with keys and sizes

Query options

interface QueryOptions {
  filter?: object;       // MongoDB filter document, e.g. { status: "active" }
  sort?: object;         // Sort spec, e.g. { createdAt: -1 }
  projection?: object;   // Field projection, e.g. { name: 1, email: 1 }
  limit?: number;        // Max documents to return (default from preferences)
  skip?: number;         // Pagination offset
}

Usage

import { MongoClient } from "./data/mongo-client";

const client = new MongoClient();

// Connect
await client.connect("mongodb://localhost:27017");

// List databases
const dbs = await client.listDatabases();

// Query with filter and sort
const docs = await client.query("mydb", "users", {
  filter: { active: true },
  sort: { createdAt: -1 },
  limit: 50
});

// Insert a document
await client.insertDocument("mydb", "users", {
  name: "Alice",
  email: "alice@example.com"
});

PreferencesStore

src/data/preferences.ts persists user settings in SQLite with an in-memory cache for fast reads.

Available preferences

KeyTypeDefaultDescription
themestring"system"Color theme: "system", "light", or "dark"
pageSizenumber20Documents per query page: 20, 50, 100, or 500
expandDocumentsByDefaultbooleanfalseAuto-expand JSON documents in results
showCollectionStatsbooleanfalseShow doc count & size in sidebar
analyticsEnabledbooleantrueOpt-in/out of anonymous telemetry
lastConnectionIdstring?nullAuto-select last-used connection on launch
sidebarWidthnumber250Width of the database sidebar in pixels

Usage

import { PreferencesStore } from "./data/preferences";

const prefs = new PreferencesStore();

// Read (returns from in-memory cache)
const theme = prefs.get("theme");       // "system"
const pageSize = prefs.get("pageSize"); // 20

// Write (updates cache + SQLite)
prefs.set("theme", "dark");
prefs.set("pageSize", 100);

Web Mode & WebSocket Proxy

When compiled for the web target, Mango can't use the native MongoDB driver directly (browsers lack TCP socket access). Instead, a Fastify server (src/serve.ts) acts as a proxy:

Server (serve.ts)

// serve.ts starts a Fastify HTTP server
// Serves static assets (HTML, JS, CSS) over HTTP
// Proxies MongoDB operations over WebSocket at /ws

// Supported WebSocket messages:
{
  action: "connect",        // → MongoClient.connect(uri)
  action: "disconnect",     // → MongoClient.disconnect()
  action: "listDatabases",   // → MongoClient.listDatabases()
  action: "listCollections", // → MongoClient.listCollections(db)
  action: "find",           // → MongoClient.query(db, coll, opts)
  action: "insertOne",      // → MongoClient.insertDocument(...)
  action: "updateOne",      // → MongoClient.updateDocument(...)
  action: "deleteOne",      // → MongoClient.deleteDocument(...)
}

Client (web-mongo-client.ts)

The web-side client mirrors the MongoClient API but sends operations over WebSocket instead of TCP:

import { WebMongoClient } from "./data/web-mongo-client";

// Same API as MongoClient — the app code doesn't need to know
// whether it's running natively or in a browser
const client = new WebMongoClient();
await client.connect(uri);
const dbs = await client.listDatabases();

CLI options for serve.ts

FlagDefaultDescription
--hostlocalhostBind address
--port3000Listen port
--transientfalseDemo mode — no data persistence
--html(auto)Path to static HTML assets

Telemetry

src/data/telemetry.ts provides optional, anonymous event tracking via the Chirp API. It respects the user's analyticsEnabled preference and is fully try-catch wrapped — a telemetry failure will never crash the app.

Tracked events

EventWhenDimensions
app_launchApp startsplatform, version
connectUser connects to MongoDBplatform, version
queryUser runs a queryplatform, version
Privacy: Telemetry never sends database contents, connection URIs, credentials, or personally identifiable information. Users can opt out at any time in preferences.

Adding Features

Here's a walkthrough of adding a new feature to Mango — for example, an aggregation pipeline builder.

Step 1: Add the data operation

Add a new method to src/data/mongo-client.ts:

async aggregate(
  dbName: string,
  collName: string,
  pipeline: object[]
): Promise<object[]> {
  const db = this.client.db(dbName);
  const coll = db.collection(collName);
  return await coll.aggregate(pipeline).toArray();
}

Step 2: Add the WebSocket action (for web mode)

In src/serve.ts, add a handler for the new action:

case "aggregate":
  const results = await client.aggregate(
    msg.dbName, msg.collName, msg.pipeline
  );
  ws.send(JSON.stringify({ action: "aggregate", results }));
  break;

Mirror this in src/data/web-mongo-client.ts so the web client can call the same API.

Step 3: Build the UI

In src/app.ts, create a new function for the aggregation screen:

function buildAggregationView() {
  const container = new VStack();
  setPadding(container, 16, 16, 16, 16);

  // Pipeline stage editor
  const editor = new TextArea('[{ "$match": {} }]');
  container.addArrangedSubview(editor);

  // Run button
  const runBtn = new Button("Run Pipeline", async () => {
    const pipeline = JSON.parse(editor.content);
    const results = await client.aggregate(currentDb, currentColl, pipeline);
    displayResults(results);
  });
  container.addArrangedSubview(runBtn);

  return container;
}

Step 4: Write tests

Add a test in tests/ following the existing patterns.

Adding Screens

To add a new screen to Mango:

  1. Create the builder function — e.g., buildSettingsScreen() in app.ts
  2. Assign a screen index — add a new constant for navigation
  3. Add navigation — add a button or menu item that sets currentScreen and shows the new container
  4. Wire up the back button — ensure the user can return to the previous screen
// Define a new screen index
const SCREEN_SETTINGS = 3;

// Builder function
function buildSettingsScreen() {
  const root = new VStack();

  // Back button
  const back = new Button("Back", () => {
    navigateTo(SCREEN_BROWSER);
  });
  root.addArrangedSubview(back);

  // ... settings UI ...

  return root;
}

// Navigate to it
function navigateTo(screen: number) {
  // Hide current screen, show target screen
  currentScreen = screen;
}

New Database Operations

To add a new MongoDB operation to Mango, you need to update three files:

FileWhat to add
src/data/mongo-client.tsNew method using the mongodb driver
src/data/web-mongo-client.tsMatching method that sends via WebSocket
src/serve.tsNew WebSocket action handler in the switch block
Keep the API symmetric. The MongoClient and WebMongoClient must have identical method signatures so that app.ts can use either one depending on the platform, without conditional logic.

Custom Themes

To create a new color theme:

  1. Open src/theme/colors.ts
  2. Create a new ThemeColors object following the light/dark pattern
  3. Add a new theme option in PreferencesStore
  4. Update the theme selection logic in app.ts
// Example: adding a "solarized" theme to colors.ts
export const solarized: ThemeColors = {
  background: { r: 0.0, g: 0.169, b: 0.212, a: 1.0 },  // #002b36
  surface:    { r: 0.027, g: 0.212, b: 0.259, a: 1.0 },  // #073642
  text:       { r: 0.514, g: 0.580, b: 0.588, a: 1.0 },  // #839496
  accent:     { r: 0.710, g: 0.537, b: 0.0,   a: 1.0 },  // #b58900
  // ... remaining color keys
};

Testing

Mango uses Bun as its test runner with a preload script that mocks Perry and platform APIs.

Test setup

tests/preload.ts sets up mocks before any test file runs:

Writing a test

import { describe, it, expect, beforeEach } from "bun:test";
import { resetDatabase } from "./mocks/reset-database";
import { ConnectionStore } from "../src/data/connection-store";

describe("ConnectionStore", () => {
  let store: ConnectionStore;

  beforeEach(() => {
    resetDatabase();
    store = new ConnectionStore();
  });

  it("creates and retrieves a connection", () => {
    const id = store.createConnection({
      name: "Test",
      host: "localhost",
      port: 27017
    });
    const conn = store.getConnection(id);
    expect(conn.name).toBe("Test");
  });
});

Integration tests

Tests in tests/mongo-client.test.ts run against a real MongoDB server. They are automatically skipped if MongoDB is not available at localhost:27017. To run them:

# Start MongoDB locally, then:
bun test tests/mongo-client.test.ts