mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
docs: monorepo migration guide + moderator boundary analysis
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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:1454–1463, 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 106–113); 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)?
|
||||
@@ -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 & 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 · <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 · <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 · <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 · <span style="font-weight:400"><code>@civitai/db-schema</code> · <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 · 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 <span class="contract" style="color:#0c8599">◆</span> contract layer <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><<span class="f">RedisConfig</span>> & {
|
||||
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 & policies.</p></div>
|
||||
<div class="step"><span class="stepn">4</span><h4>Use & 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<Config></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><<span class="f">DB</span>>({ 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 & 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 + <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 · <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 · <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 · <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 app’s tRPC instead of owning these.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="note amber"><strong>Extract-clean-slice.</strong> The moderator’s 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 app’s 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 & 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> Pure file relocation of infra into <code>packages/*</code> (history preserved as renames).</li>
|
||||
<li><span class="pill ok">done</span> 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> <code>@civitai/db-schema</code> contract package (schema + migrations + generated enums/models).</li>
|
||||
<li><span class="pill ok">done</span> 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> 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> Runtime verification (dev-server boot) to exercise env parsing + factory wiring.</li>
|
||||
<li><span class="pill plan">next</span> Extract <code>@civitai/domain</code> (8 whole-file moves + 2 member extractions) per the plan.</li>
|
||||
<li><span class="pill plan">later</span> 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> Wire the <code>prisma-kysely</code> generator when the first Kysely-based app needs it.</li>
|
||||
<li><span class="pill plan">later</span> 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>
|
||||
Reference in New Issue
Block a user