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:
2026-03-18 17:31:18 -04:00
parent 3a0374c825
commit 3c1b7cfe11
55 changed files with 3048 additions and 2079 deletions
+20 -40
View File
@@ -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);
});
+11 -108
View File
@@ -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", {
+2 -9
View File
@@ -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 }) => {
+6 -23
View File
@@ -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 };
});