|
| 1 | +# PORTING.md — thin UI, thick core |
| 2 | + |
| 3 | +The playbook for porting a feature so its **business logic is portable** (runs on |
| 4 | +web/mobile, not just Electron) and its **UI is a thin shell**. Patterns validated on |
| 5 | +`connectivity` and matched against the existing `git` / `focus` / `sessions` / `billing` code. |
| 6 | + |
| 7 | +If anything contradicts [AGENTS.md](./AGENTS.md) / [CLAUDE.md](./CLAUDE.md), those win on |
| 8 | +layering. For multi-agent coordination use [REFACTOR.md](./REFACTOR.md) + `REFACTOR_SLICES.json`. |
| 9 | + |
| 10 | +--- |
| 11 | + |
| 12 | +## Port a feature by answering three questions |
| 13 | + |
| 14 | +Most mistakes come from skipping these or conflating them (we built `connectivity` ~3 ways |
| 15 | +before getting it right). Answer them in order. |
| 16 | + |
| 17 | +### Q1 — Where does the data / host access come from? *(picks the wiring)* |
| 18 | + |
| 19 | +- **a. ws-server backend** — git, fs, process, the connectivity probe. |
| 20 | + → A **core service that injects the workspace client** and calls ws-server; bound in the |
| 21 | + **main process**, reached from the renderer over tRPC. *(see `git`, `focus`)* |
| 22 | +- **b. PostHog cloud API** — tasks, billing, projects, anything on the Django API. |
| 23 | + → A **core service / functions using `@posthog/api-client`**. Portable anywhere there's an |
| 24 | + HTTP client; no host capability needed. *(see `billing`, `projects`)* |
| 25 | +- **c. Client-local host capability** — clipboard, dialog, OS notifications, `navigator.onLine`. |
| 26 | + → A **`@posthog/platform` interface + per-host adapter**. *(see `clipboard`)* |
| 27 | + |
| 28 | +> ws-client injection (a) is **main-process only** — the renderer's ws-client is built inside |
| 29 | +> React (`Providers.tsx`), connection-dependent, and can drop. Don't inject it into a |
| 30 | +> renderer-resident service. |
| 31 | +
|
| 32 | +### Q2 — Is there real logic? → **make a service.** *(this is the "thick core")* |
| 33 | + |
| 34 | +**The logic of a feature lives in a service — an `@injectable` class in `@posthog/core`.** |
| 35 | +Not in components, not in hooks, not in stores. |
| 36 | + |
| 37 | +A service: |
| 38 | +- holds business logic — orchestration, retries, dedup, rules, transforms, sagas; |
| 39 | +- **injects** its dependencies (workspace client, `api-client`, platform interfaces, other services); |
| 40 | +- has **no React, no JSX**; it may read/write a store but **is not** a store; |
| 41 | +- is the thing web/mobile reuse unchanged. |
| 42 | + |
| 43 | +When **not** to make one: a feature with no real logic — a value streamed from the backend |
| 44 | +into a store, a one-line passthrough — does **not** get a service. `connectivity` is one |
| 45 | +boolean fed by a subscription, so it's a store + host glue, **no service**. Don't manufacture a |
| 46 | +`FooService` for a 1-field feature; that was the connectivity over-engineering. |
| 47 | + |
| 48 | +> Rule of thumb: if you can't name an algorithm/decision the service makes, you don't need one. |
| 49 | +
|
| 50 | +### Q3 — Where does the state live? → **a store, on the correct side.** |
| 51 | + |
| 52 | +A store is a **state cell** (zustand): holds state, **no logic / async / `trpcClient`**. |
| 53 | + |
| 54 | +- **Domain state of record** — a *fact* business logic reads (`isOnline` drives `sessions` |
| 55 | + retries) → **`@posthog/core`, `zustand/vanilla`**. Fed by a service or host glue; observed |
| 56 | + by UI and core. |
| 57 | +- **Pure view state** — scroll position, open panel, draft text, selection → **`@posthog/ui`, |
| 58 | + `zustand`** (`create`). |
| 59 | + |
| 60 | +Components read via selectors; a hook re-bundles for ergonomics (`createSelectors` → |
| 61 | +`store.use.field()`). |
| 62 | + |
| 63 | +--- |
| 64 | + |
| 65 | +## Layers |
| 66 | + |
| 67 | +| Package | Owns | Never contains | |
| 68 | +|---|---|---| |
| 69 | +| `@posthog/platform` | Host-capability **interfaces** + tokens. Host-neutral. | Implementations, Node, DOM, tRPC, Electron | |
| 70 | +| `@posthog/workspace-server` | Node backend services + their tRPC. | UI, core, Electron | |
| 71 | +| `@posthog/api-client` | PostHog/Django HTTP client. | UI, Node-only host syscalls | |
| 72 | +| `@posthog/core` | Portable **services** + domain types + **domain stores**. Injects workspace client / api-client / platform interfaces. | React, `trpcClient`, Node syscalls, Electron, host-router types | |
| 73 | +| `@posthog/ui` | React glue: components, hooks, contributions, **view-state stores**. | Business logic, `trpcClient`, Node | |
| 74 | +| `apps/code` | Electron lifecycle + **platform adapters** + tRPC routers + DI wiring. | Business logic | |
| 75 | + |
| 76 | +`apps/code/src/main/platform-adapters/` — capabilities **main** consumes. |
| 77 | +`apps/code/src/renderer/platform-adapters/` — capabilities the **renderer** consumes (wrap `trpcClient`). |
| 78 | + |
| 79 | +--- |
| 80 | + |
| 81 | +## Skeletons |
| 82 | + |
| 83 | +### Service (Q2) — the logic, injectable, in core |
| 84 | + |
| 85 | +```ts |
| 86 | +// @posthog/core/<feature>/<feature>.ts |
| 87 | +@injectable() |
| 88 | +export class FeatureService { |
| 89 | + constructor( |
| 90 | + @inject(FEATURE_WORKSPACE_CLIENT) private readonly ws: FeatureWorkspaceClient, // Q1a |
| 91 | + // or @inject(API_CLIENT) private readonly api: PostHogApiClient, // Q1b |
| 92 | + // or @inject(THING_SERVICE) private readonly thing: IThing, // Q1c |
| 93 | + ) {} |
| 94 | + async doThing() { /* orchestration, rules, retries — the actual logic */ } |
| 95 | +} |
| 96 | +``` |
| 97 | + |
| 98 | +### Q1a — ws-server backend (`git` / `focus`) |
| 99 | + |
| 100 | +Core declares a **narrow slice** of the workspace client and injects it; bound in main with the |
| 101 | +real client; exposed to the renderer via a host-router tRPC router. |
| 102 | + |
| 103 | +```ts |
| 104 | +// @posthog/core/<feature>/identifiers.ts |
| 105 | +import type { WorkspaceClient } from "@posthog/workspace-client/client"; |
| 106 | +export interface FeatureWorkspaceClient { feature: WorkspaceClient["feature"]; } |
| 107 | +export const FEATURE_SERVICE = Symbol.for("posthog.core.featureService"); |
| 108 | +export const FEATURE_WORKSPACE_CLIENT = Symbol.for("posthog.core.featureWorkspaceClient"); |
| 109 | +``` |
| 110 | +```ts |
| 111 | +// apps/code/src/main/index.ts (composition — the real client; cloud client on web) |
| 112 | +container.bind(MAIN_TOKENS.FeatureService).toConstantValue(new FeatureService(workspaceClient)); |
| 113 | +container.bind(FEATURE_SERVICE).toService(MAIN_TOKENS.FeatureService); |
| 114 | +``` |
| 115 | + |
| 116 | +### Q1c — client-local capability (`clipboard`) |
| 117 | + |
| 118 | +```ts |
| 119 | +// @posthog/platform/src/<cap>.ts host-neutral: onDidChange(listener): () => void |
| 120 | +export interface IThing { read(): Promise<T>; onDidChange(l: (v: T) => void): () => void; } |
| 121 | +export const THING_SERVICE = Symbol.for("posthog.platform.thing"); |
| 122 | +``` |
| 123 | +```ts |
| 124 | +// apps/code/src/{main,renderer}/platform-adapters/<cap>.ts |
| 125 | +container.bind(THING_SERVICE).toConstantValue(electronImpl); |
| 126 | +``` |
| 127 | +**Never** put tRPC `{ onData, onError }` / `{ unsubscribe }` shapes in the interface — translate |
| 128 | +them in the adapter. Platform ships built `dist/`: a new file needs `src/<cap>.ts` + a |
| 129 | +`tsup.config.ts` entry + a `package.json` export + `pnpm --filter @posthog/platform build`. |
| 130 | + |
| 131 | +### Streamed state, no service (`connectivity`) — Q1a data + Q3 domain store |
| 132 | + |
| 133 | +```ts |
| 134 | +// @posthog/core/<feature>/<feature>Store.ts (domain fact → core, vanilla) |
| 135 | +import { createStore } from "zustand/vanilla"; |
| 136 | +export const featureStore = createStore<{ value: T; setValue: (v: T) => void }>((set) => ({ |
| 137 | + value: initial, setValue: (value) => set({ value }), |
| 138 | +})); |
| 139 | +export const getValue = () => featureStore.getState().value; |
| 140 | +``` |
| 141 | +```ts |
| 142 | +// apps/code/src/renderer/platform-adapters/<feature>.ts (host glue — the ONLY trpcClient touch) |
| 143 | +import { featureStore } from "@posthog/core/<feature>/<feature>Store"; |
| 144 | +import { trpcClient } from "@renderer/trpc/client"; |
| 145 | +const { setValue } = featureStore.getState(); |
| 146 | +void trpcClient.feature.get.query().then(setValue).catch(() => undefined); |
| 147 | +trpcClient.feature.onChange.subscribe(undefined, { onData: setValue }); |
| 148 | +``` |
| 149 | +```ts |
| 150 | +// @posthog/ui/hooks/useFeature.ts (read via auto-selectors) |
| 151 | +import { featureStore } from "@posthog/core/<feature>/<feature>Store"; |
| 152 | +import { createSelectors } from "./createSelectors"; |
| 153 | +const feature = createSelectors(featureStore); |
| 154 | +export const useFeature = () => ({ value: feature.use.value() }); |
| 155 | +``` |
| 156 | +A core consumer (e.g. `sessions`' `getIsOnline`) imports the store's `getValue` getter directly. |
| 157 | + |
| 158 | +--- |
| 159 | + |
| 160 | +## DI |
| 161 | + |
| 162 | +- **Plain Inversify.** Interface + `Symbol.for` token in the owning package; constructor `@inject(TOKEN)`; bind in the feature's `.module.ts` (ui/core) or `index.ts` composition (main). |
| 163 | +- **Never call them "ports."** These are **interfaces** — name them as such (`IThing` / `FeatureWorkspaceClient`), not `FooPort` / `FOO_PORT` / `ports.ts`. The existing `*_PORT` tokens, `*Port` types, and `ports.ts` files are legacy; new code uses "interface", and rename old ones when you touch them. |
| 164 | +- **Do NOT use `@inversifyjs/binding-decorators` (`@provide`) or `@inversifyjs/strongly-typed`.** Tried both on `connectivity`, removed them — `@provide`'s side-effect-import is a footgun, `strongly-typed`'s binding-map is pure tax. |
| 165 | +- **`resolveService` is a service-locator smell.** Constructor-inject in services; `useService(TOKEN)` at the React boundary only. `resolveService` is tolerated only in host composition seams (`apps/`). |
| 166 | + |
| 167 | +--- |
| 168 | + |
| 169 | +## Anti-patterns (removed this session — do not reintroduce) |
| 170 | + |
| 171 | +| Anti-pattern | Fix | |
| 172 | +|---|---| |
| 173 | +| A platform interface for **backend** data | Q1a/Q1b — workspace client / api-client | |
| 174 | +| A **service** for a trivial passthrough (1 field, no logic) | Q3 store + host glue, no service | |
| 175 | +| A **domain** store in `@posthog/ui` | Domain facts → core (`zustand/vanilla`) | |
| 176 | +| A renderer-resident core service injecting the workspace client | ws-client is React-bound/fragile; stream into a core store | |
| 177 | +| Bespoke `IFeatureClient` that 1:1 wraps `trpcClient.x` | Use the real client / `HOST_TRPC_CLIENT` | |
| 178 | +| Per-feature `FeatureLogger` interface + token | Generic `logger.scope` (UI) / shared logger (core) | |
| 179 | +| `@inversifyjs/strongly-typed` + a `Deps` map | `@inject(TOKEN)` | |
| 180 | +| Separate `IFeatureService` interface for one impl | Inject the concrete class | |
| 181 | +| `{ onData, onError }` / `{ unsubscribe }` in a platform interface | `onDidChange(listener): () => void` | |
| 182 | +| Logic / async in a Zustand store action | A service (Q2); the store does only `set` | |
| 183 | +| `trpcClient` imported in `@posthog/ui` | host glue in `apps/` feeds the store | |
| 184 | +| Adapter in a `features/<x>/` folder | `apps/code/.../platform-adapters/<x>.ts` | |
| 185 | +| A bridge mirroring a service that mirrors another service | collapse it; each consumer caches what it needs | |
| 186 | + |
| 187 | +--- |
| 188 | + |
| 189 | +## Validation gates |
| 190 | + |
| 191 | +```sh |
| 192 | +pnpm typecheck # all packages green |
| 193 | +pnpm exec biome lint packages/core/src/<feature> # core purity: zero noRestrictedImports |
| 194 | +pnpm --filter <pkg> exec vitest run src/<feature> # unit tests (services test with fake deps) |
| 195 | +pnpm biome check --write <touched paths> # format |
| 196 | +``` |
| 197 | +- Touched a `@posthog/platform` interface (Q1c)? **rebuild platform dist** or typecheck lies. |
| 198 | +- Moved a service/store to core? the **core purity gate** must be clean (no Node/Electron/React/`trpcClient`). |
| 199 | +- Repoint test mocks to the new import specifier (a shim hides the break from typecheck). |
| 200 | +- Renderer `vite build` is the cheap runtime smoke when DI/boot wiring changed. |
| 201 | + |
| 202 | +--- |
| 203 | + |
| 204 | +## Reference: `connectivity` (streamed domain state, no service) |
| 205 | + |
| 206 | +``` |
| 207 | +@posthog/core/connectivity/connectivityStore.ts domain store { isOnline } + getIsOnline (vanilla) |
| 208 | +@posthog/ui/features/connectivity/connectivityToast.ts subscribes the core store → offline toast |
| 209 | +@posthog/ui/hooks/useConnectivity.ts reads the core store via createSelectors |
| 210 | +apps/code/src/renderer/platform-adapters/connectivity.ts host glue: trpc subscription → core store + toast |
| 211 | +``` |
| 212 | +`sessions` imports `getIsOnline` from the core store. The probe lives in `workspace-server`'s |
| 213 | +connectivity service, served over tRPC. No core service (nothing to orchestrate), no platform |
| 214 | +interface, no per-feature DI — the store *is* in core because `isOnline` is a domain fact. |
0 commit comments