https://github.com/mehotkhan/safarnak.app
Safarnak - AI-powered offline-first travel companion with multi-storage architecture. Built with Expo React Native, Cloudflare Workers (D1, KV, Vectorize, R2, Durable Objects), Drizzle ORM, and GraphQL. Features bilingual support (EN/FA), NativeWind styling, and type-safe end-to-end architecture.
https://github.com/mehotkhan/safarnak.app
ai-powered cloudflare-d1 cloudflare-workers drizzle-orm durable-objects expo graphql i18n mobile-app nativewind offline-first react-native real-time serverless travel-app typescript vector-database
Last synced: 15 days ago
JSON representation
Safarnak - AI-powered offline-first travel companion with multi-storage architecture. Built with Expo React Native, Cloudflare Workers (D1, KV, Vectorize, R2, Durable Objects), Drizzle ORM, and GraphQL. Features bilingual support (EN/FA), NativeWind styling, and type-safe end-to-end architecture.
- Host: GitHub
- URL: https://github.com/mehotkhan/safarnak.app
- Owner: mehotkhan
- License: mit
- Created: 2025-10-23T14:22:49.000Z (6 months ago)
- Default Branch: master
- Last Pushed: 2025-12-18T09:41:02.000Z (4 months ago)
- Last Synced: 2025-12-20T08:29:22.866Z (4 months ago)
- Topics: ai-powered, cloudflare-d1, cloudflare-workers, drizzle-orm, durable-objects, expo, graphql, i18n, mobile-app, nativewind, offline-first, react-native, real-time, serverless, travel-app, typescript, vector-database
- Language: TypeScript
- Homepage: https://safarnak.app
- Size: 35.1 MB
- Stars: 2
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- Contributing: CONTRIBUTING.md
- Funding: .github/FUNDING.yml
- License: LICENSE
- Code of conduct: CODE_OF_CONDUCT.md
Awesome Lists containing this project
README
# π Safarnak
> **Ψ³ΩΨ±ΩΨ§Ϊ©** - A modern offline-first travel companion built with Expo React Native and Cloudflare Workers
[](https://www.typescriptlang.org/)
[](https://reactnative.dev/)
[](https://expo.dev/)
[](https://workers.cloudflare.com/)
[](https://the-guild.dev/graphql/codegen)
[](https://reactnative.dev/blog/2024/10/23/the-new-architecture-is-here)
[](LICENSE)
[](https://github.com/mehotkhan/safarnak.app/releases)
[](https://github.com/mehotkhan/safarnak.app/actions)
**Live Demo**: [safarnak.app](https://safarnak.app) | **Download APK**: [Latest Release](https://github.com/mehotkhan/safarnak.app/releases)
---
## Table of Contents
- [What is This?](#-what-is-this)
- [Architecture Overview](#-architecture-overview)
- [Quick Start](#-quick-start)
- [Codebase Structure](#-codebase-structure)
- [Routing & URLs](#-routing--urls)
- [Database Model](#-database-model-er-diagram)
- [How to Add New Features](#-how-to-add-new-features)
- [Configuration](#-configuration)
- [Common Commands](#-common-commands)
- [Technology Stack](#-technology-stack)
- [Development Tips](#-development-tips)
- [Authentication Flow](#-authentication-flow)
- [Internationalization](#-internationalization)
- [Key Concepts](#-key-concepts)
- [Offline-First Architecture](#-offline-first-architecture)
- [Technical Review & Checklist (Summary)](#-technical-review--checklist-summary)
- [Contributing](#-contributing)
- [Code of Conduct](#-code-of-conduct)
- [Suggested Improvements](#-suggested-improvements)
- [License](#-license)
- [Resources](#-resources)
## π What is This?
**Safarnak** (Ψ³ΩΨ±ΩΨ§Ϊ©) is a fullβstack, offlineβfirst travel companion. It helps users discover destinations, plan trips, and share experiences. The project is a singleβroot monorepo with a clean separation of concerns and shared types across client and server.
### Highlights
- **Offlineβfirst by design**: Automatic Apollo β Drizzle sync via `DrizzleCacheStorage` on every cache write. Works seamlessly offline with SQL queries over cached data.
- **Shared GraphQL + Codegen**: One schema in `graphql/` powers both the Worker and the client. Codegen produces stronglyβtyped hooks in `api/`.
- **Unified Drizzle schema**: A single `database/schema.ts` defines both server tables (Cloudflare D1) and client cached tables (Expo SQLite), all with UUID IDs. Separate adapters (`server.ts`, `client.ts`) consume the same schema.
- **Edge backend**: Cloudflare Workers with GraphQL Yoga, Cloudflare D1 (SQLite), KV, R2, and Durable Objects for realβtime subscriptions.
- **Modern RN stack**: React 19, Expo Router 6, NativeWind 4 (Tailwind), New Architecture enabled.
- **Great DX**: Path aliases, oneβcommand dev, `yarn codegen`, `yarn db:migrate`, friendly linting.
### Onboarding roadmap
- Start with [Quick Start](#-quick-start) (install, migrate DB, codegen, run dev)
- Skim [Architecture Overview](#-architecture-overview) (keep charts; refer often)
- Learn the [Schema β Codegen β Hooks](#-how-to-add-new-features) workflow
- Review [OfflineβFirst Architecture](#-offline-first-architecture) and local DB usage
---
## ποΈ Architecture Overview
### System Architecture (High-Level)
```mermaid
flowchart TB
subgraph Client["π± Client Layer (React Native + Expo)"]
subgraph UI["UI Layer"]
A["app/ - Expo Router Pages"]
B["ui/ - UI Components, Hooks, State, Utils"]
end
subgraph State["State Management"]
C["ui/state/ - Redux + Persist"]
D["api/ - Apollo Client + DrizzleCacheStorage"]
end
subgraph Local["Local Storage"]
E["Apollo Cache
(raw)"]
F["Drizzle Cache
(structured)"]
G["AsyncStorage
(Mutation Queue)"]
end
end
subgraph Shared["π Shared Layer"]
H["graphql/ - Schema + Operations"]
I["database/schema.ts - Shared Drizzle Schema
(Server + Client Tables)"]
end
subgraph Worker["β‘ Server Layer (Cloudflare Workers)"]
J["worker/ - GraphQL Resolvers"]
K["GraphQL Yoga Server"]
L["D1 Database
(SQLite)"]
M["KV Store
(Sessions/Cache)"]
N["Vectorize
(Embeddings)"]
O["R2 Storage
(Media Files)"]
end
A --> C
B --> C
C --> D
D --> E
D --> F
D --> G
D <-->|HTTPS GraphQL
Auth Headers| K
K --> J
J --> L
J --> M
J --> N
J --> O
H --> D
H --> K
I --> F
I --> L
I --> J
```
### Client Architecture (Detailed)
```mermaid
flowchart TB
subgraph Component["React Components"]
C1["app/*.tsx
Pages"]
C2["ui/*.tsx
UI Components"]
end
subgraph Hooks["Hook Layer"]
H1["DrizzleCacheStorage
api/cache-storage.ts"]
H2["Custom Hooks
ui/hooks/*.ts"]
H3["Redux Hooks
ui/state/hooks.ts"]
end
subgraph Data["Data Layer"]
D1["Apollo Client
api/client.ts"]
D2["Redux Store
ui/state/index.ts"]
D3["Local DB
database/client.ts"]
end
subgraph Storage["Storage Layer"]
S1["Apollo Cache (raw)
safarnak_local.db: apollo_cache_entries"]
S2["Drizzle Cache
(structured)"]
S3["AsyncStorage
Mutation Queue"]
S4["Redux Persist
AsyncStorage"]
end
C1 --> H1
C2 --> H1
C1 --> H3
H1 --> D1
H2 --> D3
H3 --> D2
D1 --> S1
D1 --> S2
D2 --> S4
D3 --> S2
D1 --> S3
```
### Data Flow Architecture
```mermaid
flowchart LR
subgraph Query["Query Flow"]
Q1["Component"] --> Q2["Generated Hook"]
Q2 --> Q3["Apollo Client"]
Q3 --> Q4["GraphQL Server"]
Q4 --> Q5["D1 Database"]
Q5 --> Q4
Q4 --> Q3
Q3 --> Q6["Apollo Cache (raw)"]
Q6 --> Q7["Auto Sync"]
Q7 --> Q8["Drizzle Cache (structured)"]
Q2 --> Q1
end
subgraph Mutation["Mutation Flow"]
M1["Component"] --> M2["Generated Hook"]
M2 --> M3{"Online?"}
M3 -->|Yes| M4["Apollo Client"]
M3 -->|No| M5["Queue in
AsyncStorage"]
M4 --> M6["GraphQL Server"]
M6 --> M7["D1 Database"]
M7 --> M6
M6 --> M4
M4 --> M8["Apollo Cache"]
M8 --> M9["Auto Sync"]
M9 --> M10["Drizzle Cache"]
M5 --> M11["Process Queue
on Reconnect"]
M11 --> M4
end
```
### Offline-First Sync Architecture
```mermaid
sequenceDiagram
participant UI as UI Component
participant AH as Generated Hook
participant AC as Apollo Client
participant API as GraphQL API
participant ACache as Apollo Cache
(SQLite)
participant DCache as Drizzle Cache
(SQLite)
participant Queue as Mutation Queue
(AsyncStorage)
Note over UI,Queue: Online Scenario
UI->>AH: useGetTripsQuery()
AH->>AC: Query with cache-and-network
AC->>API: GraphQL Request
API-->>AC: Response Data
AC->>ACache: Persist to SQLite
AC-->>AH: onCompleted callback
AC->>DCache: DrizzleCacheStorage.setItem() (automatic)
DCache-->>AH: Sync complete
AH-->>UI: Return data
Note over UI,Queue: Offline Mutation
UI->>AH: useCreateTripMutation()
AH->>AC: Mutation request
AC->>API: GraphQL Request
API-->>AC: Network Error
AC->>Queue: Queue mutation
AC-->>AH: Error (handled)
AH->>DCache: Optimistic update
DCache-->>AH: Update complete
AH-->>UI: Optimistic UI update
Note over UI,Queue: Online Sync
AC->>Queue: Check pending mutations
Queue-->>AC: Return queued mutations
AC->>API: Process queued mutations
API-->>AC: Success
AC->>Queue: Remove from queue
AC->>DCache: Mark as synced
```
### Storage Layer Architecture
```mermaid
flowchart TB
subgraph ClientStorage["Client Storage"]
subgraph Apollo["Apollo Cache"]
A1["safarnak_local.db
(apollo_cache_entries table)"]
A2["Normalized Cache
Key-Value Pairs"]
end
subgraph Drizzle["Drizzle Cache"]
D1["safarnak_local.db
(SQLite)"]
D2["cachedTrips"]
D3["cachedUsers"]
D4["cachedTours"]
D5["syncMetadata"]
D6["pendingMutations"]
end
subgraph Async["AsyncStorage"]
AS1["@safarnak_user
(Auth Data)"]
AS2["Mutation Queue
(Offline)"]
AS3["Redux Persist
(UI State)"]
end
end
subgraph ServerStorage["Server Storage"]
subgraph D1DB["Cloudflare D1"]
S1["users"]
S2["trips"]
S3["tours"]
S4["messages"]
end
subgraph KV["Cloudflare KV"]
K1["token:xxx
(Sessions)"]
K2["cache:xxx
(API Cache)"]
end
subgraph Vector["Vectorize"]
V1["User Preferences
Embeddings"]
V2["Locations
Embeddings"]
end
subgraph R2Storage["R2 Storage"]
R2Avatars["avatars/"]
R2Images["images/"]
R2Attachments["attachments/"]
end
end
A1 --> A2
D1 --> D2
D1 --> D3
D1 --> D4
D1 --> D5
D1 --> D6
A2 -.->|Auto Sync| D2
A2 -.->|Auto Sync| D3
A2 -.->|Auto Sync| D4
D2 -.->|Sync| S2
D3 -.->|Sync| S1
D4 -.->|Sync| S3
```
### Authentication Flow
```mermaid
sequenceDiagram
participant U as User
participant UI as Login Screen
participant AC as Apollo Client
participant AL as Auth Link
participant API as GraphQL API
participant KV as KV Store
participant DB as D1 Database
participant RS as Redux Store
participant AS as AsyncStorage
U->>UI: Enter credentials
UI->>AC: useLoginMutation()
AC->>AL: Add Bearer token header
AL->>API: POST /graphql (login)
API->>DB: Verify user (PBKDF2)
DB-->>API: User record
API->>API: Generate token (SHA-256)
API->>KV: Store token:userId (30 days)
KV-->>API: Stored
API-->>AC: Return user + token
AC->>RS: Dispatch login action
RS->>AS: Persist user data
AC->>AS: Store token
RS-->>UI: Update auth state
UI-->>U: Navigate to app
Note over AC,API: Subsequent Requests
UI->>AC: useGetTripsQuery()
AC->>AL: Get token from AsyncStorage
AL->>API: POST /graphql (Authorization: Bearer token)
API->>KV: Verify token:userId
KV-->>API: userId
API->>DB: Query trips for userId
DB-->>API: Trips data
API-->>AC: Return trips
```
### Error Handling Architecture
```mermaid
flowchart TB
subgraph Network["Network Errors"]
N1["Apollo Request"] --> N2{"Network
Available?"}
N2 -->|No| N3["Catch Network Error"]
N2 -->|Yes| N4{"Backend
Reachable?"}
N4 -->|No| N3
N4 -->|Yes| N5["Process Request"]
N3 --> N6["Error Link"]
N6 --> N7["Suppress Network Errors
(Expected when offline)"]
N7 --> N8["Use Cached Data
(errorPolicy: all)"]
end
subgraph GraphQL["GraphQL Errors"]
G1["GraphQL Response"] --> G2{"Has
Errors?"}
G2 -->|Yes| G3["Error Link"]
G3 --> G4["Log in Dev Mode"]
G4 --> G5["Return Partial Data
(if available)"]
G2 -->|No| G6["Process Successfully"]
end
subgraph Component["Component Error Handling"]
C1["Generated Hook"] --> C2{"Error
Present?"}
C2 -->|Yes| C3["onError Callback"]
C3 --> C4["Display Error UI"]
C4 --> C5["Show Retry Button"]
C2 -->|No| C6["Render Data"]
end
N8 --> C1
G5 --> C1
G6 --> C1
```
### Network Status & Connectivity
```mermaid
flowchart TB
subgraph Detection["Status Detection"]
D1["NetInfo
Listener"] --> D2["Network State"]
D3["Backend Probe
checkBackendReachable()"] --> D4["Backend State"]
D2 --> D5["useSystemStatus Hook"]
D4 --> D5
end
subgraph UI["UI Updates"]
D5 --> U1["Update isOnline"]
D5 --> U2["Update isBackendReachable"]
U1 --> U3["Offline Icon
(Home Page)"]
U2 --> U3
U3 --> U4["System Status Page"]
end
subgraph Actions["Offline Actions"]
U1 --> A1{"isOnline = false?"}
A1 -->|Yes| A2["Disable Mutations"]
A1 -->|Yes| A3["Queue Mutations"]
A2 --> A4["Show Offline Indicator"]
A3 --> A4
A1 -->|No| A5{"isBackendReachable = false?"}
A5 -->|Yes| A2
A5 -->|Yes| A3
A5 -->|No| A6["Normal Operation"]
end
subgraph Sync["Auto Sync on Reconnect"]
A2 --> S1["Monitor Network"]
S1 --> S2{"Connection
Restored?"}
S2 -->|Yes| S3["processQueue()"]
S3 --> S4["Retry Queued Mutations"]
S4 --> S5["Update UI"]
end
```
### Dev-time GraphQL Codegen Pipeline
```mermaid
flowchart TB
subgraph Input["Source Files"]
I1["graphql/schema.graphql
Type Definitions"]
I2["graphql/queries/*.graphql
Operations"]
end
subgraph Codegen["Code Generation"]
C1["yarn codegen"] --> C2["GraphQL Codegen"]
C2 --> C3["Parse Schema"]
C2 --> C4["Parse Operations"]
C3 --> C5["Generate Types"]
C4 --> C6["Generate Hooks"]
end
subgraph Output["Generated Files"]
C5 --> O1["api/types.ts
TypeScript Types"]
C6 --> O2["api/hooks.ts
React Apollo Hooks"]
end
subgraph Enhancement["Hook Enhancement"]
O2 --> E1["api/cache-storage.ts"]
E1 --> E2["DrizzleCacheStorage"]
E2 --> E3["Automatic Sync on Write"]
E3 --> E4["Dual-Write: Raw + Structured"]
end
subgraph Usage["App Usage"]
E4 --> U1["Export from @api"]
U1 --> U2["Import in Components"]
U2 --> U3["Use Generated Hooks"]
end
I1 --> C1
I2 --> C1
U3 --> U4["Automatic Offline Support"]
```
### Complete Request Lifecycle
```mermaid
sequenceDiagram
participant C as Component
participant AH as Generated Hook
participant AC as Apollo Client
participant EL as Error Link
participant AL as Auth Link
participant API as GraphQL API
participant DB as D1 Database
participant Cache as Apollo Cache
participant Drizzle as Drizzle Cache
C->>AH: useGetTripsQuery()
AH->>AC: Query with cache-and-network
AC->>Cache: Check cache first
Cache-->>AC: Return cached data (if available)
AC-->>C: Render with cached data (optimistic)
AC->>AL: Add auth headers
AL->>API: POST /graphql
API-->>AC: Response or Error
alt Success
AC->>Cache: Update cache
Cache->>AH: onCompleted callback
AC->>Drizzle: DrizzleCacheStorage.setItem() (automatic)
Drizzle-->>AH: Sync complete
AH->>AC: Update with network data
AC-->>C: Re-render with fresh data
else Network Error
AC->>EL: Network error detected
EL->>EL: Suppress error (offline expected)
EL-->>AC: Continue with cached data
AC-->>C: Use cached data (errorPolicy: all)
else GraphQL Error
AC->>EL: GraphQL error detected
EL->>EL: Log error (dev mode)
EL-->>AC: Return partial data
AC-->>C: Render with partial data
end
```
### How It Works
1. **Define GraphQL Schema** (`graphql/schema.graphql`) - Shared between client and worker
2. **Define Operations** (`graphql/queries/*.graphql`) - Queries and mutations
3. **Run Codegen** - Auto-generates TypeScript types and React hooks in `api/`
4. **DrizzleCacheStorage** (`api/cache-storage.ts`) - Automatically syncs Apollo cache to Drizzle on every cache write
5. **Implement Resolvers** (`worker/queries/`, `worker/mutations/`) - Server-side logic using `getServerDB()` from `@database/server`
6. **Use in App** (`app/`, `ui/`) - Import hooks from `@api` (automatic sync via DrizzleCacheStorage)
---
## π Quick Start
### Prerequisites
- **Node.js 20+** (check with `node --version`)
- **Yarn** package manager (install via `npm install -g yarn`)
- **Android Studio** (for Android development)
- **Git** (for cloning the repository)
### Setup (5-10 minutes)
```bash
# 1. Clone the repository
git clone https://github.com/mehotkhan/safarnak.app.git
cd safarnak.app
# 2. Install dependencies
yarn install
# 3. Setup local database (Cloudflare D1)
yarn db:migrate
# 4. Generate GraphQL types and hooks
yarn codegen
# 5. Start development servers
yarn dev # Runs both worker (port 8787) and Expo client (port 8081)
```
This will start:
- **Cloudflare Worker** on `http://localhost:8787` (GraphQL API)
- **Expo Dev Server** on `http://localhost:8081` (React Native app)
### Run on Device/Emulator
```bash
# Android (New Architecture - recommended)
yarn android:newarch
# Android (Legacy Architecture)
yarn android
# Web browser
yarn web
# iOS (macOS only, not actively tested)
yarn ios
```
### First Time Setup Tips
- **Worker URL**: If you see connection errors, check that the worker is running on port 8787
- **GraphQL Playground**: Visit `http://localhost:8787/graphql` to test GraphQL queries
- **Metro Bundler**: If you see cache issues, run `yarn clean` and restart
- **Database**: The local D1 database is stored in `.wrangler/state/v3/d1/`
### Verify Installation
1. Check worker is running: Visit `http://localhost:8787/graphql` - you should see GraphQL Playground
2. Check Expo: Open Expo Go app on your phone or press `w` for web
3. Try a query: In GraphQL Playground, run `{ me { id username } }` (after logging in)
---
## π Codebase Structure
### Client-Side (React Native - What You'll Modify Most)
```
app/ # π± Expo Router pages (file-based routing)
βββ _layout.tsx # Root layout with providers
βββ (auth)/ # Auth route group (public routes)
β βββ _layout.tsx # Auth stack layout
β βββ welcome.tsx # /auth/welcome
β βββ login.tsx # /auth/login
β βββ register.tsx # /auth/register
βββ (app)/ # Main app group (protected routes)
βββ _layout.tsx # Tab bar layout (4 tabs: feed, explore, trips, profile)
βββ (feed)/ # Feed tab
β βββ index.tsx # / (home feed)
β βββ [id].tsx # /:id (post detail)
β βββ new.tsx # /new (create post)
βββ (explore)/ # Explore tab
β βββ index.tsx # /explore
β βββ places/[id].tsx # /explore/places/:id
β βββ tours/[id].tsx # /explore/tours/:id
β βββ tours/[id]/book.tsx # /explore/tours/:id/book
β βββ locations/[id].tsx # /explore/locations/:id
β βββ users/[id].tsx # /explore/users/:id
βββ (trips)/ # Trips tab
β βββ index.tsx # /trips (trip list)
β βββ new.tsx # /trips/new (create trip)
β βββ [id]/ # /trips/:id
β βββ index.tsx # Trip details
β βββ edit.tsx # Edit trip
βββ (profile)/ # Profile tab
βββ index.tsx # /profile
βββ edit.tsx # /profile/edit
βββ trips.tsx # /profile/trips
βββ messages.tsx # /profile/messages
βββ messages/[id].tsx # /profile/messages/:id
βββ notifications/[id].tsx # /profile/notifications/:id
βββ payments.tsx # /profile/payments
βββ subscription.tsx # /profile/subscription
βββ settings.tsx # /profile/settings
ui/ # π¨ All client UI code
βββ auth/ # Authentication components
β βββ AuthWrapper.tsx # Authentication guard
βββ maps/ # Map components
β βββ MapView.tsx # MapLibre GL map (native, replaces Leaflet)
β βββ MapLibreView.tsx # MapLibre GL map component
β βββ MapLibreLayerSelector.tsx # Map layer selector UI
βββ forms/ # Form components
β βββ CustomButton.tsx
β βββ InputField.tsx
β βββ TextArea.tsx
β βββ ...
βββ display/ # Display components
β βββ CustomText.tsx
β βββ UserAvatar.tsx
β βββ ...
βββ feedback/ # Loading/error states
β βββ LoadingState.tsx
β βββ ErrorState.tsx
β βββ ...
βββ context/ # React contexts
β βββ LanguageContext.tsx
β βββ LanguageSwitcher.tsx
β βββ ThemeContext.tsx
βββ hooks/ # πͺ Custom React hooks
β βββ useColorScheme.ts
β βββ useAuth.ts
β βββ ...
βββ state/ # π¦ Redux Toolkit state management
β βββ index.ts # Store configuration
β βββ hooks.ts # Typed hooks (useAppDispatch, useAppSelector)
β βββ slices/ # Redux slices
β β βββ authSlice.ts
β β βββ themeSlice.ts
β βββ middleware/ # Redux middleware
β βββ offlineMiddleware.ts
βββ utils/ # Client utilities
βββ clipboard.ts
βββ validation.ts
βββ ...
api/ # π GraphQL client layer
βββ hooks.ts # β¨ Auto-generated React Apollo hooks (never edit manually)
βββ types.ts # β¨ Auto-generated TypeScript types (never edit manually)
βββ cache-storage.ts # DrizzleCacheStorage - automatic Apollo β Drizzle sync
βββ client.ts # Apollo Client setup
βββ utils.ts # API utilities
βββ globals.d.ts # TypeScript global declarations
βββ index.ts # Main exports
constants/ # π App constants
βββ app.ts # App-wide constants
βββ Colors.ts # Color palette (light/dark themes)
βββ index.ts # Exports
locales/ # π i18n translation files
βββ en/translation.json # English translations
βββ fa/translation.json # Persian (Farsi) translations
global.css # π¨ Tailwind CSS directives (@tailwind base/components/utilities)
tailwind.config.js # π¨ Tailwind configuration (NativeWind v4)
babel.config.js # βοΈ Babel config (NativeWind preset)
metro.config.js # π¦ Metro bundler config (path aliases, NativeWind)
```
### Server-Side
```
worker/ # β‘ Cloudflare Worker
βββ queries/ # Query resolvers (myConversations, me)
βββ mutations/ # Mutation resolvers (register, login)
βββ subscriptions/ # Subscription resolvers (conversationMessages, tripUpdates)
graphql/ # π‘ Shared GraphQL
βββ schema.graphql # GraphQL schema (shared)
βββ queries/ # Query definitions (.graphql files)
database/ # ποΈ Shared database schema and adapters
βββ schema.ts # Unified schema with UUIDs (server + client tables in one file)
βββ server.ts # Server adapter (Cloudflare D1) - exports getServerDB()
βββ client.ts # Client adapter (Expo SQLite) - exports getLocalDB(), sync utilities
βββ index.ts # Main exports (re-exports from schema, server, client)
βββ types.ts # Database types
βββ utils.ts # UUID utilities (createId, isValidId)
migrations/ # Server-only migrations (Cloudflare D1, at project root)
```
## π§ Routing & URLs
Safarnak uses **Expo Router** with file-based routing. Routes are organized into groups using parentheses (which don't appear in URLs).
### Auth Routes (Public)
- `/auth/welcome` β Onboarding/Welcome screen
- `/auth/login` β User login
- `/auth/register` β User registration
### App Routes (Protected - Requires Authentication)
#### Feed Tab (`(feed)`)
- `/` β Home feed (social posts from community)
- `/:id` β Post detail view with comments
- `/new` β Create new post
#### Explore Tab (`(explore)`)
- `/explore` β Main explore/search page
- `/explore/places/:id` β Place details page
- `/explore/tours/:id` β Tour details page
- `/explore/tours/:id/book` β Tour booking page
- `/explore/locations/:id` β Location details page
- `/explore/users/:id` β User profile (public view)
#### Trips Tab (`(trips)`)
- `/trips` β User's trip list
- `/trips/new` β Create new trip (AI-powered)
- `/trips/:id` β Trip details view
- `/trips/:id/edit` β Edit trip
#### Profile Tab (`(profile)`)
- `/profile` β User profile home
- `/profile/edit` β Edit profile
- `/profile/trips` β User's trips list
- `/profile/messages` β Messages inbox
- `/profile/messages/:id` β Individual message/conversation
- `/profile/notifications` β Notifications list
- `/profile/notifications/:id` β Notification detail
- `/profile/payments` β Payment history
- `/profile/subscription` β Subscription management
- `/profile/settings` β App settings
### Route Organization
- Route groups `(auth)` and `(app)` don't appear in URLs
- Tab groups `(feed)`, `(explore)`, `(trips)`, `(profile)` don't appear in URLs
- Dynamic routes use `[id]` in file names
- Nested routes create URL paths (e.g., `trips/[id]/edit.tsx` β `/trips/:id/edit`)
## ποΈ Database Model (ER Diagram)
```mermaid
erDiagram
USERS ||--o{ MESSAGES : sends
USERS ||--o{ USER_SUBSCRIPTIONS : has
USERS ||--o{ SUBSCRIPTIONS : has "GraphQL subscriptions"
USERS ||--o{ USER_PREFERENCES : has
USERS ||--o{ TRIPS : creates
USERS ||--o{ TOURS : creates
USERS ||--o{ POSTS : authors
USERS ||--o{ COMMENTS : writes
USERS ||--o{ REACTIONS : adds
USERS ||--o{ PAYMENTS : makes
USERS ||--o{ DEVICES : owns
USERS ||--o{ SESSIONS : has
USERS ||--o{ NOTIFICATIONS : receives
TRIPS ||--o{ ITINERARIES : has
TRIPS ||--o{ PLANS : has
TRIPS ||--o{ THOUGHTS : generated_by
TRIPS ||--o{ MESSAGES : discusses
TOURS ||--o{ PAYMENTS : requires
LOCATIONS ||--o{ PLACES : contains
POSTS ||--o{ COMMENTS : has
POSTS ||--o{ REACTIONS : has
POSTS ||--o| TRIPS : references "via relatedId"
POSTS ||--o| TOURS : references "via relatedId"
POSTS ||--o| PLANS : references "via relatedId"
CACHE }|..|{ EXTERNAL_API : stores
USER_SUBSCRIPTIONS ||--|{ USERS : for
USER_SUBSCRIPTIONS ||--o{ PAYMENTS : via
USERS {
uuid id PK "UUID (text)"
string name
string username UK
string passwordHash
string email UK
string phone
string avatar "R2 URL"
boolean isActive
timestamp createdAt
timestamp updatedAt
}
USER_PREFERENCES {
uuid id PK "UUID (text)"
uuid userId FK "UUID (text)"
json interests
json budgetRange
string travelStyle
json preferredDestinations
json dietaryRestrictions
string embedding "Vectorize"
timestamp updatedAt
}
TRIPS {
uuid id PK "UUID (text)"
uuid userId FK "UUID (text)"
string title
date startDate
date endDate
string destination
integer budget "cents/stored units"
string status
boolean aiGenerated
json metadata
timestamp createdAt
timestamp updatedAt
}
ITINERARIES {
uuid id PK "UUID (text)"
uuid tripId FK "UUID (text)"
integer day
json activities
json accommodations
json transport
string notes
integer costEstimate
timestamp createdAt
timestamp updatedAt
}
PLANS {
uuid id PK "UUID (text)"
uuid tripId FK "UUID (text)"
json mapData "stops, directions"
json details
string aiOutput
timestamp createdAt
}
TOURS {
uuid id PK "UUID (text)"
string title
text description
integer price "cents"
integer rating
string location
string category
boolean isActive
timestamp createdAt
timestamp updatedAt
}
MESSAGES {
uuid id PK "UUID (text)"
text content
uuid userId FK "UUID (text)"
string type
json metadata
boolean isRead
timestamp createdAt
}
POSTS {
uuid id PK "UUID (text)"
uuid userId FK "UUID (text)"
text content
json attachments "R2 URLs"
string type "plan/trip/tour"
uuid relatedId "trip/tour/plan UUID"
timestamp createdAt
}
COMMENTS {
uuid id PK "UUID (text)"
uuid postId FK "UUID (text)"
uuid userId FK "UUID (text)"
text content
timestamp createdAt
}
REACTIONS {
uuid id PK "UUID (text)"
uuid postId FK "UUID (text)"
uuid commentId FK "UUID (text)"
uuid userId FK "UUID (text)"
string emoji
timestamp createdAt
}
PAYMENTS {
uuid id PK "UUID (text)"
uuid userId FK "UUID (text)"
uuid tourId FK "UUID (text)"
uuid subscriptionId FK "UUID (text)"
string transactionId
integer amount
string currency "default: IRR"
string status
timestamp createdAt
}
USER_SUBSCRIPTIONS {
uuid id PK "UUID (text)"
uuid userId FK "UUID (text)"
string tier "free/member/pro"
date startDate
date endDate
boolean active
timestamp createdAt
}
DEVICES {
uuid id PK "UUID (text)"
uuid userId FK "UUID (text)"
string deviceId UK
string type
timestamp lastSeen
}
SESSIONS {
string id PK "KV key (not in D1)"
uuid userId FK "UUID (text)"
string token
timestamp expiresAt
}
NOTIFICATIONS {
uuid id PK "UUID (text)"
uuid userId FK "UUID (text)"
string type "tour invite/payment/etc"
json data
boolean read
timestamp createdAt
}
LOCATIONS {
uuid id PK "UUID (text)"
string name UK
string country
json coordinates
text description
json popularActivities
integer averageCost
string embedding "Vectorize"
string imageUrl "R2"
timestamp createdAt
}
PLACES {
uuid id PK "UUID (text)"
string name
uuid locationId FK "UUID (text)"
uuid ownerId FK "UUID (text)"
string type "market/room/etc"
text description
integer price
integer rating
json coordinates
string embedding "Vectorize"
string imageUrl "R2"
timestamp createdAt
}
THOUGHTS {
uuid id PK "UUID (text)"
uuid tripId FK "UUID (text)"
text step "AI reasoning step"
json data "logs/sources"
timestamp createdAt
}
SUBSCRIPTIONS {
uuid id PK "UUID (text)"
string connectionId "GraphQL subscription"
string connectionPoolId "Durable Object"
string subscription "GraphQL query"
string topic
string filter
uuid userId FK "UUID (text)"
boolean isActive
timestamp createdAt
timestamp expiresAt
}
CACHE {
string key PK "KV key"
json value "cached API data"
timestamp expiresAt
}
```
### Data Storage Architecture
- **D1 (Relational DB with Drizzle)**:
- **Server Tables**: Users, user preferences, trips, itineraries, plans, tours, messages, posts, comments, reactions, payments, user subscriptions (tiers), devices, notifications, locations, places, thoughts, subscriptions (GraphQL subscriptions).
- **All IDs are UUID (text)** - consistent across server and client
- **Shared Schema**: Same schema definitions used by both server (D1) and client (expo-sqlite) adapters
- **KV (Key-Value Store)**: Sessions (user tokens stored as `token:userId`), cache (external API data like TripAdvisor, web searches).
- **Vectorize (Vector DB)**: Embeddings (user preferences, destinations, places, activities for similarity searches).
- **R2 (Object Storage)**: Avatars, image URLs, galleries, attachments (media, maps, docs).
- **Durable Objects**: Real-time subscriptions (connection state for GraphQL subs via `SubscriptionPool`).
**Note**: The ER diagram above shows the server-side D1 database schema. The client uses cached versions of these tables (e.g., `cachedUsers`, `cachedTrips`) with additional sync metadata fields (`cachedAt`, `lastSyncAt`, `pending`) for offline-first functionality.
### Shared (Critical)
- **`graphql/`** - GraphQL schema and operations (shared between client & worker)
- **`database/schema.ts`** - Unified Drizzle schema (shared between client & worker - same table definitions, different adapters)
- Server tables: `users`, `trips`, `tours`, etc. (used by worker via `database/server.ts`)
- Client cached tables: `cachedUsers`, `cachedTrips`, etc. (used by client via `database/client.ts`)
- Both use UUID (text) IDs for consistency
- **`api/`** - Auto-generated client code (run `yarn codegen` to update)
## π‘ Social Feed & Streaming (How it Works)
### Overview
- Unified feed via normalized `feed_events` for all entities (Post/Trip/Tour/Place/Location).
- Semiβrealtime: GraphQL `feedNewEvents` subscription with serverβside filters; client shows βShow 3 newβ banner (capped), merges on tap.
- Personalization: Perβuser `feed_preferences` (topics, entity types, followingβonly, closeβfriendsβonly, mutes).
- Follow graph: `follow_edges`, `close_friends` with filters and boosts (following +1, closeβfriends +2, topic matches +0.5 each).
- Trending: KV counters updated on writes; Durable Object `TrendingRollup` compacts/decays periodically; `getTrending` prefers KV (fallback to D1 for ENTITY).
- Search: `search` (lexical) and `searchSemantic` (Vectorize + Workers AI + Queues). Writers upsert into `search_index`; producers enqueue embeddings; consumer upserts vectors.
- Offlineβfirst: Queries work from Apollo/Drizzle cache; subscriptions noβop when offline.
### Key GraphQL APIs
- Feed:
- `getFeed(first, after, filter: FeedFilter)` β paginated `FeedConnection`
- `feedNewEvents(filter: FeedFilter)` β subscription (server filters applied)
- `getFeedPreferences` / `updateFeedPreferences(input: FeedFilter!)`
- Search:
- `search(query, entityTypes, topics, first, after)` (lexical)
- `searchSuggest(prefix, limit)`
- `searchSemantic(query, entityTypes, first, after)` (Vectorize KNN)
- Trending:
- `getTrending(type: TrendingType!, window: TimeWindow!, entityTypes?, limit)` (KV preferred)
### Storage & Infra
- D1: `feed_events`, `feed_preferences`, `search_index`, `follow_edges`, `close_friends`, `embeddings_meta`.
- KV: `top:entity:`, `top:topic:` lists.
- DO: `SubscriptionPool` (subs), `TrendingRollup` (decay/trim); cron every 10 minutes.
- Queues: `EMBED_QUEUE` producer/consumer; Workers AI (`@cf/baai/bge-m3`) to embed.
- Vectorize: upsert vectors with metadata `{ entityType, entityId, lang }`.
---
## π‘ How to Add New Features
### Complete Workflow: Adding a GraphQL Query/Mutation
This is the **standard workflow** for adding new features. Follow these steps:
#### Step 1: Define in GraphQL Schema
```graphql
# graphql/schema.graphql
type Query {
getTours(category: String, limit: Int): [Tour!]!
}
type Tour {
id: ID!
title: String!
location: String!
price: Float!
# ... other fields
}
```
#### Step 2: Create Operation File
```graphql
# graphql/queries/getTours.graphql
query GetTours($category: String, $limit: Int) {
getTours(category: $category, limit: $limit) {
id
title
location
price
rating
reviews
}
}
```
#### Step 3: Run GraphQL Codegen
```bash
yarn codegen
```
This generates:
- `api/types.ts` - TypeScript types for `Tour`, `GetToursQuery`, etc.
- `api/hooks.ts` - React hooks like `useGetToursQuery()`
#### Step 4: Implement Resolver (Worker)
```typescript
// worker/queries/getTours.ts
import { getServerDB, tours } from '@database/server';
import { eq, and } from 'drizzle-orm';
export const getTours = async (
_: any,
{ category, limit }: { category?: string; limit?: number },
context: any
) => {
const db = getServerDB(context.env.DB);
let query = db.select().from(tours).where(eq(tours.isActive, true));
if (category) {
query = query.where(eq(tours.category, category));
}
const results = await query.limit(limit || 100).all();
return results;
};
```
Don't forget to export it:
```typescript
// worker/queries/index.ts
export * from './getTours';
```
#### Step 5: Use in Component
```typescript
// app/(app)/(explore)/tours/index.tsx
import { useGetToursQuery } from '@api'; // Auto-generated hook with automatic Drizzle sync
import { ActivityIndicator, View, Text } from 'react-native';
export default function ToursScreen() {
// DrizzleCacheStorage automatically syncs to Drizzle on every cache write
const { data, loading, error } = useGetToursQuery({
variables: { category: 'adventure', limit: 10 }
// fetchPolicy defaults to 'cache-and-network' for offline support
});
if (loading) return ;
if (error) return Error: {error.message};
return (
{data?.getTours.map(tour => (
))}
);
}
```
**Important**: After changing the GraphQL schema or operations, **always run `yarn codegen`** before using the new hooks.
### Adding a New UI Component
1. **Create Component** (using NativeWind/Tailwind):
```typescript
// ui/cards/TourCard.tsx
import { View, Text, TouchableOpacity } from 'react-native';
import { CustomText } from '@ui/CustomText';
interface TourCardProps {
tour: { id: string; name: string };
onPress?: () => void;
}
export default function TourCard({ tour, onPress }: TourCardProps) {
return (
{tour.name}
);
}
```
2. **Use Path Aliases**:
```typescript
import { useColorScheme } from '@hooks/useColorScheme';
import { colors } from '@constants/Colors';
import { useAppDispatch } from '@state/hooks';
```
### Adding to Redux Store
1. **Create Slice**:
```typescript
// ui/state/slices/toursSlice.ts
import { createSlice } from '@reduxjs/toolkit';
const toursSlice = createSlice({
name: 'tours',
initialState: { tours: [] },
reducers: {
setTours: (state, action) => {
state.tours = action.payload;
},
},
});
export const { setTours } = toursSlice.actions;
export default toursSlice.reducer;
```
2. **Add to Store**:
```typescript
// ui/state/index.ts
import toursReducer from './slices/toursSlice';
// Add to combineReducers
tours: toursReducer,
```
---
## π§ Configuration
### Environment & Configuration
#### GraphQL Endpoint
The client determines the GraphQL URL in this order:
1. `app.config.js` β `expo.extra.graphqlUrl` (recommended)
2. `process.env.EXPO_PUBLIC_GRAPHQL_URL_DEV` or `process.env.EXPO_PUBLIC_GRAPHQL_URL`
3. `process.env.GRAPHQL_URL_DEV` or `process.env.GRAPHQL_URL`
4. Fallback in dev to `http://192.168.1.51:8787/graphql`
Configure production and development endpoints via environment variables used by `app.config.js`:
```bash
# .env
EXPO_PUBLIC_GRAPHQL_URL=https://safarnak.app/graphql
# Optional dev override
EXPO_PUBLIC_GRAPHQL_URL_DEV=http://127.0.0.1:8787/graphql
```
Relevant sources:
- `api/client.ts` (URI resolution and auth link)
- `app.config.js` (`expo.extra.graphqlUrl` derived from env)
#### App Identity (Android)
Customize via env for EAS or local builds:
```bash
APP_NAME="Ψ³ΩΨ±ΩΨ§Ϊ©"
BUNDLE_IDENTIFIER=ir.mohet.safarnak
APP_SCHEME=safarnak
ANDROID_VERSION_CODE=800 # optional override
```
### Path Aliases
```typescript
import { useLoginMutation } from '@api';
import { useAppDispatch } from '@state/hooks';
import { login } from '@state/slices/authSlice';
import { useColorScheme } from '@hooks/useColorScheme';
import Colors from '@constants/Colors';
```
**Never use relative imports** (`../../api`, `../store`). Always use path aliases.
### GraphQL Codegen
Auto-generates TypeScript types and React hooks from GraphQL schema:
1. **Schema** (`graphql/schema.graphql`) defines types
2. **Operations** (`graphql/queries/*.graphql`) define queries/mutations
3. **Run** `yarn codegen` to generate `api/hooks.ts` and `api/types.ts`
4. **Use** generated hooks: `import { useLoginMutation } from '@api'`
**Important**: Always run `yarn codegen` after modifying GraphQL schema or operations.
---
## π Common Commands
```bash
# Development
yarn dev # Start both worker & client
yarn start # Expo dev server only
yarn worker:dev # Worker only
# Database
yarn db:generate # Generate migration from schema changes (server tables only)
yarn db:migrate # Apply migrations to local D1 (server database)
yarn db:studio # Open Drizzle Studio
# GraphQL
yarn codegen # Generate types & hooks
yarn codegen:watch # Watch mode
# Build
yarn android # Run on Android
yarn build:debug # EAS debug build (Android)
yarn build:release # Build release APK
yarn build:local # Local gradle release build
# Utilities
yarn clean # Clear caches
yarn lint # Check code quality
yarn lint:fix # Fix issues
# Versioning & Commits
yarn commit:generate # Generate a conventional commit message
yarn version:minor # Release-it minor bump (CI)
```
---
## π οΈ Technology Stack
| Layer | Technology | Purpose |
|-------|-----------|---------|
| **Frontend** | React Native 0.81.5 | Mobile UI |
| **Backend** | Cloudflare Workers | Serverless API |
| **Server Database** | Cloudflare D1 (SQLite) | Server database via Drizzle |
| **Client Database** | Expo SQLite | Local offline database via Drizzle |
| **GraphQL** | GraphQL Yoga 5.16.0 | API layer |
| **ORM** | Drizzle 0.44.7 | Type-safe queries (shared schema for both server & client) |
| **Styling** | NativeWind 4.1.21 + Tailwind CSS 3.4.17 | Utility-first CSS |
| **State** | Redux Toolkit 2.9.2 | Client state |
| **Codegen** | GraphQL Codegen 6.0.1 | Auto-generate types |
| **Router** | Expo Router 6.0.13 | File-based routing |
**Full stack**: TypeScript 5.9, ESLint, Prettier, React i18next, New Architecture enabled
---
## π§ͺ Development Tips
1. **Metro Cache Issues**: Run `yarn clean`
2. **Database Reset**: Delete `.wrangler/state/v3/d1/` and run `yarn db:migrate`
3. **Type Errors**: Run `yarn codegen` to regenerate types
4. **GraphQL Changes**: Always run `yarn codegen` after schema changes
5. **Worker Logs**: Check terminal running `yarn worker:dev`
6. **Worker URL**: `http://127.0.0.1:8787/graphql` (or `http://localhost:8787/graphql`)
7. **Styling Issues**: Ensure `global.css` is imported in `app/_layout.tsx`, check `tailwind.config.js` content paths
8. **NativeWind Not Working**: Clear Metro cache and restart: `yarn clean && yarn start`
---
## π Authentication Flow
1. User logs in β Client calls `login` mutation
2. Worker validates β Returns user + token
3. Client stores β Redux + AsyncStorage
4. Apollo adds token β Automatic auth headers
5. Auto-redirect β Logged-in users can't access auth pages
**Auth Pages**: `app/(auth)/login.tsx`, `app/(auth)/register.tsx`, `app/(auth)/welcome.tsx`
**Auth Guard**: `ui/auth/AuthWrapper.tsx`
---
## π¨ Styling with NativeWind (Tailwind CSS)
Safarnak uses **NativeWind v4** for utility-first styling with Tailwind CSS. This provides a consistent, maintainable styling approach across the app.
### Key Features
- **Utility-first CSS**: Use Tailwind classes directly via `className` prop
- **Dark mode**: Automatic dark mode support with `dark:` prefix
- **Theme integration**: Automatically syncs with Redux theme state
- **Responsive**: Built-in responsive utilities
### Usage Example
```typescript
import { View, Text } from 'react-native';
export default function MyScreen() {
return (
Welcome to Safarnak
Primary Button
);
}
```
### Configuration Files
- `tailwind.config.js` - Tailwind configuration with custom colors and fonts
- `global.css` - Tailwind directives (imported in `app/_layout.tsx`)
- `babel.config.js` - NativeWind Babel preset
- `metro.config.js` - NativeWind Metro integration
### Custom Colors
The app uses custom colors defined in `tailwind.config.js`:
- `primary` - Main brand color (#30D5C8)
- `danger` - Error/warning color (#ef4444)
- `success` - Success color (#10b981)
- `neutral` - Neutral grays
---
## π Internationalization
Supports English and Persian (Farsi). Note: RTL layout toggling is currently disabled (Android `supportsRtl=false`); translations work without forcing RTL.
```typescript
import { useTranslation } from 'react-i18next';
const { t } = useTranslation();
{t('common.welcome')}
```
**Translation files**: `locales/en/translation.json`, `locales/fa/translation.json`
---
## π― Key Concepts
### Perfect Separation
- **`graphql/`** - Shared schema and operations (used by both client & worker)
- **`api/`** - Auto-generated client code only (client-side GraphQL hooks with automatic Drizzle sync)
- **`worker/`** - Server-only resolvers (entry: `worker/index.ts`)
- **`database/`** - **Shared** database schema with separate adapters for server and client
- `schema.ts` - **Unified schema** defining both server tables (users, trips, etc.) and client cached tables (cachedUsers, cachedTrips, etc.) with UUID IDs
- `server.ts` - Server adapter: `getServerDB(d1)` for Cloudflare D1 (worker resolvers use this)
- `client.ts` - Client adapter: `getLocalDB()` for Expo SQLite (client components use this)
- Both adapters import from the same `schema.ts` file, ensuring schema consistency
- `migrations/` - Server-only migrations (located at project root, for D1 database)
### Auto-Generated Code
**Never manually edit**:
- `api/hooks.ts` - Auto-generated React Apollo hooks (from GraphQL operations)
- `api/types.ts` - Auto-generated TypeScript types (from GraphQL schema)
**Use these instead**:
- `api/cache-storage.ts` - DrizzleCacheStorage automatically syncs on every Apollo cache write
- `api/index.ts` - Main exports (re-exports hooks + utilities)
**Generation Process**:
1. Define schema in `graphql/schema.graphql`
2. Define operations in `graphql/queries/*.graphql`
3. Run `yarn codegen` to generate `api/hooks.ts` and `api/types.ts`
4. Use generated hooks directly; DrizzleCacheStorage handles sync automatically
### Path Aliases
Always use aliases, never relative imports:
- β
`@api`, `@ui/*`, `@hooks/useColorScheme`, `@state/*`, `@utils/*`
- β `../../api`, `../store/hooks`, `@components/*`, `@store/*`
---
## πΆ Offline-First Architecture
Safarnak implements a comprehensive **offline-first architecture** with automatic data synchronization. The system uses a **shared Drizzle schema** that works seamlessly between client and server, with automatic Apollo cache synchronization to enable advanced SQL queries on cached data.
### Shared Drizzle Schema Architecture
The app uses a **unified Drizzle schema** (`database/schema.ts`) that is **shared between worker and client**. This single source of truth ensures schema consistency across both environments:
#### Schema Structure
1. **Single Schema File** (`database/schema.ts`):
- Contains **all table definitions** in one file
- Defines both server tables and client cached tables
- Uses **UUID (text) IDs** throughout for consistency
- Shared field definitions reduce duplication (`userFields`, `tripFields`, etc.)
2. **Server Tables** (for Cloudflare D1):
- Tables: `users`, `trips`, `tours`, `messages`, `subscriptions`, etc.
- Used by worker resolvers via `database/server.ts` β `getServerDB(d1)`
- All IDs are UUIDs: `text('id').primaryKey().$defaultFn(() => createId())`
- No ID conversions needed - UUIDs work seamlessly with GraphQL `ID!` type
- Server-only fields: `passwordHash` (users), `aiGenerated` (trips), etc.
3. **Client Cached Tables** (for Expo SQLite):
- Tables: `cachedUsers`, `cachedTrips`, `cachedTours`, `cachedPlaces`, `cachedMessages`
- Used by client components via `database/client.ts` β `getLocalDB()`
- Same UUID format as server tables - perfect consistency
- Reuses shared field definitions from server tables via spread operator
- Includes sync metadata: `cachedAt`, `lastSyncAt`, `pending`, `deletedAt`
- Sync management tables: `pendingMutations`, `syncMetadata`
4. **Shared Field Definitions**:
- Common columns extracted into reusable objects: `userFields`, `tripFields`, `tourFields`, `placeFields`, `messageFields`
- Metadata columns: `timestampColumns`, `syncMetadataColumns`, `pendingColumn`
- Reduces duplication and improves maintainability
5. **Separate Adapters**:
- **Server**: `database/server.ts` exports `getServerDB(d1)` - uses Cloudflare D1
- **Client**: `database/client.ts` exports `getLocalDB()` - uses Expo SQLite
- Both import from the same `schema.ts` file, ensuring perfect schema alignment
6. **UUID Generation** (`database/utils.ts`):
- **Cloudflare Workers**: Uses native `crypto.randomUUID()` (fastest, most secure)
- **React Native Expo**: Uses `crypto.getRandomValues()` with manual UUID construction
- **Fallback**: Math.random() only if crypto APIs unavailable (with dev warning)
- RFC 4122 compliant UUID v4 format
### Folder Structure
```
database/
βββ schema.ts # Unified schema with UUIDs (server + client tables)
βββ server.ts # Server adapter (Cloudflare D1)
βββ client.ts # Client utilities (db, sync, stats)
βββ index.ts # Main exports
βββ types.ts # TypeScript types and enums
βββ utils.ts # UUID utilities
migrations/ # Server-only migrations (Cloudflare D1, at project root)
```
**Important Architecture Points**:
- **Shared Schema**: Both worker and client import from the same `database/schema.ts` file
- **Separate Adapters**: Server uses `getServerDB(d1)` (D1), client uses `getLocalDB()` (expo-sqlite)
- **UUID Consistency**: All tables use UUID (text) IDs - no conversions needed between server/client
- **Migrations**:
- Server migrations at project root (`migrations/`) for D1 database
- Client cached tables auto-migrated on app initialization (see `database/client.ts`)
- **Schema Exports**: `drizzle.config.ts` points to `schema.ts` (exports `serverSchema` as `schema` for migrations)
- **UUID Generation**: `createId()` from `database/utils.ts` (runtime-optimized for each platform)
### GraphQL Query System with Automatic Sync
All GraphQL queries and mutations automatically sync to the local Drizzle database:
1. **Query Flow**:
```
Component β Generated Hook (useGetTripsQuery) β Apollo Client β GraphQL Server
β
Apollo Cache (raw)
β
Automatic Sync
β
Drizzle DB (structured)
```
2. **DrizzleCacheStorage** (`api/cache-storage.ts`):
- Implements Apollo's PersistentStorage interface
- Automatically syncs on every Apollo cache write (via `setItem()`)
- Dual-write: raw cache (`apollo_cache_entries`) + structured tables (cachedUsers, cachedTrips, etc.)
- No wrapper hooks needed - all Apollo hooks automatically benefit
3. **Sync Mechanism**:
- **Event-driven**: Triggers on every Apollo cache write (via `DrizzleCacheStorage.setItem()`)
- **Automatic**: No manual sync calls needed - happens transparently
- **Background**: Sync runs in background, doesn't block UI
- **Dual-write**: Single transaction writes to both raw cache and structured tables
### Data Storage
The app uses three storage layers:
1. **Apollo Cache (SQLite)**: Normalized GraphQL cache
- Stored in `apollo_cache_entries` table via DrizzleCacheStorage
- Stored as JSON string in SQLite
- Handles GraphQL query responses automatically
2. **Drizzle Cache (SQLite)**: Structured relational cache
- Separate tables per entity type (`cachedTrips`, `cachedUsers`, etc.)
- Enables advanced SQL queries (filtering, sorting, aggregations)
- Sync metadata for offline management
3. **AsyncStorage**: Mutation queue
- Stores pending mutations when offline
- Automatically processed when connection restored
### Offline Capabilities
- **Read**: Query local Drizzle database even when offline
- **Write**: Queue mutations when offline, sync when online
- **Sync**: Automatic bidirectional sync when connection restored
- **Statistics**: Real-time database statistics (entity counts, sync status, pending mutations)
#### Reconnect & Queue Behavior
- When offline, mutations are persisted to an AsyncStorage-backed queue.
- On reconnect, the queue is processed in order; successful mutations are removed and corresponding cached entities are marked synced.
- DrizzleCacheStorage continues to dual-write on every Apollo cache update; no wrapper hooks are required.
- Network status is derived from NetInfo plus a lightweight backend reachability probe.
### Usage Examples
#### Query with Automatic Sync
```typescript
import { useGetTripsQuery } from '@api';
function TripsScreen() {
// DrizzleCacheStorage automatically syncs to Drizzle on every cache write
const { data, loading, error } = useGetTripsQuery({
fetchPolicy: 'cache-and-network', // Recommended for offline support
});
// Data is automatically synced to both Apollo cache and Drizzle database
}
```
#### Query Local Database (Offline)
```typescript
import { getLocalDB, cachedTrips } from '@database/client';
import { eq, desc } from 'drizzle-orm';
// Works offline - queries local SQLite database
const db = await getLocalDB();
const trips = await db
.select()
.from(cachedTrips)
.where(eq(cachedTrips.userId, userId))
.orderBy(desc(cachedTrips.cachedAt));
```
#### Worker Resolver Usage (Server)
```typescript
import { getServerDB, trips } from '@database/server';
import { eq } from 'drizzle-orm';
export const getTrips = async (_: any, args: any, context: any) => {
const db = getServerDB(context.env.DB);
const userTrips = await db
.select()
.from(trips)
.where(eq(trips.userId, context.userId));
return userTrips;
};
```
#### Get Database Statistics
```typescript
import { getDatabaseStats } from '@database/client';
const stats = await getDatabaseStats();
console.log(stats.entities.trips.count); // Number of cached trips
console.log(stats.pendingMutations.total); // Pending mutations
```
### System Status
The app includes a comprehensive system status page (`app/(app)/(profile)/system-status.tsx`) that shows:
- Network connectivity status
- Backend reachability
- Database statistics (entity counts, sync status, storage usage)
- Pending mutations queue
- Sync timestamps per entity type
### Technical Details
- **Sync Triggers**: On query/mutation completion (event-driven, no polling)
- **Performance**: Sync runs in background, doesn't block UI
- **Storage**:
- Apollo Cache: stored in `apollo_cache_entries` table (normalized GraphQL cache)
- Drizzle Cache: `safarnak_local.db` (structured relational cache)
- Server: Cloudflare D1 (SQLite via Drizzle)
- **ID Types**: All tables use UUID (text) IDs - consistent across server, client, and GraphQL
- **Schema Consistency**: Single source of truth (`database/schema.ts`) ensures server and client schemas stay in sync
For more details, see the offline architecture implementation in `database/` folder.
---
---
## π€ Contributing
Please read `CONTRIBUTING.md` for setup, workflow, and PR checklist.
---
## π§ Code of Conduct
Community guidelines are in `CODE_OF_CONDUCT.md`.
---
## π Suggested Improvements & Roadmap
This section outlines potential features and improvements. These are suggestions, not commitments.
### π― Priority Features (Near-term)
#### Offline & Sync Management
- **Offline Downloads Manager**: UI to manage cached trips, tours, and places for offline access
- **Sync Queue Screen**: View pending offline mutations with retry controls
- **Data Management**: Clear cache, view storage usage, selective data purge
#### Trip Planning Enhancements
- **Itinerary Editor**: Day-by-day editable view using `itineraries` table
- **AI Planner Chat**: Conversational interface for refining trips using `thoughts` table
- **Trip Map View**: Visual map showing trip activities and locations
- **Trip Export/Share**: Export itinerary as PDF or ICS calendar file
#### Explore & Discovery
- **Global Search**: Unified search across tours, places, locations, and users
- **Advanced Filters**: Price range, rating, duration, distance with saved filter sets
- **Bookmarks System**: Implement UI for `bookmarkTour` and `bookmarkPlace` mutations
- **Location/Tour/Place Indexes**: Dedicated browse pages for each content type
### π Future Enhancements
#### Social Features
- **Rich Post Composer**: Multi-image upload, location tagging, trip linking
- **Comments Thread**: Full-screen comment view with reactions
- **Enhanced User Profiles**: Public profiles with posts, trips, and places showcase
#### Profile & Settings
- **Travel Preferences**: Edit `user_preferences` (interests, budget, style, dietary)
- **Device Management**: View and revoke logged-in devices using `devices` table
- **Billing History**: Detailed payment history with receipts from `payments` table
- **Notification Settings**: Per-category notification preferences
#### Commerce
- **My Bookings**: List of purchased tours from `payments.tourId`
- **Booking Details**: Receipt, cancellation, refund status
- **Checkout Flow**: Dedicated checkout page with payment integration
### π‘ Where We're Going
The project is currently at **v1.13.0**. Our focus is on:
1. **Stability**: Fixing authentication security issues, adding input validation
2. **Core Features**: Completing trip planning, explore, and social features
3. **Offline Support**: β
**Implemented** - Shared Drizzle schema with automatic Apollo β Drizzle sync (see [Offline-First Architecture](#-offline-first-architecture) section)
4. **Testing**: Adding unit and integration tests
5. **Documentation**: β
**Updated** - Comprehensive docs in README
See `TECHNICAL_REVIEW.md` for current technical debt and priorities.
## π License
MIT
---
## π Resources
- [Expo Docs](https://docs.expo.dev/)
- [Cloudflare Workers](https://developers.cloudflare.com/workers/)
- [GraphQL Codegen](https://the-guild.dev/graphql/codegen)
- [Drizzle ORM](https://orm.drizzle.team/)
Built with β€οΈ using Expo, Cloudflare Workers, and GraphQL Codegen