mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 01:35:39 -04:00
refactor: enforce layered architecture by extracting all DB queries into @sofa/db/queries/*
- Create 18 new query modules under `packages/db/src/queries/` covering every domain (availability, cache, colors, credits, cron, discovery, image-cache, imports, integrations, lists, metadata, person, settings, system-health, thumbhash, title, tracking, webhooks) - Remove all direct `db` client imports and raw Drizzle expressions from `@sofa/core` services and `apps/server` procedures/cron; replace with named query functions - Extract cron DB helpers (`startCronRun`, `completeCronRun`, `failCronRun`, `getLibraryTitleIds`, `getStaleLibraryTitles`, etc.) into `packages/core/src/cron.ts` - Extract integration DB helpers into `packages/core/src/integrations.ts` - Add `drizzle-orm` as a direct dependency of `@sofa/db` and remove it from `@sofa/core` - Update `AGENTS.md` to document the strict layered architecture rule
This commit is contained in:
@@ -4,15 +4,15 @@ import { AppErrorCode } from "@sofa/api/errors";
|
||||
import type { ParseResult } from "@sofa/core/imports";
|
||||
import {
|
||||
countUnresolved,
|
||||
getActiveImportJobForUser,
|
||||
insertImportJob,
|
||||
parseLetterboxdExport,
|
||||
parseSimklPayload,
|
||||
parseTraktPayload,
|
||||
processImportJob,
|
||||
readImportJob,
|
||||
updateImportJobProgress,
|
||||
} from "@sofa/core/imports";
|
||||
import { db } from "@sofa/db/client";
|
||||
import { and, eq, inArray } from "@sofa/db/helpers";
|
||||
import { importJobs } from "@sofa/db/schema";
|
||||
import { createLogger } from "@sofa/logger";
|
||||
|
||||
import { os } from "../context";
|
||||
@@ -100,34 +100,18 @@ export const createJob = os.imports.createJob.use(authed).handler(async ({ input
|
||||
|
||||
// Prevent concurrent imports per user.
|
||||
// Auto-cancel stale *pending* jobs (server crashed before worker started).
|
||||
// Running jobs are never auto-cancelled — there's no heartbeat to
|
||||
// distinguish active work from a dead worker, and killing a healthy
|
||||
// long-running import is worse than making the user manually cancel.
|
||||
const PENDING_STALE_MS = 5 * 60 * 1000; // 5 minutes
|
||||
const now = Date.now();
|
||||
const existing = db
|
||||
.select()
|
||||
.from(importJobs)
|
||||
.where(
|
||||
and(
|
||||
eq(importJobs.userId, context.user.id),
|
||||
inArray(importJobs.status, ["pending", "running"]),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
const existing = getActiveImportJobForUser(context.user.id);
|
||||
if (existing) {
|
||||
const isPending = existing.status === "pending";
|
||||
const isStale = isPending && now - existing.createdAt.getTime() > PENDING_STALE_MS;
|
||||
if (isStale) {
|
||||
// Mark as cancelled so the worker loop also stops if it starts late
|
||||
db.update(importJobs)
|
||||
.set({
|
||||
status: "cancelled",
|
||||
finishedAt: new Date(),
|
||||
currentMessage: "Import timed out (stale job auto-cancelled)",
|
||||
})
|
||||
.where(eq(importJobs.id, existing.id))
|
||||
.run();
|
||||
updateImportJobProgress(existing.id, {
|
||||
status: "cancelled",
|
||||
finishedAt: new Date(),
|
||||
currentMessage: "Import timed out (stale job auto-cancelled)",
|
||||
});
|
||||
log.warn(`Auto-cancelled stale import job ${existing.id}`);
|
||||
} else {
|
||||
throw new ORPCError("CONFLICT", {
|
||||
@@ -137,20 +121,16 @@ export const createJob = os.imports.createJob.use(authed).handler(async ({ input
|
||||
}
|
||||
}
|
||||
|
||||
const job = db
|
||||
.insert(importJobs)
|
||||
.values({
|
||||
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(),
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
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(),
|
||||
});
|
||||
|
||||
// Fire-and-forget processing
|
||||
processImportJob(job.id).catch((err) => {
|
||||
@@ -172,7 +152,7 @@ export const cancelJob = os.imports.cancelJob.use(authed).handler(({ input, cont
|
||||
data: { code: AppErrorCode.IMPORT_CANNOT_CANCEL },
|
||||
});
|
||||
}
|
||||
db.update(importJobs).set({ status: "cancelled" }).where(eq(importJobs.id, input.id)).run();
|
||||
updateImportJobProgress(input.id, { status: "cancelled" });
|
||||
return readImportJob(input.id);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,132 +1,35 @@
|
||||
import { ORPCError } from "@orpc/server";
|
||||
|
||||
import { AppErrorCode } from "@sofa/api/errors";
|
||||
import { db } from "@sofa/db/client";
|
||||
import { and, desc, eq } from "@sofa/db/helpers";
|
||||
import { integrationEvents, integrations } from "@sofa/db/schema";
|
||||
import {
|
||||
createOrUpdateIntegration,
|
||||
deleteIntegration as coreDeleteIntegration,
|
||||
listUserIntegrations,
|
||||
regenerateToken as coreRegenerateToken,
|
||||
serializeIntegration,
|
||||
} from "@sofa/core/integrations";
|
||||
|
||||
import { os } from "../context";
|
||||
import { authed } from "../middleware";
|
||||
|
||||
const LIST_PROVIDERS = new Set(["sonarr", "radarr"]);
|
||||
|
||||
function integrationTypeFor(provider: string): "webhook" | "list" {
|
||||
return LIST_PROVIDERS.has(provider) ? "list" : "webhook";
|
||||
}
|
||||
|
||||
function generateToken() {
|
||||
return Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("hex");
|
||||
}
|
||||
|
||||
function serializeIntegration(row: {
|
||||
id: string;
|
||||
provider: string;
|
||||
type: "webhook" | "list";
|
||||
token: string;
|
||||
enabled: boolean;
|
||||
lastEventAt: Date | null;
|
||||
createdAt: Date;
|
||||
}) {
|
||||
return {
|
||||
...row,
|
||||
lastEventAt: row.lastEventAt?.toISOString() ?? null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export const list = os.integrations.list.use(authed).handler(({ context }) => {
|
||||
const userIntegrations = db
|
||||
.select()
|
||||
.from(integrations)
|
||||
.where(eq(integrations.userId, context.user.id))
|
||||
.all();
|
||||
|
||||
const eventsByIntegration = new Map<string, (typeof integrationEvents.$inferSelect)[]>();
|
||||
for (const integration of userIntegrations) {
|
||||
const events = db
|
||||
.select()
|
||||
.from(integrationEvents)
|
||||
.where(eq(integrationEvents.integrationId, integration.id))
|
||||
.orderBy(desc(integrationEvents.receivedAt))
|
||||
.limit(10)
|
||||
.all();
|
||||
eventsByIntegration.set(integration.id, events);
|
||||
}
|
||||
|
||||
const result = userIntegrations.map((integration) => {
|
||||
const events = eventsByIntegration.get(integration.id) ?? [];
|
||||
return Object.assign(serializeIntegration(integration), {
|
||||
recentEvents: events.map((e) => ({
|
||||
id: e.id,
|
||||
eventType: e.eventType,
|
||||
mediaType: e.mediaType,
|
||||
mediaTitle: e.mediaTitle,
|
||||
status: e.status,
|
||||
receivedAt: e.receivedAt.toISOString(),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
return { integrations: result };
|
||||
return listUserIntegrations(context.user.id);
|
||||
});
|
||||
|
||||
export const create = os.integrations.create.use(authed).handler(({ input, context }) => {
|
||||
const existing = db
|
||||
.select()
|
||||
.from(integrations)
|
||||
.where(and(eq(integrations.userId, context.user.id), eq(integrations.provider, input.provider)))
|
||||
.get();
|
||||
|
||||
if (existing) {
|
||||
if (input.enabled !== undefined) {
|
||||
const row = db
|
||||
.update(integrations)
|
||||
.set({ enabled: input.enabled })
|
||||
.where(eq(integrations.id, existing.id))
|
||||
.returning()
|
||||
.get();
|
||||
return serializeIntegration(row);
|
||||
}
|
||||
return serializeIntegration(existing);
|
||||
}
|
||||
|
||||
const row = db
|
||||
.insert(integrations)
|
||||
.values({
|
||||
userId: context.user.id,
|
||||
provider: input.provider,
|
||||
type: integrationTypeFor(input.provider),
|
||||
token: generateToken(),
|
||||
enabled: input.enabled ?? true,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
|
||||
return serializeIntegration(row);
|
||||
return createOrUpdateIntegration(context.user.id, input.provider, input.enabled);
|
||||
});
|
||||
|
||||
export const deleteIntegration = os.integrations.delete
|
||||
.use(authed)
|
||||
.handler(({ input, context }) => {
|
||||
db.delete(integrations)
|
||||
.where(
|
||||
and(eq(integrations.userId, context.user.id), eq(integrations.provider, input.provider)),
|
||||
)
|
||||
.run();
|
||||
coreDeleteIntegration(context.user.id, input.provider);
|
||||
});
|
||||
|
||||
export const regenerateToken = os.integrations.regenerateToken
|
||||
.use(authed)
|
||||
.handler(({ input, context }) => {
|
||||
const row = db
|
||||
.update(integrations)
|
||||
.set({ token: generateToken() })
|
||||
.where(
|
||||
and(eq(integrations.userId, context.user.id), eq(integrations.provider, input.provider)),
|
||||
)
|
||||
.returning()
|
||||
.get();
|
||||
const row = coreRegenerateToken(context.user.id, input.provider);
|
||||
|
||||
if (!row) {
|
||||
throw new ORPCError("NOT_FOUND", {
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
import { logEpisodeWatchBatch, unwatchSeason } from "@sofa/core/tracking";
|
||||
import { db } from "@sofa/db/client";
|
||||
import { eq } from "@sofa/db/helpers";
|
||||
import { episodes } from "@sofa/db/schema";
|
||||
import { unwatchSeason, watchSeason } from "@sofa/core/tracking";
|
||||
|
||||
import { os } from "../context";
|
||||
import { authed } from "../middleware";
|
||||
|
||||
export const watch = os.seasons.watch.use(authed).handler(({ input, context }) => {
|
||||
const seasonEps = db.select().from(episodes).where(eq(episodes.seasonId, input.id)).all();
|
||||
logEpisodeWatchBatch(
|
||||
context.user.id,
|
||||
seasonEps.map((ep) => ep.id),
|
||||
);
|
||||
watchSeason(context.user.id, input.id);
|
||||
});
|
||||
|
||||
export const unwatch = os.seasons.unwatch.use(authed).handler(({ input, context }) => {
|
||||
|
||||
@@ -8,13 +8,11 @@ import {
|
||||
getUserTitleInfo,
|
||||
logMovieWatch,
|
||||
markAllEpisodesWatched,
|
||||
quickAddTitle,
|
||||
rateTitleStars,
|
||||
removeTitleStatus,
|
||||
setTitleStatus,
|
||||
} from "@sofa/core/tracking";
|
||||
import { db } from "@sofa/db/client";
|
||||
import { and, eq } from "@sofa/db/helpers";
|
||||
import { titles, userTitleStatus } from "@sofa/db/schema";
|
||||
|
||||
import { os } from "../context";
|
||||
import { authed } from "../middleware";
|
||||
@@ -65,31 +63,16 @@ export const recommendations = os.titles.recommendations
|
||||
});
|
||||
|
||||
export const quickAdd = os.titles.quickAdd.use(authed).handler(async ({ input, context }) => {
|
||||
// Look up the title (it exists as a shell from browse/search import)
|
||||
const title = db
|
||||
.select({ id: titles.id, tmdbId: titles.tmdbId, type: titles.type })
|
||||
.from(titles)
|
||||
.where(eq(titles.id, input.id))
|
||||
.get();
|
||||
if (!title) {
|
||||
const result = quickAddTitle(context.user.id, input.id);
|
||||
if (!result) {
|
||||
throw new ORPCError("NOT_FOUND", {
|
||||
message: "Title not found",
|
||||
data: { code: AppErrorCode.TITLE_NOT_FOUND },
|
||||
});
|
||||
}
|
||||
|
||||
// Trigger full TMDB import if still a shell
|
||||
getOrFetchTitleByTmdbId(title.tmdbId, title.type as "movie" | "tv").catch(() => {});
|
||||
// Trigger full TMDB import if still a shell (fire-and-forget)
|
||||
getOrFetchTitleByTmdbId(result.tmdbId, result.type as "movie" | "tv").catch(() => {});
|
||||
|
||||
const existing = db
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(and(eq(userTitleStatus.userId, context.user.id), eq(userTitleStatus.titleId, title.id)))
|
||||
.get();
|
||||
|
||||
if (!existing) {
|
||||
setTitleStatus(context.user.id, title.id, "watchlist");
|
||||
}
|
||||
|
||||
return { id: title.id, alreadyAdded: !!existing };
|
||||
return { id: result.id, alreadyAdded: result.alreadyAdded };
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user