Data flow
Request lifecycle (web)
Section titled “Request lifecycle (web)”sequenceDiagram
participant B as Browser
participant SK as SvelteKit Server
participant BA as Better Auth
participant DB as PostgreSQL
participant VK as Valkey
participant MS as Meilisearch
participant IP as imgproxy
B->>SK: HTTP Request
SK->>BA: Validate session cookie
BA-->>SK: Session + User
SK->>DB: Kysely query (property data)
SK->>VK: GEOSEARCH (property search)
SK->>MS: Location autocomplete
SK->>IP: Sign image URLs (HMAC)
SK-->>B: SSR HTML + hydration payload
hooks.server.ts validates the Better Auth session cookie on every request and populates event.locals.user / event.locals.session. Protected routes (/portal/*, /dashboard/*) check locals.user in +layout.server.ts.
Property search pipeline
Section titled “Property search pipeline”- User enters a location → Meilisearch provides autocomplete suggestions.
- Map viewport changes → client sends bounds to server.
- Server calls
@repo/redis-search→ Valkey GEOSEARCH returns property IDs within the viewport. - Additional filters (price, beds, type) are applied in-memory.
- Property IDs fetch full records from PostgreSQL via Kysely.
- Image URLs are HMAC-signed via imgproxy and returned.
The price filter is multi-unit aware — overlap check against ZIDX_PRICE + ZIDX_MAX_PRICE so any unit’s price falling within the range qualifies the property.
Image pipeline
Section titled “Image pipeline”flowchart LR
Upload["Client upload"] --> API["SvelteKit API route"]
API --> S3["Cloudflare R2"]
S3 --> imgproxy["imgproxy (HMAC)"]
imgproxy --> T["Thumb 400×300"]
imgproxy --> M["Medium 800×600"]
imgproxy --> L["Large 1200×800"]
- Uploads go through a SvelteKit API route to Cloudflare R2 via
@aws-sdk/client-s3. - imgproxy serves images on-demand: resize, WebP conversion, quality optimization.
- URLs are HMAC-signed server-side — attackers cannot manipulate dimensions or request arbitrary resources.
Background jobs (BullMQ)
Section titled “Background jobs (BullMQ)”The standalone worker at apps/worker/ consumes nine queues:
| Queue | Jobs |
|---|---|
search-indexing | sync-property (2s debounce, 3 retries) → Valkey (includes priceDropPercent from price_history); sync-agent, sync-team → Meilisearch |
leads | lead-ingestion (reliable Inquiry creation with DLQ to failed_lead_ingestions); sla-monitor (repeatable */15 * * * *) |
alerts | price-drop-notify, status-change-notify (event-driven); saved-search-scan (repeatable */30 * * * *) |
ai | generate-description (Gemini 2.5 Flash); one-release compatibility for legacy staging entries |
ai-staging | Resumable stage-room predictions through Replicate’s official openai/gpt-image-2 wrapper |
image-sanitize | sanitize-property-image (decode + re-encode a listing photo off the request path, promote its object, flip the row PENDING → READY; DLQ to failed_image_sanitize); image-sanitize-sweep (repeatable */5 * * * *) |
billing | trial-scan (T-3d warnings + expiry, repeatable every 30 min); stripe-reconcile (daily Stripe ⇄ local cross-check + receipt pruning) |
cdn-purge | purge-property-images — evicts a property’s Cache-Tag from the CDN edge after it leaves ACTIVE (15s delay past the image routes’ 10s status cache). No DLQ table: a lost purge degrades to “the stale copy expires” |
storage | drain-deletion-outbox (repeatable * * * * *) — claims storage_deletion_outbox rows, re-checks the key against the table its prefix belongs to, deletes the object, stamps the row; orphan-sweep (repeatable 23 4 * * *) — lists the upload prefixes and reports objects nothing references. No DLQ table: the outbox row is the durable record |
Every queue name (including the Bull Board registration) is suffixed with process.env.QUEUE_SUFFIX. Production leaves it empty; local dev sets -dev so jobs enqueued from the dev svelte-web don’t get stolen by deployed workers sharing the same Valkey.
Room staging is isolated from descriptions. ai-staging consumes payloads containing only { aiJobId }, uses that ID as the stable BullMQ ID, and reloads all canonical state from PostgreSQL. A startup/30-second reconciler redispatches missing PENDING, RETRYING, or cancel-requested work. AI_STAGING_CONCURRENCY and AI_STAGING_RATE_LIMIT_PER_MINUTE tune only staging; provider attempts are separately persisted and capped at two paid predictions per logical job.
Listing-photo uploads no longer decode inside the request (PRO-13). POST /api/property-images stores the raw bytes under pending/properties/{propertyId}/{uuid} — outside both hosts’ image-proxy ALLOWED_PREFIXES, so nothing there is servable — inserts the row processingStatus = PENDING, and enqueues a confirmed sanitize-property-image job; an unacknowledged enqueue marks the row FAILED('enqueue_failed') so the agent gets a Retry button rather than a permanently pending photo — and if that status write itself throws, the handler swallows it and leaves the row PENDING, where the 5-minute stale sweep picks it up instead (the response never fails after the row is committed). The worker’s final key is derived from the pending key, so a duplicate run overwrites instead of orphaning, and every status write is guarded on the expected processingStatus. Public readers gate on READY, including both search-index fetchers — the Valkey index stores image urls, so the filter has to be applied at index time. Full state machine and runbook: apps/worker/src/image-sanitize.FEATURE.md.
Mobile flow
Section titled “Mobile flow”flowchart LR
Mobile["Mobile (Expo + native iOS/Android)"] -->|"Cookie: header"| API["apps/api (Hono)"]
API -->|tRPC| Service["@repo/api services"]
API -->|Better Auth| Postgres["PostgreSQL"]
Service --> Postgres
Service --> Valkey["Valkey"]
The native iOS/Android apps talk to a single host (apps/api) which mounts both /api/auth/* (Better Auth) and /api/trpc/* (tRPC fetch adapter). They use a hand-written tRPC-over-HTTP client (SuperJSON envelope, no batching; response models generated from @repo/contract). Auth state lives as a cookie — in the Keychain (iOS) / EncryptedSharedPreferences (Android) — never a Bearer token. No CSRF token is sent: mobile requests carry no Origin and are exempt from the server’s Origin allowlist.
See Conventions → Mobile (Native) for the rationale and gotchas.