Skip to content

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.jsenqueue(), 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, lastSyncAt
outbox { 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):

  1. Flush outbox → push any queued local writes to Supabase
  2. Fetch all cloud records → merge into local IndexedDB (last-write-wins)
  3. Download any images that exist in Storage but not locally
  4. Reload React state from IndexedDB

On write (note/book/idea create or delete):

  1. Write to IndexedDB immediately (UI updates instantly)
  2. 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

AreaLimitationPlanned
Conflict resolutionLast-write-wins onlySufficient for v1 single-user
TypeScriptNone — plain JavaScriptMigration planned for v2
TestingNo automated testsUnit tests for db.js and sync logic
Deleted recordsAccumulate indefinitelyScheduled purge of old soft-deletes
API key per-deviceNot synced across devicesBy design — it’s a credential
App.jsx size~600 lines — doing too muchSplit into focused components in v2