The Lab

How Synapse
is built

A transparent look at every architecture decision, tech choice, and design principle - from a single developer building the thinking tool they always wanted.

Flutter
Hive
Firebase Auth
Fastify
PostgreSQL
Gemini

Built on three beliefs

Every decision traces back to these. They aren't slogans - they're constraints that shaped the architecture from day one.

Local-first
Your graph lives in Hive on your phone. The backend is never the source of truth - it's a backup layer and an AI endpoint. No internet? Synapse still works, completely, always.
Privacy by default
Notes never leave your device without permission. Cloud sync is opt-in, encrypted in transit, and stored as opaque JSONB snapshots. The server never reads your content - it stores bytes.
AI as assistant
Gemini suggests connections between your notes - it never auto-creates them. Every AI suggestion requires a human accept. The graph is always a reflection of your thinking, not the model's.

Six tools, one decision each

Each layer was chosen for a specific reason - not trends, not familiarity. Here's the full breakdown.

Flutter
Client · iOS / Android
One codebase, two platforms, native performance. Flutter's canvas rendering is ideal for force-directed graphs - the core UI of the app. Dart's strong typing pairs well with the graph data model.
Cross-platform + custom canvas rendering
Hive
Local storage · NoSQL
Pure-Dart key-value store with type adapters. Faster than SQLite for graph traversal patterns, no native code to compile, and works offline everywhere. The entire knowledge graph is a Hive Box.
Local-first · fast · pure Dart
Firebase Auth
Identity · JWT tokens
Handles the hard part of auth - email/password, token refresh, and session management. The Flutter app gets an ID token and sends it as a Bearer header. The backend verifies it with Firebase Admin SDK, zero custom auth code.
Battle-tested identity, zero custom auth
Fastify
Backend API · TypeScript
The fastest Node.js HTTP framework with schema-driven validation and plugin architecture. TypeScript gives end-to-end type safety from route handler to database query. Railway deploys it from a single railway.toml.
Speed + type safety + simple deploy
PostgreSQL
Database · Railway
Five normalized tables: users, books, notes, note_links, and graph_layout. Push upserts the full graph; pull assembles it back. Hive is always the source of truth - Postgres is the durable mirror.
Normalized schema + proven durability
Gemini
AI · Link suggestions
The POST /v1/ai/suggest endpoint sends a JSONB snapshot of your graph to Google's AI API and gets back a ranked list of suggested connections - each with a "Because:" explanation grounded in your own notes.
Fast, high-quality cross-domain reasoning

Local-first, cloud-optional

The app works fully offline. The backend only activates for sync and AI - and only when you ask.

FLUTTER APP Hive Database Knowledge Graph · Local Graph UI Canvas + Flutter Widgets Firebase Auth SDK API Client HTTP + JWT sync / AI data / suggestions FASTIFY API · RAILWAY GET /health POST /v1/users/sync POST /v1/sync/push GET /v1/sync/pull POST /v1/sync/layout POST /v1/ai/suggest ✦ POST /v1/ai/discover ✦ POSTGRESQL · RAILWAY users books notes note_links graph_layout 5 normalized tables Hive mirror · push / pull GOOGLE AI API Gemini suggest + discover connections
Data flow
AI path
Always local - Hive is source of truth

Nodes, edges, and meaning

The graph is the app. Sources (books, movies, series, topics) and their notes are nodes - and the links between them are the thinking.

Node types
Book
The root node. Each book is a hub - notes and highlights live attached to it. Multiple books can link to each other through shared ideas.
Note
Free-form thoughts, quotes, or highlights. A note belongs to a book but can link to any other node in the graph via [[wikilinks]].
Source variant
Every source has a type - book · movie · serie · personal · topic - each with its own colour palette and creator label. The same graph model handles all five.
Hive structure
// Real Hive models (Book · Note · NoteLink)
class Book {
  final String     id;
  final String     title;
  final String     author;
  final int        colorIndex;
  final SourceType type;   // book|movie|serie|personal|topic
  final DateTime   createdAt;
}

