Skip to content

Local development

Build, run, and test Lumastra from source. You’ll need Node.js 22 or newer, Docker with Compose, Git, and a Rust toolchain installed with rustup.

RequirementMinimumNotes
Node.js22.0.0The codebase uses Svelte 5 / Vitest 5 APIs that require Node 22+. Use .nvmrc (pin: 24) or any Node ≥ 22.
npm10.xBundled with Node 22+
Docker + ComposeDocker ≥ 24, Compose ≥ 2.20Runs PostgreSQL 18 + pgvector and two Valkeys locally
Gitany recent—
RustPinned in native/rust-toolchain.tomlInstall with rustup; required to build the worker’s Rust watcher from source.

Jellyfin is not required for development. The app starts in setup mode and most UI can be exercised without it.

  1. Clone and install.

    Terminal window
    git clone https://github.com/Sapd/lumastra.git
    cd lumastra
    npm install
  2. Start the infrastructure. The repo ships a docker-compose.dev.yml that starts PostgreSQL 18 + pgvector and two Valkeys (the plain docker-compose.yml name is reserved for an operator’s own copy of the example, and is gitignored):

    Terminal window
    # from the repo root
    cp .env.example .env
    # Edit .env — set POSTGRES_PASSWORD and CACHE_PASSWORD at minimum (see below)
    docker compose -f docker-compose.dev.yml up -d

    The compose file exposes PostgreSQL on 127.0.0.1:5432 (database lumastra, user lumastra), persistent task/shared-coordination Valkey on 127.0.0.1:6379, and disposable cache Valkey on 127.0.0.1:6380.

  3. Set environment variables. Copy .env.example to .env. The minimum set to start a dev server (replace password placeholders with actual values):

    Terminal window
    DATABASE_URL=postgresql://lumastra:<POSTGRES_PASSWORD>@localhost:5432/lumastra
    CACHE_URL=redis://default:<CACHE_PASSWORD>@localhost:6380/0
    TASK_CACHE_URL=redis://default:<CACHE_PASSWORD>@localhost:6379/0
    TMDB_API_KEY=<your_tmdb_key> # required for metadata enrichment
    # --- Dev auth bypass ---
    # Identity headers are FAIL-CLOSED by default. For local dev (no proxy),
    # enable trust-any-proxy so the self-signed DEFAULT_USER identity is accepted:
    LUMASTRA_TRUST_ANY_PROXY=true
    DEFAULT_USER=admin # username used when no auth header arrives

    Key optional vars for richer local testing:

    Terminal window
    OPENAI_API_KEY=sk-... # embeddings for AI recommendations
    OPENAI_CHAT_API_KEY=sk-... # the assistant's chat backend
    VOYAGE_API_KEY=... # alternative embedding provider
    LUMASTRA_MASTER_KEY=$(openssl rand -hex 32) # encrypts stored secrets
    LOG_LEVEL=debug # verbose server logs

    Every variable is listed in the environment variables reference.

  4. Run migrations and check config.

    Terminal window
    npm run startup-migration
    npm run doctor

    npm run doctor validates your environment (embedding dimensions, DB/cache connectivity, proxy posture). A misconfiguration causes a loud startup error, so check the output before starting the dev server.

  5. Start the web server and dedicated worker.

    Terminal window
    npm run dev

    The web server starts on http://localhost:5173 (Vite default). Hot Module Replacement is enabled for .svelte, .ts, and .css files. In a second terminal, start exactly one worker:

    Terminal window
    npm run dev:worker

    This builds and runs the lean TypeScript task host, existing tasks/plugins and one Rust watcher child. Rust is the only file watcher, including for bare-metal and development installs. Web remains separate from task execution.

Terminal window
npm run check # svelte-check (type errors)
npm run lint # ESLint — zero-warning policy (--max-warnings=0)
npm run lint -- --fix # auto-fixable issues
npm run format # Prettier

The pre-commit hook (Husky + lint-staged) runs Prettier and ESLint on staged files automatically.

Terminal window
npm test # all unit tests (excludes *.integration.test.ts)
npm test -- --watch # watch mode
npm test -- src/lib/utils/... # single file
npm test -- --coverage

Unit tests use Vitest in the Node environment. Component tests use happy-dom through vitest.config.components.ts; npm test runs both suites. Tests are co-located with their source files.

Integration tests run Vitest against a real PostgreSQL database (lumastra_tests). They live next to the services they test and are named *.integration.test.ts.

Bootstrap the test database first (one-time):

Terminal window
npm run db:test:create # creates lumastra_tests on localhost:5432
npm run db:test:migrate # runs all migrations against it

Then:

