Relocate infrastructure into packages/* as rename-only moves (history preserved); no content changes to moved files. Build is intentionally broken until re-export shims land in follow-up commits.
- packages/civitai-db-schema: Prisma schema, migrations, generated enums/models (contract layer)
- packages/civitai-db: Prisma-client + pg-pool runtime
- packages/civitai-{redis,clickhouse,axiom,telemetry}: infra clients
- docs/: conversion plan, package adaptation plan, directory snapshot, handoff
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
33 KiB
Monorepo — Base Package Adaptation Plan
Status: Planning only. The base-package file moves are staged but not yet committed; none of the content rewrites below have been applied. This document is the concrete implementation spec for turning the five staged base packages into true, infrastructure-only packages that import external npm dependencies only.
Read first: monorepo-bootstrap-handoff.md and
monorepo-conversion-plan.md. This doc supersedes the
"Phase 1–5" sketch in those with worked, code-level detail.
1. The rule we are enforcing
A base package (@civitai/db, @civitai/redis, @civitai/clickhouse, @civitai/axiom,
@civitai/telemetry) may import external npm packages only. It must not import:
- another base package (no
db → axiom, notelemetry → db), - an app service (
~/server/flipt,~/server/auth, …), - app config (
~/env/server,~/env/other), - app utilities (
~/utils/*,~/server/utils/*), - app domain types/schemas (
~/server/schema/*,~/server/common/*,~/shared/*).
Anything that needs one of those is a consumer of infrastructure, not infrastructure, and either (a) gets its dependency injected, or (b) moves back to the main app.
One exception — the contract layer. @civitai/db-schema (Prisma schema + generated
types, §4.0) is a leaf artifact with no runtime. Infra packages may depend downward on
it (e.g. @civitai/db imports the generated Prisma client from @civitai/db-schema), exactly
as they depend on any npm package. The no-sibling rule still holds: it only forbids one infra
package importing another infra package's runtime. A pure types/schema package is a lower
layer, not a sibling.
2. The pattern: factory + injected deps + app-side shim
Every package stops exporting eager module-level singletons and instead exports a
factory: createXClients(config). The config carries two kinds of things:
- Plain values the package reads from
envtoday (URLs, timeouts, booleans). - Injected functions for concerns the package may not own:
- a
logfunction (replaces~/utils/logging'screateLogger), - cross-cutting callbacks (
onSlowQuery → axiom,isEnhancedFailoverEnabled → flipt, …).
- a
Each app keeps a thin shim at the original import path
(src/server/db/client.ts, src/server/redis/client.ts, …). The shim:
- calls the factory with that app's real
env, real logger, real flipt/axiom wiring, - owns the dev/HMR
global.*singleton caching (per decision: globals live where the factory is called, never inside the package), - re-exports the same names (
dbRead,dbWrite,redis,sysRedis, …) so every existing call site keeps working unchanged.
Because the shims are app code, they may freely compose multiple base packages (e.g. the
db shim may import logToAxiom from the axiom shim). The package-level "no sibling
imports" rule is what stays inviolate.
2.1 The injected logger
Each factory takes an optional log parameter, defaulting to a no-op. The package never
logs on its own; when an app wants visibility it injects a logger (the main app injects one
into every factory). To avoid a shared logger package (which would itself be a cross-package
import), each package declares its own structural logger type; the app's single logger
duck-types into all of them:
// declared independently inside each package — structural, so one app logger satisfies all
export type LogFn = (message: string, ...args: unknown[]) => void;
const noop: LogFn = () => {};
// in the factory: const log = config.log ?? noop;
The main app builds one LogFn per domain from the existing createLogger(name, color) and
passes it in. Structured telemetry that currently goes to Axiom (db slow queries, clickhouse
insert errors) is a separate injected callback, not a log line — see each package below.
2.2 Environment variables — packages never import an env module
A base package must not import any env — not the app's ~/env/server, not a shared one.
Factories accept plain typed values (isProd, URLs, timeouts). The app reads its own env
and passes those values in. This keeps packages reuse-agnostic: a package never assumes how
its config is sourced.
The current monolithic
~/env/serverexists only because the project started from a starter that validated all env vars against one schema. We are not carrying that coupling into the packages. If we later want per-domain env→schema validation back, it lives as a scoped env file inside each package (the package ships its own zod/@t3-oss/envschema + aconfigFromEnv(process.env)helper). That stays opt-in — the core factory still takes plain values, so an app can validate however it likes (or not at all).
No isBuild in factories. The old if (!env.IS_BUILD) guard (skip opening connections
during next build) is an app/runtime concern — isBuild ≠ isProd (a production build
has isProd === true but must still not connect). Since the shim owns instantiation, the
shim keeps the build guard; the package factory only takes isProd where behavior genuinely
differs (e.g. slow-query → console vs Axiom). See §4.2.
3. Boundary-import inventory (complete)
Every ~/… import found in the five packages, and its resolution:
| Import | Package(s) | Resolution |
|---|---|---|
env (~/env/server) |
all | never imported — factory takes plain config values; app sources them (§2.2) |
isProd (~/env/other) |
db, redis, clickhouse, axiom | config boolean isProd (the only env-flag; no isBuild) |
logToAxiom (~/server/logging/client) |
db, clickhouse | inject onSlowQuery / onError; axiom stays a sibling, never imported |
isFlipt / FLIPT_FEATURE_FLAGS (~/server/flipt/client) |
redis | inject isEnhancedFailoverEnabled |
createLogger (~/utils/logging) |
db, redis, clickhouse | inject optional log?: LogFn (default no-op) |
slugit (~/utils/string-helpers) |
redis | inline (pure 3-line helper) |
limitConcurrency (~/server/utils/concurrency-helpers) |
db (db-helpers.ts) |
vendor or inline (see §4.6) |
sleep (~/utils/errorHandling) |
clickhouse | moves out with the Tracker (or inline) |
getServerAuthSession (~/server/auth/...) |
clickhouse | moves back with the Tracker |
domain type imports (~/server/common/enums, ~/server/jobs/..., ~/server/schema/..., ~/shared/...) |
clickhouse | moves back with the Tracker |
pgDb/notifDb/datapacketDb reads (~/server/db/...) |
telemetry | pool-gauge block moves back to the app |
dbWrite (~/server/db/client) |
db (db-helpers.ts) |
in-package cycle — pass dbWrite as a param (§4.5) |
4. The contract layer + @civitai/db (heaviest)
Today's staged @civitai/db actually bundles two separable concerns — the schema/types
contract and the Prisma-client runtime. The civitai-advertising
project drives Kysely off a Prisma-generated schema (two generators on one
schema.prisma: prisma-client → ./generated, prisma-kysely → Kysely DB types; runtime
is new Kysely<DB>() over a raw pg.Pool, never the Prisma client). To let a future app pick
Kysely without dragging in the Prisma-client runtime, the contract is split into its own
package.
4.0 @civitai/db-schema — source-of-truth contract (LEAF package)
Pure artifact: schema + migrations + generated types. No runtime, no env, no @civitai/*
dependency. Both @civitai/db (Prisma runtime) and any future Kysely app/package depend
downward on it — this is allowed (it's a lower layer, like depending on @prisma/client);
it does not violate the no-sibling-imports rule, which only forbids one infra package
importing another infra package.
Layout (packages/civitai-db-schema/):
| Path | Role |
|---|---|
prisma/schema.full.prisma |
source of truth — datasource + both generators |
prisma/migrations/, prisma/views/, programmability |
migration history (applied manually, per CLAUDE.md) |
generated/client/ |
prisma-client generator output |
src/kysely/types.ts, src/kysely/enums.ts |
prisma-kysely generator output (baked in now) |
src/enums.ts, src/models.ts |
Prisma-generated enums/models (moved from @civitai/db) |
Both generators off one schema:
generator client {
provider = "prisma-client"
output = "../generated/client"
previewFeatures = ["views"]
}
generator kysely {
provider = "prisma-kysely"
output = "../src/kysely"
fileName = "types.ts"
enumFileName = "enums.ts"
}
datasource db { provider = "postgresql"; url = env("DATABASE_URL") }
Owns the db:generate / db:migrate* scripts — their paths move from prisma/... to
packages/civitai-db-schema/prisma/.... Migrations stay manual — this move doesn't change
the apply path; scripts/prisma-migrate-with-views-workaround.mjs references update to the new
path. Exports: generated Prisma client + types, enums, models, and the Kysely DB type via
subpath @civitai/db-schema/kysely.
Adding
prisma-kysely+ creating this package is the only content edit authorized here (schema gets a second generator block +prisma-kyselydev-dep); the actual file relocation ofprisma/,enums.ts,models.tsintopackages/civitai-db-schema/is a pure move.
4.1 @civitai/db — Prisma-client runtime (depends on @civitai/db-schema)
Package (packages/civitai-db/src/) — reusable Postgres machinery, no env, no globals;
imports the generated client + types from @civitai/db-schema:
| File | Role |
|---|---|
client.ts |
createPrismaClients(config) — Prisma read/write factory |
db-helpers.ts |
getClient(config) pg-Pool factory + pure SQL utils + param-bound stateful helpers |
(enums.ts, models.ts, prisma/ have moved down to @civitai/db-schema.)
App shims (src/server/db/) — instantiate with env, own the global.* caches,
re-export the existing names:
| Shim | Calls | Re-exports |
|---|---|---|
client.ts |
createPrismaClients |
dbRead, dbWrite |
pgDb.ts |
getClient ×3 |
pgDbWrite, pgDbRead, pgDbReadLong |
notifDb.ts |
getClient ×2 |
notifDbWrite, notifDbRead |
datapacketDb.ts |
getClient ×1 |
datapacketDbRead |
db-helpers.ts |
re-export pkg utils; bind dbWrite into stateful helpers |
getCurrentLSN, checkNotUpToDate, dbKV, all pure utils |
Note:
pgDb.ts,notifDb.ts,datapacketDb.tscurrently sit inside the package (we moved them there). Under this plan their singleton halves move back tosrc/server/db/as shims; onlygetClient(their factory) stays in the package. This is a follow-up content commit, done after the pure-move commit lands.
4.2 Prisma factory (client.ts)
Before — eager singleton, reads env, imports axiom
(client.ts:31):
import { env } from '~/env/server';
import { logToAxiom } from '~/server/logging/client';
...
export let dbRead: PrismaClient;
export let dbWrite: PrismaClient;
if (!env.IS_BUILD) { /* isProd ? … : global.globalDbWrite ??= … */ }
After — package side, factory only:
// packages/civitai-db/src/client.ts
// Prisma client + types come from the contract package, never @prisma/client directly:
import type { Prisma } from '@civitai/db-schema';
import { PrismaClient } from '@civitai/db-schema';
export type LogFn = (message: string, ...args: unknown[]) => void;
export type PrismaClientsConfig = {
databaseUrl: string;
replicaUrl: string;
isProd: boolean; // the only env-flag the factory needs (no isBuild)
logging: string[]; // env.LOGGING
log?: LogFn; // optional injected logger (defaults to no-op)
/** structured slow-query telemetry (the old logToAxiom call). Optional. */
onSlowQuery?: (e: { query: string; duration: number; target: 'read' | 'write' }) => void;
};
export type PrismaClients = { dbRead: PrismaClient; dbWrite: PrismaClient };
export function createPrismaClients(config: PrismaClientsConfig): PrismaClients {
const singleClient = config.replicaUrl === config.databaseUrl;
const logFor = (target: 'read' | 'write') =>
(e: { query: string; params: string; duration: number }) => {
if (e.duration < 2000) return;
const query = substituteParams(e.query, e.params); // existing $1-substitution logic
if (!config.isProd) console.log(query);
else config.onSlowQuery?.({ query, duration: e.duration, target }); // ← injected, no axiom import
};
const createOne = ({ readonly }: { readonly: boolean }): PrismaClient => {
const log = buildPrismaLogDefs(config.logging); // existing log-def logic
const url = readonly ? config.replicaUrl : config.databaseUrl;
const prisma = new PrismaClient({ log, datasources: { db: { url } } });
// prisma-showparams / prisma-slow-* wiring unchanged, gated on config.logging
return prisma;
};
// no isBuild here — the shim decides whether to call the factory at all during `next build`
const dbWrite = createOne({ readonly: false });
const dbRead = singleClient ? dbWrite : createOne({ readonly: true });
// slow-query $on wiring uses logFor(...) + config.logging, exactly as today
return { dbRead, dbWrite };
}
After — app shim owns env + globals + axiom wiring:
// src/server/db/client.ts (app shim, original path preserved)
import { createPrismaClients, type PrismaClients } from '@civitai/db';
import { isProd } from '~/env/other';
import { env } from '~/env/server';
import { logToAxiom } from '~/server/logging/client';
import { createLogger } from '~/utils/logging';
const log = createLogger('prisma', 'green');
declare global {
// eslint-disable-next-line no-var
var __civitaiPrisma: PrismaClients | undefined;
}
// build guard lives in the shim (not the factory): don't open connections during `next build`
const clients = env.IS_BUILD
? ({ dbRead: undefined as never, dbWrite: undefined as never })
: (global.__civitaiPrisma ??= createPrismaClients({
databaseUrl: env.DATABASE_URL,
replicaUrl: env.DATABASE_REPLICA_URL,
isProd,
logging: env.LOGGING,
log,
onSlowQuery: ({ query, duration, target }) => logToAxiom({ query, duration, target }, 'db-logs'),
}));
export const dbRead = clients.dbRead;
export const dbWrite = clients.dbWrite;
In production
??=still assigns once per process; in dev it reuses the HMR global — identical behavior to today'sglobal.globalDbWriteblock, just relocated to the shim.
4.3 pg Pool factory (getClient in db-helpers.ts)
getClient (db-helpers.ts:85) currently reads
env for URLs, timeouts, pool sizes, PODNAME, IS_DATAPACKET, SSL. Convert it to take a
config object. The types.setTypeParser(TIMESTAMP, …) side effect (currently top-of-file in
each singleton) moves into the pool factory so it runs at pool creation.
// packages/civitai-db/src/db-helpers.ts (package side)
export type PgInstance =
| 'primary' | 'primaryRead' | 'primaryReadLong'
| 'notification' | 'notificationRead' | 'datapacketRead';
export type PgClientConfig = {
log?: LogFn; // optional, default no-op
isDatapacket: boolean;
podName?: string;
ssl: boolean; // env.DATABASE_SSL !== false
urls: Record<PgInstance, string>; // resolved URLs (with fallbacks) from the app
timeouts: {
connection: number; read?: number; write?: number; poolIdle: number;
};
poolMax: number; notificationPoolMax?: number;
};
export function getClient(instance: PgInstance, config: PgClientConfig): AugmentedPool {
// identical pool construction, but every `env.X` becomes `config.X`
// pgPoolAcquireHistogram (raw prom-client) stays — prom-client is an external dep, allowed
}
App shim instantiates the singletons and owns the globals (one shim file per existing path):
// src/server/db/pgDb.ts (app shim)
import { getClient, type AugmentedPool, type PgClientConfig } from '@civitai/db';
import { isProd } from '~/env/other';
import { env } from '~/env/server';
import { createLogger } from '~/utils/logging';
const cfg: PgClientConfig = {
log: createLogger('pgDb', 'blue'),
isDatapacket: env.IS_DATAPACKET,
podName: env.PODNAME,
ssl: env.DATABASE_SSL !== false,
urls: {
primary: env.DATABASE_URL,
primaryRead: env.DATABASE_REPLICA_URL ?? env.DATABASE_URL,
primaryReadLong: env.DATABASE_REPLICA_LONG_URL ?? env.DATABASE_URL,
notification: env.NOTIFICATION_DB_URL,
notificationRead: env.NOTIFICATION_DB_REPLICA_URL ?? env.NOTIFICATION_DB_URL,
datapacketRead: env.DATAPACKET_DATABASE_RO_URL ?? env.DATABASE_URL,
},
timeouts: {
connection: env.DATABASE_CONNECTION_TIMEOUT,
read: env.DATABASE_READ_TIMEOUT, write: env.DATABASE_WRITE_TIMEOUT,
poolIdle: env.DATABASE_POOL_IDLE_TIMEOUT,
},
poolMax: env.DATABASE_POOL_MAX, notificationPoolMax: env.NOTIFICATION_POOL_MAX,
};
declare global {
// eslint-disable-next-line no-var
var globalPgWrite: AugmentedPool | undefined;
// eslint-disable-next-line no-var
var globalPgRead: AugmentedPool | undefined;
// eslint-disable-next-line no-var
var globalPgReadLong: AugmentedPool | undefined;
}
const single = (env.DATABASE_REPLICA_URL ?? env.DATABASE_URL) === env.DATABASE_URL;
export const pgDbWrite = (global.globalPgWrite ??= getClient('primary', cfg));
export const pgDbRead = (global.globalPgRead ??= single ? pgDbWrite : getClient('primaryRead', cfg));
export const pgDbReadLong = (global.globalPgReadLong ??= single ? pgDbWrite : getClient('primaryReadLong', cfg));
notifDb.ts and datapacketDb.ts shims follow the same shape (their own globals + instances).
4.4 Pure utilities — re-export untouched
These functions in db-helpers.ts have no env/client dependency and stay in the
package as plain exports: queryWithTimeout, dataProcessor, batchProcessor,
templateHandler, parameterizedTemplateHandler, combineSqlWithParams, getExplainSql,
jsonbArrayFrom, formatSqlType. The app db-helpers.ts shim just export * from '@civitai/db'
for these.
4.5 Breaking the in-package cycle (db-helpers → client)
getCurrentLSN, checkNotUpToDate, and dbKV
(db-helpers.ts:343-553) call dbWrite.
Today that's import { dbWrite } from '~/server/db/client' — an in-package cycle once both
files live in @civitai/db. Fix: pass dbWrite in.
// package side — stateless, takes the client
export async function getCurrentLSN(dbWrite: PrismaClient): Promise<string> { /* … */ }
export function makeDbKV(dbWrite: PrismaClient) {
return { get: async <T>(k: string, d?: T) => { /* … */ }, set: async <T>(k: string, v: T) => { /* … */ } };
}
// src/server/db/db-helpers.ts (app shim) — bind the app's dbWrite once
import { dbWrite } from '~/server/db/client';
import * as pkg from '@civitai/db';
export * from '@civitai/db'; // pure utils + getClient
export const getCurrentLSN = () => pkg.getCurrentLSN(dbWrite);
export const checkNotUpToDate = (lsn: string) => pkg.checkNotUpToDate(dbWrite, lsn);
export const dbKV = pkg.makeDbKV(dbWrite);
Call sites (import { dbKV } from '~/server/db/db-helpers') are unchanged.
4.6 limitConcurrency
dataProcessor/batchProcessor use limitConcurrency from
~/server/utils/concurrency-helpers. Resolution: verify that file is dependency-free
(pure Promise scheduling); if so, vendor a copy into packages/civitai-db/src/ (or a
small @civitai/db internal util). If it has app deps, inline the single function. Do
not import it from the app. (Sub-task: confirm concurrency-helpers.ts purity.)
5. @civitai/redis (keep client.ts only)
Package surface: createRedisClients(config) returning { redis, sysRedis } plus the static
REDIS_KEYS / REDIS_SYS_KEYS / REDIS_SUB_KEYS key definitions (these are pure constants,
exported directly).
5.1 Inject the Flipt-gated failover policy
Before (client.ts:333):
import { FLIPT_FEATURE_FLAGS, isFlipt } from '~/server/flipt/client';
import { slugit } from '~/utils/string-helpers';
const enabled = await isFlipt(FLIPT_FEATURE_FLAGS.REDIS_CLUSTER_ENHANCED_FAILOVER, 'redis-cluster', fliptContext);
After — package knows nothing about Flipt; the answer is injected:
// packages/civitai-redis/src/client.ts
export type RedisClientsConfig = {
url: string; sysUrl: string; timeout: number;
cluster: boolean; clusterNodes?: string; clusterRefreshInterval: number;
nextAuthUrl?: string; fliptDeploymentId?: string; // failover-context inputs (were env.*)
log?: LogFn; // optional; replaces createLogger
/** app policy, injected. Defaults to OFF — package never names Flipt. */
isEnhancedFailoverEnabled?: (ctx: Record<string, string>) => Promise<boolean>;
};
// inlined — was ~/utils/string-helpers
const slugit = (s: string) =>
s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
export function createRedisClients(config: RedisClientsConfig) {
// …existing client/cluster construction, env.X → config.X…
// in the cluster failover block:
const enabled = (await config.isEnhancedFailoverEnabled?.(fliptContext)) ?? false;
if (enabled) triggerTopologyRediscovery(baseClient, reason);
return { redis, sysRedis };
}
export const REDIS_KEYS = { /* … unchanged constant tree … */ } as const;
export const REDIS_SYS_KEYS = { /* … */ } as const;
export const REDIS_SUB_KEYS = { /* … */ } as const;
App shim wires real Flipt + owns globals:
// src/server/redis/client.ts (app shim)
import { createRedisClients, REDIS_KEYS, REDIS_SYS_KEYS, REDIS_SUB_KEYS } from '@civitai/redis';
import { env } from '~/env/server';
import { createLogger } from '~/utils/logging';
import { FLIPT_FEATURE_FLAGS, isFlipt } from '~/server/flipt/client';
declare global { /* eslint-disable-next-line no-var */ var __civitaiRedis: ReturnType<typeof createRedisClients> | undefined; }
// build guard in the shim: skip client creation during `next build`
const clients = global.__civitaiRedis ??= env.IS_BUILD ? ({} as ReturnType<typeof createRedisClients>) : createRedisClients({
url: env.REDIS_URL, sysUrl: env.REDIS_SYS_URL, timeout: env.REDIS_TIMEOUT,
cluster: env.REDIS_CLUSTER, clusterNodes: env.REDIS_CLUSTER_NODES,
clusterRefreshInterval: env.REDIS_CLUSTER_REFRESH_INTERVAL,
nextAuthUrl: env.NEXTAUTH_URL, fliptDeploymentId: env.FLIPT_DEPLOYMENT_ID,
log: createLogger('redis', 'green'),
isEnhancedFailoverEnabled: (ctx) =>
isFlipt(FLIPT_FEATURE_FLAGS.REDIS_CLUSTER_ENHANCED_FAILOVER, 'redis-cluster', ctx),
});
export const { redis, sysRedis } = clients;
export { REDIS_KEYS, REDIS_SYS_KEYS, REDIS_SUB_KEYS };
// plus re-export the RedisKeyTemplate* types consumers import from here
Everything that previously lived in civitai-redis (caches.ts, queues.ts,
resource-data.redis.ts, entity-metric.redis.ts, entity-metric-populate.ts,
fail-open-log.ts) is already moved back to src/server/redis/ and imports the shim — no
change needed there.
6. @civitai/clickhouse (split: base client stays, Tracker moves back)
clickhouse/client.ts mixes two concerns:
- Infrastructure —
createClient+ the$query/$exectemplate helpers. Needs onlyCLICKHOUSE_HOST/USERNAME/PASSWORDand an injected error hook. - The Tracker — request/session-bound event recording. Imports
getServerAuthSession,NextApiRequest/Response,Session,request-ip, and app schemas/enums (new-order.schema,entity-moderation,user.schema,browsingLevel.constants). This is main-app domain and, per discussion, other apps won't use it the same way — at most they need to pass auserIdto an insert.
6.1 Package = base client only
// packages/civitai-clickhouse/src/client.ts
import type { ClickHouseClient } from '@clickhouse/client';
import { createClient } from '@clickhouse/client';
export type ClickhouseConfig = {
host: string; username: string; password: string;
isProd: boolean;
log?: LogFn; // optional, default no-op
/** insert/query error telemetry (was logToAxiom). Injected. */
onError?: (data: Record<string, unknown>, datastream?: string) => void;
};
export type CustomClickHouseClient = ClickHouseClient & {
$query: <T extends object>(q: TemplateStringsArray | string, ...v: any[]) => Promise<T[]>;
$exec: (q: TemplateStringsArray | string, ...v: any[]) => Promise<void>;
};
export function createClickhouseClient(config: ClickhouseConfig): CustomClickHouseClient {
// existing createClient + $query/$exec wiring; env.X → config.X; logToAxiom → config.onError
}
6.2 App shim + relocated Tracker
// src/server/clickhouse/client.ts (app shim)
import { createClickhouseClient } from '@civitai/clickhouse';
import { isProd } from '~/env/other';
import { env } from '~/env/server';
import { createLogger } from '~/utils/logging';
import { logToAxiom } from '~/server/logging/client';
declare global { /* … */ var globalClickhouse: ReturnType<typeof createClickhouseClient> | undefined; }
// build guard in the shim
export const clickhouse = global.globalClickhouse ??= env.IS_BUILD
? (undefined as never)
: createClickhouseClient({
host: env.CLICKHOUSE_HOST, username: env.CLICKHOUSE_USERNAME, password: env.CLICKHOUSE_PASSWORD,
isProd, log: createLogger('clickhouse', 'blue'),
onError: (data, ds) => logToAxiom(data, ds),
});
The Tracker class and all its request/session/schema imports move to a new app file
src/server/clickhouse/tracker.ts, built on top of clickhouse from the shim. Its public
import path is preserved for existing call sites. (Sub-task: extract the exact Tracker
surface from the current client.ts; confirm what other modules import from
~/server/clickhouse/client so re-exports stay complete.)
Future multi-app note: the base client already supports arbitrary inserts; an app that only needs "attach a userId" passes it in the row payload — no Tracker required.
7. @civitai/axiom (factory-ize the logger)
axiom/client.ts is already free of sibling/app-service imports, but it still reads env
directly. Make it a factory for consistency and so other apps get their own datastream/pod
config. safeError is pure — export it directly.
// packages/civitai-axiom/src/client.ts
import { Client } from '@axiomhq/axiom-node';
export type AxiomConfig = {
token?: string; orgId?: string; datastream?: string;
podName?: string; isProd: boolean;
logErrorsToStdout: boolean; // process.env.LOG_ERRORS_TO_STDOUT === 'true'
};
export function safeError(e: unknown): MixedObject | undefined { /* unchanged, pure */ }
export function createAxiomLogger(config: AxiomConfig) {
const axiom = (config.token && config.orgId)
? new Client({ token: config.token, orgId: config.orgId }) : null;
async function logToAxiom(data: MixedObject, datastream?: string) {
const sendData = { pod: config.podName, ...data };
if (!config.isProd) { console.log('logToAxiom', sendData); return; }
if (!axiom) return;
datastream ??= config.datastream;
if (!datastream) return;
if (config.logErrorsToStdout) console.error(JSON.stringify({ _axiom: datastream, ...sendData }));
await axiom.ingestEvents(datastream, sendData);
}
return { logToAxiom, safeError };
}
// src/server/logging/client.ts (app shim — the path db/clickhouse shims import)
import { createAxiomLogger, safeError } from '@civitai/axiom';
import { isProd } from '~/env/other';
import { env } from '~/env/server';
// build guard in the shim: don't construct the Axiom client during `next build`
const noopLog = async (_data: MixedObject, _datastream?: string) => {};
export const logToAxiom = env.IS_BUILD
? noopLog
: createAxiomLogger({
token: env.AXIOM_TOKEN, orgId: env.AXIOM_ORG_ID, datastream: env.AXIOM_DATASTREAM,
podName: env.PODNAME, isProd,
logErrorsToStdout: process.env.LOG_ERRORS_TO_STDOUT === 'true',
}).logToAxiom;
export { safeError };
This shim is the composition seed: the db and clickhouse shims import logToAxiom from here
to build their onSlowQuery / onError. No package imports another package.
8. @civitai/telemetry (helpers stay, pool gauges go back)
telemetry/client.ts splits cleanly:
- Keep in package (no factory needed — pure
prom-client):registerCounter,registerCounterWithLabels,registerGaugeWithLabels,registerHistogram, and the HMR-safegetSingleMetricfallback. These take noenvand import no app code. - Move back to the app: the DB pool-depth gauge block
(
telemetry/client.ts:~210-296) that readspgDbRead.totalCount,idleCount,waitingCount, … across all six pools. It composes@civitai/dbpools + prom helpers → app-level glue. It originally lived in the app'sprom/client.ts; it returns there and registers gauges using the package'sregister*helpers plus the pool singletons from the db shims:
// src/server/prom/client.ts (app)
import { registerGaugeWithLabels } from '@civitai/telemetry';
import { pgDbRead, pgDbReadLong, pgDbWrite } from '~/server/db/pgDb';
import { notifDbRead, notifDbWrite } from '~/server/db/notifDb';
import { datapacketDbRead } from '~/server/db/datapacketDb';
// …register the node_postgres_pool_* gauges exactly as before…
No @civitai/telemetry → @civitai/db edge remains.
9. App composition order
Shims form an acyclic graph (package level has no edges; app shims compose upward):
@civitai/db-schema (LEAF: schema + generated Prisma client/types + Kysely types)
▲
│ (generated client/types — downward dep, allowed)
@civitai/db ─────────────────────────────────────────────┐
@civitai/axiom ─▶ src/server/logging/client.ts (logToAxiom)│
│ │
┌───────────────┼───────────────┐ │
▼ ▼ ▼ ▼
db/client.ts clickhouse/client.ts (onSlowQuery / onError)
redis/client.ts ─▶ ~/server/flipt/client (isEnhancedFailoverEnabled)
db/pgDb,notifDb,datapacketDb ─▶ getClient(config)
prom/client.ts ─▶ @civitai/telemetry + db pool shims
There is no db ↔ redis ↔ clickhouse import among packages; any historical coupling (e.g.
the old db-lag-helpers needing both) lives in app code, which already moved back. The only
cross-package edge is the downward @civitai/db → @civitai/db-schema (generated types).
10. Verification checklist (per package, after each refactor commit)
grep -rE "from '~/" packages/<pkg>/srcreturns nothing (no app imports remain).- Package imports only external npm deps + its own
./files. pnpm run typecheckpasses (shims satisfy all existing call-site imports).pnpm run build(Next standalone) succeeds withtranspilePackages: ['@civitai/*'].- Dev server boots; redis/db/clickhouse connect; HMR does not duplicate clients (globals reused).
- Prom metrics still register once (no duplicate-registration crash on HMR).
- Slow-query logs still reach Axiom in a prod-like env (
onSlowQuerywired).
11. Open items / decisions
- @ai:*
concurrency-helpers.ts— confirm it's dependency-free so we can vendor it into@civitai/dbrather than inlinelimitConcurrency. (§4.6) - @ai:* Tracker extraction — enumerate exactly what other modules import from
~/server/clickhouse/clienttoday, so the post-split shim re-exports everything callers expect. (§6.2) - @ai:* Confirm the
register*helpers should stay a bare function module (no factory), given they hold no per-app config. (§8) - @ai:* Env handling (§2.2): agreed the factories take plain values and never import a
central
env. Still open — do we add per-package scoped env files (each package ships its own zod/@t3-oss/envschema +configFromEnvhelper) now, or keep packages fully env-agnostic and let each app own all env validation? Leaning env-agnostic until a second app exists, then extract the scoped-env helper only where it pays off. - Six packages now, not five:
@civitai/db-schema(contract) +@civitai/db(Prisma runtime) +redis+clickhouse+axiom+telemetry. This is distinct from the rejectedcivitai-schema-common(decision 1 in the handoff) — that was domain constants; this is the Prisma-generated DB contract. - Commit sequencing: pure-move commit first (current staged state plus the
prisma/+enums.ts+models.tsrelocation into@civitai/db-schema), then one content commit per package (db-schema generators → db → redis → clickhouse → axiom → telemetry), each independently verifiable.