class Note {
  final String   id;
  final String   title;
  final String   body;
  final String   bookId;    // FK → Book
  final DateTime createdAt;
  final DateTime updatedAt;
}

class NoteLink {
  final String   id;
  final String   sourceId;  // FK → Note
  final String   targetId;  // FK → Note
  final bool     isManual;
  final DateTime createdAt;
}
Tap a node to highlight its connections
Actual Hive data schema
BOOKS id String PK title String author String colorIndex int createdAt DateTime type SourceType NOTES id String PK title String body String bookId String FK topic String createdAt DateTime updatedAt DateTime LINKS id String PK sourceId String FK targetId String FK isManual bool createdAt DateTime 1 N 1 N
PK - primary key (Hive box key)
FK - foreign key reference
SourceType: book · movie · serie · personal · topic

Two features, two models

Suggested Connections runs on Gemini 2.5 Flash - it needs deep reasoning to surface non-obvious links across your notes. Discover runs on the lighter 2.5 Flash-Lite - a simpler recommendation task that rewards speed. Same JSON-native pipeline; the prompts, payloads, caches, and user interactions are completely different.

✦ Discover
AI-powered source recommendations
Pick a source from your library. The app fires three parallel calls - one per return type - and each card appears as soon as its own response arrives. Runs on 2.5 Flash-Lite: no thinking overhead, so cold cards land in ~1-2s instead of ~4-6s.
1 Library source 2 3× parallel API calls 3 Theme prompt built Gemini 2.5 Flash-Lite 4 Swipe deck · add 5 Book → Hive
// 3 calls fire in parallel - cards appear as each resolves
void _fetchAll() {
  for (var i = 0; i < 3; i++) {
    _fetchCard(i, _returnTypes[i]); // ['book','movie','serie']
  }
}

// On manual Refresh: library + all previously shown items sent as
// exclusions; forceRefresh=true bypasses the 24 h server cache.
final existing = [...libraryItems, ..._seenItems];
final result = await ApiService.discoverSingle(
  title: book.title, creator: book.author,
  type: book.type.name, returnType: returnType,
  existing: existing, forceRefresh: isRefresh,
);
// forceRefresh=true deletes the cache entry so the AI is called fresh.
// Without it, the 24 h server cache returns an instant response.
if (!forceRefresh) {
  const cached = cacheGet(key)
  if (cached) return cached
} else {
  _discoverCache.delete(key) // evict stale entry
}
// Exclusion list = library + every card shown this session
const exclusions = existing.map(
  e => `- "${e.title}" by ${e.creator} (${e.type})`
).join('\n')
const result = await discoverModel.generateContent(
  buildDiscoverSinglePrompt(title, creator, type, returnType, existing)
)
cacheSet(key, item)
"Recommend based on genre / themes / emotional tone / cultural context / narrative structure - NEVER on shared words in the title.

❌ WRONG - 'The Exorcist' for 'Demon Slayer' (matched the word 'demon')
✅ RIGHT - 'Kagurabachi' for 'Demon Slayer' (both: shonen manga, grief-driven protagonist, samurai-era Japan)"
{ "title": "Kagurabachi", "creator": "Takeru Hokazono", "reason": "Because both are shonen manga set in samurai-era Japan, driven by a protagonist whose grief over a slain parent becomes the engine of duty and vengeance." }
Session cache keyed bookId_returnType - switching books fires new calls; switching back to a previous book is instant.
Suggested Connections
Semantic links found across your notes
Once per day, your notes are serialised and sent in a single call. Gemini finds three non-obvious cross-domain connections and explains each. You accept or dismiss them individually.
1 All notes 2 Serialize ≤60 × 400ch 3 POST /v1/ai/suggest Gemini 2.5 Flash 4 Filter pairs 5 NoteLink → Hive
// Guard: skip if already have today's cached suggestions
if (db.hasTodaySuggestions) { _loadCachedIfAvailable(); return; }