Terminal window
npm run test:integration # all integration tests
npm run test:integration -- <file-path> # single test file
# Override the target database:
TEST_DATABASE_URL=postgres://user:pass@host:port/lumastra_tests \
npm run test:integration
Terminal window
npm run dev # start the dev server first
npm run test:e2e:api # Playwright API tests
Terminal window
npm run build # production build → build/
npm run preview # preview the production build locally

The build pipeline: compiles plugins and the stream proxy, runs paraglide:compile for i18n, increments the service-worker version, then runs Vite. It requires Node ≥ 22 and approximately 4 GB of heap (NODE_OPTIONS='--max-old-space-size=4096' is set automatically).

LayerTechnology
FrameworkSvelteKit + TypeScript (Svelte 5), @sveltejs/adapter-node
UITailwindCSS 4, Skeleton UI
DatabasePostgreSQL 18 + pgvector
Cache / queuesValkey 9 (Redis-compatible), BullMQ
Background jobsSeparate BullMQ worker process
AIOpenAI or Voyage AI embeddings, pgvector similarity, RAG chat
TestingVitest 5 (unit + integration), Playwright (E2E)
BuildVite 8

These conventions are enforced by ESLint and TypeScript and must be followed in all contributions.

New database code belongs in one of three layers. Do not add methods to the Database class (src/lib/server/database/connection.ts); it’s legacy.

TierLocationUse for
Query modulessrc/lib/server/database/queries/Pure SQL functions. First parameter is sql: postgres.Sql. No side effects.
Servicessrc/lib/server/services/Transactions, race conditions, multi-table writes, external coordination.
Service managerssrc/lib/server/services/*-manager.tsBusiness logic, write orchestration. Used by routes.

Routes must call service managers for writes. Direct write queries in route files are a linted warning.

Database migrations: two-step registration

Section titled “Database migrations: two-step registration”

Creating a file in migrations/ is not enough. You must also import and register it in src/lib/server/database/migrate-schema.ts in the MIGRATIONS array, or the migration will never run.

All API response types are defined as Zod schemas in src/lib/schemas/. The files in src/lib/types/ re-export types via z.infer<>. When adding a new field:

  1. Add to the Zod schema with .describe('...') for OpenAPI.
  2. Run npm run openapi:generate: types update automatically.
  3. Prefer .optional() over .nullable() (Swift client compatibility). Use .nullable() only when the database or external API returns null. At service boundaries: row.field ?? undefined.
RuleRequirement
No anyUse unknown with type guards
Explicit return typesRequired on all functions, especially async
Generic collectionsMap<string, MediaItem>, not Map<any, any>

npm run lint runs with --max-warnings=0. All rules must pass before a commit lands. Key enforced rules:

RuleSeverityWhat it prevents
No direct external_ids writesERRORBypasses composite-key integrity: use CatalogService.upsertExternalId()
No throw error() in API routesERRORUse ApiResponses.unauthorized(), .notFound(), .badRequest(), .error()
No direct SQL in route filesWARNINGUse query modules from src/lib/server/database/queries/
No sql.unsafe()WARNINGUse parameterized template literals: sql`...${value}`
No unscoped media_items queriesWARNINGQueries must apply library/age filter or be admin-scoped
has_files = true filter requiredWARNINGPrevents catalog ghost items leaking as playable cards

See docs/dev/eslint-exceptions.md in the repo for documented exceptions.

External provider IDs (TMDB, IMDB) are not unique across media types. TMDB ID 1429 maps to both a movie and a TV series. Always identify media by the composite key (provider_id, media_type):

import { ExternalIdMap } from '$lib/server/utils/tmdb-composite-key';
const map = new ExternalIdMap<string>();
map.set(tmdbId, mediaType, internalId);
// SQL — always include media_type
WHERE ei.provider = 'tmdb'
AND ei.external_id = ${tmdbId}
AND mi.media_type = ${mediaType}

All CatalogService methods and embedding functions require a mediaType parameter.

Three case conventions coexist. Never string-compare media types directly; use the comparison helpers:

ConventionExamplesUsed by
UPPERCASE'MOVIE', 'SERIES'Database, MediaItem
PascalCase'Movie', 'Series'Jellyfin API
lowercase'movie', 'tv'TMDB, playlist items
import { isEpisode, isMovie, isSeries, isSeason } from '$lib/server/utils/media-types';
if (isMovie(item.media_type)) { ... } // handles any case convention

The internal UUID (media_items.id) is the canonical identifier end-to-end. Routes use the withIdResolution middleware, which resolves any incoming format (bare UUID, tmdb:550:movie, Jellyfin UUID) to locals.resolvedIds.internalId. User-data queries (ratings, watch history, watchlist) use internalId directly, with no external_ids join needed in those handlers.