Files
CherryHQ-cherry-studio/docs/references/shared-layer-architecture.md
fullex 7cbf30222d refactor(shared-error): route error cluster to real homes; drop dead @shared AI-SDK guards
- Slim @shared/types/error.ts to the cross-process SerializedError shape
- Relocate serializeError (+ toSerializable) to src/main/ai/utils/serializeError.ts;
  re-point the 4 main/ai consumers
- Delete the @shared-side dead AI-SDK subtype family + 23 isSerialized* guards
  (utils/error.ts) and the unused ProviderSpecificError class
  (renderer keeps its own live parallel copy)
- Move the serializeError test to main/ai/utils; assert discriminant fields directly
  instead of via the deleted guards
- Doc: mark the section 6 error/serializable row resolved
2026-06-27 06:16:26 -07:00

113 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Shared Layer Architecture (`src/shared` / `@shared`)
This is the canonical reference for what belongs in `@shared`, how it is organized, and the rules that keep its top level from sprawling. It owns the `@shared`-internal rules; [Architecture Overview](./architecture-overview.md), [Renderer Architecture](./renderer-architecture.md), and [Naming Conventions](./naming-conventions.md) reference it.
`@shared` is the **cross-process primitive layer** — Layer 4 in [Renderer Architecture §2](./renderer-architecture.md). It depends on no app code and is importable by `main`, `renderer`, and `preload`.
## 1. Two Invariants
Everything in `@shared` must satisfy **both**, or it does not belong here.
### 1.1 Cross-process
A module belongs in `@shared` only if **both** `main` and `renderer` actually use it — types included, with one deliberate carve-out: the Cache schema registry (§1.1.1).
- **Why**: `@shared` is the single source of truth shared across the process boundary; single-process code already has a process to live in.
- Reachable from only one process → it lives in that process's own layer (`src/main/*` or `src/renderer/{utils,hooks,services}`).
- **No speculative placement.** If something only *might* become cross-process, write it in `main`/`renderer` first and move it here once it actually crosses. Do not park it in `@shared` for a possibility — the common failure is a type or util added "in case", then never used cross-process and left as cruft.
#### 1.1.1 Carve-out: the Cache schema registry
The Cache subsystem is the one exception to §1.1. Every Cache **key schema and its value type** lives in `@shared/data/cache/` (`cacheSchemas.ts` + `cacheValueTypes.ts`) **regardless of which process consumes it** — including renderer-only types (`Tab`, `ChatScrollAnchor`, `AgentOpenExternalAppTarget`, …). A renderer-only cache value type here is **compliant, not a §1.1 violation** — do not flag or relocate it. Cache subsystem only; §1.1 holds everywhere else.
### 1.2 No mutable runtime state
`@shared` exports **types, pure functions, and immutable data only**. It exports **no class-instance singletons** (services / managers / registries) and nothing that holds runtime-mutable state.
- **Why**: `main` and `renderer` are separate V8 realms; an `@shared` module is loaded **once per process**. A "shared singleton" is a fiction — it silently becomes N per-process instances that diverge. Mutable state has no coherent shared owner; it belongs to the process whose lifecycle and context it reflects.
- **`new` is not the test — runtime mutability + identity is.** `new` is allowed only to build immutable data that is then frozen and exported (a `Map` / `Set` / `RegExp` lookup built once from static data and never mutated, e.g. the private `commandMap` in `command/definitions.ts`).
- A stateful class ships only its **definition (blueprint)** from `@shared`; its **instance** is created per-process. Example: `ContextKeyService` is defined cross-process, but `new ContextKeyService()` lives in the renderer's `CommandContextKeyProvider`.
| Allowed | Banned |
|---|---|
| `type` / `interface` / `enum`, schema-derived types | `export const x = new XService()` (any exported instance singleton) |
| pure functions, predicates, converters | a registry / manager / service instance |
| immutable data — consts, definitions, frozen lookups built via `new Map`/`Set` | any module-level value holding runtime-mutable state |
| stateful-class **definitions** (blueprints) | a live **instance** of such a class |
## 2. The Closed Top-Level Set
The top level is a **closed set** — this is [Naming Conventions §4.8](./naming-conventions.md) (top-level closed by default) applied to `@shared`. Exactly these five, by three principled categories:
| Dir | Category | Why it earns a top-level home |
|---|---|---|
| `ai` | **Core domain** | Cherry Studio *is* an AI product; AI's cross-process contracts and pure logic are first-class (mirrors `src/main/ai/`). Holds AI's cross-process slice only — not AI UI or per-process services. |
| `data` | **Cross-process infra** | The data layer's cross-process contracts: API entity/request types, cache/preference/bootConfig schemas, migration mappings, presets. Framework-like, domain-agnostic. |
| `ipc` | **Cross-process infra** | The IpcApi framework: route `define` helpers, request + event schemas, error model, and shared types (`IpcContext`, `WindowId`). Domain-agnostic. |
| `types` | **Shape bucket** | Cross-process type declarations with no single owner. |
| `utils` | **Shape bucket** | Cross-process pure logic plus its supporting constants and class blueprints. |
**Governance rule**: a new capability **never** earns a new top-level dir. It is either (a) the core domain [only `ai`], (b) genuine cross-process infrastructure, or (c) decomposed **by shape** into `types` / `utils`. Anything else → `types` / `utils`.
Naming respects [§4.9](./naming-conventions.md): `ai` / `data` / `ipc` are singular namespaces, `types` / `utils` are plural buckets.
## 3. Shape: `types` vs `utils`
`@shared` has only **two** shape buckets. With no UI, no React, and no per-process runtime, the renderer's rich shapes (`components` / `hooks` / `services` / `pages`) collapse to **declarations vs pure logic**.
| `types/` | `utils/` |
|---|---|
| type aliases, interfaces, enums, schema-derived types | pure functions, predicates, converters |
| (plus the small consts a type needs) | plus the constants / static data those functions need, and stateful-class blueprints |
Routing between the two follows the [Naming Conventions §5.2](./naming-conventions.md) route-by-shape table.
### 3.1 File vs subdirectory, and barrels
- **A single `.ts` file is the default.** Most topics are one file — `types/<topic>.ts`, `utils/<topic>.ts`, imported directly. Promote to a subdirectory only when the topic actually owns multiple files ([Naming Conventions §4.4](./naming-conventions.md)); never pre-create one.
- **A topic subdirectory has exactly one `index.ts`** as its public API — `types/<topic>/index.ts`, `utils/<topic>/index.ts`, explicit named exports, no `export *`. The import surface is then identical whether the topic is a file or a subdir (`@shared/utils/<topic>` either way), and the subdir's other files stay private behind it.
- **The bucket roots `types/` and `utils/` have no `index.ts`.** A bucket is a category, not a module — a root barrel re-exporting every file buys no aggregate API and only adds churn and import-cycle risk on every addition. Import the specific file or topic, never the bucket.
- **`types/` has no runtime tests.** A declarations bucket has no runtime behavior to test, so a *behavioral* test under `types/` (`expect(fn(...))…`) signals the file holds logic — a predicate, type guard, converter, factory, or function — that belongs in `utils/` (route-by-shape, §3). Move the logic to `utils/<topic>.ts` (importing the types it needs from `types/`, the blessed `utils → types` direction) and the test follows it. Type guards (`x is T`) are runtime predicates too — co-locate them with the logic in `utils/`, not with the interface in `types/`. Schemas built from a validator function (`z.custom(isFoo)`) follow the function to `utils/`; a purely declarative schema (`z.object({…})`) may stay in `types/`. The **one** test that does belong in `types/` is a **type-level test** (`expectTypeOf` / `assertType`): it asserts a type contract itself and has no runtime to relocate. Such a test is a **transitional guard** — it earns its place only while a hand-written type is the source of truth; once a runtime schema (Zod / IpcApi) owns the contract and the type is `z.infer`-derived, the schema's own validation subsumes it and the type-level test retires with that migration.
### 3.2 Constants & static data
- **Default: a constant lives in its domain/topic single-file**, beside the logic it serves (AI model defaults → `ai/`; a file-type list → `utils/file/`).
- **`utils/constants.ts` is NOT a bucket.** It carries only the genuinely **global, cross-process** residue (`KB`/`MB`/`GB`, `APP_NAME`). Add to it only when you are 100% certain a constant is app-global and cross-cutting; if it belongs to any domain, it goes in that domain's file. — **Why**: this is precisely the guardrail the old `config/constant.ts` lacked, which is how it grew into an 82-importer junk drawer (now dissolved — §6).
- **Single-process constants → leave `@shared`** (Invariant 1.1).
- **There is no `config/` bucket.** A constant is data; a frozen value in its domain file (or `utils/`) expresses everything a `config/` dir would, without inviting unrelated globals.
### 3.3 Stateful-class blueprints
A stateful class's **definition** is pure code, so it rides in its topic module under `utils/` — precedent: the stateful `MatchPatternMap` class in `utils/blacklistMatchPattern.ts`. `@shared` has **no `services/` bucket**, because services are per-process (Invariant 1.2).
## 4. Placement Decision
Two gates, in order, then categorize:
1. **Cross-process?** Reached by both processes — no → it goes to a process layer (`src/main/*` or `src/renderer/*`). *(Carve-out: a Cache key's schema entry + value type stay in `@shared/data/cache/` even when single-process — §1.1.1.)*
2. **Stateless / immutable?** No exported instance, no mutable state — no → only the blueprint and static data stay; the **instance** goes per-process.
3. **Categorize**: core domain (`ai`) / infra (`data`, `ipc`) / shape (`types`, `utils`). Not one of the first two → decompose by shape into `types` / `utils`; **never** open a new top-level dir.
## 5. Anti-Patterns
- **Exported instance singleton** — `export const x = new XService()`, or any registry / manager / service instance. Violates Invariant 1.2.
- **Single-process code in `@shared`** — main-only or renderer-only logic placed here for convenience. Violates Invariant 1.1. *(Former epicenter: the now-dissolved `config/constant.ts` — §6. The Cache schema registry is the one sanctioned exception — §1.1.1.)*
- **Junk-drawer file or dir** — a `config/` bucket or a `constant.ts` accumulating unrelated globals across domains and processes. Decompose by domain + process; do not relocate as a blob.
- **A new top-level dir per capability** — every capability decomposes by shape; the top level is closed (§2).
- **A stateful "service" in `@shared`** — state has no coherent shared owner; it belongs to `main` or `renderer`.
## 6. Migration (target vs current — deferred, tracked)
The structural decomposition is **done**: `command`, `file`, `shortcuts`, `externalApp`, and `config` were dissolved out of the top level — cross-process slices into `types/` + `utils/` by shape, single-process code back into `main`/`renderer` (Invariant 1.1) — and `menuRegistry`'s exported instance was replaced by the pure `resolveMenu` (Invariant 1.2). `config`'s ~82-importer `constant.ts` was decomposed by domain + process (file-ext lists → `utils/file/`, `KB`/`MB`/`GB`/`APP_NAME``utils/constants.ts`, terminal/update/OAuth/timeout/window-sizing blocks back to their owning `main`/`renderer` modules); the actual consumer process was confirmed per item rather than trusted from a directional plan (e.g. `API_SERVER_DEFAULTS` proved renderer-only, `MIN_WINDOW_*` cross-process, `providers.ts` renderer-only). The `utils/index.ts` bucket-root barrel was later split into topic files (§3.1). A subsequent `types`/`utils` audit confirmed the single-process residue in the table below and removed dead code (`types/codeTools.ts`'s unused `LoaderReturn`, which also dragged a `@types` renderer import into `@shared` — a layering violation now gone); `keywordSearch` and `SerializableSchema` looked main-only/dead on `main` but proved cross-process against the `feat/chat-page` truth branch (renderer `GlobalSearch`, `renderer/types/serialize.ts`) and correctly stay. A later pass cleared three of the rows below — `searchSnippet.ts` and `pdf.ts` moved to `src/main/utils/` (searchSnippet is a generic, DB-free text helper, so it landed in `main/utils/` rather than the `data/services/utils/` first pencilled in), and `externalApp.ts`'s `EXTERNAL_APPS` const was inlined into `ExternalAppsService` — and switched the `types/file`, `utils/file`, `utils/command`, and `utils/api` topic barrels from `export *` to explicit named exports (§3.1). The error/serializable cluster was then resolved by routing each part to its real home: the genuinely cross-process `SerializedError` shape stays in `@shared/types/error.ts`, the write-side `serializeError` moved to `src/main/ai/utils/`, and the AI-SDK subtype/guard family — dead on the `@shared` side and ~98% duplicated by the renderer's own copy — was deleted along with the unused `ProviderSpecificError` (the renderer keeps its live parallel copy). Remaining deviations:
| Area | Current | Target |
|---|---|---|
| `data/types/` converters/guards — `coerceSearchRole`, `deriveRootSpanId`, `readCherryMeta`/`withCherryMeta`, `knowledge.ts` string helpers | logic living in the `data` type bucket; behavioral tests under `data/types/__tests__/` flag it (§3.1, last paragraph) | open question: route-by-shape would move them to a `utils` location, but schema-derived guards are conventionally co-located — decision deferred |
| `IpcChannel.ts` | 18 KB v1 channel enum at the root | v1 legacy; folded into `ipc/` as the IpcApi migration retires channels — not part of this governance |
## 7. Related
- [Architecture Overview](./architecture-overview.md) — process model and the `@shared` one-line summary.
- [Renderer Architecture §2§3](./renderer-architecture.md) — the layer model and how the renderer depends on `@shared`; §6 owns command's **renderer-side** cells (this doc owns its `@shared` cell).
- [Naming Conventions §4.8](./naming-conventions.md) — top-level closed by default (this doc is its `@shared` application); §4.9 singular vs plural; §5.2 route-by-shape.