// Serialize: id + title + topic joined with body
final notes = db.notes.map((n) => {
  'id': n.id, 'title': n.title,
  'content': [n.topic, n.body].where((s) => s.isNotEmpty).join('\n'),
}).toList();

final raw = await ApiService.getSuggestions(notes);
await db.saveDailySuggestions(raw); // cached in _settingsBox

// Filter before display
if (db.areLinked(fromId, toId))       continue; // already connected
if (db.isDismissedPair(fromId, toId)) continue; // user dismissed before
const MAX_NOTES = 60, MAX_CONTENT = 400 // chars per note

const formatted = notes
  .slice(0, MAX_NOTES)
  .map(n => `[${n.id}] ${n.title}\n${n.content.slice(0, MAX_CONTENT)}`)
  .join('\n\n---\n\n')

// Validate IDs in the response match IDs we actually sent
suggestions = parsed.suggestions
  .filter(s => noteIds.has(s.source_note_id)
             && noteIds.has(s.target_note_id)
             && s.source_note_id !== s.target_note_id)
  .slice(0, 3)
"Suggest EXACTLY 3 connections - no more, no fewer.
Prioritise surprising cross-domain connections over obvious same-topic links.
Every suggestion must include a short 'Because:' explanation grounded in the actual note content - no generic filler.
Write the reason field in the same language the notes are written in."
{ "suggestions": [ { "source_note_id": "abc123", "target_note_id": "xyz789", "reason": "Because both explore how the language we use about ourselves quietly shapes long-term behaviour and identity." } ] }
One API call per day. Accept → addManualLink() → NoteLink in Hive. Dismiss → dismissPair() → never resurfaces.
Shared infrastructure
gemini-2.5-flash · flash-lite
Both routes use @google/generative-ai with responseMimeType: 'application/json' so Gemini returns valid JSON natively - no parsing gymnastics needed. Suggest picks flash for reasoning depth; Discover picks flash-lite for latency.
Defensive JSON extraction
raw.indexOf('{') + raw.lastIndexOf('}') slices the first and last braces - catches any preamble text Gemini occasionally leaks around the payload.
Firebase JWT on every call
Flutter attaches Authorization: Bearer <token> via AuthService.getIdToken(). The Fastify plugin verifies it with firebase-admin before any route handler runs.
Prompts as .md files
prompt-loader.ts reads suggest.md and discover-single.md at startup. Variables ({{notes}}, {{source}}, {{exclusions}}) are interpolated at call time.

Encrypted at every layer

Notes are personal - thoughts, reflections, ideas. They're protected independently on the device and in the cloud, so a breach of one layer doesn't expose the other.

On-Device · Local Encryption
Hive boxes encrypted with AES-256
A 32-byte key is generated once and stored in the OS keychain. Every Hive box opens with that key - notes never touch the filesystem in plain text.
FLUTTER APP Hive Boxes AES-256 encrypted books · notes · links · settings 32-byte key flutter_secure_storage reads / writes to OS keychain backed by OS iOS Keychain Android Keystore
Data never written to disk in plain text
In the Cloud · PostgreSQL Encryption
Note fields encrypted with AES-256-GCM
title, body, and topic are encrypted by the API before writing to Postgres. Pull decrypts before returning to Flutter. A DB dump shows unreadable ciphertext.
Flutter plain text in memory HTTPS / TLS push pull FASTIFY API encrypt() DB_ENCRYPTION_KEY Railway env var decrypt() write read POSTGRES title: enc body: enc topic: enc AES-256-GCM ciphertext IV random per write
DB dump shows only unreadable ciphertext
DEVICE Hive AES-256 key in Keychain HTTPS plain text TRANSIT TLS 1.3 HTTPS Railway cert API encrypt() API LAYER Fastify AES-256-GCM Firebase JWT auth write ciphertext DATABASE PostgreSQL App-level enc. + disk AES-256 Railway private net Railway infra disk enc.
Device - Hive + iOS Keychain / Android Keystore
Cloud - AES-256-GCM field encryption before DB write
A credential leak or DB dump yields only ciphertext

