Migrate web app from Next.js to Vite + TanStack Router SPA (#6)

* Convert to Turborepo monorepo with shared API contract package

Restructure the repository as a monorepo in preparation for adding
future clients (mobile app, CLI). Extract the oRPC contract and Zod
schemas into `@sofa/api` (packages/api/) as a JIT internal package,
and relocate the Next.js app to `@sofa/web` (apps/web/).

- Add Turborepo with Bun workspaces for task orchestration and caching
- Extract `contract.ts` and `schemas.ts` into `@sofa/api` package
- Move all app code, configs, tests, and migrations to `apps/web/`
- Update 17 import paths from `@/lib/orpc/schemas` to `@sofa/api/schemas`
- Add `outputFileTracingRoot` and `transpilePackages` to next.config.ts
- Rewrite Dockerfile with `turbo prune --docker` for efficient builds
- Update CI workflows to use `turbo run` for lint/check-types/test
- Update CLAUDE.md with monorepo structure and commands

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Extract standalone Hono API server and split shared packages

Separate all server-side concerns from the Next.js frontend into a new
`apps/server/` Hono app and dedicated shared packages, making `@sofa/web`
a frontend-only app with no direct DB or service access.

- Add `@sofa/server` (`apps/server/`) — Hono API on port 3001 hosting
  oRPC procedures, Better Auth, cron jobs, and non-RPC routes
- Add `@sofa/core` (`packages/core/`) — All 15 business logic services
  moved from `apps/web/lib/services/`; tests moved to `packages/core/test/`
- Add `@sofa/db` (`packages/db/`) — DB client, schema, migrations,
  constants, and logger extracted from `apps/web/lib/db/` and `lib/`
- Add `@sofa/tmdb` (`packages/tmdb/`) — TMDB client and image helpers
  moved from `apps/web/lib/tmdb/`
- Add `@sofa/auth` (`packages/auth/`) — Better Auth server config moved
  from `apps/web/lib/auth/`
- Move oRPC procedures, handler, router, middleware to `apps/server/src/orpc/`
- Move Hono route handlers (avatars, backups, images, lists, webhooks,
  health) to `apps/server/src/routes/`; delete equivalent Next.js API routes
- Strip `apps/web` to frontend-only: no DB imports, no service imports,
  all data via oRPC client calls to the API server
- Add `entrypoint.sh` to start API server, wait for health, then Next.js
- Update `next.config.ts` rewrites to proxy `/rpc/*` and `/api/*` to
  `INTERNAL_API_URL` (default `http://localhost:3001`)
- Update Dockerfile and CLAUDE.md for the new structure

* Migrate web app from Next.js to Vite + TanStack Router SPA and add workspace catalog

Replace Next.js with a pure Vite SPA using TanStack Router for file-based routing,
removing all SSR complexity. The API server (Hono) now serves both API routes and
SPA static files in production, simplifying Docker to a single-process container.

Key changes:
- Vite 7 + @tanstack/react-router with file-based routing via plugin
- Route guards via beforeLoad + authClient.getSession() (replaces server-side auth)
- Route loaders with queryClient.ensureQueryData() (replaces SSR data fetching)
- Self-hosted fonts via @fontsource (replaces next/font/google)
- Tailwind v4 via @tailwindcss/vite (replaces @tailwindcss/postcss)
- Single oRPC client (removed SSR client and server-side session helper)
- Hono serves SPA static files in production (single port 3000)
- Single-process Dockerfile (removed entrypoint.sh)
- Bun workspace catalog for centralized dependency version management

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Extract @sofa/logger and @sofa/config shared packages

- Add `@sofa/logger` (`packages/logger/`) — standalone logger package
  extracted from `@sofa/db/logger`; update all imports across server,
  core, auth, db, and tmdb packages
- Add `@sofa/config` (`packages/config/`) — standalone config/constants
  package extracted from `@sofa/db/constants`; exports `DATA_DIR`,
  `DATABASE_URL`, `CACHE_DIR`, `AVATAR_DIR`, `BACKUP_DIR`
- Move `.env.example` from `apps/web/` to repo root; update server dev
  scripts to load it via `--env-file=../../.env`
- Move image serving from `/api/images` to `/images`; add `serveStatic`
  fast path in `index.ts` for cached files before falling back to the
  TMDB fetch route; add `/images` proxy to Vite dev config
- Fix `Sparkline` component: replace `ResponsiveContainer` with
  `ResizeObserver` to avoid SSR/hydration issues with recharts
- Replace `VITE_SERVER_URL` env var with `window.location.origin` in
  the oRPC client (always same-origin in both dev and production)

* Fix asset caching, SPA 404 fallback, and DATA_DIR resolution

- Add `Cache-Control: immutable` header for hashed `/assets/*` files;
  return 404 for missing asset paths instead of falling back to
  `index.html` (prevents serving stale chunks after deploy)
- Wrap `query.invalidate` in an arrow function in the oRPC QueryClient
  error handler to avoid illegal invocation errors
- Resolve `DATA_DIR` to an absolute path via `path.resolve()` so
  relative paths work regardless of the process working directory

* Migrate @sofa/logger to pino for structured logging

- Replace custom logger implementation in `packages/logger/` with pino
  + pino-pretty; add both as workspace catalog dependencies
- Add `pino` and `pino-pretty` to the workspace catalog in `package.json`
- Fix `log.error()` calls in oRPC and OpenAPI handlers to pass the
  error directly instead of wrapping it in `{ error }` to match pino's
  serializer expectations

* Rename discoverProcedure/statsProcedure exports to discover/stats

* Add TanStackDevtools unified panel and VS Code workspace config

- Replace separate Router/Query devtools with unified `TanStackDevtools`
  from `@tanstack/react-devtools` + `@tanstack/devtools-vite` plugin
- Wrap app in `<StrictMode>` in `main.tsx`
- Add `.vscode/settings.json` (Biome formatter, format-on-save, readonly
  `routeTree.gen.ts`) and `.vscode/extensions.json` (recommended extensions)

* Move test DB helpers to @sofa/db/test-utils and add root bunfig.toml

Extract in-memory SQLite setup and fixture helpers (insertUser, insertTitle,
etc.) from packages/core/test/sqlite.ts into packages/db/src/test-utils.ts
so DB test utilities live alongside the schema they depend on. Use
import.meta.dir for CWD-independent migration path resolution.

Add root bunfig.toml so `bun test` works from the repo root in addition
to `bun run test` (turbo).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix devtools plugin order and whitespace-only TMDB token check

Move devtools() to first position in Vite plugins array per TanStack
docs, and trim TMDB_API_READ_ACCESS_TOKEN before boolean coercion so
whitespace-only values are treated as unconfigured.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jakeandClaude Opus 4.6 authored and GitHub committed 2026-03-10 16:50:34 -04:00
1 parent 26558b29e4
commit a326c968b7
325 files changed
+26970 -28519

No files matched your search

+13
View File
@@ -0,0 +1,13 @@
import path from "node:path";
import { defineConfig } from "drizzle-kit";
export default defineConfig({
dialect: "sqlite",
schema: "./src/schema.ts",
out: "./drizzle",
dbCredentials: {
url:
process.env.DATABASE_URL ||
path.join(process.env.DATA_DIR || "./data", "sqlite.db"),
},
});
@@ -0,0 +1,164 @@
CREATE TABLE `account` (
`id` text PRIMARY KEY,
`userId` text NOT NULL,
`accountId` text NOT NULL,
`providerId` text NOT NULL,
`accessToken` text,
`refreshToken` text,
`accessTokenExpiresAt` integer,
`refreshTokenExpiresAt` integer,
`scope` text,
`password` text,
`createdAt` integer NOT NULL,
`updatedAt` integer NOT NULL,
CONSTRAINT `fk_account_userId_user_id_fk` FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE TABLE `availabilityOffers` (
`titleId` text NOT NULL,
`region` text DEFAULT 'US' NOT NULL,
`providerId` integer NOT NULL,
`providerName` text NOT NULL,
`logoPath` text,
`offerType` text NOT NULL,
`link` text,
`lastFetchedAt` integer,
CONSTRAINT `fk_availabilityOffers_titleId_titles_id_fk` FOREIGN KEY (`titleId`) REFERENCES `titles`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE TABLE `episodes` (
`id` text PRIMARY KEY,
`seasonId` text NOT NULL,
`episodeNumber` integer NOT NULL,
`name` text,
`overview` text,
`stillPath` text,
`airDate` text,
`runtimeMinutes` integer,
CONSTRAINT `fk_episodes_seasonId_seasons_id_fk` FOREIGN KEY (`seasonId`) REFERENCES `seasons`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE TABLE `seasons` (
`id` text PRIMARY KEY,
`titleId` text NOT NULL,
`seasonNumber` integer NOT NULL,
`name` text,
`overview` text,
`posterPath` text,
`airDate` text,
`lastFetchedAt` integer,
CONSTRAINT `fk_seasons_titleId_titles_id_fk` FOREIGN KEY (`titleId`) REFERENCES `titles`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE TABLE `session` (
`id` text PRIMARY KEY,
`userId` text NOT NULL,
`token` text NOT NULL UNIQUE,
`expiresAt` integer NOT NULL,
`ipAddress` text,
`userAgent` text,
`createdAt` integer NOT NULL,
`updatedAt` integer NOT NULL,
CONSTRAINT `fk_session_userId_user_id_fk` FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE TABLE `titleRecommendations` (
`titleId` text NOT NULL,
`recommendedTitleId` text NOT NULL,
`source` text NOT NULL,
`rank` integer NOT NULL,
`lastFetchedAt` integer,
CONSTRAINT `fk_titleRecommendations_titleId_titles_id_fk` FOREIGN KEY (`titleId`) REFERENCES `titles`(`id`) ON DELETE CASCADE,
CONSTRAINT `fk_titleRecommendations_recommendedTitleId_titles_id_fk` FOREIGN KEY (`recommendedTitleId`) REFERENCES `titles`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE TABLE `titles` (
`id` text PRIMARY KEY,
`tmdbId` integer NOT NULL,
`type` text NOT NULL,
`title` text NOT NULL,
`originalTitle` text,
`overview` text,
`releaseDate` text,
`firstAirDate` text,
`posterPath` text,
`backdropPath` text,
`popularity` real,
`voteAverage` real,
`voteCount` integer,
`status` text,
`lastFetchedAt` integer
);
--> statement-breakpoint
CREATE TABLE `user` (
`id` text PRIMARY KEY,
`name` text NOT NULL,
`email` text NOT NULL UNIQUE,
`emailVerified` integer DEFAULT false NOT NULL,
`image` text,
`createdAt` integer NOT NULL,
`updatedAt` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE `userEpisodeWatches` (
`id` text PRIMARY KEY,
`userId` text NOT NULL,
`episodeId` text NOT NULL,
`watchedAt` integer NOT NULL,
`source` text DEFAULT 'manual' NOT NULL,
CONSTRAINT `fk_userEpisodeWatches_userId_user_id_fk` FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON DELETE CASCADE,
CONSTRAINT `fk_userEpisodeWatches_episodeId_episodes_id_fk` FOREIGN KEY (`episodeId`) REFERENCES `episodes`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE TABLE `userMovieWatches` (
`id` text PRIMARY KEY,
`userId` text NOT NULL,
`titleId` text NOT NULL,
`watchedAt` integer NOT NULL,
`source` text DEFAULT 'manual' NOT NULL,
CONSTRAINT `fk_userMovieWatches_userId_user_id_fk` FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON DELETE CASCADE,
CONSTRAINT `fk_userMovieWatches_titleId_titles_id_fk` FOREIGN KEY (`titleId`) REFERENCES `titles`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE TABLE `userRatings` (
`userId` text NOT NULL,
`titleId` text NOT NULL,
`ratingStars` integer NOT NULL,
`ratedAt` integer NOT NULL,
CONSTRAINT `fk_userRatings_userId_user_id_fk` FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON DELETE CASCADE,
CONSTRAINT `fk_userRatings_titleId_titles_id_fk` FOREIGN KEY (`titleId`) REFERENCES `titles`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE TABLE `userTitleStatus` (
`userId` text NOT NULL,
`titleId` text NOT NULL,
`status` text NOT NULL,
`addedAt` integer NOT NULL,
`updatedAt` integer NOT NULL,
CONSTRAINT `fk_userTitleStatus_userId_user_id_fk` FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON DELETE CASCADE,
CONSTRAINT `fk_userTitleStatus_titleId_titles_id_fk` FOREIGN KEY (`titleId`) REFERENCES `titles`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE TABLE `verification` (
`id` text PRIMARY KEY,
`identifier` text NOT NULL,
`value` text NOT NULL,
`expiresAt` integer NOT NULL,
`createdAt` integer,
`updatedAt` integer
);
--> statement-breakpoint
CREATE UNIQUE INDEX `availabilityOffers_unique` ON `availabilityOffers` (`titleId`,`region`,`providerId`,`offerType`);--> statement-breakpoint
CREATE UNIQUE INDEX `episodes_seasonId_episodeNumber` ON `episodes` (`seasonId`,`episodeNumber`);--> statement-breakpoint
CREATE UNIQUE INDEX `seasons_titleId_seasonNumber` ON `seasons` (`titleId`,`seasonNumber`);--> statement-breakpoint
CREATE UNIQUE INDEX `titleRecommendations_unique` ON `titleRecommendations` (`titleId`,`recommendedTitleId`,`source`);--> statement-breakpoint
CREATE UNIQUE INDEX `titles_tmdbId_unique` ON `titles` (`tmdbId`);--> statement-breakpoint
CREATE INDEX `titles_type_releaseDate` ON `titles` (`type`,`releaseDate`);--> statement-breakpoint
CREATE INDEX `titles_type_firstAirDate` ON `titles` (`type`,`firstAirDate`);--> statement-breakpoint
CREATE INDEX `userEpisodeWatches_userId_watchedAt` ON `userEpisodeWatches` (`userId`,`watchedAt`);--> statement-breakpoint
CREATE INDEX `userEpisodeWatches_episodeId` ON `userEpisodeWatches` (`episodeId`);--> statement-breakpoint
CREATE INDEX `userMovieWatches_userId_watchedAt` ON `userMovieWatches` (`userId`,`watchedAt`);--> statement-breakpoint
CREATE INDEX `userMovieWatches_titleId` ON `userMovieWatches` (`titleId`);--> statement-breakpoint
CREATE UNIQUE INDEX `userRatings_userId_titleId` ON `userRatings` (`userId`,`titleId`);--> statement-breakpoint
CREATE UNIQUE INDEX `userTitleStatus_userId_titleId` ON `userTitleStatus` (`userId`,`titleId`);--> statement-breakpoint
CREATE INDEX `userTitleStatus_userId_status` ON `userTitleStatus` (`userId`,`status`);
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,10 @@
CREATE TABLE `appSettings` (
`key` text PRIMARY KEY,
`value` text
);
--> statement-breakpoint
ALTER TABLE `session` ADD `impersonatedBy` text;--> statement-breakpoint
ALTER TABLE `user` ADD `role` text DEFAULT 'user';--> statement-breakpoint
ALTER TABLE `user` ADD `banned` integer DEFAULT false;--> statement-breakpoint
ALTER TABLE `user` ADD `banReason` text;--> statement-breakpoint
ALTER TABLE `user` ADD `banExpires` integer;
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,27 @@
CREATE TABLE `webhookConnections` (
`id` text PRIMARY KEY,
`userId` text NOT NULL,
`provider` text NOT NULL,
`token` text NOT NULL,
`mediaServerUsername` text NOT NULL,
`enabled` integer DEFAULT true NOT NULL,
`createdAt` integer NOT NULL,
`lastEventAt` integer,
CONSTRAINT `fk_webhookConnections_userId_user_id_fk` FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE TABLE `webhookEventLog` (
`id` text PRIMARY KEY,
`connectionId` text NOT NULL,
`eventType` text,
`mediaType` text,
`mediaTitle` text,
`status` text NOT NULL,
`errorMessage` text,
`receivedAt` integer NOT NULL,
CONSTRAINT `fk_webhookEventLog_connectionId_webhookConnections_id_fk` FOREIGN KEY (`connectionId`) REFERENCES `webhookConnections`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE UNIQUE INDEX `webhookConnections_userId_provider` ON `webhookConnections` (`userId`,`provider`);--> statement-breakpoint
CREATE UNIQUE INDEX `webhookConnections_token` ON `webhookConnections` (`token`);--> statement-breakpoint
CREATE INDEX `webhookEventLog_connectionId_receivedAt` ON `webhookEventLog` (`connectionId`,`receivedAt`);
File diff suppressed because it is too large. Load diff
@@ -0,0 +1 @@
ALTER TABLE `titles` ADD `colorPalette` text;
File diff suppressed because it is too large. Load diff
@@ -0,0 +1 @@
ALTER TABLE `account` ADD `idToken` text;
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,10 @@
CREATE TABLE `cronRuns` (
`id` text PRIMARY KEY,
`jobName` text NOT NULL,
`status` text NOT NULL,
`startedAt` integer NOT NULL,
`finishedAt` integer,
`errorMessage` text
);
--> statement-breakpoint
CREATE INDEX `cronRuns_jobName_startedAt` ON `cronRuns` (`jobName`,`startedAt`);
File diff suppressed because it is too large. Load diff
@@ -0,0 +1 @@
ALTER TABLE `webhookConnections` DROP COLUMN `mediaServerUsername`;
File diff suppressed because it is too large. Load diff
@@ -0,0 +1 @@
ALTER TABLE `cronRuns` ADD `durationMs` integer;
File diff suppressed because it is too large. Load diff
@@ -0,0 +1 @@
ALTER TABLE `titles` ADD `trailerVideoKey` text;
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,34 @@
CREATE TABLE `persons` (
`id` text PRIMARY KEY,
`tmdbId` integer NOT NULL,
`name` text NOT NULL,
`biography` text,
`birthday` text,
`deathday` text,
`placeOfBirth` text,
`profilePath` text,
`knownForDepartment` text,
`popularity` real,
`imdbId` text,
`lastFetchedAt` integer
);
--> statement-breakpoint
CREATE TABLE `titleCast` (
`id` text PRIMARY KEY,
`titleId` text NOT NULL,
`personId` text NOT NULL,
`character` text,
`department` text DEFAULT 'Acting' NOT NULL,
`job` text,
`displayOrder` integer DEFAULT 0 NOT NULL,
`episodeCount` integer,
`lastFetchedAt` integer,
CONSTRAINT `fk_titleCast_titleId_titles_id_fk` FOREIGN KEY (`titleId`) REFERENCES `titles`(`id`) ON DELETE CASCADE,
CONSTRAINT `fk_titleCast_personId_persons_id_fk` FOREIGN KEY (`personId`) REFERENCES `persons`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE UNIQUE INDEX `persons_tmdbId_unique` ON `persons` (`tmdbId`);--> statement-breakpoint
CREATE INDEX `persons_name` ON `persons` (`name`);--> statement-breakpoint
CREATE UNIQUE INDEX `titleCast_unique` ON `titleCast` (`titleId`,`personId`,`department`,`character`);--> statement-breakpoint
CREATE INDEX `titleCast_titleId_displayOrder` ON `titleCast` (`titleId`,`displayOrder`);--> statement-breakpoint
CREATE INDEX `titleCast_personId` ON `titleCast` (`personId`);
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,16 @@
CREATE TABLE `genres` (
`id` integer PRIMARY KEY,
`name` text NOT NULL
);
--> statement-breakpoint
CREATE TABLE `titleGenres` (
`titleId` text NOT NULL,
`genreId` integer NOT NULL,
CONSTRAINT `fk_titleGenres_titleId_titles_id_fk` FOREIGN KEY (`titleId`) REFERENCES `titles`(`id`) ON DELETE CASCADE,
CONSTRAINT `fk_titleGenres_genreId_genres_id_fk` FOREIGN KEY (`genreId`) REFERENCES `genres`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE UNIQUE INDEX `titleGenres_titleId_genreId` ON `titleGenres` (`titleId`,`genreId`);--> statement-breakpoint
CREATE INDEX `titleGenres_genreId` ON `titleGenres` (`genreId`);--> statement-breakpoint
CREATE INDEX `userEpisodeWatches_userId_episodeId` ON `userEpisodeWatches` (`userId`,`episodeId`);--> statement-breakpoint
CREATE INDEX `userMovieWatches_userId_titleId` ON `userMovieWatches` (`userId`,`titleId`);
File diff suppressed because it is too large. Load diff
@@ -0,0 +1 @@
ALTER TABLE `titles` ADD `contentRating` text;
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,33 @@
CREATE TABLE `integrationEvents` (
`id` text PRIMARY KEY,
`integrationId` text NOT NULL,
`eventType` text,
`mediaType` text,
`mediaTitle` text,
`status` text NOT NULL,
`errorMessage` text,
`receivedAt` integer NOT NULL,
CONSTRAINT `fk_integrationEvents_integrationId_integrations_id_fk` FOREIGN KEY (`integrationId`) REFERENCES `integrations`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE TABLE `integrations` (
`id` text PRIMARY KEY,
`userId` text NOT NULL,
`provider` text NOT NULL,
`type` text NOT NULL,
`token` text NOT NULL,
`enabled` integer DEFAULT true NOT NULL,
`createdAt` integer NOT NULL,
`lastEventAt` integer,
CONSTRAINT `fk_integrations_userId_user_id_fk` FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
ALTER TABLE `titles` ADD `tvdbId` integer;--> statement-breakpoint
DROP INDEX IF EXISTS `webhookConnections_userId_provider`;--> statement-breakpoint
DROP INDEX IF EXISTS `webhookConnections_token`;--> statement-breakpoint
DROP INDEX IF EXISTS `webhookEventLog_connectionId_receivedAt`;--> statement-breakpoint
CREATE INDEX `integrationEvents_integrationId_receivedAt` ON `integrationEvents` (`integrationId`,`receivedAt`);--> statement-breakpoint
CREATE UNIQUE INDEX `integrations_userId_provider` ON `integrations` (`userId`,`provider`);--> statement-breakpoint
CREATE UNIQUE INDEX `integrations_token` ON `integrations` (`token`);--> statement-breakpoint
DROP TABLE `webhookConnections`;--> statement-breakpoint
DROP TABLE `webhookEventLog`;
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,3 @@
CREATE INDEX `titleRecommendations_titleId_rank` ON `titleRecommendations` (`titleId`,`rank`);--> statement-breakpoint
CREATE INDEX `titles_lastFetchedAt` ON `titles` (`lastFetchedAt`);--> statement-breakpoint
CREATE INDEX `titles_type_status_lastFetchedAt` ON `titles` (`type`,`status`,`lastFetchedAt`);
File diff suppressed because it is too large. Load diff
+32
View File
@@ -0,0 +1,32 @@
{
"name": "@sofa/db",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
"./client": "./src/client.ts",
"./schema": "./src/schema.ts",
"./helpers": "./src/helpers.ts",
"./migrate": "./src/migrate.ts",
"./test-utils": "./src/test-utils.ts"
},
"scripts": {
"lint": "biome check",
"format": "biome format --write",
"check-types": "tsc --noEmit",
"db:generate": "bun --bun drizzle-kit generate",
"db:migrate": "bun --bun drizzle-kit migrate",
"db:push": "bun --bun drizzle-kit push",
"db:studio": "bun --bun drizzle-kit studio"
},
"dependencies": {
"@sofa/config": "workspace:*",
"@sofa/logger": "workspace:*",
"drizzle-orm": "1.0.0-beta.16-ea816b6"
},
"devDependencies": {
"@types/bun": "catalog:",
"drizzle-kit": "1.0.0-beta.16-ea816b6",
"typescript": "catalog:"
}
}
+64
View File
@@ -0,0 +1,64 @@
import { Database } from "bun:sqlite";
import { DATABASE_URL } from "@sofa/config";
import { createLogger } from "@sofa/logger";
import type { Logger } from "drizzle-orm";
import { drizzle } from "drizzle-orm/bun-sqlite";
import * as schema from "./schema";
const log = createLogger("drizzle");
const drizzleLogger: Logger = {
logQuery(query: string, params: unknown[]) {
log.debug(query, params.length ? params : "");
},
};
// Lazy-init singleton via globalThis. Next.js evaluates module-level code at
// build time (when no database exists) and re-imports modules on HMR in dev
// (which would create duplicate connections). Stashing the instances on
// globalThis and wrapping `db` in a Proxy defers all real work to the first
// property access at runtime, sidestepping both problems.
//
// Only the Drizzle instance (`db`) is exported as a Proxy — the raw bun:sqlite
// Database is kept internal because its native C++ methods lose their `this`
// binding when accessed through Reflect.get, so a Proxy around it would break.
// Use `closeDatabase()` for graceful shutdown instead.
const globalForDb = globalThis as unknown as {
_db: ReturnType<typeof drizzle> | undefined;
_client: Database | undefined;
};
function getClient() {
if (!globalForDb._client) {
globalForDb._client = new Database(DATABASE_URL);
globalForDb._client.run("PRAGMA journal_mode = WAL");
globalForDb._client.run("PRAGMA foreign_keys = ON");
globalForDb._client.run("PRAGMA busy_timeout = 5000");
}
return globalForDb._client;
}
function getDb() {
if (!globalForDb._db) {
globalForDb._db = drizzle({
client: getClient(),
schema,
logger: drizzleLogger,
});
}
return globalForDb._db;
}
export const db = new Proxy({} as ReturnType<typeof drizzle>, {
get(_, prop) {
return Reflect.get(getDb(), prop);
},
});
/** Close the current connection, and clear singletons so the Proxy re-initializes on next access. */
export function closeDatabase() {
globalForDb._client?.close();
globalForDb._client = undefined;
globalForDb._db = undefined;
}
+12
View File
@@ -0,0 +1,12 @@
export {
and,
count,
desc,
eq,
gte,
inArray,
isNotNull,
lt,
or,
sql,
} from "drizzle-orm";
+14
View File
@@ -0,0 +1,14 @@
import path from "node:path";
import { createLogger } from "@sofa/logger";
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
import { db } from "./client";
const log = createLogger("db");
export function runMigrations(
migrationsFolder = path.join(import.meta.dir, "../drizzle"),
) {
log.info("Running database migrations...");
migrate(db, { migrationsFolder });
log.info("Database migrations complete");
}
+459
View File
@@ -0,0 +1,459 @@
import {
index,
int,
real,
sqliteTable,
text,
uniqueIndex,
} from "drizzle-orm/sqlite-core";
// Helper for UUID primary keys
const uuidPk = () =>
text("id")
.primaryKey()
.$defaultFn(() => Bun.randomUUIDv7());
// ─── Better Auth tables ──────────────────────────────────────────────
export const user = sqliteTable("user", {
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: int("emailVerified", { mode: "boolean" })
.notNull()
.default(false),
image: text("image"),
role: text("role").default("user"),
banned: int("banned", { mode: "boolean" }).default(false),
banReason: text("banReason"),
banExpires: int("banExpires", { mode: "timestamp" }),
createdAt: int("createdAt", { mode: "timestamp" }).notNull(),
updatedAt: int("updatedAt", { mode: "timestamp" }).notNull(),
});
export const session = sqliteTable("session", {
id: text("id").primaryKey(),
userId: text("userId")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
token: text("token").notNull().unique(),
expiresAt: int("expiresAt", { mode: "timestamp" }).notNull(),
ipAddress: text("ipAddress"),
userAgent: text("userAgent"),
impersonatedBy: text("impersonatedBy"),
createdAt: int("createdAt", { mode: "timestamp" }).notNull(),
updatedAt: int("updatedAt", { mode: "timestamp" }).notNull(),
});
export const account = sqliteTable("account", {
id: text("id").primaryKey(),
userId: text("userId")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
accountId: text("accountId").notNull(),
providerId: text("providerId").notNull(),
accessToken: text("accessToken"),
refreshToken: text("refreshToken"),
idToken: text("idToken"),
accessTokenExpiresAt: int("accessTokenExpiresAt", { mode: "timestamp" }),
refreshTokenExpiresAt: int("refreshTokenExpiresAt", { mode: "timestamp" }),
scope: text("scope"),
password: text("password"),
createdAt: int("createdAt", { mode: "timestamp" }).notNull(),
updatedAt: int("updatedAt", { mode: "timestamp" }).notNull(),
});
export const verification = sqliteTable("verification", {
id: text("id").primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: int("expiresAt", { mode: "timestamp" }).notNull(),
createdAt: int("createdAt", { mode: "timestamp" }),
updatedAt: int("updatedAt", { mode: "timestamp" }),
});
// ─── Genres ─────────────────────────────────────────────────────────
export const genres = sqliteTable("genres", {
id: int("id").primaryKey(),
name: text("name").notNull(),
});
export const titleGenres = sqliteTable(
"titleGenres",
{
titleId: text("titleId")
.notNull()
.references(() => titles.id, { onDelete: "cascade" }),
genreId: int("genreId")
.notNull()
.references(() => genres.id, { onDelete: "cascade" }),
},
(table) => [
uniqueIndex("titleGenres_titleId_genreId").on(table.titleId, table.genreId),
index("titleGenres_genreId").on(table.genreId),
],
);
// ─── App tables ──────────────────────────────────────────────────────
export const titles = sqliteTable(
"titles",
{
id: uuidPk(),
tmdbId: int("tmdbId").notNull(),
tvdbId: int("tvdbId"),
type: text("type", { enum: ["movie", "tv"] }).notNull(),
title: text("title").notNull(),
originalTitle: text("originalTitle"),
overview: text("overview"),
releaseDate: text("releaseDate"),
firstAirDate: text("firstAirDate"),
posterPath: text("posterPath"),
backdropPath: text("backdropPath"),
popularity: real("popularity"),
voteAverage: real("voteAverage"),
voteCount: int("voteCount"),
status: text("status"),
contentRating: text("contentRating"),
colorPalette: text("colorPalette"),
trailerVideoKey: text("trailerVideoKey"),
lastFetchedAt: int("lastFetchedAt", { mode: "timestamp" }),
},
(table) => [
uniqueIndex("titles_tmdbId_unique").on(table.tmdbId),
index("titles_type_releaseDate").on(table.type, table.releaseDate),
index("titles_type_firstAirDate").on(table.type, table.firstAirDate),
index("titles_lastFetchedAt").on(table.lastFetchedAt),
index("titles_type_status_lastFetchedAt").on(
table.type,
table.status,
table.lastFetchedAt,
),
],
);
export const seasons = sqliteTable(
"seasons",
{
id: uuidPk(),
titleId: text("titleId")
.notNull()
.references(() => titles.id, { onDelete: "cascade" }),
seasonNumber: int("seasonNumber").notNull(),
name: text("name"),
overview: text("overview"),
posterPath: text("posterPath"),
airDate: text("airDate"),
lastFetchedAt: int("lastFetchedAt", { mode: "timestamp" }),
},
(table) => [
uniqueIndex("seasons_titleId_seasonNumber").on(
table.titleId,
table.seasonNumber,
),
],
);
export const episodes = sqliteTable(
"episodes",
{
id: uuidPk(),
seasonId: text("seasonId")
.notNull()
.references(() => seasons.id, { onDelete: "cascade" }),
episodeNumber: int("episodeNumber").notNull(),
name: text("name"),
overview: text("overview"),
stillPath: text("stillPath"),
airDate: text("airDate"),
runtimeMinutes: int("runtimeMinutes"),
},
(table) => [
uniqueIndex("episodes_seasonId_episodeNumber").on(
table.seasonId,
table.episodeNumber,
),
],
);
export const userTitleStatus = sqliteTable(
"userTitleStatus",
{
userId: text("userId")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
titleId: text("titleId")
.notNull()
.references(() => titles.id, { onDelete: "cascade" }),
status: text("status", {
enum: ["watchlist", "in_progress", "completed"],
}).notNull(),
addedAt: int("addedAt", { mode: "timestamp" }).notNull(),
updatedAt: int("updatedAt", { mode: "timestamp" }).notNull(),
},
(table) => [
uniqueIndex("userTitleStatus_userId_titleId").on(
table.userId,
table.titleId,
),
index("userTitleStatus_userId_status").on(table.userId, table.status),
],
);
export const userMovieWatches = sqliteTable(
"userMovieWatches",
{
id: uuidPk(),
userId: text("userId")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
titleId: text("titleId")
.notNull()
.references(() => titles.id, { onDelete: "cascade" }),
watchedAt: int("watchedAt", { mode: "timestamp" }).notNull(),
source: text("source", {
enum: ["manual", "import", "plex", "jellyfin", "emby"],
})
.notNull()
.default("manual"),
},
(table) => [
index("userMovieWatches_userId_watchedAt").on(
table.userId,
table.watchedAt,
),
index("userMovieWatches_titleId").on(table.titleId),
index("userMovieWatches_userId_titleId").on(table.userId, table.titleId),
],
);
export const userEpisodeWatches = sqliteTable(
"userEpisodeWatches",
{
id: uuidPk(),
userId: text("userId")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
episodeId: text("episodeId")
.notNull()
.references(() => episodes.id, { onDelete: "cascade" }),
watchedAt: int("watchedAt", { mode: "timestamp" }).notNull(),
source: text("source", {
enum: ["manual", "import", "plex", "jellyfin", "emby"],
})
.notNull()
.default("manual"),
},
(table) => [
index("userEpisodeWatches_userId_watchedAt").on(
table.userId,
table.watchedAt,
),
index("userEpisodeWatches_episodeId").on(table.episodeId),
index("userEpisodeWatches_userId_episodeId").on(
table.userId,
table.episodeId,
),
],
);
export const userRatings = sqliteTable(
"userRatings",
{
userId: text("userId")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
titleId: text("titleId")
.notNull()
.references(() => titles.id, { onDelete: "cascade" }),
ratingStars: int("ratingStars").notNull(),
ratedAt: int("ratedAt", { mode: "timestamp" }).notNull(),
},
(table) => [
uniqueIndex("userRatings_userId_titleId").on(table.userId, table.titleId),
],
);
export const availabilityOffers = sqliteTable(
"availabilityOffers",
{
titleId: text("titleId")
.notNull()
.references(() => titles.id, { onDelete: "cascade" }),
region: text("region").notNull().default("US"),
providerId: int("providerId").notNull(),
providerName: text("providerName").notNull(),
logoPath: text("logoPath"),
offerType: text("offerType", {
enum: ["flatrate", "rent", "buy", "free", "ads"],
}).notNull(),
link: text("link"),
lastFetchedAt: int("lastFetchedAt", { mode: "timestamp" }),
},
(table) => [
uniqueIndex("availabilityOffers_unique").on(
table.titleId,
table.region,
table.providerId,
table.offerType,
),
],
);
export const titleRecommendations = sqliteTable(
"titleRecommendations",
{
titleId: text("titleId")
.notNull()
.references(() => titles.id, { onDelete: "cascade" }),
recommendedTitleId: text("recommendedTitleId")
.notNull()
.references(() => titles.id, { onDelete: "cascade" }),
source: text("source", {
enum: ["tmdb_similar", "tmdb_recommendations"],
}).notNull(),
rank: int("rank").notNull(),
lastFetchedAt: int("lastFetchedAt", { mode: "timestamp" }),
},
(table) => [
uniqueIndex("titleRecommendations_unique").on(
table.titleId,
table.recommendedTitleId,
table.source,
),
index("titleRecommendations_titleId_rank").on(table.titleId, table.rank),
],
);
// ─── Persons & Cast ─────────────────────────────────────────────────
export const persons = sqliteTable(
"persons",
{
id: uuidPk(),
tmdbId: int("tmdbId").notNull(),
name: text("name").notNull(),
biography: text("biography"),
birthday: text("birthday"),
deathday: text("deathday"),
placeOfBirth: text("placeOfBirth"),
profilePath: text("profilePath"),
knownForDepartment: text("knownForDepartment"),
popularity: real("popularity"),
imdbId: text("imdbId"),
lastFetchedAt: int("lastFetchedAt", { mode: "timestamp" }),
},
(table) => [
uniqueIndex("persons_tmdbId_unique").on(table.tmdbId),
index("persons_name").on(table.name),
],
);
export const titleCast = sqliteTable(
"titleCast",
{
id: uuidPk(),
titleId: text("titleId")
.notNull()
.references(() => titles.id, { onDelete: "cascade" }),
personId: text("personId")
.notNull()
.references(() => persons.id, { onDelete: "cascade" }),
character: text("character"),
department: text("department").notNull().default("Acting"),
job: text("job"),
displayOrder: int("displayOrder").notNull().default(0),
episodeCount: int("episodeCount"),
lastFetchedAt: int("lastFetchedAt", { mode: "timestamp" }),
},
(table) => [
uniqueIndex("titleCast_unique").on(
table.titleId,
table.personId,
table.department,
table.character,
),
index("titleCast_titleId_displayOrder").on(
table.titleId,
table.displayOrder,
),
index("titleCast_personId").on(table.personId),
],
);
// ─── Integrations ───────────────────────────────────────────────────
export const integrations = sqliteTable(
"integrations",
{
id: uuidPk(),
userId: text("userId")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
provider: text("provider").notNull(),
type: text("type", { enum: ["webhook", "list"] }).notNull(),
token: text("token").notNull().unique(),
enabled: int("enabled", { mode: "boolean" }).notNull().default(true),
createdAt: int("createdAt", { mode: "timestamp" }).notNull(),
lastEventAt: int("lastEventAt", { mode: "timestamp" }),
},
(table) => [
uniqueIndex("integrations_userId_provider").on(
table.userId,
table.provider,
),
uniqueIndex("integrations_token").on(table.token),
],
);
export const integrationEvents = sqliteTable(
"integrationEvents",
{
id: uuidPk(),
integrationId: text("integrationId")
.notNull()
.references(() => integrations.id, { onDelete: "cascade" }),
eventType: text("eventType"),
mediaType: text("mediaType"),
mediaTitle: text("mediaTitle"),
status: text("status", {
enum: ["success", "ignored", "error"],
}).notNull(),
errorMessage: text("errorMessage"),
receivedAt: int("receivedAt", { mode: "timestamp" }).notNull(),
},
(table) => [
index("integrationEvents_integrationId_receivedAt").on(
table.integrationId,
table.receivedAt,
),
],
);
// ─── Cron Run History ────────────────────────────────────────────────
export const cronRuns = sqliteTable(
"cronRuns",
{
id: uuidPk(),
jobName: text("jobName").notNull(),
status: text("status", {
enum: ["running", "success", "error"],
}).notNull(),
startedAt: int("startedAt", { mode: "timestamp" }).notNull(),
finishedAt: int("finishedAt", { mode: "timestamp" }),
durationMs: int("durationMs"),
errorMessage: text("errorMessage"),
},
(table) => [
index("cronRuns_jobName_startedAt").on(table.jobName, table.startedAt),
],
);
// ─── App Settings ───────────────────────────────────────────────────
export const appSettings = sqliteTable("appSettings", {
key: text("key").primaryKey(),
value: text("value"),
});
+225
View File
@@ -0,0 +1,225 @@
import { Database } from "bun:sqlite";
import { drizzle } from "drizzle-orm/bun-sqlite";
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
import * as schema from "./schema";
const {
user,
titles,
seasons,
episodes,
userMovieWatches,
userEpisodeWatches,
userTitleStatus,
userRatings,
availabilityOffers,
titleRecommendations,
integrations,
} = schema;
export const testClient = new Database(":memory:");
testClient.run("PRAGMA foreign_keys = ON");
export const testDb = drizzle({ client: testClient, schema });
export function applyMigrations() {
const migrationsFolder = `${import.meta.dir}/../drizzle`;
migrate(testDb, { migrationsFolder });
}
export function clearAllTables() {
const tables = testClient
.query(
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE '__drizzle%'",
)
.all() as { name: string }[];
testClient.run("PRAGMA foreign_keys = OFF");
for (const { name } of tables) {
testClient.run(`DELETE FROM "${name}"`);
}
testClient.run("PRAGMA foreign_keys = ON");
}
const now = new Date();
export function insertUser(id = "user-1") {
testDb
.insert(user)
.values({
id,
name: "Test User",
email: `${id}@test.com`,
emailVerified: true,
createdAt: now,
updatedAt: now,
})
.run();
return id;
}
export function insertTitle(
overrides: {
id?: string;
tmdbId?: number;
tvdbId?: number;
type?: "movie" | "tv";
title?: string;
} = {},
) {
const id = overrides.id ?? "title-1";
testDb
.insert(titles)
.values({
id,
tmdbId: overrides.tmdbId ?? 12345,
tvdbId: overrides.tvdbId,
type: overrides.type ?? "movie",
title: overrides.title ?? "Test Movie",
})
.run();
return id;
}
export function insertTvShow(
titleId = "tv-1",
tmdbId = 99999,
seasonCount = 1,
epsPerSeason = 3,
) {
insertTitle({ id: titleId, tmdbId, type: "tv", title: "Test Show" });
const episodeIds: string[] = [];
for (let s = 1; s <= seasonCount; s++) {
const seasonId = `${titleId}-s${s}`;
testDb
.insert(seasons)
.values({ id: seasonId, titleId, seasonNumber: s })
.run();
for (let e = 1; e <= epsPerSeason; e++) {
const epId = `${titleId}-s${s}e${e}`;
testDb
.insert(episodes)
.values({
id: epId,
seasonId,
episodeNumber: e,
name: `S${s}E${e}`,
})
.run();
episodeIds.push(epId);
}
}
return { titleId, episodeIds };
}
export function insertMovieWatch(
userId: string,
titleId: string,
watchedAt?: Date,
) {
testDb
.insert(userMovieWatches)
.values({
userId,
titleId,
watchedAt: watchedAt ?? new Date(),
source: "manual",
})
.run();
}
export function insertEpisodeWatch(
userId: string,
episodeId: string,
watchedAt?: Date,
) {
testDb
.insert(userEpisodeWatches)
.values({
userId,
episodeId,
watchedAt: watchedAt ?? new Date(),
source: "manual",
})
.run();
}
export function insertStatus(
userId: string,
titleId: string,
status: "watchlist" | "in_progress" | "completed",
) {
const now = new Date();
testDb
.insert(userTitleStatus)
.values({ userId, titleId, status, addedAt: now, updatedAt: now })
.run();
}
export function insertRating(
userId: string,
titleId: string,
ratingStars: number,
) {
testDb
.insert(userRatings)
.values({ userId, titleId, ratingStars, ratedAt: new Date() })
.run();
}
export function insertAvailabilityOffer(
titleId: string,
overrides: {
providerId?: number;
providerName?: string;
offerType?: "flatrate" | "rent" | "buy" | "free" | "ads";
} = {},
) {
testDb
.insert(availabilityOffers)
.values({
titleId,
providerId: overrides.providerId ?? 8,
providerName: overrides.providerName ?? "Netflix",
offerType: overrides.offerType ?? "flatrate",
})
.run();
}
export function insertIntegration(
userId: string,
provider: string,
token = "test-token",
) {
const type =
provider === "sonarr" || provider === "radarr" ? "list" : "webhook";
return testDb
.insert(integrations)
.values({
userId,
provider,
type,
token,
enabled: true,
createdAt: new Date(),
})
.returning()
.get();
}
export function insertRecommendation(
titleId: string,
recommendedTitleId: string,
overrides: {
source?: "tmdb_recommendations" | "tmdb_similar";
rank?: number;
} = {},
) {
testDb
.insert(titleRecommendations)
.values({
titleId,
recommendedTitleId,
source: overrides.source ?? "tmdb_recommendations",
rank: overrides.rank ?? 1,
})
.run();
}
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["esnext"],
"strict": true,
"noEmit": true,
"module": "esnext",
"moduleResolution": "bundler",
"isolatedModules": true,
"skipLibCheck": true,
"types": ["bun"]
},
"include": ["src"]
}