mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 01:35:39 -04:00
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:
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export {
|
||||
and,
|
||||
count,
|
||||
desc,
|
||||
eq,
|
||||
gte,
|
||||
inArray,
|
||||
isNotNull,
|
||||
lt,
|
||||
or,
|
||||
sql,
|
||||
} from "drizzle-orm";
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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"),
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user