Why, not just what

The choices that weren't obvious. Each one was a trade-off - here's the reasoning.

01
Why Hive instead of SQLite or Firestore?
SQLite would require a relational schema - but graph data is fundamentally non-relational. Every new node type would need a migration. Firestore would make the app cloud-dependent and add latency to every read.

Hive stores typed Dart objects directly as bytes. Graph traversal is a simple key lookup. No SQL, no network, no schema. The trade-off: no ad-hoc queries - but the app's query patterns are always "give me all edges from node X", which Hive handles in microseconds.
Hive - local speed over query flexibility
02
Why not use Firestore for the backend?
The original plan was Firebase + Firestore + Cloud Run. But Firestore charges per read/write - a graph with hundreds of nodes syncing in real-time would generate thousands of operations per session.

Railway's PostgreSQL is a flat monthly cost. Five normalized tables (users · books · notes · note_links · graph_layout) mirror the Hive database exactly. Push upserts the full graph; pull assembles it back. No per-document pricing, no Firestore data rules, no lock-in.
Railway + PostgreSQL - predictable cost, normalized schema
03
Why keep Firebase Auth if you dropped Firestore?
Auth is the hardest part to build correctly - token refresh, session expiry, security edge cases. Firebase Auth does all of it for free on the generous Spark plan.

The backend verifies Firebase ID tokens with Admin SDK. We get battle-tested identity without a single line of custom auth code. The trade-off: a Google dependency - but auth is exactly the right place to accept one.
Firebase Auth - best free identity layer
04
Why Gemini for link suggestions?
The AI feature is cross-domain connection discovery - finding links between a philosophy book note written six months ago and a film note from last week. This requires genuine reasoning, not keyword matching.

Gemini was chosen for its balance of reasoning quality and speed - suggestions arrive fast enough to feel interactive while still being genuinely insightful. The "Because:" explanation is the key feature - a model that just returns node pairs would be useless. Google AI's API fits naturally alongside Firebase Auth already in the stack. The trade-off: cost per API call - which is why AI suggestions are on-demand, not automatic.
Gemini - speed, quality, and Google ecosystem fit
05
Why Flutter over React Native or SwiftUI?
The core UI is a force-directed graph - rendered on a custom Canvas. React Native's bridge model would add latency to every frame. SwiftUI would lock us to iOS.

Flutter owns the canvas entirely. The graph renders at 60fps with zero bridge overhead. Dart's type system makes the graph data model safe to evolve. One codebase ships to iOS and Android without compromising the render performance the graph needs.
Flutter - custom canvas + cross-platform
06
Why keep the graph export as JSON?
Lock-in is the enemy of trust. A knowledge graph you've built over years is irreplaceable - it should never be hostage to a single app.

JSON export means your data works with Obsidian, Roam, custom scripts, or anything. The JSONB snapshots stored in Postgres use the same schema. If Synapse disappeared tomorrow, your graph would survive in a format you can read with any text editor.
JSON export - your graph, your data, always

What's coming

Honest status on every milestone. No vaporware - if it's planned, it has a reason to exist.

Launching
Knowledge graph (iOS)
Sources, notes, graph canvas
AI link suggestions
Gemini + "Because:" reasons
AI Discover
Deck UI · 3 daily refreshes · smart exclusions
In progress
App Store validation
Review + submission pipeline
Android support
Same codebase, new target
Graph canvas polish
Performance + new interactions
Planned
Book import (Kindle / Readwise)
Highlights → nodes automatically
macOS / iPad
Flutter desktop target
Web graph explorer
Read-only browser view