docs: monorepo migration guide + moderator boundary analysis

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Briant Diehl
2026-06-09 15:39:23 -06:00
parent 2e4e017280
commit 1a992ca26d
3 changed files with 1145 additions and 0 deletions
+224
View File
@@ -0,0 +1,224 @@
# Moderator App — Package Boundary (deep dive)
**Status:** analysis / proposal · **Date:** 2026-06-04
> **Superseded in part:** the *package recommendations* below (`@civitai/moderator-server`, `@civitai/shared-schema`, `@civitai/ui-common`) are replaced by [`moderator-app-package-extraction-plan.md`](./moderator-app-package-extraction-plan.md), which narrows the forced extraction to a single `@civitai/domain` contract package. The app-local / proxy / stays-in-main analysis here is still valid.
**Supersedes:** [`moderator-app-shared-modules.md`](./moderator-app-shared-modules.md) — that doc predates the current architecture (it assumed **git submodules** and a `civitai-schema-common` package, both rejected in the [handoff](./monorepo-bootstrap-handoff.md)). This doc reconciles the moderator analysis with the **5 pnpm `@civitai/*` base packages + `@civitai/db-schema` contract layer** that actually shipped.
## Scope (confirmed)
- **Pages:** the **content-moderation** subset (~22), *not* the commerce/admin pages under `/moderator` (cosmetic-store, rewards, challenges, paddle, cash-management, auctions, contests, code-gifts, home-blocks stay in the main app for now).
- **Backend topology:** the moderator app runs **its own tRPC routers against the shared DB** (`@civitai/db`). It is **not** a thin proxy. This is the decision that makes the server-side import-closure analysis the hard part of this work.
In-scope pages: `images`, `images/to-ingest`, `image-tags`, `image-rating-review`, `downleveled-review`, `ingestion-error-review`, `articles`, `models/index`, `comics-review`, `reports`, `tags`, `blocklists`, `auditor`, `strikes`, `scanner-audit/{index,[mode]/index,[mode]/[label]}`, `training-models`, `review/training-data/{index,[versionId]}`, `csam/{index,[userId]}`, `generation`, `generation-config`, `generation-restrictions`.
---
## 1. The governing rule (why imports are the whole game)
A package may import **only external npm deps and other packages** (`@civitai/*`). It may **never** import app code (`~/…`). So for *every* file we propose sharing, the question is not "is this moderator-ish?" but:
> **Is the file's entire transitive `~/…` import closure also moveable?** If one leaf reaches into a zustand store, the tRPC client, a provider, or `image.service`, the whole candidate is blocked until that leaf is dealt with.
Everything below is organized around that closure test.
## 2. Layering (what already exists vs. what we'd add)
```
┌─ apps/moderator NEW app. Own Next.js, own tRPC client, own env, own
│ session/feature-flags glue, thin re-export pages.
│ …composes downward into…
├─ @civitai/moderator-server NEW (domain tier). Isolated moderator services +
│ moderator routers/controllers/selectors. Owns the
│ "own routers, shared DB" surface.
├─ @civitai/shared-schema NEW (contract tier). zod input contracts + the
│ server/common enums & constants the schemas need.
├─ @civitai/ui-common NEW (optional, deferrable). Generic UI primitives.
│ Vendor-copy first; promote when drift hurts.
│ …all of the above may use the EXISTING layers…
├─ @civitai/{db,redis,clickhouse,axiom,telemetry} EXIST. Infra-only base pkgs.
└─ @civitai/db-schema (contract) EXISTS. Prisma client, enums,
models. Already absorbs the
biggest Tier-A item.
```
> The new `@civitai/moderator-server`, `@civitai/shared-schema`, and `@civitai/ui-common` are **domain/feature packages**, a *higher tier* than the base infra packages. Per the handoff, base packages stay infra-only and independent; higher-level packages **may** compose multiple base packages. These do not violate the [base-package rules](../C:/Users/bkdie/.claude/projects/c--Work-model-share-monorepo-bootstrap/memory/monorepo-bootstrap-base-package-rules.md) because they are not base packages.
## 3. Already solved by the completed migration ✅
These were the heaviest items in the old analysis. They no longer need work:
| Item | Old verdict | Reality now |
|---|---|---|
| `~/shared/utils/prisma/enums` (21 pages) | "move to schema-common" | **Already a re-export shim → `@civitai/db-schema/enums`.** Both apps import the same enums today. |
| Prisma client / models | "schema-common" | `@civitai/db-schema`, done. |
| Postgres / Redis / ClickHouse access | submodule | `@civitai/{db,redis,clickhouse}` factories, done. The moderator app calls `createPrismaClients()` etc. exactly as the guide describes. |
| `@civitai/*` tsconfig paths + `transpilePackages` | — | Wired. A new `apps/moderator` is picked up by the `apps/*` workspace glob automatically. |
So Tier A from the old doc collapses to **just the zod schemas + a handful of `server/common` enums/constants** (see §5).
---
## 4. Server side — the hard part ("own routers, shared DB")
Verified by reading the service import headers directly. The moderator features split cleanly into an **isolated periphery** and an **entangled core**.
### 4a. Isolated services — extract as-is into `@civitai/moderator-server` ✅
These have **zero or trivial** service-to-service coupling (verified):
| Service | Lines | Service-to-service imports | Verdict |
|---|---|---|---|
| `moderator.service` (audit log) | 80 | none | **clean** — only `dbWrite` |
| `blocklist.service` | 125 | none | **clean** — db + redis + constants |
| `scanner-content.service` | 380 | `orchestrator/client` only | **clean** |
| `scanner-review.service` | 591 | `scanner-content` only | **clean** — db + clickhouse + scanner-review.schema + enums |
| `training.service` | 923 | `orchestrator/client` only | **clean** |
| `strike.service` | 776 | `notification.service`, `user.service` | **near-clean** — see note |
`strike.service` pulls `notification.service` (itself dependency-free) and a **narrow slice** of `user.service` (`getById`/`updateUserById`). Move `notification.service` alongside it; for `user.service`, extract just the functions strike needs into a small `moderator-server/user-ops.ts` rather than dragging the whole (auth/session/preferences-heavy) file.
These six are the backbone of the moderator app's own routers. They query `@civitai/db` / `@civitai/clickhouse` and the orchestrator client — **all available to a package.**
### 4b. The entangled core — `image.service` is the hub ⚠️
`image.service.ts` (7,982 lines) imports **14 sibling services**:
```
post.service (↔ bidirectional) report.service tag.service notification.service
cosmetic.service nsfwLevels.service image-flag.service games/new-order.service
moderator.service tagsOnImageNew.service feature-flags.service storage-resolver
orchestrator/orchestrator.service orchestrator/(via others)
```
And the moderation-relevant services that *look* peripheral actually reach back into it:
- `report.service` → imports `image.service`, `post.service`, `tag.service`, …
- `csam.service` → imports `image.service`, `file.service`
- `generation.service` → imports `image.service`, `model.service`, `model-version.service`, …
So **pulling `image.service` (or anything that imports it) whole = importing the main app's content graph** (feed cache invalidation via `post.service`, NSFW re-queue, cosmetics, games). That cannot live in a package.
### 4c. The resolution: split **read queues** from **cross-graph writes**
The moderator pages' server needs decompose into two very different shapes:
**(i) Read queues — own them.** The review surfaces are essentially SQL:
`getImageModerationReviewQueue`, `getImageRatingRequests`, `getDownleveledImages`, `getIngestionErrorImages`, scanner queues, reports list, strikes standings/history, CSAM report paging, training queue, flagged-models list.
Most are *defined inside* `image.service`/`report.service` today, but they only need `dbRead` + selectors + enums. **Lift these query functions out** into `@civitai/moderator-server/queries/*` that import `@civitai/db` + `@civitai/db-schema` only. This is mechanical extraction, not a redesign — the SQL doesn't change.
**(ii) Cross-graph write actions — do not own the whole service.** A handful of mutations genuinely touch the main app's graph:
`moderateImages``post.service.bustCachesForPosts` (feed cache); `updateImageNsfwLevel``nsfwLevels.service` (comic re-queue); report-status changes → multiple services.
For these, pick per-action (cheap → expensive):
- **Cache-bust via Redis only.** `bustCachesForPosts` ultimately just invalidates Redis keys. If the moderator write does the DB mutation and then invalidates the **same `@civitai/redis` keys** (expose the key builders via a subpath, the way the guide already does for `REDIS_KEYS`), no `post.service` import is needed. Preferred where the side-effect is "invalidate cache X."
- **Proxy the action to the main app's tRPC** for the few writes whose side-effects are real orchestration (comic re-queue, notification fan-out, games/new-order). The moderator router calls one main-app procedure; main app owns the graph. Accept the network hop for these low-frequency moderator clicks.
> **Bottom line:** "own routers, shared DB" is achievable for **reads and the isolated services**, and for **writes** it's a per-action choice between *replicate-the-cache-bust* (Redis keys, in-package) and *proxy-the-orchestration* (one tRPC call to main). Nothing forces `image.service`/`post.service`/`model.service`/`generation.service` into a package — and nothing should.
### 4d. Server modules that stay in the main app (blockers)
`image.service`, `post.service`, `model.service`, `model-version.service`, `generation/generation.service` — all are deeply cross-linked into feed/marketplace/orchestration. **Leave in place.** The moderator app reaches their *effects* through (i) lifted read queries against shared DB or (ii) the proxy procedures above. `generation.tsx`'s `getResources`/ecosystem-config slice is the one worth extracting separately (`generation-config.service`) since it's read-mostly.
---
## 5. Contract tier — `@civitai/shared-schema`
The zod `~/server/schema/*.schema.ts` files are the input contracts both apps' routers validate against. Closure check (verified):
| Schema | `~/` imports beyond already-shared enums | Extractable? |
|---|---|---|
| `scanner-review.schema` | none (just shared enums) | ✅ trivially |
| `strike.schema` | `base.schema` | ✅ with `base.schema` |
| `report.schema` | `server/common/{constants,enums}`, `base.schema`, `report-helpers` | ✅ once common moves |
| `image.schema` | **`~/components/ImageGeneration/.../resource-select.types`, `~/components/Search/parsers/base`** | ⚠️ **dirty** — a schema importing component types is a layering smell. Untangle first (move those two leaf types out of `components/`), or keep `image.schema` app-side and have the moderator router define a narrower local input schema. |
To unblock the clean ones, `@civitai/shared-schema` must also carry the **stable, enum-shaped** parts of:
- `~/server/common/enums` (used by 10 pages — `NsfwLevel`, `BlockedReason`, `BlocklistType`, `ImageScanType`, …)
- `~/server/common/constants`**but** this file imports `~/env/client`, so split out the pure constant tables from the env-coupled ones; only the pure tables move.
- `~/server/common/moderation-helpers.unpublishReasons` — pure lookup table, move it.
- `~/server/schema/base.schema`, `~/shared/utils/report-helpers` (pure `ReportEntity` enum).
`@civitai/shared-schema` depends only on `@civitai/db-schema` (for enums) + `zod`. Clean.
---
## 6. Client side — the import-closure verdicts
### 6a. Generic UI primitives (`@civitai/ui-common`, or vendor-copy)
**CLEAN — extract as-is** (only Mantine/external + sibling-clean deps):
`NextLink`, `PageLoader`, `LegacyActionIcon`, `NoContent`, `PopConfirm`, `ButtonTooltip`, `ContentClamp`, `DescriptionTable` (+ `InfoPopover`), `TwCard`, `EndOfFeed`, `InViewLoader`, `ImageHash`, `MasonryProvider`, `MasonryContainer`, `ScrollArea`, `AppLayout/Page` (type helper).
**EXTRACT WITH A SMALL DEP** (move one leaf too):
- `BackButton` → move `store/ClientHistoryStore` (app-agnostic zustand).
- `Meta` → refactor to take `canIndex`/`deIndex` as props instead of reading `useAppContext`, then clean.
- `MasonryColumns` → inject/move `Ads/AdUnitRenderable` (couples to ad store) — or pass the ad slot as a prop.
- `RenderHtml` → move `TypographyStylesWrapper` + the consent context + `profanity-simple`.
**COUPLED — leave app-side / reimplement** (reach into tRPC, auth, generation, dialog, cosmetics):
`EdgeMedia`/`EdgeVideo` (media infra), `ImageMeta` (generation store + trpc + tracking), `ImageGuard2` (auth + dialog + browsing-level), `VotableTags` (trpc voting + auth), `UserAvatar` (trpc + cosmetics selectors), `AppLayout/NotFound` (trpc data-fetch).
> **Recommendation:** ship `@civitai/ui-common` with the CLEAN set, **vendor-copy** the COUPLED ones into the moderator app and let them re-bind to the moderator app's own trpc/auth (they're a small set). Promote to a real package only when visual drift becomes painful — exactly the old doc's "option 3," still correct.
### 6b. Moderator-specific components (`@civitai/moderator-ui` / inside the app)
**READY — clean closure, move now:** `Moderator/ScannerAuditLayout`, `Moderator/ScannerPolicySidebar`, `Moderator/scannerLabelPolicies`, `Moderation/RuleDefinitionPopover`, `Csam/CsamProvider`, `Csam/useCsamImageSelect.store`, `store/select.store`, `hooks/useCheckProfanity` (pure — only `libs/profanity-simple`), `Image/PromptHighlight` **+ `utils/metadata/audit`** (self-contained: static word-lists + string helpers, **not** the blocker the old doc feared).
**NEEDS-DEPS / LIGHT REFACTOR:** `Moderation/GenerationStatusCard` (move generation-schema *types*), `Moderation/ModerationNav` (take feature flags as props), `Csam/CsamImageSelection` (needs MasonryColumns), `Moderation/ImpersonateButton` (account context → props).
**BLOCKED on the dialog system** (see §6c): `FlaggedModelsList`, `Csam/CsamDetailsForm`, `Profile/UserBanModal`, `useReportCsamImages`.
### 6c. The one real cross-cutting blocker: the Dialog system
`FlaggedModelsList`, `CsamDetailsForm`, `UserBanModal`, and `useReportCsamImages` all couple to `Dialog/dialogStore` + `useDialogContext()` + the app's `dialog-registry`/routed-dialog machinery. **This is the single highest-leverage refactor** — fixing it unblocks four moderator components at once.
Minimal refactor (medium, a few hours):
1. Move `dialogStore` (pure zustand) + base dialog types into a shared package.
2. Make `useDialogContext()`-style components accept `{opened, onClose, …}` as **props** rather than requiring the app provider.
3. The moderator app stands up its **own** lightweight `DialogProvider` (no routed-dialog/registry coupling).
4. Split `useReportCsamImages` so the **mutation** (trpc) is the hook and the **modal/notification side-effects** move to the caller.
### 6d. Utils / hooks / providers — corrections to the old read
Verified closures, with two corrections to the sub-analyses:
- **PURE — share freely:** `string-helpers`, `number-helpers`, `type-guards`, `normalize-text`, `file-utils`, `lazy`, `qs`, `date-helpers` (→ pure `shared/utils/dayjs`), all `shared/constants/*`, `moderator.util`, `report-helpers`, `AspectRatio`, `libs/form/useForm`. `login-helpers` rides along with `qs`.
- **`utils/notifications` is SHAREABLE** (correcting one sub-analysis that flagged it "coupled"): its only deps are `@mantine/notifications` + `@tabler/icons-react`**external**, allowed in a package. Used by 30 pages; put it in `@civitai/ui-common`.
- **PER-APP, not shared** (each app authors its own — these are *glue*, not shared code): `utils/trpc` (binds to `~/server/routers` AppRouter — the moderator app has its **own** router type), `types/router`, `env/client`, `server/utils/server-side-helpers` (binds to `appRouter` + `getServerAuthSession` + feature-flags), `useCurrentUser` (binds to `CivitaiSessionProvider`), `FeatureFlagsProvider`, `BrowsingLevelProvider`. The moderator app re-creates thin versions against its own session — cheap, and intentionally *not* shared so the two apps' auth surfaces stay independent.
- **`cf-images-utils`:** refactor to take `isModerator` as an arg (drops the `useCurrentUser` import), then it's pure.
---
## 7. Phased plan
**Phase 0 — prerequisites in the main app (in place, no new app yet)**
- Extract `@civitai/shared-schema`: pure `server/common` enums/constants tables, `moderation-helpers.unpublishReasons`, `base.schema`, and the clean moderator schemas (`scanner-review`, `strike`, `report`). Leave `image.schema` until its component-type leak is untangled.
- **Dialog-system decouple** (§6c) — props-based dialogs + portable `dialogStore`. Highest leverage.
- Lift the **read-queue** functions out of `image.service`/`report.service` into a query module that imports `@civitai/db` only (§4c-i).
**Phase 1 — `@civitai/moderator-server`**
- Move the 6 isolated services (§4a) + the lifted read queries + the moderator routers/controllers/selectors.
- Decide per cross-graph write: Redis-key cache-bust vs. proxy-to-main (§4c-ii).
**Phase 2 — `@civitai/ui-common`** (CLEAN set + `notifications`) and **`@civitai/moderator-ui`** (the READY moderator components).
**Phase 3 — stand up `apps/moderator`**
- `createPrismaClients()` / `createRedisClients()` / `createClickhouseClient()` per the migration guide.
- Own tRPC client + session/feature-flags glue. Vendor-copy the COUPLED UI primitives.
- Port the **7 Easy** pages first (to-ingest, rating-review, downleveled, ingestion-error, comics-review, strikes, training-data/index) as proof-of-concept.
**Phase 4 — Medium pages**, then **Phase 5 — Hard pages** (`images`, `auditor`, `scanner-audit/[mode]/[label]`, `generation`, `generation-restrictions`) once their specific couplings (PromptHighlight ✅ already cleared, profanity ✅ cleared, scanner-content ✅ clean, `useUnsupportedResources`/`ResourceSelectHandler` modal-trigger, `UserGenerationsDrawer`) are addressed.
---
## 8. Decisions needed
@dev: a few forks I couldn't resolve from the code — flag your call inline:
1. **Cross-graph writes:** default to *Redis-key cache-bust in-package* where the side-effect is pure cache invalidation, and *proxy-to-main* only for true orchestration (comic re-queue, notifications, games/new-order)? Or proxy **all** moderator writes for simplicity at v1 and optimize later?
2. **`image.schema` leak:** untangle the two `components/*` type imports now (small but touches main-app files), or have the moderator router define a local narrow input schema and defer the untangle?
3. **CSAM on the satellite at all?** It's the most sensitive surface and `csam.service` reaches `image.service`. Confirm it moves vs. stays in main app for audit-trail reasons.
4. **`@civitai/ui-common` now or vendor-copy first?** Recommendation is vendor-copy the CLEAN set into the moderator app and only formalize the package once a second consumer exists — avoids a big main-app rewrite during the initial split.
@@ -0,0 +1,278 @@
# Moderator App — Minimal Package Extraction Plan
**Status:** plan · **Date:** 2026-06-04
**Companion to:** [`moderator-app-package-boundary.md`](./moderator-app-package-boundary.md) — that doc's *package recommendations* (`@civitai/moderator-server`, `@civitai/shared-schema`, `@civitai/ui-common`) are **superseded by this doc**. Its analysis of what stays app-local / proxied remains valid.
## Premise (the only test)
The moderator app runs **its own routers against the shared DB**. So a file must become a **package** *only* when duplicating it would corrupt shared data — i.e. it's a **hand-authored contract governing a column both apps read or write**. Everything else is app-local (copy), stays in main (proxy), or is vendor-copied. This is demand-driven, not purity-driven: `src/shared/` is the main app's *client/server* boundary and is irrelevant to cross-app packaging.
## What goes into the package
Derived from a full sweep of every domain-vocabulary import across the 22 in-scope pages + the app-local (isolated) services, then closure- and forcing-checked. **Two extraction modes** feed the package:
- **Whole-file move** — the file *is* the shared unit. Pure `git mv` (R100). Most rows below.
- **Member-level extraction** — a small, pure, shared member is buried in an app-coupled file. Carve *that member* into the package and leave a **re-export at its old location**; the coupled file keeps working for its other consumers untouched. A content edit, not a rename — so no R100 for the source file. Used for `CacheTTL` (and `ReportEntity`, which also folds into `enums`).
### Whole-file moves
| File | Bucket · why it belongs here | `~/` import closure |
|---|---|---|
| `src/server/common/enums.ts` (450 lines) | **Contract.** Non-Prisma domain enums keying shared columns: `BlocklistType` (`Blocklist`), `NotificationCategory` (`Notification`), `NsfwLevel`, `BlockedReason`, `TagSort`, report/scan status. **Also absorbs `ReportEntity`** (Q1 — see member extractions). | **none** (0 imports) |
| `src/server/common/moderation-helpers.ts` (88 lines) | **Contract.** `unpublishReasons` codes written by moderator, read/displayed by main. Mis-filed under `server/common/`. | **none** (pure) |
| `src/shared/constants/mime-types.ts` (72 lines) | **Contract.** `MIME_TYPES`/`MEDIA_TYPE` map file-ext ↔ `MediaType`; both apps categorize uploaded files (csam/training) into shared columns. | `@civitai/db-schema/enums` |
| `src/shared/constants/basemodel.constants.ts` (V2 ecosystem schema) | **Vocabulary.** Shared base-model / ecosystem identity — generation config both apps must agree on. | `./lazy`, `./type-guards`, `@civitai/db-schema/enums` |
| `src/utils/type-guards.ts` | **Pure leaf dep** of basemodel. **174 consumers** app-wide → strong single-source argument. *(Generic, not strictly "domain" — could relocate to a future `@civitai/utils`.)* | **none** (pure) |
| `src/shared/utils/lazy.ts` | **Pure leaf dep** of basemodel. | **none** (pure) |
### Member-level extractions (carve + re-export)
| Member | From (stays, re-exports) | Into | Why |
|---|---|---|---|
| `ReportEntity` enum | `src/shared/utils/report-helpers.ts` (the file is *only* this enum → becomes a shim) | `enums.ts` | **Contract.** Keys the shared `Report` table — same class as `BlocklistType`. Consolidating into the canonical enums file (Q1). |
| `CacheTTL` const | `src/server/common/constants.ts` (1,734 ln; env- + feature-flag-service-coupled — can't whole-file move) | a small `cache.ts` (or `enums.ts`) | Pure TTL numbers (constants.ts:1454, 0 deps), used by the moderator's blocklist/cache code. **Extract the member, not the file** (Q3). |
### Conditional — only if the moderator app filters feeds by browsing level (Q2)
| File | Status |
|---|---|
| `src/shared/constants/browsingLevel.constants.ts` | The drift-unsafe contract is the **`NsfwLevel` enum** (already in `enums.ts`). This file is the *derived label/flag layer*; the moderator pages use only `browsingLevels` + `getBrowsingLevelLabel` (display labels). **If no browsing-level filtering → copy the label slice** (cosmetic drift only), don't package. ⚠ verify: do the image review-queue functions lifted from `image.service` filter by browsing-level *flags*? If yes, this + `flags` come back as a contract. |
| `src/shared/utils/flags.ts` | In the package **only** as `browsing-level`'s dep. If `browsing-level` is copied/dropped, `flags` drops too — unless the moderator app does its own bit-ops on `nsfwLevel`. |
After the move the package's only external edge is the **downward** dep `@civitai/domain → @civitai/db-schema` (`MediaType`/`ModelType`, used by `mime-types` + `basemodel`) — the allowed direction onto the contract leaf, exactly like `@civitai/db → @civitai/db-schema`. No `~/` imports, no sibling-infra deps.
### Sweep results — candidates rejected (the discriminating part)
The sweep deliberately **excluded** look-alikes, proving the rule isn't "extract anything pure":
- `scanner-label-highlight-terms.ts` — pure, but it's **scanner-audit UI display config used only by moderator pages** → single consumer → `apps/moderator/`, not a package.
- `object-helpers.ts` — generic lodash wrapper used by an app-local service → **copy**; no shared-data contract.
- `client-utils/cf-images-utils.ts` — couples to `useCurrentUser` + a provider → **app-local**.
- `server/common/constants.ts` (minus `CacheTTL`) — the rest is env- + feature-flag-service-coupled → **stays in main**; only the `CacheTTL` member is carved out (above).
> `src/shared/constants/base-model.constants.ts` (legacy, distinct from the V2 file) has the identical `type-guards`-only closure and can ride along if still live.
>
> **Scope note:** this sweep covers *direct* page + isolated-service imports. A few more domain constants may surface *transitively* when the coupled UI components get ported — closure-check each at port time, per "Growing the package later."
### Explicitly NOT forced (and why)
- `server/schema/*.schema.ts` (zod) — tRPC **input** contracts bound to a router; the moderator app authors its own. Only the *enums* they reference are shared (covered above). **App-local.**
- Moderator routers / controllers / services / pages / trpc client / providers / hooks — **one consumer → `apps/moderator/`**, not packages.
- `errorHandling`, `pagination-helpers`, `notification.service` slices — no DB contract → **copy**.
- `image.service` / `post.service`-entangled writes — **stay in main, proxy** (see boundary doc §4).
## The package: `@civitai/domain`
A new hand-authored contract package, peer to `@civitai/db-schema`. (Alternative considered: add a subpath to `@civitai/db-schema` — rejected to keep that package a *purely generated* artifact. Open decision #1 below.)
```
packages/civitai-domain/
├── package.json # depends on @civitai/db-schema
├── tsconfig.json
└── src/
├── index.ts # barrel re-export
├── enums.ts # ← src/server/common/enums.ts (+ ReportEntity merged in)
├── moderation-helpers.ts # ← src/server/common/moderation-helpers.ts
├── flags.ts # ← src/shared/utils/flags.ts
├── browsing-level.ts # ← src/shared/constants/browsingLevel.constants.ts
├── mime-types.ts # ← src/shared/constants/mime-types.ts
├── basemodel.constants.ts # ← src/shared/constants/basemodel.constants.ts
├── type-guards.ts # ← src/utils/type-guards.ts
├── lazy.ts # ← src/shared/utils/lazy.ts
└── cache.ts # ← CacheTTL carved from src/server/common/constants.ts
```
**8 whole-file moves** (enums, moderation-helpers, flags, browsing-level, mime-types, basemodel.constants, type-guards, lazy) + **2 member extractions** (`ReportEntity``enums.ts`; `CacheTTL``cache.ts`). `browsing-level` + `flags` are confirmed forced: the lifted `image.service` queries `getImageModerationCounts` (uses `sfwBrowsingLevelsFlag`) and `getImageRatingRequests` (uses `Flags.arrayToInstance`) both decode/write the `nsfwLevel` bitfield server-side.
---
## Move procedure — preserving git history
This repo's established discipline (see [handoff](./monorepo-bootstrap-handoff.md) "Things to be careful about"): **git rename detection has a 50% similarity threshold, so a move + edit in one commit can lose `--follow` history.** The two extraction modes handle this differently:
- **Whole-file moves** → `git mv` as a **pure rename (R100) in its own commit (Commit 1)**; all edits to them land in Commit 2. Build is intentionally broken between the two — same as the original bootstrap commit.
- **Member extractions** (`ReportEntity`, `CacheTTL`) → not renames at all; they're content edits to a file that *stays*. They happen entirely in Commit 2. (History doesn't follow a copied member; acceptable for a 14-line enum and an 8-line const.)
### Commit 1 — pure whole-file moves (R100, zero content change)
```bash
mkdir -p packages/civitai-domain/src
git mv src/server/common/enums.ts packages/civitai-domain/src/enums.ts
git mv src/server/common/moderation-helpers.ts packages/civitai-domain/src/moderation-helpers.ts
git mv src/shared/utils/flags.ts packages/civitai-domain/src/flags.ts
git mv src/shared/constants/browsingLevel.constants.ts packages/civitai-domain/src/browsing-level.ts
git mv src/shared/constants/mime-types.ts packages/civitai-domain/src/mime-types.ts
git mv src/shared/constants/basemodel.constants.ts packages/civitai-domain/src/basemodel.constants.ts
git mv src/utils/type-guards.ts packages/civitai-domain/src/type-guards.ts
git mv src/shared/utils/lazy.ts packages/civitai-domain/src/lazy.ts
git commit -m "refactor(domain): move shared domain contracts into @civitai/domain (pure rename)"
```
Verify before moving on: `git diff --cached -M --stat` shows **R100** for all eight; no content bytes changed. (At this point ~832 call sites of the moved files don't resolve — expected, fixed in Commit 2 by the shims. `report-helpers.ts` and `constants.ts` are untouched here — they're member-extracted in Commit 2.)
### Commit 2 — post-move changes (scaffolding + shims + import rewrites + member extractions)
Everything below is the **"additional changes required after the move."**
---
## Additional changes required after the move
### A. Edits to the moved files
Only **three** of the eight moved files change — `enums`, `moderation-helpers`, `flags`, `type-guards`, `lazy` are import-free and move untouched. A package may not import `~/`, so the consumers rewrite to intra-package relative paths (and to the contract leaf for Prisma enums):
- `packages/civitai-domain/src/{enums,moderation-helpers,flags,type-guards,lazy}.ts`**no change** (all import-free).
- `packages/civitai-domain/src/mime-types.ts`:
```diff
- import { MediaType } from '~/shared/utils/prisma/enums';
+ import { MediaType } from '@civitai/db-schema/enums';
```
- `packages/civitai-domain/src/browsing-level.ts`:
```diff
- import { NsfwLevel } from '~/server/common/enums';
- import { Flags } from '~/shared/utils/flags';
+ import { NsfwLevel } from './enums';
+ import { Flags } from './flags';
```
- `packages/civitai-domain/src/basemodel.constants.ts`:
```diff
- import { ModelType, type MediaType } from '~/shared/utils/prisma/enums';
- import { lazy } from '~/shared/utils/lazy';
- import { isDefined } from '~/utils/type-guards';
+ import { ModelType, type MediaType } from '@civitai/db-schema/enums';
+ import { lazy } from './lazy';
+ import { isDefined } from './type-guards';
```
(Confirm exact named imports against each file; the modules above are their only `~/` imports.)
### A2. Member extractions (carve + re-export)
Neither is a `git mv`; both are content edits in Commit 2.
**`ReportEntity` → `enums.ts`.** `src/shared/utils/report-helpers.ts` is *only* this enum, so:
1. Append the `ReportEntity` enum verbatim to `packages/civitai-domain/src/enums.ts`.
2. Replace `src/shared/utils/report-helpers.ts`'s body with a shim (see §B). Its 30 consumers keep importing `~/shared/utils/report-helpers` unchanged.
**`CacheTTL` → `cache.ts`.** `src/server/common/constants.ts` can't whole-file move (env + feature-flag-service coupled), so carve the member:
1. Create `packages/civitai-domain/src/cache.ts` with the `CacheTTL` const (constants.ts:14541463, zero deps).
2. In `constants.ts`, replace the `export const CacheTTL = {…}` block with a re-export:
```ts
export { CacheTTL } from '@civitai/domain/cache';
```
Every `import { CacheTTL } from '~/server/common/constants'` site keeps working; the rest of `constants.ts` is untouched.
### B. New shim files at the original paths (so the ~862 call sites never change)
One shim per moved file, mirroring the existing `src/shared/utils/prisma/enums.ts` pattern exactly:
```ts
// src/server/common/enums.ts (350 consumers)
// Re-export shim: moved to @civitai/domain. Existing call sites import unchanged.
export * from '@civitai/domain/enums';
```
```ts
// src/shared/utils/report-helpers.ts (30 consumers) — ReportEntity now lives in enums
export { ReportEntity } from '@civitai/domain/enums';
```
```ts
// src/server/common/moderation-helpers.ts (11 consumers)
export * from '@civitai/domain/moderation-helpers';
```
```ts
// src/shared/constants/mime-types.ts (34 consumers)
export * from '@civitai/domain/mime-types';
```
```ts
// src/shared/utils/flags.ts (53 consumers)
export * from '@civitai/domain/flags';
```
```ts
// src/shared/constants/browsingLevel.constants.ts (120 consumers)
export * from '@civitai/domain/browsing-level';
```
```ts
// src/shared/constants/basemodel.constants.ts (85 consumers)
export * from '@civitai/domain/basemodel.constants';
```
```ts
// src/utils/type-guards.ts (174 consumers)
export * from '@civitai/domain/type-guards';
```
```ts
// src/shared/utils/lazy.ts (5 consumers)
export * from '@civitai/domain/lazy';
```
> Note: `src/server/common/constants.ts` imports `./enums` (relative) — it resolves to the shim and keeps working. Same for any in-package consumer using `@civitai/db-schema/enums` directly.
### C. New package barrel
```ts
// packages/civitai-domain/src/index.ts
export * from './enums'; // includes ReportEntity (merged)
export * from './moderation-helpers';
export * from './flags';
export * from './browsing-level';
export * from './mime-types';
export * from './basemodel.constants';
export * from './type-guards';
export * from './lazy';
export * from './cache';
```
> The shims import **subpaths** (`@civitai/domain/enums`, …), not this barrel, so a name collision between two `export *`d modules can't break them — but `pnpm run typecheck` will flag any collision in the barrel itself; resolve with an explicit named re-export if it occurs.
### D. New package scaffolding (mirror `@civitai/db-schema`)
```jsonc
// packages/civitai-domain/package.json
{ "name": "@civitai/domain", "version": "0.0.0", "private": true,
"main": "./src/index.ts", "types": "./src/index.ts",
"dependencies": { "@civitai/db-schema": "workspace:*" } }
```
`packages/civitai-domain/tsconfig.json` — copy from an existing `packages/civitai-*/tsconfig.json`. (The `@civitai/db-schema` tsconfig path already exists at the root, so basemodel's `@civitai/db-schema/enums` import resolves for typecheck.)
### E. Workspace / build wiring (edits to existing config)
1. **Root `tsconfig.json`** — add to `paths` (mirror the db-schema entries):
```jsonc
"@civitai/domain": ["../packages/civitai-domain/src/index"],
"@civitai/domain/*": ["../packages/civitai-domain/src/*"],
```
2. **`next.config.mjs`** — `transpilePackages` is an explicit list (lines 106113); add `'@civitai/domain'`.
3. **Root `package.json`** — add `"@civitai/domain": "workspace:*"` to dependencies (mirror how the other `@civitai/*` packages are declared so the main app resolves the workspace package).
4. `pnpm-workspace.yaml` already globs `packages/*` → **no change**.
5. Run `pnpm install` to link the workspace package.
### F. Verification
```bash
# History survived the move (must trace through the R100 rename):
git log --follow --oneline -- packages/civitai-domain/src/enums.ts
# Types resolve across all ~911 preserved call sites (862 via shims + 49 CacheTTL via the constants re-export):
pnpm run typecheck
```
Spot-check one consumer of each path still compiles: `blocklist.service` → `BlocklistType` **and** `CacheTTL`; any `nsfwLevel` decoder → `browsing-level`/`flags`; a report consumer → `ReportEntity` (now via `enums`); a generation page → `basemodel.constants`; any `isDefined` consumer → `type-guards`.
---
## Growing the package later
`@civitai/domain` is the home for any future hand-authored contract that proves both (a) needed by a second app and (b) drift-unsafe or shared vocabulary, with a clean closure. Each addition uses the **identical Commit-1 (pure `git mv`) / Commit-2 (shim + relative-import rewrite)** procedure above. Likely near-term candidates as more pages port: the legacy `base-model.constants.ts` (same `type-guards`-only closure, if still live), and other generation-config constants once their own closures are verified pure. Do **not** batch-move on suspicion — run the closure check first, exactly as done here. Use a whole-file move when the file *is* the shared unit, or a member extraction (carve + re-export) when only one member is shared.
## Part 2 — Server-side shared surface (service-closure traces)
The §1 sweep covered *direct* page + service imports. This part traces the **full transitive closure of the services behind the 22 pages** — page → tRPC procedure → router → controller → service → everything it pulls in — across four domains (image-moderation; reports/strikes/blocklists/tags; scanner-audit/CSAM; training/models/generation). That's where the cross-app surface actually lives.
### Structural finding (the 80/20)
The moderator server is **~80% cleanly separable** — read queues, simple-writes, Redis config, orchestrator calls — and **~20% entangled** in feed/marketplace machinery. Critically, the clean 80% is **moderator-specific (one consumer)** → it belongs **in `apps/moderator/`, not a package**. The genuinely *shared* surface is narrow. Three patterns recur in every domain:
1. **Extract-clean-slice.** The moderator functions are buried inside giant entangled services but are themselves clean. e.g. CSAM imports `bulkAddBlockedImages` (a ~20-line ClickHouse insert) from the 7,982-line `image.service`; `toggleCannotPublish` / `getTrainingModelsForModerators` are clean slices of `model.service`; `getResources` / ecosystem-config are clean slices of `generation.service`; the image review-queues are clean slices of `image.service`. **Refactor these out into small modules → they move with the moderator app (app-local), not a package.**
2. **A consistent PROXY boundary.** Every domain hits the same wall: `post.service` (feed cache-bust, `updatePostNsfwLevel`), search-index sync, `games/new-order.service` (image-rating game state), `nsfwLevels.service` (article/comic recompute), `rewards`, `buzz.service`, `upsertModel` + EventEngine. These do **not** move — the moderator app calls the main app's tRPC for them.
3. **A thin shared infra/contract surface** both apps need identically (below).
### Classified shared surface — forcing function applied
| Surface | Trace evidence | Verdict |
|---|---|---|
| **Orchestrator client** `server/services/orchestrator/client.ts` (+ `get-orchestrator-token`, `http/orchestrator/*`) | 13 lines: `@civitai/client` + `env/server`. Used by scanner-content, csam, training. Highest cross-domain recurrence. | **NEW infra package `@civitai/orchestrator`** — tiny, clean closure, genuinely shared. The one clear new package. |
| **Persisted JSON-column shapes** — `ModelMeta`(`Model.meta`), `UserMeta`(`User.meta`), `scanContentBody`(`ScannerContentSnapshot.content`), CSAM report payload, `BlocklistDTO` | One app writes the blob, the other reads it → drift = corruption. | **Domain-tier contracts → fold into `@civitai/domain`** (per "Growing the package later"), each after a closure-check / possible schema-file split. |
| **Cross-cutting writes to shared tables** — `moderator.service.trackModActivity` (`ModActivity`), `notification.service.createNotification` (`Notification`, category already shared), `auth/session-invalidation` (shared session cache) | Both apps must write these identically or audit/notify/mute behavior diverges. | **Extract clean slice → shared module** (small; `@civitai/orchestrator`-style) *or* copy. Judgment; the contract part (categories/keys) is already in `@civitai/domain`. |
| **Server utils** — `pagination-helpers` (284 ln; pulls `base.schema`+`qs`+`dayjs`+`env`), `errorHandling` (292 ln; pulls logging+stacktrace) | Recur 4/4 domains. Behavioral consistency, **not** corruption. | **COPY** into the app (or a later `@civitai/server-utils` if a 3rd app appears). Not forced. |
| **Selectors** `server/selectors/*.selector.ts` | Import only `@prisma/client` + sibling selectors — clean cluster. But each app consumes its **own** query results. | **COPY.** Drift ≠ corruption; no forcing function. |
| **tRPC input schemas** `server/schema/*.schema.ts` (the non-persisted parts) + `base.schema` | Per-router validation; each app authors its own. `base.schema` closure now covered by `@civitai/domain`+db-schema. | **COPY** (app-local). Only the persisted-shape rows above are contracts. |
| **S3 / file helpers** `utils/s3-utils`, `file-utils` (CSAM NCMEC archival) | Env-coupled (`S3_*`); generic. | **COPY** (or fold into infra if reused). `http/ncmec/*` is **moderator-only → app-local** (single consumer). |
| **Feed/marketplace services** | post/search-index/new-order/nsfwLevels/rewards/buzz/upsertModel | **PROXY** — stays in main, moderator calls its tRPC. |
### Deliberately NOT doing
The traces tempt a pile of per-domain packages (`@civitai/moderation-enums`, `@civitai/image-moderation-schema`, `@civitai/model-moderator-service`, `@civitai/generation-config-service`, `@civitai/moderator-infra`, …). **Rejected** — same reason as the earlier `moderator-server` proposal: those are one-consumer (moderator-only) → they're `apps/moderator/` code, not packages. The shared surface that crosses *both* apps reduces to: **`@civitai/domain`** (8 whole-file moves + 2 member extractions, plus the persisted-shape contracts as they're closure-checked) and **one new `@civitai/orchestrator`** infra package — everything else is copy, proxy, or app-local extract-clean-slice.
## Open decisions
@dev: two forks before execution:
1. **Package vs. db-schema subpath.** New `@civitai/domain` (recommended — keeps `@civitai/db-schema` purely generated), or fold these hand-authored enums/constants into `@civitai/db-schema` as a subpath (one fewer package, but mixes generated + hand-authored)?
2. **Whole-file `enums.ts` vs. slice.** Move the whole 450-line file (recommended — it's import-free, the shim makes it transparent, and the main app keeps one source of truth), or carve out only the moderator-referenced enums (smaller package surface, but now *two* files define domain enums)?
+643
View File
@@ -0,0 +1,643 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Civitai Monorepo Migration — Guide</title>
<style>
:root {
--bg: #f6f8fb;
--card: #ffffff;
--ink: #1a1b1e;
--muted: #6b7280;
--line: #e6e9ef;
--accent: #1971c2;
--accent-soft: #e7f1fb;
--green: #2f9e44;
--green-soft: #e9f7ee;
--amber: #e8961b;
--amber-soft: #fdf3e2;
--violet: #7048e8;
--violet-soft: #efeafe;
--code-bg: #0f172a;
--code-ink: #e2e8f0;
--radius: 12px;
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
--sans: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
margin: 0; background: var(--bg); color: var(--ink);
font-family: var(--sans); line-height: 1.62; font-size: 16px;
-webkit-font-smoothing: antialiased;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
code { font-family: var(--mono); font-size: 0.86em; }
:not(pre) > code {
background: #eef1f6; color: #324; padding: 0.12em 0.4em;
border-radius: 5px; border: 1px solid #e2e6ee; white-space: nowrap;
}
/* layout */
.wrap { display: grid; grid-template-columns: 256px minmax(0, 1fr); max-width: 1180px; margin: 0 auto; gap: 40px; }
nav.toc {
position: sticky; top: 0; align-self: start; height: 100vh; overflow-y: auto;
padding: 28px 8px 28px 20px; border-right: 1px solid var(--line);
}
nav.toc .brand { font-weight: 800; font-size: 15px; letter-spacing: -0.01em; margin-bottom: 4px; }
nav.toc .brand span { color: var(--accent); }
nav.toc .tag { font-size: 11.5px; color: var(--muted); margin-bottom: 22px; text-transform: uppercase; letter-spacing: 0.06em; }
nav.toc a { display: block; color: #4b5563; font-size: 13.5px; padding: 5px 10px; border-radius: 7px; margin: 1px 0; }
nav.toc a:hover { background: #eef1f6; text-decoration: none; color: var(--ink); }
nav.toc a.sub { padding-left: 22px; font-size: 12.8px; color: #6b7280; }
main { padding: 40px 32px 120px 0; min-width: 0; }
/* hero */
.hero {
background: linear-gradient(135deg, #1971c2 0%, #1098ad 100%);
color: #fff; border-radius: 16px; padding: 38px 40px; margin-bottom: 36px;
box-shadow: 0 12px 30px -12px rgba(25,113,194,.5);
}
.hero h1 { margin: 0 0 8px; font-size: 30px; letter-spacing: -0.02em; }
.hero p { margin: 0; opacity: .94; font-size: 16.5px; max-width: 60ch; }
.badges { margin-top: 20px; display: flex; gap: 10px; flex-wrap: wrap; }
.badge {
background: rgba(255,255,255,.16); border: 1px solid rgba(255,255,255,.28);
padding: 5px 12px; border-radius: 999px; font-size: 12.5px; font-weight: 600;
}
section { margin-bottom: 46px; scroll-margin-top: 24px; }
h2 { font-size: 22px; letter-spacing: -0.01em; margin: 0 0 6px; padding-top: 8px; }
h2 .num { color: var(--accent); font-weight: 800; margin-right: 10px; }
h3 { font-size: 16.5px; margin: 26px 0 8px; }
.lead { color: var(--muted); margin: 0 0 18px; font-size: 15.5px; }
p { margin: 0 0 14px; }
.card { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); padding: 22px 24px; }
/* callouts */
.note { border-left: 4px solid var(--accent); background: var(--accent-soft); padding: 14px 18px; border-radius: 0 10px 10px 0; margin: 16px 0; font-size: 14.5px; }
.note.green { border-color: var(--green); background: var(--green-soft); }
.note.amber { border-color: var(--amber); background: var(--amber-soft); }
.note.violet { border-color: var(--violet); background: var(--violet-soft); }
.note strong { font-weight: 700; }
/* code */
pre {
background: var(--code-bg); color: var(--code-ink); border-radius: 10px;
padding: 18px 20px; overflow-x: auto; font-family: var(--mono);
font-size: 13px; line-height: 1.7; margin: 16px 0; border: 1px solid #1e293b;
}
pre .c { color: #7c8aa5; font-style: italic; } /* comment */
pre .k { color: #93c5fd; } /* keyword */
pre .s { color: #86efac; } /* string */
pre .f { color: #fcd34d; } /* function/type */
pre .p { color: #c4b5fd; } /* punctuation accent */
pre .d { color: #5eead4; } /* decorator/path */
/* tree */
.tree { background: #0f172a; color: #cbd5e1; border-radius: 10px; padding: 20px 22px; font-family: var(--mono); font-size: 12.6px; line-height: 1.75; overflow-x: auto; }
.tree .root { color: #fff; font-weight: 700; }
.tree .pk { color: #fcd34d; } /* package name */
.tree .contract { color: #5eead4; }
.tree .shim { color: #f0abfc; }
.tree .cm { color: #64748b; } /* comment */
.tree .badge2 { color: #94a3b8; }
/* layer diagram */
.layers { display: flex; flex-direction: column; gap: 10px; margin: 18px 0; }
.layer { border-radius: 10px; padding: 14px 18px; border: 1px solid var(--line); position: relative; }
.layer .lt { font-weight: 700; font-size: 14.5px; }
.layer .ld { color: var(--muted); font-size: 13.5px; }
.layer.app { background: #fff; }
.layer.shim { background: #fdf0fe; border-color: #f5d0fe; }
.layer.base { background: var(--accent-soft); border-color: #c7e0f7; }
.layer.contract { background: var(--green-soft); border-color: #c3e8d1; }
.arrow { text-align: center; color: var(--muted); font-size: 13px; margin: -4px 0; }
/* diagram figure */
figure.diagram { margin: 18px 0 8px; }
figure.diagram svg { width: 100%; height: auto; display: block; background: #fbfcfe; border: 1px solid var(--line); border-radius: 12px; }
figure.diagram figcaption { color: var(--muted); font-size: 12.5px; text-align: center; margin-top: 8px; }
/* step flow */
.steps { display: flex; gap: 14px; margin: 18px 0 10px; flex-wrap: wrap; }
.step { flex: 1; min-width: 168px; background: #fff; border: 1px solid var(--line); border-radius: 10px; padding: 14px 16px; position: relative; }
.step::after { content: "→"; position: absolute; right: -13px; top: 50%; transform: translateY(-50%); color: #c2cbd8; font-weight: 700; font-size: 16px; }
.step:last-child::after { content: ""; }
.stepn { display: inline-flex; width: 24px; height: 24px; border-radius: 50%; background: var(--accent); color: #fff; font-size: 13px; font-weight: 700; align-items: center; justify-content: center; margin-bottom: 8px; }
.step h4 { margin: 0 0 4px; font-size: 14px; }
.step p { margin: 0; font-size: 12.8px; color: var(--muted); line-height: 1.5; }
@media (max-width: 640px) { .step::after { content: ""; } }
/* tables */
table { width: 100%; border-collapse: collapse; margin: 16px 0; font-size: 14px; }
th, td { text-align: left; padding: 10px 12px; border-bottom: 1px solid var(--line); vertical-align: top; }
th { font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); }
td code { font-size: 12.5px; }
.pill { display: inline-block; font-size: 11px; font-weight: 700; padding: 2px 9px; border-radius: 999px; }
.pill.ok { background: var(--green-soft); color: var(--green); }
.pill.plan { background: var(--amber-soft); color: var(--amber); }
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
ul.clean { margin: 8px 0 16px; padding-left: 20px; }
ul.clean li { margin: 5px 0; }
hr.soft { border: none; border-top: 1px solid var(--line); margin: 36px 0; }
.foot { color: var(--muted); font-size: 13px; border-top: 1px solid var(--line); padding-top: 18px; }
@media (max-width: 900px) {
.wrap { grid-template-columns: 1fr; }
nav.toc { position: static; height: auto; border-right: none; border-bottom: 1px solid var(--line); }
main { padding: 24px 20px 80px; }
.grid2 { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div class="wrap">
<nav class="toc">
<div class="brand">Civitai <span>Monorepo</span></div>
<div class="tag">Migration Guide</div>
<a href="#overview">Overview</a>
<a href="#layers">The layering model</a>
<a href="#layout">Directory layout</a>
<a href="#rules">Architecture rules</a>
<a href="#anatomy">Anatomy of a package</a>
<a href="#newapp">Using packages in a new app</a>
<a href="#newapp" class="sub">Postgres &amp; Redis</a>
<a href="#newapp" class="sub">Overrides</a>
<a href="#newapp" class="sub">Kysely instead of Prisma</a>
<a href="#domain">The domain layer</a>
<a href="#sharing">Package · copy · proxy</a>
<a href="#reference">Package reference</a>
<a href="#status">Migration status</a>
</nav>
<main>
<div class="hero">
<h1>Civitai Monorepo Migration</h1>
<p>Extracting our infrastructure (Postgres, Redis, ClickHouse, Axiom, telemetry) into app-agnostic packages any future app can reuse — and the <strong>domain contracts</strong> two apps must agree on — without copying connection code, re-validating env, or drifting on shared data.</p>
<div class="badges">
<span class="badge">pnpm workspaces</span>
<span class="badge">6 packages + domain layer</span>
<span class="badge">Factory + injected behavior</span>
<span class="badge">Per-package env schemas</span>
<span class="badge">Moderator app: first consumer</span>
</div>
</div>
<!-- OVERVIEW -->
<section id="overview">
<h2><span class="num">01</span>Overview</h2>
<p class="lead">Why we're doing this, in one paragraph.</p>
<p>Today the main Next.js app owns all of its infrastructure clients directly. As we add more apps (a moderator app, an advertising service, internal tools), each one needs the <em>same</em> Postgres / Redis / ClickHouse connections — and we don't want to copy-paste connection setup, drift on pool tuning, or validate the same env vars three different ways.</p>
<p>The migration moves each piece of infrastructure into its own package under <code>packages/</code>. A package owns <strong>how to connect</strong> (validated env + a factory that builds the client); each app decides <strong>whether and with what behavior</strong> to instantiate it (its own logger, feature flags, etc.). The main app keeps working unchanged through thin <em>shim</em> files at the original import paths.</p>
<div class="note green"><strong>Net result:</strong> a new app gets production-grade DB/Redis/ClickHouse access in a few lines, with the exact same connection behavior and env contract as the main app.</div>
</section>
<!-- LAYERS -->
<section id="layers">
<h2><span class="num">02</span>The layering model</h2>
<p class="lead">Four layers, each only depending downward.</p>
<div class="layers">
<div class="layer app">
<div class="lt">Apps &nbsp;·&nbsp; <span style="font-weight:400">main app (repo root), <code>apps/*</code></span></div>
<div class="ld">Business logic. Imports infrastructure through shims / factories. Owns its own env values, loggers, and policies.</div>
</div>
<div class="arrow">▲ imports</div>
<div class="layer shim">
<div class="lt">App shims &nbsp;·&nbsp; <span style="font-weight:400"><code>src/server/{db,redis,clickhouse,logging,prom}/…</code></span></div>
<div class="ld">Thin app-owned files at the original import paths. Call the factory, inject app behavior, own HMR globals + the Next build guard, re-export the same names so call sites never change.</div>
</div>
<div class="arrow">▲ calls factory</div>
<div class="layer base">
<div class="lt">Base packages &nbsp;·&nbsp; <span style="font-weight:400"><code>@civitai/{db,redis,clickhouse,axiom,telemetry}</code></span></div>
<div class="ld">Infrastructure only. Each owns a zod env schema + a <code>createX()</code> factory. Imports external npm deps only — <strong>never</strong> app code, <strong>never</strong> a sibling base package.</div>
</div>
<div class="arrow">▲ generated types only</div>
<div class="layer contract">
<div class="lt">Contract layer &nbsp;·&nbsp; <span style="font-weight:400"><code>@civitai/db-schema</code> &nbsp;·&nbsp; <code>@civitai/domain</code></span></div>
<div class="ld"><strong>db-schema</strong> — pure <em>generated</em> artifact: Prisma schema, migrations, generated client / enums / models (+ Kysely types). No runtime; a Kysely app uses it without the Prisma runtime. <strong>domain</strong> — pure <em>hand-authored</em> vocabulary both apps must agree on: non-Prisma enums, the <code>nsfwLevel</code> bit-decoders, base-model identity. Both are leaves.</div>
</div>
</div>
<div class="note">The only cross-package edges point <em>downward onto the contract leaves</em>: <code>@civitai/db → @civitai/db-schema</code> (imports the generated Prisma client) and <code>@civitai/domain → @civitai/db-schema</code> (references generated enums). Like depending on <code>@prisma/client</code> — not sibling coupling.</div>
</section>
<!-- LAYOUT -->
<section id="layout">
<h2><span class="num">03</span>Directory layout</h2>
<p class="lead">How the monorepo is organized and how every app shares the same infrastructure.</p>
<figure class="diagram">
<svg viewBox="0 0 900 532" role="img" aria-label="Monorepo: apps share the @civitai/* packages via factories">
<defs>
<marker id="ah" markerWidth="9" markerHeight="9" refX="5.5" refY="3" orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L6,3 L0,6 z" fill="#94a3b8"/>
</marker>
<style>
.lbl{font:700 12px system-ui,-apple-system,"Segoe UI",sans-serif;fill:#64748b;letter-spacing:.07em}
.bt{font:700 15px system-ui,-apple-system,"Segoe UI",sans-serif}
.bs{font:400 11px ui-monospace,Menlo,Consolas,monospace;fill:#64748b}
.pn{font:700 13px system-ui,-apple-system,"Segoe UI",sans-serif}
.nw{font:800 9px system-ui;fill:#c026d3;letter-spacing:.08em}
.fl{font:600 12.5px system-ui,-apple-system,"Segoe UI",sans-serif;fill:#1971c2}
.lg{font:400 11px system-ui;fill:#475569}
</style>
</defs>
<rect x="6" y="6" width="888" height="520" rx="18" fill="#ffffff" stroke="#d8dee9"/>
<text x="28" y="33" class="bt" fill="#0f172a">model-share/ <tspan class="bs" font-size="11.5">monorepo root</tspan></text>
<!-- APPS -->
<text x="40" y="60" class="lbl">APPS · + REPO ROOT</text>
<rect x="40" y="70" width="200" height="72" rx="12" fill="#e7f1fb" stroke="#1971c2" stroke-width="1.6"/>
<text x="140" y="103" text-anchor="middle" class="bt" fill="#13447a">main app</text>
<text x="140" y="123" text-anchor="middle" class="bs">repo root · Next.js</text>
<rect x="348" y="70" width="200" height="72" rx="12" fill="#fbfcfe" stroke="#c026d3" stroke-width="1.5" stroke-dasharray="5 4"/>
<text x="448" y="99" text-anchor="middle" class="bt" fill="#0f172a">moderator</text>
<text x="448" y="117" text-anchor="middle" class="bs">apps/moderator</text>
<text x="448" y="133" text-anchor="middle" class="nw">NEW · DROPS IN HERE</text>
<rect x="656" y="70" width="200" height="72" rx="12" fill="#fbfcfe" stroke="#c026d3" stroke-width="1.5" stroke-dasharray="5 4"/>
<text x="756" y="99" text-anchor="middle" class="bt" fill="#0f172a">ads service</text>
<text x="756" y="117" text-anchor="middle" class="bs">apps/ads</text>
<text x="756" y="133" text-anchor="middle" class="nw">NEW · DROPS IN HERE</text>
<!-- arrows into factory band -->
<line x1="140" y1="144" x2="140" y2="172" stroke="#94a3b8" stroke-width="1.6" marker-end="url(#ah)"/>
<line x1="448" y1="144" x2="448" y2="172" stroke="#94a3b8" stroke-width="1.6" marker-end="url(#ah)"/>
<line x1="756" y1="144" x2="756" y2="172" stroke="#94a3b8" stroke-width="1.6" marker-end="url(#ah)"/>
<!-- factory band -->
<rect x="40" y="176" width="816" height="40" rx="10" fill="#eef5fd" stroke="#c7e0f7"/>
<text x="448" y="201" text-anchor="middle" class="fl">@civitai/* factory layer — createX({ log, …policy }), env-validated on boot</text>
<line x1="448" y1="216" x2="448" y2="240" stroke="#94a3b8" stroke-width="1.6" marker-end="url(#ah)"/>
<!-- PACKAGES -->
<text x="40" y="262" class="lbl">PACKAGES · base packages (infrastructure only)</text>
<g>
<rect x="40" y="272" width="140" height="62" rx="11" fill="#e7f1fb" stroke="#9ac3ea"/>
<text x="110" y="300" text-anchor="middle" class="pn" fill="#13447a">db</text>
<text x="110" y="318" text-anchor="middle" class="bs">Prisma + pg</text>
<rect x="209" y="272" width="140" height="62" rx="11" fill="#e7f1fb" stroke="#9ac3ea"/>
<text x="279" y="300" text-anchor="middle" class="pn" fill="#13447a">redis</text>
<text x="279" y="318" text-anchor="middle" class="bs">cache + sys</text>
<rect x="378" y="272" width="140" height="62" rx="11" fill="#e7f1fb" stroke="#9ac3ea"/>
<text x="448" y="300" text-anchor="middle" class="pn" fill="#13447a">clickhouse</text>
<text x="448" y="318" text-anchor="middle" class="bs">analytics</text>
<rect x="547" y="272" width="140" height="62" rx="11" fill="#e7f1fb" stroke="#9ac3ea"/>
<text x="617" y="300" text-anchor="middle" class="pn" fill="#13447a">axiom</text>
<text x="617" y="318" text-anchor="middle" class="bs">logging</text>
<rect x="716" y="272" width="140" height="62" rx="11" fill="#e7f1fb" stroke="#9ac3ea"/>
<text x="786" y="300" text-anchor="middle" class="pn" fill="#13447a">telemetry</text>
<text x="786" y="318" text-anchor="middle" class="bs">prom + otel</text>
</g>
<!-- contract -->
<line x1="110" y1="334" x2="110" y2="372" stroke="#94a3b8" stroke-width="1.6" marker-end="url(#ah)"/>
<text x="150" y="356" class="bs" fill="#0c8599">generated types only ↓</text>
<rect x="40" y="376" width="816" height="62" rx="11" fill="#e9f7ee" stroke="#9fd6b4"/>
<text x="60" y="404" class="pn" fill="#0b6e3f">◆ db-schema — contract layer</text>
<text x="60" y="423" class="bs">Prisma schema · migrations · generated client / enums / models / Kysely types &nbsp;·&nbsp; tool-agnostic, no runtime</text>
<!-- legend -->
<g transform="translate(40,462)">
<rect x="0" y="0" width="14" height="14" rx="3" fill="#e7f1fb" stroke="#1971c2"/><text x="20" y="11" class="lg">app</text>
<rect x="78" y="0" width="14" height="14" rx="3" fill="#fbfcfe" stroke="#c026d3" stroke-dasharray="4 3"/><text x="98" y="11" class="lg">new app (planned)</text>
<rect x="240" y="0" width="14" height="14" rx="3" fill="#e7f1fb" stroke="#9ac3ea"/><text x="260" y="11" class="lg">base package</text>
<rect x="372" y="0" width="14" height="14" rx="3" fill="#e9f7ee" stroke="#9fd6b4"/><text x="392" y="11" class="lg">contract layer</text>
<text x="520" y="11" class="lg" fill="#1971c2">↑ arrows = dependency direction (downward only)</text>
</g>
</svg>
<figcaption>Every app — the main app today, new apps tomorrow — imports the same <code>@civitai/*</code> packages and builds clients through the factories. New apps slot into <code>apps/</code> with no changes to the packages.</figcaption>
</figure>
<h3>Detailed file tree</h3>
<p class="lead" style="margin-bottom:12px"><span class="pk" style="color:#b8860b"></span> base package &nbsp; <span class="contract" style="color:#0c8599"></span> contract layer &nbsp; <span style="color:#c026d3"></span> app shim.</p>
<div class="tree"><span class="root">model-share/</span> <span class="cm"># repo root = main Next.js app</span>
├─ <span class="pk">apps/</span> <span class="cm"># future apps live here (moderator, …)</span>
├─ <span class="pk">packages/</span>
│ ├─ <span class="contract">◆ civitai-db-schema/</span> <span class="cm"># CONTRACT LAYER (leaf — no @civitai deps)</span>
│ │ ├─ prisma/
│ │ │ ├─ schema.full.prisma <span class="cm"># source of truth (+ generators)</span>
│ │ │ ├─ migrations/ <span class="cm"># applied manually</span>
│ │ │ └─ programmability/
│ │ ├─ generated/client/ <span class="cm"># prisma-client output (gitignored)</span>
│ │ └─ src/
│ │ ├─ enums.ts models.ts <span class="cm"># generated types</span>
│ │ └─ index.ts <span class="cm"># re-exports the Prisma client</span>
│ │
│ ├─ <span class="contract">◆ civitai-domain/</span> <span class="cm"># CONTRACT LAYER · hand-authored vocabulary → db-schema</span>
│ │ └─ src/ enums · browsing-level · flags · mime-types · basemodel · cache
│ │
│ ├─ <span class="pk">● civitai-db/</span> <span class="cm"># Prisma + pg runtime → db-schema</span>
│ │ └─ src/ env · client · db-helpers · kv-helpers · concurrency-helpers
│ ├─ <span class="pk">● civitai-redis/</span> <span class="cm"># src/ env · client</span>
│ ├─ <span class="pk">● civitai-clickhouse/</span> <span class="cm"># src/ env · client</span>
│ ├─ <span class="pk">● civitai-axiom/</span> <span class="cm"># src/ env · client</span>
│ ├─ <span class="pk">● civitai-telemetry/</span> <span class="cm"># src/ client · otel-helpers</span>
│ └─ <span class="pk">● civitai-orchestrator/</span> <span class="cm"># planned · @civitai/client wrapper (scanner/csam/training)</span>
├─ <span class="pk">src/</span> <span class="cm"># main app</span>
│ └─ server/
│ ├─ <span class="shim">▸ db/</span> client · pgDb · notifDb · datapacketDb · db-helpers
│ ├─ <span class="shim">▸ redis/</span> client <span class="cm">(+ cache files: caches, queues, …)</span>
│ ├─ <span class="shim">▸ clickhouse/</span> client <span class="cm">+ tracker.ts (app-owned)</span>
│ ├─ <span class="shim">▸ logging/</span> client <span class="cm">(axiom shim)</span>
│ └─ <span class="shim">▸ prom/</span> client <span class="cm">(telemetry shim + pg pool gauges)</span>
├─ pnpm-workspace.yaml <span class="cm"># packages: ['.', 'packages/*', 'apps/*']</span>
├─ tsconfig.json <span class="cm"># @civitai/* path mappings</span>
└─ next.config.mjs <span class="cm"># transpilePackages: ['@civitai/*']</span></div>
<div class="note amber"><strong>The main app stays at the repo root.</strong> We deliberately did <em>not</em> move it into <code>apps/main/</code> — that would be a giant freeze-week move for little gain. New apps go in <code>apps/</code>; the main app stays put.</div>
</section>
<!-- RULES -->
<section id="rules">
<h2><span class="num">04</span>Architecture rules</h2>
<p class="lead">The handful of rules that keep packages reusable. Follow these when adding a package.</p>
<div class="grid2">
<div class="card">
<h3>① Packages import external deps only</h3>
<p style="margin:0;font-size:14.5px">No <code>~/…</code> app imports. No sibling base package. The moment a file needs app code, it's a <em>consumer</em> of infra, not infra — it stays in the app (or a higher-level package).</p>
</div>
<div class="card">
<h3>② Env values → the package's schema</h3>
<p style="margin:0;font-size:14.5px">Each package owns a <code>env.ts</code> zod schema (mirrors the app's <code>server-schema.ts</code>) and reads <code>process.env</code> through it. Validated on deploy. Connection config never crosses the boundary as plain arguments.</p>
</div>
<div class="card">
<h3>③ App behavior → injected as functions</h3>
<p style="margin:0;font-size:14.5px">Loggers, the Flipt failover resolver, the slow-query → Axiom sink: these are <em>functions/policy</em> the app owns, passed into the factory. Not env, not baked into the package.</p>
</div>
<div class="card">
<h3><code>isProd</code> in package, <code>isBuild</code> in shim</h3>
<p style="margin:0;font-size:14.5px"><code>NODE_ENV</code> is universal, so packages may read it. Detecting <code>next build</code> is a Next-specific concern, so the build guard (<code>env.IS_BUILD ? skip : createX()</code>) lives in the app shim.</p>
</div>
</div>
<div class="note violet" style="margin-top:18px"><strong>Mnemonic:</strong> a package answers <em>“how do I connect?”</em> (env + factory). An app answers <em>“should I, and with what behavior?”</em> (build guard + injected logger/policy + globals).</div>
</section>
<!-- ANATOMY -->
<section id="anatomy">
<h2><span class="num">05</span>Anatomy of a package</h2>
<p class="lead">Every package is the same three pieces. Here's <code>@civitai/redis</code> end to end.</p>
<h3>1 · <code>env.ts</code> — package-owned, validated config</h3>
<pre><span class="c">// packages/civitai-redis/src/env.ts</span>
<span class="k">import</span> * <span class="k">as</span> z <span class="k">from</span> <span class="s">'zod'</span>;
<span class="k">const</span> schema = z.<span class="f">object</span>({
<span class="f">REDIS_URL</span>: z.<span class="f">url</span>(),
<span class="f">REDIS_SYS_URL</span>: z.<span class="f">url</span>(),
<span class="f">REDIS_TIMEOUT</span>: z.<span class="f">preprocess</span>((x) => (x ? <span class="f">parseInt</span>(<span class="f">String</span>(x)) : <span class="p">5000</span>), z.<span class="f">number</span>().<span class="f">optional</span>()),
<span class="f">REDIS_CLUSTER</span>: z.<span class="f">preprocess</span>((x) => x === <span class="s">'true'</span>, z.<span class="f">boolean</span>().<span class="f">default</span>(<span class="k">false</span>)),
<span class="c">/* … cluster nodes, refresh interval, flipt context … */</span>
});
<span class="k">const</span> parsed = schema.<span class="f">safeParse</span>(process.env);
<span class="k">if</span> (!parsed.success)
<span class="k">throw new</span> <span class="f">Error</span>(<span class="s">'[@civitai/redis] Invalid env:\n'</span> + z.<span class="f">prettifyError</span>(parsed.error));
<span class="c">// Normalized defaults the factory can override per call.</span>
<span class="k">export const</span> redisEnv = { url: parsed.data.<span class="f">REDIS_URL</span>, <span class="c">/* … */</span> };
<span class="k">export type</span> <span class="f">RedisConfig</span> = <span class="k">typeof</span> redisEnv;</pre>
<h3>2 · <code>client.ts</code> — the factory (reads env, injects behavior)</h3>
<pre><span class="c">// packages/civitai-redis/src/client.ts</span>
<span class="k">export function</span> <span class="f">createRedisClients</span>(
options: <span class="f">Partial</span>&lt;<span class="f">RedisConfig</span>&gt; & {
log?: <span class="f">RedisLogFn</span>; <span class="c">// injected app logger</span>
isEnhancedFailoverEnabled?: <span class="f">RedisFailoverResolver</span>; <span class="c">// injected Flipt policy</span>
} = {}
): { redis: <span class="f">CustomRedisClientCache</span>; sysRedis: <span class="f">CustomRedisClientSys</span> } {
<span class="k">const</span> config = { ...redisEnv, ...envOverrides }; <span class="c">// env defaults + per-call overrides</span>
<span class="c">// build cache + sys clients from config; failover uses the injected resolver</span>
<span class="k">return</span> { redis, sysRedis };
}
<span class="k">export const</span> REDIS_KEYS = { <span class="c">/* … key definitions stay in the package … */</span> };</pre>
<h3>3 · The app shim — wires reality, owns globals</h3>
<pre><span class="c">// src/server/redis/client.ts (main app — original import path preserved)</span>
<span class="k">import</span> { createRedisClients } <span class="k">from</span> <span class="s">'@civitai/redis/client'</span>;
<span class="k">export</span> * <span class="k">from</span> <span class="s">'@civitai/redis/client'</span>; <span class="c">// re-export keys, types, factory</span>
<span class="k">const</span> make = () => <span class="f">createRedisClients</span>({
log: <span class="f">createLogger</span>(<span class="s">'redis'</span>, <span class="s">'green'</span>), <span class="c">// THIS app's logger</span>
isEnhancedFailoverEnabled: (ctx) => <span class="f">isFlipt</span>(FLAG, <span class="s">'redis-cluster'</span>, ctx),
});
<span class="c">// build guard (Next-specific) + HMR singleton both live here, not in the package</span>
<span class="k">const</span> clients = env.<span class="f">IS_BUILD</span> ? EMPTY : isProd ? <span class="f">make</span>() : (global.__redis ??= <span class="f">make</span>());
<span class="k">export const</span> { redis, sysRedis } = clients;</pre>
<div class="note green">Because the shim re-exports <code>redis</code>, <code>sysRedis</code>, and <code>REDIS_KEYS</code> under the same names, <strong>every existing <code>import { redis } from '~/server/redis/client'</code> call site keeps working unchanged.</strong></div>
</section>
<!-- NEW APP -->
<section id="newapp">
<h2><span class="num">06</span>Using base packages in a new app</h2>
<p class="lead">This is the payoff. A new app in <code>apps/</code> gets the same infrastructure in a few lines.</p>
<div class="steps">
<div class="step"><span class="stepn">1</span><h4>Scaffold</h4><p>Create <code>apps/moderator/</code> with a <code>package.json</code>. <code>pnpm-workspace.yaml</code> already globs <code>apps/*</code>, so it's picked up automatically.</p></div>
<div class="step"><span class="stepn">2</span><h4>Configure env</h4><p>Set the vars each package validates (<code>DATABASE_URL</code>, <code>REDIS_URL</code>, …). Missing → fail fast on boot.</p></div>
<div class="step"><span class="stepn">3</span><h4>Call factories</h4><p>Instantiate clients with <code>createX({ log })</code>, injecting this app's logger &amp; policies.</p></div>
<div class="step"><span class="stepn">4</span><h4>Use &amp; ship</h4><p>Same typed API as the main app — no connection code, pooling, or env validation to rewrite.</p></div>
</div>
<h3>Step 1 · Set the env vars (same names the packages validate)</h3>
<p>Each package validates its own slice of <code>process.env</code> on boot. Provide the same variable names the main app uses — the package's schema enforces them.</p>
<pre><span class="c"># apps/moderator/.env</span>
DATABASE_URL=postgres://…
DATABASE_REPLICA_URL=postgres://…
REDIS_URL=redis://…
REDIS_SYS_URL=redis://…
<span class="c"># missing/invalid → the app fails fast on boot with a clear message</span></pre>
<h3>Step 2 · Build the clients via the factories</h3>
<pre><span class="c">// apps/moderator/src/db.ts</span>
<span class="k">import</span> { createPrismaClients } <span class="k">from</span> <span class="s">'@civitai/db'</span>;
<span class="k">export const</span> { dbRead, dbWrite } = <span class="f">createPrismaClients</span>(); <span class="c">// env supplies the config</span>
<span class="c">// apps/moderator/src/redis.ts</span>
<span class="k">import</span> { createRedisClients } <span class="k">from</span> <span class="s">'@civitai/redis'</span>;
<span class="k">export const</span> { redis, sysRedis } = <span class="f">createRedisClients</span>({
log: myAppLogger, <span class="c">// inject YOUR logger</span>
<span class="c">// no Flipt in this app? omit it — enhanced failover stays off by default</span>
});
<span class="c">// apps/moderator/src/clickhouse.ts</span>
<span class="k">import</span> { createClickhouseClient } <span class="k">from</span> <span class="s">'@civitai/clickhouse'</span>;
<span class="k">export const</span> clickhouse = <span class="f">createClickhouseClient</span>({ log: myAppLogger });</pre>
<h3>Step 3 · Use them — identical typed API to the main app</h3>
<pre><span class="k">import</span> { dbRead } <span class="k">from</span> <span class="s">'./db'</span>;
<span class="k">import</span> { redis, REDIS_KEYS } <span class="k">from</span> <span class="s">'./redis'</span>;
<span class="k">const</span> user = <span class="k">await</span> dbRead.user.<span class="f">findUnique</span>({ where: { id } });
<span class="k">await</span> redis.packed.<span class="f">set</span>(REDIS_KEYS.SOMETHING, value);</pre>
<div class="note green"><strong>What you did <em>not</em> do:</strong> write pool tuning, re-implement the typed Redis wrapper, set up the TIMESTAMP parser, or re-declare env validation. The package owns all of that — so every app connects the same way.</div>
<h3 id="overrides">Per-call overrides</h3>
<p>Env supplies defaults; the factory accepts a <code>Partial&lt;Config&gt;</code> to override any of them — handy for tests, multi-instance setups, or an alternate config source.</p>
<pre><span class="c">// override an env default for this instance only</span>
<span class="k">const</span> logger = <span class="f">createAxiomLogger</span>({ datastream: <span class="s">'moderator-logs'</span> });
<span class="c">// point a test at a throwaway DB without touching process.env</span>
<span class="k">const</span> { dbWrite } = <span class="f">createPrismaClients</span>({ databaseUrl: TEST_DB_URL, replicaUrl: TEST_DB_URL });</pre>
<h3 id="kysely">Prefer Kysely over Prisma? Use the contract layer directly</h3>
<p>This is why <code>@civitai/db-schema</code> is split out from <code>@civitai/db</code>. The schema is the source of truth; the Prisma <em>runtime</em> is just one way to query it. An app that wants Kysely (as our advertising service does) consumes the generated types and brings its own query builder — <strong>without</strong> the Prisma-client runtime.</p>
<pre><span class="c">// 1. add a prisma-kysely generator to schema.full.prisma (one block):</span>
<span class="c">// generator kysely { provider = "prisma-kysely" output = "../src/kysely" }</span>
<span class="c">// 2. apps/ads/src/db.ts — Kysely over a raw pg pool, typed by the same schema</span>
<span class="k">import</span> { Kysely, PostgresDialect } <span class="k">from</span> <span class="s">'kysely'</span>;
<span class="k">import type</span> { <span class="f">DB</span> } <span class="k">from</span> <span class="s">'@civitai/db-schema/kysely'</span>;
<span class="k">export const</span> db = <span class="k">new</span> <span class="f">Kysely</span>&lt;<span class="f">DB</span>&gt;({ dialect: <span class="k">new</span> <span class="f">PostgresDialect</span>({ pool }) });</pre>
<div class="note amber"><strong>Status:</strong> the <code>prisma-kysely</code> generator isn't wired into the schema yet — this is the supported path for the first Kysely-based app to turn on. Migrations &amp; enums/models already generate from the same schema today.</div>
</section>
<!-- DOMAIN -->
<section id="domain">
<h2><span class="num">07</span>The domain layer — sharing more than infrastructure</h2>
<p class="lead">Infra was the easy part. The first new app forces a second, subtler kind of sharing.</p>
<p>Standing up the <strong>moderator app</strong> — the first app to run its <em>own</em> routers against the <em>shared</em> database — surfaced a new class of shared code. Beyond connections, two apps that write the same tables must agree on the hand-authored <em>meaning</em> of those columns: which bit of <code>nsfwLevel</code> is R-rated, what <code>BlocklistType.LinkDomain</code> equals, which <code>ReportEntity</code> keys the <code>Report</code> table. Keep two copies and they drift — and drift on a shared column is silent data corruption.</p>
<div class="note green"><strong><code>@civitai/domain</code></strong> — a new pure, hand-authored contract package, peer to <code>@civitai/db-schema</code>. It holds the vocabulary both apps must agree on: non-Prisma enums (<code>BlocklistType</code>, <code>NotificationCategory</code>, <code>NsfwLevel</code>, <code>ReportEntity</code>, <code>unpublishReasons</code>), the <code>nsfwLevel</code> bit-decoders (browsing levels&nbsp;+&nbsp;<code>Flags</code>), file-ext ↔ <code>MediaType</code> maps, and base-model / ecosystem identity. Its only edge is the downward dep <code>@civitai/domain → @civitai/db-schema</code>.</div>
<h3>The forcing function</h3>
<p>The rule for what becomes a package is deliberately narrow — not “is it pure” or “is it reused,” but:</p>
<div class="note violet"><strong>Extract to a package only when duplicating the code would corrupt shared data</strong> — a hand-authored contract on a column both apps read or write. Everything else is <em>copied</em> into the app (generic utils, display strings) or <em>left in the main app</em> behind its API (feed / marketplace machinery). <code>src/shared/</code> is a client/server boundary <em>within</em> the main app — orthogonal to cross-app packaging.</div>
<h3>Two ways code enters the package</h3>
<div class="grid2">
<div class="card">
<h3 style="margin-top:0">① Whole-file move</h3>
<p style="margin:0;font-size:14.5px">The file <em>is</em> the shared unit. Pure <code>git mv</code> (R100 rename, history preserved), then a re-export shim at the old path so every existing call site keeps working. Used for <code>enums</code>, <code>browsingLevel.constants</code>, <code>flags</code>, <code>mime-types</code>, <code>basemodel.constants</code>.</p>
</div>
<div class="card">
<h3 style="margin-top:0">② Member extraction</h3>
<p style="margin:0;font-size:14.5px">A small shared member is buried in an app-coupled file (e.g. <code>CacheTTL</code> inside a 1,700-line env-coupled <code>constants.ts</code>). Carve out <em>just that member</em>, leave a re-export behind — the coupled file never moves. A content edit, not a rename.</p>
</div>
</div>
</section>
<!-- SHARING -->
<section id="sharing">
<h2><span class="num">08</span>Package · copy · proxy</h2>
<p class="lead">How every dependency of a new app gets sorted — demonstrated by the moderator app.</p>
<p>Tracing the moderator pages through their tRPC routers and services showed the server is <strong>~80% cleanly separable</strong> (read queues, simple writes, Redis config, orchestrator calls) and <strong>~20% entangled</strong> in feed / marketplace machinery. Crucially, that clean 80% is <em>moderator-only</em> — one consumer — so it lives <strong>in the app</strong>, not a package. Every remaining dependency sorts into one of three buckets:</p>
<div class="layers">
<div class="layer base">
<div class="lt">📦 Package &nbsp;·&nbsp; <span style="font-weight:400">shared contract — duplication corrupts data</span></div>
<div class="ld"><code>@civitai/domain</code> (the vocabulary above) · persisted JSON-column shapes (<code>ModelMeta</code><code>Model.meta</code>, <code>UserMeta</code><code>User.meta</code>) · the 13-line <code>@civitai/orchestrator</code> client. <em>Both apps import one source of truth.</em></div>
</div>
<div class="layer app">
<div class="lt">📋 Copy &nbsp;·&nbsp; <span style="font-weight:400">generic — drift is harmless</span></div>
<div class="ld">Prisma selectors, tRPC input schemas, <code>pagination-helpers</code>, <code>errorHandling</code>, display strings. Each app keeps its own — a mismatch is cosmetic, never corrupting.</div>
</div>
<div class="layer shim">
<div class="lt">🔌 Proxy &nbsp;·&nbsp; <span style="font-weight:400">entangled — stays in the main app</span></div>
<div class="ld">Feed cache-busting, search-index sync, the image-rating game, rewards, the buzz ledger, <code>upsertModel</code>. The moderator app calls the main apps tRPC instead of owning these.</div>
</div>
</div>
<div class="note amber"><strong>Extract-clean-slice.</strong> The moderators functions are often buried inside giant entangled services — e.g. CSAM needs a ~20-line ClickHouse insert from the 7,982-line <code>image.service</code>. The fix is to lift the clean slice out into a small module; because it has one consumer, it travels <em>with the moderator app</em>, not into a package.</div>
<div class="note green"><strong>Net result:</strong> the entire moderator app adds just <strong>two</strong> packages on top of the six already shipped — <code>@civitai/domain</code> (shared vocabulary) and <code>@civitai/orchestrator</code> (the generation client). Everything else is the apps own code, a copy, or a call back to the main app.</div>
</section>
<!-- REFERENCE -->
<section id="reference">
<h2><span class="num">09</span>Package reference</h2>
<table>
<thead><tr><th>Package</th><th>Provides</th><th>Inject</th><th>Env it owns</th></tr></thead>
<tbody>
<tr>
<td><code>@civitai/db-schema</code><br><span class="pill plan">contract</span></td>
<td>Prisma schema, migrations, generated client + enums + models (+ Kysely types)</td>
<td></td>
<td><code>DATABASE_URL</code> (generate/migrate)</td>
</tr>
<tr>
<td><code>@civitai/domain</code><br><span class="pill plan">contract</span></td>
<td>Hand-authored domain vocabulary — non-Prisma enums, <code>nsfwLevel</code> bit-decoders (browsing levels + <code>Flags</code>), <code>mime-types</code>, base-model / ecosystem identity</td>
<td></td>
<td>— (no connection/env; deps on <code>@civitai/db-schema</code>)</td>
</tr>
<tr>
<td><code>@civitai/db</code><br><span class="pill ok">factory</span></td>
<td><code>createPrismaClients()</code> · <code>getClient()</code> pg-pool factory · SQL helpers</td>
<td><code>onSlowQuery</code>, pool <code>log</code></td>
<td><code>DATABASE_*</code>, <code>NOTIFICATION_DB_*</code>, <code>DATAPACKET_*</code>, pool sizing/timeouts</td>
</tr>
<tr>
<td><code>@civitai/redis</code><br><span class="pill ok">factory</span></td>
<td><code>createRedisClients()</code> · typed client wrapper · <code>REDIS_KEYS</code></td>
<td><code>log</code>, <code>isEnhancedFailoverEnabled</code></td>
<td><code>REDIS_URL</code>, <code>REDIS_SYS_URL</code>, cluster &amp; timeout vars</td>
</tr>
<tr>
<td><code>@civitai/clickhouse</code><br><span class="pill ok">factory</span></td>
<td><code>createClickhouseClient()</code> · <code>$query</code>/<code>$exec</code> helpers</td>
<td><code>log</code></td>
<td><code>CLICKHOUSE_HOST/USERNAME/PASSWORD</code></td>
</tr>
<tr>
<td><code>@civitai/axiom</code><br><span class="pill ok">factory</span></td>
<td><code>createAxiomLogger()</code><code>logToAxiom</code> · <code>safeError</code></td>
<td>— (config via env/overrides)</td>
<td><code>AXIOM_*</code>, <code>PODNAME</code>, <code>LOG_ERRORS_TO_STDOUT</code></td>
</tr>
<tr>
<td><code>@civitai/telemetry</code><br><span class="pill ok">helpers</span></td>
<td><code>registerCounter/Gauge/Histogram</code> · <code>withSpan</code> (otel)</td>
<td>— (stateless)</td>
<td>— (no connection/env)</td>
</tr>
<tr>
<td><code>@civitai/orchestrator</code><br><span class="pill plan">planned</span></td>
<td><code>createOrchestratorClient(token)</code> — 13-line wrapper over the <code>@civitai/client</code> SDK; needed by scanner / CSAM / training</td>
<td>— (token via env)</td>
<td><code>ORCHESTRATOR_ENDPOINT</code>, <code>ORCHESTRATOR_ACCESS_TOKEN</code>, <code>ORCHESTRATOR_MODE</code></td>
</tr>
</tbody>
</table>
</section>
<!-- STATUS -->
<section id="status">
<h2><span class="num">10</span>Migration status</h2>
<ul class="clean">
<li><span class="pill ok">done</span> &nbsp;Pure file relocation of infra into <code>packages/*</code> (history preserved as renames).</li>
<li><span class="pill ok">done</span> &nbsp;Workspace bootstrap — <code>pnpm-workspace.yaml</code>, per-package <code>package.json</code>, <code>@civitai/*</code> tsconfig paths, <code>transpilePackages</code>.</li>
<li><span class="pill ok">done</span> &nbsp;<code>@civitai/db-schema</code> contract package (schema + migrations + generated enums/models).</li>
<li><span class="pill ok">done</span> &nbsp;Factory + per-package env schema for all five base packages; main app wired through shims; <strong>full typecheck passes</strong>.</li>
<li><span class="pill ok">done</span> &nbsp;Moderator-app dependency analysis — service-closure traces, the package/copy/proxy boundary, and the minimal <code>@civitai/domain</code> extraction plan (<code>docs/moderator-app-package-extraction-plan.md</code>).</li>
<li><span class="pill plan">next</span> &nbsp;Runtime verification (dev-server boot) to exercise env parsing + factory wiring.</li>
<li><span class="pill plan">next</span> &nbsp;Extract <code>@civitai/domain</code> (8 whole-file moves + 2 member extractions) per the plan.</li>
<li><span class="pill plan">later</span> &nbsp;Carve out <code>@civitai/orchestrator</code>; closure-check the persisted-shape schemas (<code>ModelMeta</code>, <code>UserMeta</code>, …).</li>
<li><span class="pill plan">later</span> &nbsp;Wire the <code>prisma-kysely</code> generator when the first Kysely-based app needs it.</li>
<li><span class="pill plan">later</span> &nbsp;Stand up the moderator app under <code>apps/moderator/</code> using the factories + <code>@civitai/domain</code>.</li>
</ul>
<div class="foot">
Civitai Monorepo Migration · generated from the in-repo plan (<code>docs/monorepo-conversion-plan.md</code>,
<code>docs/monorepo-package-adaptation-plan.md</code>). Questions → the platform channel.
</div>
</section>
</main>
</div>
</body>
</html>