Developer Docs
This guide covers the FeedElity architecture, monorepo structure, package boundaries, and development conventions. Everything you need to contribute, extend, or debug the platform.
Architecture Overview
FeedElity is a monorepo built with Turborepo, using Bun as the package manager and runtime. The app follows a layered architecture with strict module boundaries:
- Frontend — Solid in
apps/web. Uses TanStack Router for routing and Vinxi for bundling. - Backend — Hono in
apps/server. Hosts oRPC procedures and better-auth endpoints. - API contract — oRPC procedures defined in
packages/api. Type-safe client and server with Zod validation. - Auth — better-auth in
packages/auth. Local email/password authentication with session management. - Database — Drizzle ORM with SQLite/libSQL in
packages/db. Schema split into catalog (global) and overlays (user-owned). - Desktop — Electrobun in
apps/desktop. Supports local-first and remote backend modes.
Monorepo Layout
FeedElity/
├── apps/
│ ├── web/ Solid frontend (TanStack Router + Vinxi)
│ ├── server/ Hono API server (oRPC + better-auth)
│ ├── desktop/ Electrobun desktop wrapper
│ └── docs/ Documentation site (TanStack Router + Vinxi)
├── packages/
│ ├── api/ oRPC routers, services, repositories, source adapters
│ ├── db/ Drizzle ORM schema, database client, migrations
│ ├── auth/ better-auth configuration and helpers
│ ├── env/ Shared environment variable validation
│ └── config/ Shared TypeScript, ESLint, and Tailwind configs
├── docker/ Dockerfiles for web, server, nginx, libSQL
├── scripts/ Utility scripts
├── turbo.json Turborepo pipeline configuration
├── bts.jsonc Better-T-Stack scaffold source of truth
└── package.json Root workspace definitionpackages/api
The core of the application. Contains oRPC router definitions, domain services (ingestion, refresh), repository functions (catalog reads/writes, overlay reads/writes), source adapters (YouTube, Odysee, PeerTube), migration logic, and domain types. This is where most backend behavior lives.
packages/db
Drizzle ORM schema definitions split into three modules: auth (user, session, account, verification), catalog (creator, feed, contentItem, contentSource, feedContent, refreshRun, refreshFeedResult), and overlays (subscription, contentStatus, playlist, playlistItem, userSetting, migrationRun, migrationMapping). Exports a shared database client.
packages/auth
Configures better-auth with the Drizzle adapter for SQLite, email/password authentication, and cookie-based sessions. Exports password hashing helpers used for migrated-user password setup.
Data Model
FeedElity separates data into two categories: a shared global catalog and private user-owned overlays.
Global Catalog
The catalog contains normalized records for all known creators, feeds, content items, and content sources. Catalog data is source-agnostic at the API level — source-specific details live inside source adapters and metadata fields.
| Table | Purpose |
|---|---|
| creator | Normalized creator/channel identity with source type, external ID, display name, description, and image. Uniqueness on (sourceType, sourceExternalId). |
| feed | Source-specific feed belonging to a creator. Stores URL, refresh cadence, last/next refresh timestamps, and adapter metadata. |
| contentItem | Normalized video item with title, description, publication date, duration, thumbnail, and content type. |
| contentSource | Playable source link for a content item. Includes embed URL, native media URL, canonical URL, and priority. One item can have multiple sources. |
| feedContent | Association between a feed and a content item. Composite primary key on (feedId, contentItemId). |
| refreshRun | Records a manual refresh attempt with scope, force flag, status, counts, timestamps, and error summary. |
| refreshFeedResult | Per-feed result within a refresh run. Status, discovery counts, and error summary. |
User-Owned Overlays
Overlay records are private to each user and reference catalog entities. Every overlay table has a userId column with cascade delete and is scoped by authenticated userId at the API boundary.
| Table | Purpose |
|---|---|
| subscription | User-to-creator subscription. Unique on (userId, creatorId). |
| contentStatus | Per-user status for opened, played, or favorite. Unique on (userId, contentItemId, status). |
| playlist / playlistItem | User playlists with ordered items. Items are unique per playlist by position and by contentItemId. |
| userSetting | Key-value settings scoped to a user. Unique on (userId, key). |
| migrationRun / migrationMapping | One-time import state from the old Strapi backend. Tracks fingerprint, counts, warnings, failures, and old-to-new entity mappings for idempotency and reporting. |
API Layer
oRPC Router Structure
The API uses oRPC with two procedure types:
publicProcedure— Accessible without authentication. Used for catalog browsing, session reading, health check, and migrated-password setup.protectedProcedure— Requires an authenticated session with an active account. A middleware validates the session and injects the user context. Used for all overlay mutations, ingestion, refresh, and migration.
The router is organized into namespaces:
appRouter = {
healthCheck, // Public
session: { current }, // Public (returns null if unauthenticated)
auth: { setupMigratedPassword },
catalog: {
creators, // Public - list creators with search/filter
feeds, // Public - list feeds
contentItems, // Public - list content items
contentDetail, // Public - single content item with sources
},
refresh: {
status, // Public - refresh run status
startAll, // Protected - start background refresh
runAll, // Protected - run refresh synchronously
runCreator, // Protected - refresh one creator
runFeed, // Protected - refresh one feed
},
ingestion: {
addSource, // Protected - add single source URL
batchAddSources, // Protected - add multiple source URLs
},
overlays: {
subscriptions, // Protected - list user subscriptions
subscribeToCreator, // Protected - subscribe
unsubscribeFromCreator, // Protected - unsubscribe
contentStatuses, // Protected - list all user statuses
markContentOpened, // Protected - mark opened
markContentPlayed, // Protected - mark played
toggleContentOpened, // Protected - toggle opened
toggleContentPlayed, // Protected - toggle played
toggleContentFavorite, // Protected - toggle favorite
favoriteContentItems, // Protected - list favorited items
contentHistory, // Protected - opened/played history
playlists, // Protected - list user playlists
createPlaylist, // Protected
updatePlaylist, // Protected
deletePlaylist, // Protected
playlistItems, // Protected - list items in playlist
addPlaylistItem, // Protected
removePlaylistItem, // Protected
reorderPlaylistItems, // Protected
settings, // Protected - list user settings
saveSetting, // Protected
deleteSetting, // Protected
},
migration: {
runImport, // Protected - import from Strapi export JSON
},
}Context and Authentication
Each request creates a context through createContext. The context contains:
db— The Drizzle database instance for querying.session— The authenticated session and user, or null for anonymous requests. Includes accountState to distinguish active users from migrated users pending password setup.sourceRegistry— The source adapter registry with YouTube, Odysee, and PeerTube adapters registered.
The protected procedure middleware rejects requests with no session (UNAUTHORIZED) or with a non-active accountState (FORBIDDEN).
Adding a New API Procedure
- Define the input schema. Use Zod schemas at the top of the router file. Follow the naming convention of descriptive input names (e.g.,
playlistNameInput). - Choose the procedure. Use
publicProcedurefor anonymous catalog reads orprotectedProcedurefor authenticated mutations and user-scoped data. - Implement the handler. Use repository functions from
repositories/catalogorrepositories/overlays. Keep the handler thin — delegate to services or repositories. - Validate existence. For operations that reference catalog entities, validate the entity exists before proceeding. Throw
ORPCError("NOT_FOUND")when missing. - Scope by userId. All user-owned data must pass
context.session.user.idto repository functions. Never trust client-supplied user IDs. - Write tests. Add test coverage through the API procedure (public interface), not private internals.
Source Adapters
Source adapters normalize platform-specific data into FeedElity's unified catalog model. Each adapter implements the SourceAdapter interface and is registered in the source adapter registry.
Source Adapter Interface
interface SourceAdapter<TSourceType extends SourceType = SourceType> {
readonly sourceType: TSourceType;
detect(input: string): SourceDetectionResult;
resolveInput(input: DetectedSourceInput): Promise<SourceAdapterResult<ResolvedSourceInput>>;
normalizeCatalogPayload(input: ResolvedSourceInput, payload: string): SourceAdapterResult<NormalizedCatalogPayload>;
fetchCatalog(input: ResolvedSourceInput): Promise<SourceAdapterResult<NormalizedCatalogPayload>>;
}- detect — Determines if a URL is supported by this adapter and classifies the input kind (feed URL, creator URL, content URL, unknown). Returns a DetectedSourceInput or an error.
- resolveInput — Resolves a detected input into creator/feed identity with a canonical URL and optional title.
- normalizeCatalogPayload — Parses raw feed or API response data (XML, JSON) into the normalized catalog structure: creator, feeds, and content items with sources.
- fetchCatalog — Fetches data from the remote source and normalizes it in one step. Combines HTTP fetch and normalization.
Source Adapter Registry
The SourceAdapterRegistry holds all registered adapters. On source detection, it iterates adapters in registration order and returns the first match. The registry is created in the context module with the three built-in adapters:
const defaultSourceRegistry = createSourceAdapterRegistry([
youtubeAdapter,
odyseeAdapter,
peertubeAdapter,
]);Adding a New Source
To add support for a new video platform:
- Add the source type to the union. Update
SourceTypeinpackages/api/src/domain/catalog.tsand the correspondingsourceTypeValuesinpackages/db/src/schema/catalog.ts. - Create the adapter file. Add a new file in
packages/api/src/sources/. Implement the full SourceAdapter interface with detect, resolveInput, normalizeCatalogPayload, and fetchCatalog. - Write fixture-backed tests. Test detection against real URL forms, normalization against saved response fixtures, and error handling for malformed input. Do not depend on live network calls.
- Register the adapter. Export the adapter from
packages/api/src/sources/index.tsand add it to the registry inpackages/api/src/context.ts. - Update the router input schema. Add the new source type to
sourceTypeInputin the router if you want filter support.
Database
Schema Structure
Drizzle schema is in packages/db/src/schema/ split into three files:
auth.ts— User, session, account, and verification tables managed by better-auth.catalog.ts— Global catalog tables (creator, feed, contentItem, contentSource, feedContent, refreshRun, refreshFeedResult) with Drizzle relations.overlays.ts— User-owned overlay tables (subscription, contentStatus, playlist, playlistItem, userSetting, migrationRun, migrationMapping) with cross-references to catalog and auth tables.
All tables use text primary keys (UUID), integer timestamps in millisecond mode, and appropriate indexes for query patterns. Unique indexes enforce source identity deduplication and user-data constraints.
Migration Workflow
bun run db:generate # Generate SQL migration files from schema changes
bun run db:migrate # Apply pending migrations to the database
bun run db:push # Push schema directly (dev only, no migration files)
bun run db:studio # Open Drizzle Studio to inspect data
bun run db:local # Start local libSQL server on port 5000Adding a New Table
- Choose the right file. Auth-related tables go in auth.ts, catalog tables in catalog.ts, user overlay tables in overlays.ts.
- Define the table. Use sqliteTable with text IDs, integer timestamps (mode: timestamp_ms), and the shared currentTimestampMs helper.
- Add indexes. Include a unique index for natural identity columns and regular indexes for foreign keys and common query patterns.
- Define relations. Add a Drizzle relations definition for the table if it has foreign keys to other tables.
- Export from index. The file re-exports through schema/index.ts.
- Generate and apply migration. Run db:generate to produce the SQL migration, then db:migrate to apply it.
Ingestion and Refresh
Ingestion Pipeline
The ingestion service handles adding new sources and creating catalog records. The flow is:
- Detect the source type from the input URL via the registry.
- Resolve the input to a canonical creator/feed identity.
- Fetch the remote catalog data through the adapter.
- Normalize into creator, feed, content items, and content sources.
- Persist to the database with upsert semantics (deduplicate by source identity).
- Create a subscription linking the user to the creator.
Batch ingestion processes each URL independently and reports per-item success or failure without aborting the entire batch.
Refresh Service
The refresh service re-fetches feed data and updates the catalog. Two modes:
- Normal refresh — Selects feeds where nextRefreshAfter has passed or is null. Skips feeds that are not due.
- Force refresh — Selects all feeds regardless of cadence metadata.
Refresh runs produce a refreshRun record and per-feed refreshFeedResult records with counts and error summaries. The startAll procedure returns immediately while the refresh continues in the background.
Development Workflow
| Command | Description |
|---|---|
| bun install | Install workspace dependencies |
| bun run dev | Start all dev targets through Turborepo |
| bun run dev:web | Start the Solid web app only |
| bun run dev:server | Start the Hono API server only |
| bun run dev:desktop | Start the Electrobun desktop app with HMR |
| bun run check-types | TypeScript type check across the entire monorepo |
| bun run build | Production build of all apps and packages |
| bun run build:desktop | Build the stable Electrobun desktop app |
Testing
FeedElity uses vertical TDD slices: write one behavior test, then implement the behavior, then repeat. Tests verify behavior through public interfaces — API procedures, domain services, source adapter contracts, migration commands, or rendered UI behavior.
- Source adapter tests — Use saved response fixtures. Do not depend on live network calls. Cover URL detection, normalization, and error handling.
- API tests — Test through the oRPC router. Verify catalog browsing is public, overlay mutations are protected, and cross-user isolation is enforced.
- Migration tests — Test with fixture export JSON. Cover malformed input rejection, successful import, idempotent re-import, and unmapped record reporting.
- Repository tests — Test repository functions against a real database. Verify constraints, uniqueness, and scoping.
Tests must cover failure paths, not only the happy path, for auth, migration, ingestion, refresh, and cross-user data access.
Code Conventions
- TypeScript strict mode. No
any,as any, or: any. - Use
import typefor type-only imports. verbatimModuleSyntax is enabled. - External imports first, blank line, then local imports.
- No placeholder code, no TODO, no FIXME.
- Keep source-specific logic inside source adapters. Generic catalog, API, and UI code uses normalized contracts.
- Keep module boundaries explicit: source adapters, domain services, repositories, API procedures, and UI state should not bleed into each other.
- Every user-owned read or write must be scoped by authenticated userId at the API or service boundary.
- Use Solid patterns for the frontend, not React patterns.