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 definition

packages/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.

TablePurpose
creatorNormalized creator/channel identity with source type, external ID, display name, description, and image. Uniqueness on (sourceType, sourceExternalId).
feedSource-specific feed belonging to a creator. Stores URL, refresh cadence, last/next refresh timestamps, and adapter metadata.
contentItemNormalized video item with title, description, publication date, duration, thumbnail, and content type.
contentSourcePlayable source link for a content item. Includes embed URL, native media URL, canonical URL, and priority. One item can have multiple sources.
feedContentAssociation between a feed and a content item. Composite primary key on (feedId, contentItemId).
refreshRunRecords a manual refresh attempt with scope, force flag, status, counts, timestamps, and error summary.
refreshFeedResultPer-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.

TablePurpose
subscriptionUser-to-creator subscription. Unique on (userId, creatorId).
contentStatusPer-user status for opened, played, or favorite. Unique on (userId, contentItemId, status).
playlist / playlistItemUser playlists with ordered items. Items are unique per playlist by position and by contentItemId.
userSettingKey-value settings scoped to a user. Unique on (userId, key).
migrationRun / migrationMappingOne-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

  1. 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).
  2. Choose the procedure. Use publicProcedure for anonymous catalog reads or protectedProcedure for authenticated mutations and user-scoped data.
  3. Implement the handler. Use repository functions from repositories/catalog or repositories/overlays. Keep the handler thin — delegate to services or repositories.
  4. Validate existence. For operations that reference catalog entities, validate the entity exists before proceeding. Throw ORPCError("NOT_FOUND") when missing.
  5. Scope by userId. All user-owned data must pass context.session.user.id to repository functions. Never trust client-supplied user IDs.
  6. 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:

  1. Add the source type to the union. Update SourceType in packages/api/src/domain/catalog.ts and the corresponding sourceTypeValues in packages/db/src/schema/catalog.ts.
  2. Create the adapter file. Add a new file in packages/api/src/sources/. Implement the full SourceAdapter interface with detect, resolveInput, normalizeCatalogPayload, and fetchCatalog.
  3. 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.
  4. Register the adapter. Export the adapter from packages/api/src/sources/index.ts and add it to the registry in packages/api/src/context.ts.
  5. Update the router input schema. Add the new source type to sourceTypeInput in 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 5000

Adding a New Table

  1. Choose the right file. Auth-related tables go in auth.ts, catalog tables in catalog.ts, user overlay tables in overlays.ts.
  2. Define the table. Use sqliteTable with text IDs, integer timestamps (mode: timestamp_ms), and the shared currentTimestampMs helper.
  3. Add indexes. Include a unique index for natural identity columns and regular indexes for foreign keys and common query patterns.
  4. Define relations. Add a Drizzle relations definition for the table if it has foreign keys to other tables.
  5. Export from index. The file re-exports through schema/index.ts.
  6. 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:

  1. Detect the source type from the input URL via the registry.
  2. Resolve the input to a canonical creator/feed identity.
  3. Fetch the remote catalog data through the adapter.
  4. Normalize into creator, feed, content items, and content sources.
  5. Persist to the database with upsert semantics (deduplicate by source identity).
  6. 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

CommandDescription
bun installInstall workspace dependencies
bun run devStart all dev targets through Turborepo
bun run dev:webStart the Solid web app only
bun run dev:serverStart the Hono API server only
bun run dev:desktopStart the Electrobun desktop app with HMR
bun run check-typesTypeScript type check across the entire monorepo
bun run buildProduction build of all apps and packages
bun run build:desktopBuild 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 type for 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.