A running log of the architectural choices made in this codebase and why — so future-you (or another contributor) can tell whether a decision still applies or has been outgrown.
Each entry follows the same pattern: Decision → Why → How to apply / break.
Decision: All client-facing config (Supabase URL/anon key, Worker URL) is
exposed to the JS bundle via EXPO_PUBLIC_* variables, declared once in .env
for local dev and duplicated into the env block of every eas.json build
profile (preview, production).
Why: EXPO_PUBLIC_* vars are inlined into the JS bundle at build time.
Expo Go reads them from .env at dev time, but EAS Build only reads them from
the profile's env block (or EAS-managed env vars). Forgetting them in the
production profile is what caused the original "Supabase not configured"
modal — the v1.0.1 Play Store APK shipped without the keys baked in.
How to apply: When adding a new public-safe variable (anything the client
must know but isn't a secret), add it to .env, .env.example, and every
profile in eas.json. Secrets (server-side) belong in the Cloudflare Worker,
not in EXPO_PUBLIC_*.
How to break it: Putting a sensitive value (private key, service-role
token) into EXPO_PUBLIC_*. It will be readable by anyone who decompiles the
APK.
Decision: Server-Sent Events from the Worker are consumed with fetch
imported from expo/fetch, not the global fetch.
Why: React Native's Hermes runtime in production builds does not expose
response.body as a ReadableStream — it buffers the whole response. So
res.body.getReader() throws Cannot read property 'getReader' of undefined.
Expo Go masks this with extra polyfills; the prod APK doesn't. expo/fetch
is a WHATWG-spec fetch shipped with Expo SDK 52+ that supports
res.body.getReader() in production RN.
How to apply: Any time you read a streaming/SSE response in client code,
use import { fetch as expoFetch } from "expo/fetch". Non-streaming requests
can stay on the global fetch.
Where it lives: lib/openrouter.ts:streamChatFromWorker.
Decision: On native (iOS/Android), upload binaries by reading them as
base64 via expo-file-system/legacy, decoding to Uint8Array, and passing
that directly to supabase.storage.from(...).upload(path, bytes, {...}).
Web still uses fetch(uri).then(r => r.blob()).
Why: Hermes' Blob constructor is broken for binary data —
new Blob([arrayBuffer], ...) produces a payload that uploads "successfully"
(you get a 200 and a URL) but stores a 0-byte file. Symptom: the model
fetches the image URL and sees nothing. The base64 → Uint8Array path is
the documented, known-working route for RN + Supabase Storage.
How to apply: Any client-side binary upload (avatars, files, audio)
should follow the same shape: readAsStringAsync({ encoding: Base64 }) →
manual base64ToUint8Array → supabase.storage.upload(path, bytes, {contentType}).
Where it lives: lib/uploads.ts:uploadToStorage and helper
base64ToUint8Array.
Decision: messages.attachments jsonb column stores each message's
attachment array. Client code (useChat + openrouter.buildMessageContent)
reads message.attachments, never a session-scoped attachments prop.
Why: The original code attached the current send's attachments to
every user message at API-build time, which had two failure modes:
(a) old turns lost their images the moment the user sent a follow-up without
a new attachment ("upload paper → ask about it" broke by turn 2), and
(b) new attachments leaked back into old turns. Persisting per-message means
"Turn 1's image belongs to Turn 1 forever," including across app restarts.
How to apply: When you add a new message-bound resource (file, audio,
tool-call payload), put it on the Message type and store it on the row.
Never reach into session-scoped React state at API-payload-build time.
Where it lives: migration 0008_message_attachments.sql,
lib/supabase.ts:{insertUserMessage, fetchMessagesForConversation},
hooks/useChat.ts (request builder), lib/openrouter.ts:buildMessageContent.
Caveat: requestMessages still uses slice(-8) — only the most recent 8
turns reach the model. If users frequently reference a turn-1 attachment 10
turns later, this window is too narrow.
Decision: buildMessageContent checks model.modality.includes("image").
If a user turn carries images but the active model can't see, the images
collapse into a textual [Note: ...] marker; the model is told to ask the
user to switch.
Why: Sending an image_url block to a text-only model either errors out
at the provider or gets silently dropped. Either way the user gets a worse
answer than if the model knew images existed but couldn't open them.
How to apply: When adding new content modalities (audio, video, tool
results), gate them the same way against model.modality.
Where it lives: lib/openrouter.ts:modelSupportsImages and
buildMessageContent.
Decision: The Cloudflare Worker strips the leading <!--type:foo-->
marker from fullContent before passing it as preview to
updateConversationAfterAssistant.
Why: The system prompt asks the model to prefix its responses with one of
<!--type:answer-->, <!--type:analysis-->, etc. so the client can render a
badge. The client strips it for display, but the preview / title saved to
Supabase used the raw output, so the drawer showed
<!--type:answer-->Hey th.... Cleaning at write-time keeps the DB free of UI
artifacts.
How to apply: Any output transformation that the model is instructed to emit should be normalized at the point of persistence, not just at the point of display.
Where it lives: worker/src/chat.ts near the updateConversationAfterAssistant
call.
Decision: editUserMessage(id, content) and regenerateLastAssistant()
both delete trailing rows in Supabase, mutate local React state to match,
then re-run the streaming pipeline via a shared runStreamForHistory helper.
Why: The model has no notion of "edit the 3rd turn." The honest semantics of "edit a past message" is "rewind the conversation to that point and resume." Persisting that rewind to Supabase means the user's next app open won't show ghost turns the model never produced.
How to apply: Any chat mutation that changes history (delete a turn, fork,
branch) should go through the same delete-then-restream shape rather than
trying to splice the assistant's reply in place.
Where it lives: hooks/useChat.ts:{editUserMessage, regenerateLastAssistant, runStreamForHistory},
plus DB helpers lib/supabase.ts:{deleteMessagesAfter, updateMessageContent}.
Decision: Top-level overlays (e.g. the drawer) call useSafeAreaInsets()
and apply insets.top / insets.bottom as padding directly. We don't wrap
in <SafeAreaView> at the app root.
Why: Wrapping at the root forces every full-screen overlay to manually
opt out, and SafeAreaView is finicky on Android (status bar vs nav bar vs
edge-to-edge). Applying insets at the component that actually owns the
chrome is more predictable, and matches app/index.tsx's existing pattern.
How to apply: For any new full-screen view that draws under the status bar
(modals, overlays, bottom sheets), import useSafeAreaInsets and add
paddingTop: insets.top to whatever wrapper holds your header.
Where it lives: components/ui/Drawer.tsx, app/index.tsx.
Decision: The outer <View> in MarkdownRenderer has no flex: 1. It
sizes to its children.
Why: When the renderer is dropped inside a vertically-laid-out parent
(like a user message bubble), flex: 1 causes it to grow to fill the entire
available height — producing the "1-character user message takes the whole
screen" bug. Markdown content should always size to its content; only the
scroll container above it owns vertical space.
How to apply: Components that render variable-length text content should
never flex: 1 their outermost wrapper. Reserve flex: 1 for layout
containers, not content containers.
Where it lives: components/chat/MarkdownRenderer.tsx.
Decision: All release Android builds use the EAS-managed keystore. Local
./gradlew bundleRelease runs are not used to produce Play Store uploads.
Why: Play Store rejects any upload signed with a different key than the
original v1.0.1. Local Gradle defaults to a debug keystore. EAS Build
automatically injects the registered production keystore into build.gradle.
Running eas build --profile production (whether --local or cloud)
guarantees signature consistency.
How to apply: Always build via EAS. If a local --local build is needed,
let EAS inject credentials (don't manually edit signing.gradle or
local.properties). Back up the keystore (eas credentials) somewhere
offline — losing it without Play App Signing enrolled means you can never
update the app again.
Where it lives: eas.json (production profile), Expo's credential
storage (not in this repo).
Decision: expo-updates is installed and configured with a single
top-level expo.runtimeVersion (literal string, currently "1.0.1"),
updates.url pointing to the EAS update CDN, and the production build
profile maps to the production update channel. The runtimeVersion is kept
in lock-step with expo.version.
Why literal, not policy: "appVersion": This is a bare workflow project
(the android/ and ios/ folders are committed). EAS rejects runtime-version
policies in bare workflow — they only work in managed workflow. So every
binary bump must also bump expo.runtimeVersion manually.
Why: Most bugs we ship are JS-only (logic, UI, copy, prompt tweaks).
Re-uploading an AAB to Play Store and waiting for review (24–48h) for a
1-line fix is wasted time. OTA lets us ship a corrected JS bundle in seconds
to all installed apps that share the same runtimeVersion.
How to apply: See the next section ("Shipping an OTA update").
How to break it: Changing native code or app.json native config without
bumping runtimeVersion. The OTA will technically apply but the JS will
reference native modules that don't exist in the binary → crash on launch.
Where it lives: app.json (expo.updates, expo.runtimeVersion),
eas.json (channel field on each profile), AndroidManifest.xml
(EXUpdatesURL, runtime version meta-data).
OTA can ship any JS-only change:
- React component changes (UI, layout, styling)
- Hooks, state logic, business rules
- Calls to your Worker / Supabase / OpenRouter
- System prompts, regex transforms, helper functions
- Asset additions that are bundled into JS (small SVGs as components)
| Trigger | Why |
|---|---|
npx expo install <native-module> |
Adds Java/Kotlin/Obj-C code |
Bumping a library that includes a native module (e.g. expo-image-picker major) |
Native binary changes |
Editing app.json plugins, android.*, ios.*, permissions |
Native config changes |
Editing android/ or ios/ directly |
Same |
Bumping expo SDK major |
Native runtime change |
When in doubt: if npx expo prebuild --clean would produce different native
files, you need a new binary. Bump runtimeVersion at the same time.
# 1. Make sure local code is what you want to ship.
git status
git diff
# 2. (Optional but smart) test the bundle locally first.
npx expo start --no-dev --minify
# Run the app from your installed build. If it works, the OTA will too.
# 3. Push the update.
eas update --branch production --message "fix: regenerate + edit user msg"
# Output shows:
# Branch: production
# Runtime version: 1.0.0
# Android update ID: ...
# Update group ID: ...Installed apps fetch the update on next cold start (default
expo.updates.checkAutomatically: ON_LOAD).
eas update:list --branch productionOr in the Expo dashboard → Updates → production.
If the OTA shipped a bug, republish the previous good update:
# find the previous update ID
eas update:list --branch production
# point production at it
eas update:republish --group <previous-group-id> --branch productionThis is faster than fixing forward when something is broken in the wild.
Before eas update --branch production:
- Did I bump
expo.runtimeVersion? → If yes, stop. OTA won't apply to the current binary. Build a new AAB instead. (And remember: bare workflow means you bump it manually — there's nopolicy: "appVersion".) - Did I add or upgrade a package with native code? → Same — new AAB.
- Does the app run locally with
npx expo start --no-dev --minify? → If no, debug locally first; the OTA will inherit the bug. - Are env vars I depend on present in
eas.json'sproduction.envblock? →EXPO_PUBLIC_*get inlined at update bundle time, same as build time. - Worker changes? → Deploy those separately
(
cd worker && npx wrangler deploy). The OTA doesn't ship server code.
| Channel | Used by | Publish here when |
|---|---|---|
development |
dev-client builds | Iterating on local dev clients |
preview |
internal-distribution builds | TestFlight / internal Play track sanity check |
production |
Play Store / public builds | Real users |
eas update --branch <name> writes to that branch. The build profile in
eas.json ties the channel to the branch (currently 1:1 by name).
| Type of change | Action |
|---|---|
| Pure JS — feature, fix, copy, regex, prompt | eas update --branch production (seconds) |
| New package without native code | npx expo install, test, then OTA |
| New package with native code | Bump runtimeVersion, rebuild AAB, submit to Play Store |
| Worker / Supabase function | cd worker && npx wrangler deploy (independent of app) |
| Supabase schema | Apply migration via supabase db push or paste SQL into the SQL editor |
app.json plugin / native config |
Rebuild AAB |
Native folder (android/, ios/) |
Rebuild AAB |