diff --git a/apps/public-api/src/app.ts b/apps/public-api/src/app.ts index da08bfa..ecf1592 100644 --- a/apps/public-api/src/app.ts +++ b/apps/public-api/src/app.ts @@ -4,7 +4,7 @@ import { Hono } from "hono"; import { cors } from "hono/cors"; import { z } from "zod"; -import { getProvider, getProviderConfig } from "./providers"; +import { getImporter, getImporterConfig } from "./importers"; const GITHUB_RELEASES_URL = "https://api.github.com/repos/jakejarvis/sofa/releases/latest"; @@ -54,8 +54,8 @@ app.post( instanceId: z.string().min(1), version: z.string().min(1), arch: z.string().optional(), - users: z.number().optional(), - titles: z.number().optional(), + users: z.union([z.number(), z.string()]).optional(), + titles: z.union([z.number(), z.string()]).optional(), features: z.record(z.string(), z.unknown()).optional(), }), ), @@ -123,16 +123,16 @@ app.post( console.warn("checkRateLimit error (import-device-code):", e); } - const { provider: providerName } = c.req.valid("param"); + const { provider } = c.req.valid("param"); - const provider = getProvider(providerName); - const config = getProviderConfig(providerName); - if (!provider || !config.clientId) { - return c.json({ error: `${providerName} is not configured` }, 503); + const importer = getImporter(provider); + const config = getImporterConfig(provider); + if (!importer || !config.clientId) { + return c.json({ error: `${provider} is not configured` }, 503); } try { - const result = await provider.getDeviceCode(config.clientId, config.clientSecret); + const result = await importer.getDeviceCode(config.clientId, config.clientSecret); return c.json(result); } catch (e) { return c.json( @@ -172,17 +172,17 @@ app.post( console.warn("checkRateLimit error (import-poll):", e); } - const { provider: providerName } = c.req.valid("param"); + const { provider } = c.req.valid("param"); const { device_code } = c.req.valid("json"); - const provider = getProvider(providerName); - const config = getProviderConfig(providerName); - if (!provider || !config.clientId) { - return c.json({ error: `${providerName} is not configured` }, 503); + const importer = getImporter(provider); + const config = getImporterConfig(provider); + if (!importer || !config.clientId) { + return c.json({ error: `${provider} is not configured` }, 503); } try { - const result = await provider.pollForToken(config.clientId, config.clientSecret, device_code); + const result = await importer.pollForToken(config.clientId, config.clientSecret, device_code); if (result.status !== "authorized") { return c.json({ status: result.status }); @@ -190,7 +190,7 @@ app.post( // Fetch user data and return it inline try { - const data = await provider.fetchUserData(result.accessToken, config.clientId); + const data = await importer.fetchUserData(result.accessToken, config.clientId); return c.json({ status: "authorized", data }); } catch (e) { // Auth succeeded but data fetch failed. Return a distinct status so diff --git a/apps/public-api/src/providers/index.ts b/apps/public-api/src/importers/index.ts similarity index 77% rename from apps/public-api/src/providers/index.ts rename to apps/public-api/src/importers/index.ts index 48bc5b5..a13917c 100644 --- a/apps/public-api/src/providers/index.ts +++ b/apps/public-api/src/importers/index.ts @@ -2,16 +2,16 @@ import { simkl } from "./simkl"; import { trakt } from "./trakt"; import type { ImportProvider } from "./types"; -const providers: Record = { +const importers: Record = { trakt, simkl, }; -export function getProvider(name: string): ImportProvider | undefined { - return providers[name]; +export function getImporter(name: string): ImportProvider | undefined { + return importers[name]; } -export function getProviderConfig(name: string): { +export function getImporterConfig(name: string): { clientId: string; clientSecret: string; } { diff --git a/apps/public-api/src/providers/simkl.ts b/apps/public-api/src/importers/simkl.ts similarity index 100% rename from apps/public-api/src/providers/simkl.ts rename to apps/public-api/src/importers/simkl.ts diff --git a/apps/public-api/src/providers/trakt.ts b/apps/public-api/src/importers/trakt.ts similarity index 100% rename from apps/public-api/src/providers/trakt.ts rename to apps/public-api/src/importers/trakt.ts diff --git a/apps/public-api/src/providers/types.ts b/apps/public-api/src/importers/types.ts similarity index 100% rename from apps/public-api/src/providers/types.ts rename to apps/public-api/src/importers/types.ts diff --git a/apps/server/src/cron.ts b/apps/server/src/cron.ts index b776d68..f8e59aa 100644 --- a/apps/server/src/cron.ts +++ b/apps/server/src/cron.ts @@ -306,6 +306,18 @@ export function startJobs() { log.info(`Started ${jobs.size} jobs`); } +export function pauseJobs() { + for (const job of jobs.values()) { + job.pause(); + } +} + +export function resumeJobs() { + for (const job of jobs.values()) { + job.resume(); + } +} + export function stopJobs() { for (const job of jobs.values()) { job.stop(); diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 50fdb15..e7d2282 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -6,7 +6,7 @@ import { CACHE_DIR } from "@sofa/config"; import { ensureBackupDir } from "@sofa/core/backup"; import { ensureImageDirs, imageCacheEnabled } from "@sofa/core/image-cache"; import { registerJobScheduleProvider } from "@sofa/core/system-health"; -import { closeDatabase } from "@sofa/db/client"; +import { closeDatabase, isDatabaseAccessBlocked } from "@sofa/db/client"; import { runMigrations } from "@sofa/db/migrate"; import { createLogger } from "@sofa/logger"; @@ -54,6 +54,13 @@ app.use( }), ); +app.use("*", async (c, next) => { + if (isDatabaseAccessBlocked() && c.req.path !== "/api/health") { + return c.json({ error: "Service unavailable during database restore" }, 503); + } + await next(); +}); + // Non-RPC routes app.route("/api/health", healthRoutes); app.route("/api/auth", authRoutes); diff --git a/apps/server/src/orpc/procedures/admin.ts b/apps/server/src/orpc/procedures/admin.ts index 2becb8b..1fd1db9 100644 --- a/apps/server/src/orpc/procedures/admin.ts +++ b/apps/server/src/orpc/procedures/admin.ts @@ -20,7 +20,7 @@ import { getSystemHealth } from "@sofa/core/system-health"; import { isTelemetryEnabled } from "@sofa/core/telemetry"; import { getCachedUpdateCheck, isUpdateCheckEnabled } from "@sofa/core/update-check"; -import { rescheduleBackup, triggerJob as triggerCronJob } from "../../cron"; +import { pauseJobs, rescheduleBackup, resumeJobs, triggerJob as triggerCronJob } from "../../cron"; import { os } from "../context"; import { admin } from "../middleware"; @@ -60,6 +60,7 @@ export const backupsRestore = os.admin.backups.restore // Stream upload to disk to avoid buffering the entire file in memory await ensureBackupDir(); const tmpPath = path.join(BACKUP_DIR, `.upload-${Date.now()}-${crypto.randomUUID()}.db`); + pauseJobs(); try { await Bun.write(tmpPath, file); await restoreFromBackup(tmpPath); @@ -73,6 +74,8 @@ export const backupsRestore = os.admin.backups.restore message: msg, data: { code: AppErrorCode.BACKUP_RESTORE_FAILED }, }); + } finally { + resumeJobs(); } }); diff --git a/apps/server/src/orpc/procedures/imports.ts b/apps/server/src/orpc/procedures/imports.ts index 0ca951b..e8b4427 100644 --- a/apps/server/src/orpc/procedures/imports.ts +++ b/apps/server/src/orpc/procedures/imports.ts @@ -124,16 +124,27 @@ export const createJob = os.imports.createJob.use(authed).handler(async ({ input } } - const job = insertImportJob({ - userId: context.user.id, - source: data.source, - status: "pending", - payload: JSON.stringify(data), - importWatches: options.importWatches, - importWatchlist: options.importWatchlist, - importRatings: options.importRatings, - createdAt: new Date(), - }); + let job; + try { + job = insertImportJob({ + userId: context.user.id, + source: data.source, + status: "pending", + payload: JSON.stringify(data), + importWatches: options.importWatches, + importWatchlist: options.importWatchlist, + importRatings: options.importRatings, + createdAt: new Date(), + }); + } catch (err) { + if (err instanceof Error && "code" in err && err.code === "SQLITE_CONSTRAINT_UNIQUE") { + throw new ORPCError("CONFLICT", { + message: "An import is already in progress", + data: { code: AppErrorCode.IMPORT_ALREADY_RUNNING }, + }); + } + throw err; + } // Fire-and-forget processing processImportJob(job.id).catch((err) => { diff --git a/packages/auth/src/server.ts b/packages/auth/src/server.ts index cdffcc0..d0e9bd7 100644 --- a/packages/auth/src/server.ts +++ b/packages/auth/src/server.ts @@ -4,7 +4,7 @@ import { APIError, createAuthMiddleware } from "better-auth/api"; import { type BetterAuthOptions, betterAuth } from "better-auth/minimal"; import { admin, genericOAuth } from "better-auth/plugins"; -import { getUserCount, isRegistrationOpen, setSetting } from "@sofa/core/settings"; +import { claimInitialAdmin, isRegistrationOpen } from "@sofa/core/settings"; import { db } from "@sofa/db/client"; import { createLogger } from "@sofa/logger"; @@ -89,25 +89,10 @@ export const auth = betterAuth({ databaseHooks: { user: { create: { - before: async (user) => { - // First user becomes admin regardless of auth method - const userCount = getUserCount(); - if (userCount === 0) { - return { - data: { - ...user, - role: "admin", - }, - }; - } - return { data: user }; - }, - after: async () => { - // Auto-close registration after first user - const userCount = getUserCount(); - if (userCount === 1) { - setSetting("registrationOpen", "false"); - } + after: async (user) => { + // Promote exactly one bootstrap user to admin, even if multiple + // sign-ups race during the first-run window. + claimInitialAdmin(user.id); }, }, }, diff --git a/packages/core/src/availability.ts b/packages/core/src/availability.ts index 9878653..ef4218c 100644 --- a/packages/core/src/availability.ts +++ b/packages/core/src/availability.ts @@ -15,35 +15,37 @@ export async function refreshAvailability(titleId: string) { const data = await getWatchProviders(title.tmdbId, title.type); const us = data.results?.US; - if (!us) { - log.debug(`No US providers for title ${titleId}`); - return; - } - const now = new Date(); const offerTypes = ["flatrate", "rent", "buy", "free", "ads"] as const; // Collect all offer rows, then batch insert in a single transaction const allOfferRows: (typeof availabilityOffers.$inferInsert)[] = []; - for (const offerType of offerTypes) { - const providers = us[offerType]; - if (!providers) continue; - for (const p of providers) { - allOfferRows.push({ - titleId, - region: "US", - providerId: p.provider_id, - providerName: p.provider_name ?? "", - logoPath: p.logo_path ?? "", - offerType, - link: us.link ?? null, - lastFetchedAt: now, - }); + if (us) { + for (const offerType of offerTypes) { + const providers = us[offerType]; + if (!providers) continue; + for (const p of providers) { + allOfferRows.push({ + titleId, + region: "US", + providerId: p.provider_id, + providerName: p.provider_name ?? "", + logoPath: p.logo_path ?? "", + offerType, + link: us.link ?? null, + lastFetchedAt: now, + }); + } } } replaceAvailabilityTransaction(titleId, "US", allOfferRows); + if (!us) { + log.debug(`No US providers for title ${titleId}; cleared cached offers`); + return; + } + const total = offerTypes.reduce((n, t) => n + (us[t]?.length ?? 0), 0); log.debug(`Refreshed availability for title ${titleId}: ${total} offers`); } diff --git a/packages/core/src/backup.ts b/packages/core/src/backup.ts index 9d4fc47..d154c37 100644 --- a/packages/core/src/backup.ts +++ b/packages/core/src/backup.ts @@ -4,7 +4,7 @@ import { mkdir, readdir } from "node:fs/promises"; import path from "node:path"; import { BACKUP_DIR, DATABASE_URL } from "@sofa/config"; -import { closeDatabase, vacuumDatabase } from "@sofa/db/client"; +import { closeDatabase, vacuumDatabase, withDatabaseAccessBlocked } from "@sofa/db/client"; import { runMigrations } from "@sofa/db/migrate"; import { createLogger } from "@sofa/logger"; @@ -240,15 +240,17 @@ export async function restoreFromBackup(source: Buffer | string): Promise log.info("Creating pre-restore safety backup..."); await createBackupInternal("pre-restore"); - // Keep the close+replace window synchronous to avoid event-loop interleaving. - log.info("Replacing database..."); - closeDatabase(); - renameSync(tempPath, DATABASE_URL); - unlinkIfExistsSync(`${DATABASE_URL}-wal`); - unlinkIfExistsSync(`${DATABASE_URL}-shm`); + await withDatabaseAccessBlocked(async () => { + // Keep the close+replace window synchronous to avoid event-loop interleaving. + log.info("Replacing database..."); + closeDatabase(); + renameSync(tempPath, DATABASE_URL); + unlinkIfExistsSync(`${DATABASE_URL}-wal`); + unlinkIfExistsSync(`${DATABASE_URL}-shm`); - // Ensure restored backups from older app versions are brought up-to-date. - runMigrations(); + // Ensure restored backups from older app versions are brought up-to-date. + runMigrations(); + }); log.info("Database restored successfully"); } finally { const tempFile = Bun.file(tempPath); diff --git a/packages/core/src/settings.ts b/packages/core/src/settings.ts index a32bc56..46756ff 100644 --- a/packages/core/src/settings.ts +++ b/packages/core/src/settings.ts @@ -1,4 +1,5 @@ import { + claimInitialAdmin as queryClaimInitialAdmin, getSettingValue, getUserCount as queryGetUserCount, upsertSetting, @@ -16,6 +17,10 @@ export function getUserCount(): number { return queryGetUserCount(); } +export function claimInitialAdmin(userId: string): boolean { + return queryClaimInitialAdmin(userId); +} + export function getInstanceId(): string { const existing = getSetting("instanceId"); if (existing) return existing; diff --git a/packages/core/src/update-check.ts b/packages/core/src/update-check.ts index 751c6d5..3eb8c9c 100644 --- a/packages/core/src/update-check.ts +++ b/packages/core/src/update-check.ts @@ -74,11 +74,11 @@ export async function performUpdateCheck(): Promise { }); if (!res.ok) throw new Error(`Public API ${res.status}`); - const data = (await res.json()) as { version: string; releaseUrl: string }; + const data = (await res.json()) as { version: string; release_url: string }; const version = data.version; setSetting("updateCheckLatestVersion", version); - setSetting("updateCheckReleaseUrl", data.releaseUrl); + setSetting("updateCheckReleaseUrl", data.release_url); setSetting("updateCheckLastCheckedAt", new Date().toISOString()); log.info(`Update check complete: current=${APP_VERSION}, latest=${version}`); @@ -87,7 +87,7 @@ export async function performUpdateCheck(): Promise { updateAvailable: isNewerVersion(version, APP_VERSION), currentVersion: APP_VERSION, latestVersion: version, - releaseUrl: data.releaseUrl, + releaseUrl: data.release_url, lastCheckedAt: new Date().toISOString(), }; } catch (err) { diff --git a/packages/core/test/availability.test.ts b/packages/core/test/availability.test.ts new file mode 100644 index 0000000..0359552 --- /dev/null +++ b/packages/core/test/availability.test.ts @@ -0,0 +1,42 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; + +import { availabilityOffers } from "@sofa/db/schema"; +import { + clearAllTables, + eq, + insertAvailabilityOffer, + insertTitle, + testDb, +} from "@sofa/db/test-utils"; + +let watchProvidersResponse: { results: Record } = { results: {} }; + +const getWatchProviders = mock(async () => watchProvidersResponse); + +mock.module("@sofa/tmdb/client", () => ({ + getWatchProviders, +})); + +import { refreshAvailability } from "../src/availability"; + +beforeEach(() => { + clearAllTables(); + watchProvidersResponse = { results: {} }; +}); + +describe("refreshAvailability", () => { + test("clears stale US offers when TMDB returns no US availability", async () => { + insertTitle({ id: "movie-1", tmdbId: 101, type: "movie", title: "Movie" }); + insertAvailabilityOffer("movie-1"); + + await refreshAvailability("movie-1"); + + const offers = testDb + .select() + .from(availabilityOffers) + .where(eq(availabilityOffers.titleId, "movie-1")) + .all(); + + expect(offers).toHaveLength(0); + }); +}); diff --git a/packages/core/test/import-processor.test.ts b/packages/core/test/import-processor.test.ts index 464046c..096dca5 100644 --- a/packages/core/test/import-processor.test.ts +++ b/packages/core/test/import-processor.test.ts @@ -344,6 +344,80 @@ describe("processImportJob — state transitions", () => { test("readImportJob throws for non-existent job", () => { expect(() => readImportJob("non-existent-id")).toThrow("Import job non-existent-id not found"); }); + + test("schema allows only one active import job per user", () => { + const userId = insertUser(); + const createdAt = new Date(); + + testDb + .insert(importJobs) + .values({ + id: "job-1", + userId, + source: "trakt", + status: "pending", + payload: JSON.stringify({ + source: "trakt", + movies: [], + episodes: [], + watchlist: [], + ratings: [], + }), + importWatches: true, + importWatchlist: true, + importRatings: true, + createdAt, + }) + .run(); + + expect(() => + testDb + .insert(importJobs) + .values({ + id: "job-2", + userId, + source: "simkl", + status: "running", + payload: JSON.stringify({ + source: "simkl", + movies: [], + episodes: [], + watchlist: [], + ratings: [], + }), + importWatches: true, + importWatchlist: true, + importRatings: true, + createdAt, + }) + .run(), + ).toThrow(); + + testDb.update(importJobs).set({ status: "success" }).where(eq(importJobs.id, "job-1")).run(); + + expect(() => + testDb + .insert(importJobs) + .values({ + id: "job-3", + userId, + source: "letterboxd", + status: "pending", + payload: JSON.stringify({ + source: "letterboxd", + movies: [], + episodes: [], + watchlist: [], + ratings: [], + }), + importWatches: true, + importWatchlist: true, + importRatings: true, + createdAt, + }) + .run(), + ).not.toThrow(); + }); }); // ── Failed Resolution ─────────────────────────────────────────────── diff --git a/packages/core/test/preload.ts b/packages/core/test/preload.ts index 39943f1..30a2e84 100644 --- a/packages/core/test/preload.ts +++ b/packages/core/test/preload.ts @@ -9,6 +9,8 @@ mock.module("@sofa/db/client", () => ({ optimizeDatabase: () => {}, vacuumDatabase: () => {}, closeDatabase: () => {}, + isDatabaseAccessBlocked: () => false, + withDatabaseAccessBlocked: async (fn: () => Promise | unknown) => await fn(), })); applyMigrations(); diff --git a/packages/core/test/settings.test.ts b/packages/core/test/settings.test.ts index 7dbe2ec..887c0b9 100644 --- a/packages/core/test/settings.test.ts +++ b/packages/core/test/settings.test.ts @@ -1,8 +1,15 @@ import { beforeEach, describe, expect, test } from "bun:test"; -import { clearAllTables, insertUser } from "@sofa/db/test-utils"; +import { user } from "@sofa/db/schema"; +import { clearAllTables, eq, insertUser, testDb } from "@sofa/db/test-utils"; -import { getSetting, getUserCount, isRegistrationOpen, setSetting } from "../src/settings"; +import { + claimInitialAdmin, + getSetting, + getUserCount, + isRegistrationOpen, + setSetting, +} from "../src/settings"; beforeEach(() => { clearAllTables(); @@ -65,3 +72,21 @@ describe("isRegistrationOpen", () => { expect(isRegistrationOpen()).toBe(false); }); }); + +describe("claimInitialAdmin", () => { + test("promotes the earliest user to admin and closes registration", () => { + insertUser("user-1"); + insertUser("user-2"); + + expect(claimInitialAdmin("user-2")).toBe(false); + expect(claimInitialAdmin("user-1")).toBe(true); + expect(claimInitialAdmin("user-1")).toBe(false); + + const firstUser = testDb.select().from(user).where(eq(user.id, "user-1")).get(); + const secondUser = testDb.select().from(user).where(eq(user.id, "user-2")).get(); + + expect(firstUser?.role).toBe("admin"); + expect(secondUser?.role).toBe("user"); + expect(getSetting("registrationOpen")).toBe("false"); + }); +}); diff --git a/packages/db/drizzle/20260321200938_secret_living_tribunal/migration.sql b/packages/db/drizzle/20260321200938_secret_living_tribunal/migration.sql new file mode 100644 index 0000000..ea6da81 --- /dev/null +++ b/packages/db/drizzle/20260321200938_secret_living_tribunal/migration.sql @@ -0,0 +1,2 @@ +CREATE INDEX `episodes_airDate` ON `episodes` (`airDate`);--> statement-breakpoint +CREATE UNIQUE INDEX `importJobs_active_user` ON `importJobs` (`userId`) WHERE "importJobs"."status" in ('pending', 'running'); \ No newline at end of file diff --git a/packages/db/drizzle/20260321200938_secret_living_tribunal/snapshot.json b/packages/db/drizzle/20260321200938_secret_living_tribunal/snapshot.json new file mode 100644 index 0000000..166dd4f --- /dev/null +++ b/packages/db/drizzle/20260321200938_secret_living_tribunal/snapshot.json @@ -0,0 +1,3177 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "4910a1f0-24c2-4ca0-a53c-0e68efafa027", + "prevIds": [ + "a69b792c-cc22-4793-a206-cfeadd9225bd" + ], + "ddl": [ + { + "name": "account", + "entityType": "tables" + }, + { + "name": "appSettings", + "entityType": "tables" + }, + { + "name": "availabilityOffers", + "entityType": "tables" + }, + { + "name": "cronRuns", + "entityType": "tables" + }, + { + "name": "episodes", + "entityType": "tables" + }, + { + "name": "genres", + "entityType": "tables" + }, + { + "name": "importJobs", + "entityType": "tables" + }, + { + "name": "integrationEvents", + "entityType": "tables" + }, + { + "name": "integrations", + "entityType": "tables" + }, + { + "name": "personFilmography", + "entityType": "tables" + }, + { + "name": "persons", + "entityType": "tables" + }, + { + "name": "seasons", + "entityType": "tables" + }, + { + "name": "session", + "entityType": "tables" + }, + { + "name": "titleCast", + "entityType": "tables" + }, + { + "name": "titleGenres", + "entityType": "tables" + }, + { + "name": "titleRecommendations", + "entityType": "tables" + }, + { + "name": "titles", + "entityType": "tables" + }, + { + "name": "user", + "entityType": "tables" + }, + { + "name": "userEpisodeWatches", + "entityType": "tables" + }, + { + "name": "userMovieWatches", + "entityType": "tables" + }, + { + "name": "userRatings", + "entityType": "tables" + }, + { + "name": "userTitleStatus", + "entityType": "tables" + }, + { + "name": "verification", + "entityType": "tables" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "userId", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "accountId", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "providerId", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "accessToken", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refreshToken", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "idToken", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "accessTokenExpiresAt", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refreshTokenExpiresAt", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "scope", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "password", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "createdAt", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "updatedAt", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "key", + "entityType": "columns", + "table": "appSettings" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "value", + "entityType": "columns", + "table": "appSettings" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "titleId", + "entityType": "columns", + "table": "availabilityOffers" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'US'", + "generated": null, + "name": "region", + "entityType": "columns", + "table": "availabilityOffers" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "providerId", + "entityType": "columns", + "table": "availabilityOffers" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "providerName", + "entityType": "columns", + "table": "availabilityOffers" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "logoPath", + "entityType": "columns", + "table": "availabilityOffers" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "offerType", + "entityType": "columns", + "table": "availabilityOffers" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "link", + "entityType": "columns", + "table": "availabilityOffers" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "lastFetchedAt", + "entityType": "columns", + "table": "availabilityOffers" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "cronRuns" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "jobName", + "entityType": "columns", + "table": "cronRuns" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "cronRuns" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "startedAt", + "entityType": "columns", + "table": "cronRuns" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "finishedAt", + "entityType": "columns", + "table": "cronRuns" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "durationMs", + "entityType": "columns", + "table": "cronRuns" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "errorMessage", + "entityType": "columns", + "table": "cronRuns" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "episodes" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seasonId", + "entityType": "columns", + "table": "episodes" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "episodeNumber", + "entityType": "columns", + "table": "episodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "episodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "overview", + "entityType": "columns", + "table": "episodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "stillPath", + "entityType": "columns", + "table": "episodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "stillThumbHash", + "entityType": "columns", + "table": "episodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "airDate", + "entityType": "columns", + "table": "episodes" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "runtimeMinutes", + "entityType": "columns", + "table": "episodes" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "genres" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "genres" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "importJobs" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "userId", + "entityType": "columns", + "table": "importJobs" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "source", + "entityType": "columns", + "table": "importJobs" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "importJobs" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "payload", + "entityType": "columns", + "table": "importJobs" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "importWatches", + "entityType": "columns", + "table": "importJobs" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "importWatchlist", + "entityType": "columns", + "table": "importJobs" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "importRatings", + "entityType": "columns", + "table": "importJobs" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "totalItems", + "entityType": "columns", + "table": "importJobs" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "processedItems", + "entityType": "columns", + "table": "importJobs" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "importedCount", + "entityType": "columns", + "table": "importJobs" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "skippedCount", + "entityType": "columns", + "table": "importJobs" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "failedCount", + "entityType": "columns", + "table": "importJobs" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "currentMessage", + "entityType": "columns", + "table": "importJobs" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "errors", + "entityType": "columns", + "table": "importJobs" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "warnings", + "entityType": "columns", + "table": "importJobs" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "createdAt", + "entityType": "columns", + "table": "importJobs" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "startedAt", + "entityType": "columns", + "table": "importJobs" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "finishedAt", + "entityType": "columns", + "table": "importJobs" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "integrationEvents" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "integrationId", + "entityType": "columns", + "table": "integrationEvents" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "eventType", + "entityType": "columns", + "table": "integrationEvents" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "mediaType", + "entityType": "columns", + "table": "integrationEvents" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "mediaTitle", + "entityType": "columns", + "table": "integrationEvents" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "integrationEvents" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "errorMessage", + "entityType": "columns", + "table": "integrationEvents" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "receivedAt", + "entityType": "columns", + "table": "integrationEvents" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "integrations" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "userId", + "entityType": "columns", + "table": "integrations" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "integrations" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "integrations" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token", + "entityType": "columns", + "table": "integrations" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "true", + "generated": null, + "name": "enabled", + "entityType": "columns", + "table": "integrations" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "createdAt", + "entityType": "columns", + "table": "integrations" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "lastEventAt", + "entityType": "columns", + "table": "integrations" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "personFilmography" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "personId", + "entityType": "columns", + "table": "personFilmography" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "titleId", + "entityType": "columns", + "table": "personFilmography" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "character", + "entityType": "columns", + "table": "personFilmography" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'Acting'", + "generated": null, + "name": "department", + "entityType": "columns", + "table": "personFilmography" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "job", + "entityType": "columns", + "table": "personFilmography" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "displayOrder", + "entityType": "columns", + "table": "personFilmography" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "persons" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "tmdbId", + "entityType": "columns", + "table": "persons" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "persons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "biography", + "entityType": "columns", + "table": "persons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "birthday", + "entityType": "columns", + "table": "persons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "deathday", + "entityType": "columns", + "table": "persons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "placeOfBirth", + "entityType": "columns", + "table": "persons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "profilePath", + "entityType": "columns", + "table": "persons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "profileThumbHash", + "entityType": "columns", + "table": "persons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "knownForDepartment", + "entityType": "columns", + "table": "persons" + }, + { + "type": "real", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "popularity", + "entityType": "columns", + "table": "persons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "imdbId", + "entityType": "columns", + "table": "persons" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "lastFetchedAt", + "entityType": "columns", + "table": "persons" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "filmographyLastFetchedAt", + "entityType": "columns", + "table": "persons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "seasons" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "titleId", + "entityType": "columns", + "table": "seasons" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seasonNumber", + "entityType": "columns", + "table": "seasons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "seasons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "overview", + "entityType": "columns", + "table": "seasons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "posterPath", + "entityType": "columns", + "table": "seasons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "posterThumbHash", + "entityType": "columns", + "table": "seasons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "airDate", + "entityType": "columns", + "table": "seasons" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "lastFetchedAt", + "entityType": "columns", + "table": "seasons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "userId", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "expiresAt", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "ipAddress", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "userAgent", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "impersonatedBy", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "createdAt", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "updatedAt", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "titleCast" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "titleId", + "entityType": "columns", + "table": "titleCast" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "personId", + "entityType": "columns", + "table": "titleCast" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "character", + "entityType": "columns", + "table": "titleCast" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'Acting'", + "generated": null, + "name": "department", + "entityType": "columns", + "table": "titleCast" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "job", + "entityType": "columns", + "table": "titleCast" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "displayOrder", + "entityType": "columns", + "table": "titleCast" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "episodeCount", + "entityType": "columns", + "table": "titleCast" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "lastFetchedAt", + "entityType": "columns", + "table": "titleCast" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "titleId", + "entityType": "columns", + "table": "titleGenres" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "genreId", + "entityType": "columns", + "table": "titleGenres" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "titleId", + "entityType": "columns", + "table": "titleRecommendations" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "recommendedTitleId", + "entityType": "columns", + "table": "titleRecommendations" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "source", + "entityType": "columns", + "table": "titleRecommendations" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "rank", + "entityType": "columns", + "table": "titleRecommendations" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "lastFetchedAt", + "entityType": "columns", + "table": "titleRecommendations" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "titles" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "tmdbId", + "entityType": "columns", + "table": "titles" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "tvdbId", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "title", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "originalTitle", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "overview", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "releaseDate", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "firstAirDate", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "posterPath", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "posterThumbHash", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "backdropPath", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "backdropThumbHash", + "entityType": "columns", + "table": "titles" + }, + { + "type": "real", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "popularity", + "entityType": "columns", + "table": "titles" + }, + { + "type": "real", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "voteAverage", + "entityType": "columns", + "table": "titles" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "voteCount", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "contentRating", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "imdbId", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "originalLanguage", + "entityType": "columns", + "table": "titles" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "runtimeMinutes", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "colorPalette", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "trailerVideoKey", + "entityType": "columns", + "table": "titles" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "lastFetchedAt", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "user" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "user" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "user" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "emailVerified", + "entityType": "columns", + "table": "user" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "image", + "entityType": "columns", + "table": "user" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": "'user'", + "generated": null, + "name": "role", + "entityType": "columns", + "table": "user" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "banned", + "entityType": "columns", + "table": "user" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "banReason", + "entityType": "columns", + "table": "user" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "banExpires", + "entityType": "columns", + "table": "user" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "createdAt", + "entityType": "columns", + "table": "user" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "updatedAt", + "entityType": "columns", + "table": "user" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "userEpisodeWatches" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "userId", + "entityType": "columns", + "table": "userEpisodeWatches" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "episodeId", + "entityType": "columns", + "table": "userEpisodeWatches" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "watchedAt", + "entityType": "columns", + "table": "userEpisodeWatches" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'manual'", + "generated": null, + "name": "source", + "entityType": "columns", + "table": "userEpisodeWatches" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "userMovieWatches" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "userId", + "entityType": "columns", + "table": "userMovieWatches" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "titleId", + "entityType": "columns", + "table": "userMovieWatches" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "watchedAt", + "entityType": "columns", + "table": "userMovieWatches" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'manual'", + "generated": null, + "name": "source", + "entityType": "columns", + "table": "userMovieWatches" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "userId", + "entityType": "columns", + "table": "userRatings" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "titleId", + "entityType": "columns", + "table": "userRatings" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "ratingStars", + "entityType": "columns", + "table": "userRatings" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "ratedAt", + "entityType": "columns", + "table": "userRatings" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "userId", + "entityType": "columns", + "table": "userTitleStatus" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "titleId", + "entityType": "columns", + "table": "userTitleStatus" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "userTitleStatus" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "addedAt", + "entityType": "columns", + "table": "userTitleStatus" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "updatedAt", + "entityType": "columns", + "table": "userTitleStatus" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "verification" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "identifier", + "entityType": "columns", + "table": "verification" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "value", + "entityType": "columns", + "table": "verification" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "expiresAt", + "entityType": "columns", + "table": "verification" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "createdAt", + "entityType": "columns", + "table": "verification" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "updatedAt", + "entityType": "columns", + "table": "verification" + }, + { + "columns": [ + "userId" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_account_userId_user_id_fk", + "entityType": "fks", + "table": "account" + }, + { + "columns": [ + "titleId" + ], + "tableTo": "titles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_availabilityOffers_titleId_titles_id_fk", + "entityType": "fks", + "table": "availabilityOffers" + }, + { + "columns": [ + "seasonId" + ], + "tableTo": "seasons", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_episodes_seasonId_seasons_id_fk", + "entityType": "fks", + "table": "episodes" + }, + { + "columns": [ + "userId" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_importJobs_userId_user_id_fk", + "entityType": "fks", + "table": "importJobs" + }, + { + "columns": [ + "integrationId" + ], + "tableTo": "integrations", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_integrationEvents_integrationId_integrations_id_fk", + "entityType": "fks", + "table": "integrationEvents" + }, + { + "columns": [ + "userId" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_integrations_userId_user_id_fk", + "entityType": "fks", + "table": "integrations" + }, + { + "columns": [ + "personId" + ], + "tableTo": "persons", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_personFilmography_personId_persons_id_fk", + "entityType": "fks", + "table": "personFilmography" + }, + { + "columns": [ + "titleId" + ], + "tableTo": "titles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_personFilmography_titleId_titles_id_fk", + "entityType": "fks", + "table": "personFilmography" + }, + { + "columns": [ + "titleId" + ], + "tableTo": "titles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_seasons_titleId_titles_id_fk", + "entityType": "fks", + "table": "seasons" + }, + { + "columns": [ + "userId" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_userId_user_id_fk", + "entityType": "fks", + "table": "session" + }, + { + "columns": [ + "titleId" + ], + "tableTo": "titles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_titleCast_titleId_titles_id_fk", + "entityType": "fks", + "table": "titleCast" + }, + { + "columns": [ + "personId" + ], + "tableTo": "persons", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_titleCast_personId_persons_id_fk", + "entityType": "fks", + "table": "titleCast" + }, + { + "columns": [ + "titleId" + ], + "tableTo": "titles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_titleGenres_titleId_titles_id_fk", + "entityType": "fks", + "table": "titleGenres" + }, + { + "columns": [ + "genreId" + ], + "tableTo": "genres", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_titleGenres_genreId_genres_id_fk", + "entityType": "fks", + "table": "titleGenres" + }, + { + "columns": [ + "titleId" + ], + "tableTo": "titles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_titleRecommendations_titleId_titles_id_fk", + "entityType": "fks", + "table": "titleRecommendations" + }, + { + "columns": [ + "recommendedTitleId" + ], + "tableTo": "titles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_titleRecommendations_recommendedTitleId_titles_id_fk", + "entityType": "fks", + "table": "titleRecommendations" + }, + { + "columns": [ + "userId" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_userEpisodeWatches_userId_user_id_fk", + "entityType": "fks", + "table": "userEpisodeWatches" + }, + { + "columns": [ + "episodeId" + ], + "tableTo": "episodes", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_userEpisodeWatches_episodeId_episodes_id_fk", + "entityType": "fks", + "table": "userEpisodeWatches" + }, + { + "columns": [ + "userId" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_userMovieWatches_userId_user_id_fk", + "entityType": "fks", + "table": "userMovieWatches" + }, + { + "columns": [ + "titleId" + ], + "tableTo": "titles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_userMovieWatches_titleId_titles_id_fk", + "entityType": "fks", + "table": "userMovieWatches" + }, + { + "columns": [ + "userId" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_userRatings_userId_user_id_fk", + "entityType": "fks", + "table": "userRatings" + }, + { + "columns": [ + "titleId" + ], + "tableTo": "titles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_userRatings_titleId_titles_id_fk", + "entityType": "fks", + "table": "userRatings" + }, + { + "columns": [ + "userId" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_userTitleStatus_userId_user_id_fk", + "entityType": "fks", + "table": "userTitleStatus" + }, + { + "columns": [ + "titleId" + ], + "tableTo": "titles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_userTitleStatus_titleId_titles_id_fk", + "entityType": "fks", + "table": "userTitleStatus" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_pk", + "table": "account", + "entityType": "pks" + }, + { + "columns": [ + "key" + ], + "nameExplicit": false, + "name": "appSettings_pk", + "table": "appSettings", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "cronRuns_pk", + "table": "cronRuns", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "episodes_pk", + "table": "episodes", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "genres_pk", + "table": "genres", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "importJobs_pk", + "table": "importJobs", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "integrationEvents_pk", + "table": "integrationEvents", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "integrations_pk", + "table": "integrations", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "personFilmography_pk", + "table": "personFilmography", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "persons_pk", + "table": "persons", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "seasons_pk", + "table": "seasons", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_pk", + "table": "session", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "titleCast_pk", + "table": "titleCast", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "titles_pk", + "table": "titles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "user_pk", + "table": "user", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "userEpisodeWatches_pk", + "table": "userEpisodeWatches", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "userMovieWatches_pk", + "table": "userMovieWatches", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "verification_pk", + "table": "verification", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "titleId", + "isExpression": false + }, + { + "value": "region", + "isExpression": false + }, + { + "value": "providerId", + "isExpression": false + }, + { + "value": "offerType", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "availabilityOffers_unique", + "entityType": "indexes", + "table": "availabilityOffers" + }, + { + "columns": [ + { + "value": "jobName", + "isExpression": false + }, + { + "value": "startedAt", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "cronRuns_jobName_startedAt", + "entityType": "indexes", + "table": "cronRuns" + }, + { + "columns": [ + { + "value": "seasonId", + "isExpression": false + }, + { + "value": "episodeNumber", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "episodes_seasonId_episodeNumber", + "entityType": "indexes", + "table": "episodes" + }, + { + "columns": [ + { + "value": "airDate", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "episodes_airDate", + "entityType": "indexes", + "table": "episodes" + }, + { + "columns": [ + { + "value": "userId", + "isExpression": false + }, + { + "value": "createdAt", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "importJobs_userId_createdAt", + "entityType": "indexes", + "table": "importJobs" + }, + { + "columns": [ + { + "value": "status", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "importJobs_status", + "entityType": "indexes", + "table": "importJobs" + }, + { + "columns": [ + { + "value": "userId", + "isExpression": false + } + ], + "isUnique": true, + "where": "\"importJobs\".\"status\" in ('pending', 'running')", + "origin": "manual", + "name": "importJobs_active_user", + "entityType": "indexes", + "table": "importJobs" + }, + { + "columns": [ + { + "value": "integrationId", + "isExpression": false + }, + { + "value": "receivedAt", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "integrationEvents_integrationId_receivedAt", + "entityType": "indexes", + "table": "integrationEvents" + }, + { + "columns": [ + { + "value": "userId", + "isExpression": false + }, + { + "value": "provider", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "integrations_userId_provider", + "entityType": "indexes", + "table": "integrations" + }, + { + "columns": [ + { + "value": "token", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "integrations_token", + "entityType": "indexes", + "table": "integrations" + }, + { + "columns": [ + { + "value": "personId", + "isExpression": false + }, + { + "value": "displayOrder", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "personFilmography_personId_displayOrder", + "entityType": "indexes", + "table": "personFilmography" + }, + { + "columns": [ + { + "value": "titleId", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "personFilmography_titleId", + "entityType": "indexes", + "table": "personFilmography" + }, + { + "columns": [ + { + "value": "tmdbId", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "persons_tmdbId_unique", + "entityType": "indexes", + "table": "persons" + }, + { + "columns": [ + { + "value": "name", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "persons_name", + "entityType": "indexes", + "table": "persons" + }, + { + "columns": [ + { + "value": "titleId", + "isExpression": false + }, + { + "value": "seasonNumber", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "seasons_titleId_seasonNumber", + "entityType": "indexes", + "table": "seasons" + }, + { + "columns": [ + { + "value": "titleId", + "isExpression": false + }, + { + "value": "personId", + "isExpression": false + }, + { + "value": "department", + "isExpression": false + }, + { + "value": "character", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "titleCast_unique", + "entityType": "indexes", + "table": "titleCast" + }, + { + "columns": [ + { + "value": "titleId", + "isExpression": false + }, + { + "value": "displayOrder", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "titleCast_titleId_displayOrder", + "entityType": "indexes", + "table": "titleCast" + }, + { + "columns": [ + { + "value": "personId", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "titleCast_personId", + "entityType": "indexes", + "table": "titleCast" + }, + { + "columns": [ + { + "value": "titleId", + "isExpression": false + }, + { + "value": "genreId", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "titleGenres_titleId_genreId", + "entityType": "indexes", + "table": "titleGenres" + }, + { + "columns": [ + { + "value": "genreId", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "titleGenres_genreId", + "entityType": "indexes", + "table": "titleGenres" + }, + { + "columns": [ + { + "value": "titleId", + "isExpression": false + }, + { + "value": "recommendedTitleId", + "isExpression": false + }, + { + "value": "source", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "titleRecommendations_unique", + "entityType": "indexes", + "table": "titleRecommendations" + }, + { + "columns": [ + { + "value": "titleId", + "isExpression": false + }, + { + "value": "rank", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "titleRecommendations_titleId_rank", + "entityType": "indexes", + "table": "titleRecommendations" + }, + { + "columns": [ + { + "value": "tmdbId", + "isExpression": false + }, + { + "value": "type", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "titles_tmdbId_type_unique", + "entityType": "indexes", + "table": "titles" + }, + { + "columns": [ + { + "value": "type", + "isExpression": false + }, + { + "value": "releaseDate", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "titles_type_releaseDate", + "entityType": "indexes", + "table": "titles" + }, + { + "columns": [ + { + "value": "type", + "isExpression": false + }, + { + "value": "firstAirDate", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "titles_type_firstAirDate", + "entityType": "indexes", + "table": "titles" + }, + { + "columns": [ + { + "value": "lastFetchedAt", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "titles_lastFetchedAt", + "entityType": "indexes", + "table": "titles" + }, + { + "columns": [ + { + "value": "type", + "isExpression": false + }, + { + "value": "status", + "isExpression": false + }, + { + "value": "lastFetchedAt", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "titles_type_status_lastFetchedAt", + "entityType": "indexes", + "table": "titles" + }, + { + "columns": [ + { + "value": "userId", + "isExpression": false + }, + { + "value": "watchedAt", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "userEpisodeWatches_userId_watchedAt", + "entityType": "indexes", + "table": "userEpisodeWatches" + }, + { + "columns": [ + { + "value": "episodeId", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "userEpisodeWatches_episodeId", + "entityType": "indexes", + "table": "userEpisodeWatches" + }, + { + "columns": [ + { + "value": "userId", + "isExpression": false + }, + { + "value": "episodeId", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "userEpisodeWatches_userId_episodeId", + "entityType": "indexes", + "table": "userEpisodeWatches" + }, + { + "columns": [ + { + "value": "userId", + "isExpression": false + }, + { + "value": "watchedAt", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "userMovieWatches_userId_watchedAt", + "entityType": "indexes", + "table": "userMovieWatches" + }, + { + "columns": [ + { + "value": "titleId", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "userMovieWatches_titleId", + "entityType": "indexes", + "table": "userMovieWatches" + }, + { + "columns": [ + { + "value": "userId", + "isExpression": false + }, + { + "value": "titleId", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "userMovieWatches_userId_titleId", + "entityType": "indexes", + "table": "userMovieWatches" + }, + { + "columns": [ + { + "value": "userId", + "isExpression": false + }, + { + "value": "titleId", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "userRatings_userId_titleId", + "entityType": "indexes", + "table": "userRatings" + }, + { + "columns": [ + { + "value": "userId", + "isExpression": false + }, + { + "value": "titleId", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "userTitleStatus_userId_titleId", + "entityType": "indexes", + "table": "userTitleStatus" + }, + { + "columns": [ + { + "value": "userId", + "isExpression": false + }, + { + "value": "status", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "userTitleStatus_userId_status", + "entityType": "indexes", + "table": "userTitleStatus" + }, + { + "columns": [ + "token" + ], + "nameExplicit": false, + "name": "session_token_unique", + "entityType": "uniques", + "table": "session" + }, + { + "columns": [ + "email" + ], + "nameExplicit": false, + "name": "user_email_unique", + "entityType": "uniques", + "table": "user" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts index 5ea9c42..d99fb25 100644 --- a/packages/db/src/client.ts +++ b/packages/db/src/client.ts @@ -1,4 +1,5 @@ import { Database } from "bun:sqlite"; +import { AsyncLocalStorage } from "node:async_hooks"; import type { Logger } from "drizzle-orm"; import { drizzle } from "drizzle-orm/bun-sqlite"; @@ -16,6 +17,13 @@ const drizzleLogger: Logger = { }, }; +export class DatabaseRestoreInProgressError extends Error { + constructor(message = "Database is temporarily unavailable during restore") { + super(message); + this.name = "DatabaseRestoreInProgressError"; + } +} + // 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 @@ -30,9 +38,19 @@ const drizzleLogger: Logger = { const globalForDb = globalThis as unknown as { _db: ReturnType | undefined; _client: Database | undefined; + _accessBlocked: boolean | undefined; }; +const dbAccessBypass = new AsyncLocalStorage(); + +function assertDatabaseAccessible() { + if (globalForDb._accessBlocked && !dbAccessBypass.getStore()) { + throw new DatabaseRestoreInProgressError(); + } +} + function getClient() { + assertDatabaseAccessible(); if (!globalForDb._client) { globalForDb._client = new Database(DATABASE_URL); globalForDb._client.run("PRAGMA journal_mode = WAL"); @@ -47,6 +65,7 @@ function getClient() { } function getDb() { + assertDatabaseAccessible(); if (!globalForDb._db) { globalForDb._db = drizzle({ client: getClient(), @@ -59,6 +78,7 @@ function getDb() { export const db = new Proxy({} as ReturnType, { get(_, prop) { + assertDatabaseAccessible(); return Reflect.get(getDb(), prop); }, }); @@ -72,6 +92,19 @@ export function vacuumDatabase(into: string): void { getClient().run("VACUUM INTO ?", [into.replace(/'/g, "''")]); } +export function isDatabaseAccessBlocked(): boolean { + return globalForDb._accessBlocked === true; +} + +export async function withDatabaseAccessBlocked(fn: () => Promise | T): Promise { + globalForDb._accessBlocked = true; + try { + return await dbAccessBypass.run(true, fn); + } finally { + globalForDb._accessBlocked = false; + } +} + /** Close the current connection, and clear singletons so the Proxy re-initializes on next access. */ export function closeDatabase() { globalForDb._client?.close(); diff --git a/packages/db/src/queries/settings.ts b/packages/db/src/queries/settings.ts index 3ae5aa9..42be2ad 100644 --- a/packages/db/src/queries/settings.ts +++ b/packages/db/src/queries/settings.ts @@ -1,8 +1,10 @@ -import { count, eq } from "drizzle-orm"; +import { asc, count, eq } from "drizzle-orm"; import { db } from "../client"; import { appSettings, user } from "../schema"; +const INITIAL_ADMIN_KEY = "initialAdminAssigned"; + export function getSettingValue(key: string): string | null { const row = db.select().from(appSettings).where(eq(appSettings.key, key)).get(); return row?.value ?? null; @@ -19,3 +21,42 @@ export function getUserCount(): number { const result = db.select({ count: count() }).from(user).get(); return result?.count ?? 0; } + +export function claimInitialAdmin(userId: string): boolean { + let claimed = false; + + db.transaction((tx) => { + const firstUser = tx + .select({ id: user.id }) + .from(user) + .orderBy(asc(user.createdAt), asc(user.id)) + .limit(1) + .get(); + if (!firstUser || firstUser.id !== userId) { + return; + } + + const lock = tx + .insert(appSettings) + .values({ key: INITIAL_ADMIN_KEY, value: userId }) + .onConflictDoNothing() + .returning({ key: appSettings.key }) + .get(); + if (!lock) { + return; + } + + tx.update(user).set({ role: "admin" }).where(eq(user.id, userId)).run(); + tx.insert(appSettings) + .values({ key: "registrationOpen", value: "false" }) + .onConflictDoUpdate({ + target: appSettings.key, + set: { value: "false" }, + }) + .run(); + + claimed = true; + }); + + return claimed; +} diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 6fb96db..8cb75e2 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -1,3 +1,4 @@ +import { sql } from "drizzle-orm"; import { index, int, real, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"; // Helper for UUID primary keys @@ -472,6 +473,9 @@ export const importJobs = sqliteTable( (table) => [ index("importJobs_userId_createdAt").on(table.userId, table.createdAt), index("importJobs_status").on(table.status), + uniqueIndex("importJobs_active_user") + .on(table.userId) + .where(sql`${table.status} in ('pending', 'running')`), ], );