A transparent look at every architecture decision, tech choice, and design principle - from a single developer building the thinking tool they always wanted.
Every decision traces back to these. They aren't slogans - they're constraints that shaped the architecture from day one.
Each layer was chosen for a specific reason - not trends, not familiarity. Here's the full breakdown.
railway.toml.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.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.The app works fully offline. The backend only activates for sync and AI - and only when you ask.
The graph is the app. Sources (books, movies, series, topics) and their notes are nodes - and the links between them are the thinking.
[[wikilinks]].book · movie · serie · personal · topic - each with its own colour palette and creator label. The same graph model handles all five.// 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; }
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.
// 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)
bookId_returnType - switching books fires new calls; switching back to a previous book is instant.
// 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)
addManualLink() → NoteLink in Hive. Dismiss → dismissPair() → never resurfaces.
@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.raw.indexOf('{') + raw.lastIndexOf('}') slices the first and last braces - catches any preamble text Gemini occasionally leaks around the payload.Authorization: Bearer <token> via AuthService.getIdToken(). The Fastify plugin verifies it with firebase-admin before any route handler runs.prompt-loader.ts reads suggest.md and discover-single.md at startup. Variables ({{notes}}, {{source}}, {{exclusions}}) are interpolated at call time.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.
The choices that weren't obvious. Each one was a trade-off - here's the reasoning.
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.
Honest status on every milestone. No vaporware - if it's planned, it has a reason to exist.