Architecture Overview (Historical)
Architecture
Historical document. Describes the original Syntopicon architecture. Many decisions here have evolved — BYOK was sunsetted (SUR-91), schema is now v9, E2EE was added (SUR-106), and a managed proxy replaced direct Anthropic calls. See the current System Architecture for the live picture.
This document records the key architectural decisions in Syntopicon, the reasoning behind them, and the trade-offs accepted.
Overview
Syntopicon is a personal reading index — a PWA that lets a reader photograph annotated book pages and handwritten notes, tag the content to philosophical ideas, and browse those ideas across their entire library.
┌─────────────────────────────────────────────┐│ Browser ││ ││ React UI ──► Dexie (IndexedDB) ││ │ │ ││ │ Outbox queue ││ │ (offline writes) │└──────┼────────────────┼────────────────────┘ │ │ sync ▼ ▼ Anthropic API Supabase (Claude) ├── Postgres (notes, books, ideas) ├── Auth (Google OAuth + email-OTP signin) └── Storage (note images)Key decisions
1. Offline-first with IndexedDB as the primary store
Decision: All reads and writes go to IndexedDB first. Supabase is a sync target, not the source of truth for the UI.
Reasoning: The app must work on mobile in poor connectivity — on the tube, in a library, travelling. Making the UI wait for network calls would make it feel unreliable.
Trade-off: Conflict resolution is simplified (last-write-wins by
updatedAt timestamp) because this is a single-user application.
Multi-user scenarios would require a more sophisticated CRDT approach.
2. Offline write queue (outbox pattern)
Decision: Writes that fail due to being offline are stored in a
local outbox table and flushed when connectivity returns.
Reasoning: Silently dropping writes when offline would cause data loss. The outbox guarantees eventual consistency without requiring the user to think about network state.
Implementation: src/db.js — enqueue(), getOutbox(),
clearOutboxItems(). Flush triggered by the online browser event
and on app start.
3. Images stored in Supabase Storage, not the database
Decision: Note images are uploaded to Supabase Storage
(note-images bucket) rather than stored as base64 in the notes
table.
Reasoning: Base64-encoding a compressed JPEG still produces ~200–400KB of text per image. Storing this in Postgres rows would bloat the database, slow sync queries, and push against Supabase’s free tier row limits quickly.
Trade-off: Images require a separate upload/download step during
sync. The local IndexedDB record retains the imageDataUrl for
offline display, while imagePath tracks the cloud storage path.
4. Row-level security on all Supabase tables
Decision: Every table has RLS policies that restrict access to
auth.uid() = user_id.
Reasoning: The anon key is exposed in client-side code (this is expected and safe for Supabase). Without RLS, any user with the anon key could read any other user’s data. RLS ensures the database enforces isolation regardless of what the client sends.
5. Claude API called directly from the browser
Decision: The Anthropic API is called directly from client-side JavaScript using the user’s own API key, stored in IndexedDB.
Reasoning: Avoids building and operating a backend proxy. The API
key belongs to the user — they bear the cost directly and control their
own usage. The anthropic-dangerous-direct-browser-access header is
required and intentional.
Trade-off: Each device must have the API key entered separately (it is intentionally not synced to Supabase — it’s a credential, not data). This is the correct security posture.
6. Soft deletes
Decision: Records are never hard-deleted from IndexedDB or Supabase.
Instead, a deleted flag is set to 1/true and updatedAt is bumped.
Reasoning: Hard deletes create sync complexity — if device A deletes a record while device B is offline, device B has no way of knowing the record should be gone when it reconnects. Soft deletes propagate through the normal sync mechanism.
Trade-off: Deleted records accumulate over time. A future maintenance task could hard-purge records deleted more than 90 days ago.
Data model
IndexedDB (Dexie, local)
books { id, title, author, createdAt, updatedAt, deleted }notes { id, bookId, text, page, tags[], imageDataUrl, imagePath, createdAt, updatedAt, deleted }customIdeas { id, name, description, createdAt, updatedAt, deleted }meta { key, value } — apiKey, lastSyncAtoutbox { id++, table, payload, createdAt }Supabase (Postgres, cloud)
books { id, user_id, title, author, created_at, updated_at, deleted }notes { id, user_id, book_id, text, page, tags jsonb, image_path, created_at, updated_at, deleted }custom_ideas { id, user_id, name, description, created_at, updated_at, deleted }Storage bucket: note-images/{user_id}/{note_id}.jpg
Sync algorithm
On app start (if authenticated and online):
- Flush outbox → push any queued local writes to Supabase
- Fetch all cloud records → merge into local IndexedDB (last-write-wins)
- Download any images that exist in Storage but not locally
- Reload React state from IndexedDB
On write (note/book/idea create or delete):
- Write to IndexedDB immediately (UI updates instantly)
- Attempt cloud write → if offline, enqueue in outbox
On reconnect (online browser event):
- Trigger full sync cycle as above
Schema versioning
The db.js file uses Dexie’s versioned migration system. The current
schema is v2. Any future change to the IndexedDB structure must be
implemented as a new db.version(n) block with an upgrade function.
Export files include a schemaVersion field. Future import logic should
check this version and apply any necessary field transformations before
loading the data.
Known limitations and future work
| Area | Limitation | Planned |
|---|---|---|
| Conflict resolution | Last-write-wins only | Sufficient for v1 single-user |
| TypeScript | None — plain JavaScript | Migration planned for v2 |
| Testing | No automated tests | Unit tests for db.js and sync logic |
| Deleted records | Accumulate indefinitely | Scheduled purge of old soft-deletes |
| API key per-device | Not synced across devices | By design — it’s a credential |
| App.jsx size | ~600 lines — doing too much | Split into focused components in v2 |