@compean/core 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +106 -2
- package/RULES.md +172 -0
- package/dist/changed.d.ts +10 -0
- package/dist/changed.js +9 -0
- package/dist/controller.d.ts +57 -0
- package/dist/controller.js +110 -0
- package/dist/eslint/index.d.ts +6 -0
- package/dist/eslint/index.js +153 -0
- package/dist/form.d.ts +43 -0
- package/dist/form.js +89 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +9 -0
- package/dist/observable.d.ts +8 -0
- package/dist/observable.js +17 -0
- package/dist/prettier/index.d.ts +7 -0
- package/dist/prettier/index.js +15 -0
- package/dist/react/index.d.ts +24 -0
- package/dist/react/index.js +63 -0
- package/dist/read-json.d.ts +13 -0
- package/dist/read-json.js +23 -0
- package/dist/registry.d.ts +12 -0
- package/dist/registry.js +34 -0
- package/dist/report-safely.d.ts +3 -0
- package/dist/report-safely.js +13 -0
- package/dist/reporting.d.ts +17 -0
- package/dist/reporting.js +17 -0
- package/dist/server/index.d.ts +22 -0
- package/dist/server/index.js +52 -0
- package/dist/testing/index.d.ts +15 -0
- package/dist/testing/index.js +27 -0
- package/dist/toast.service.d.ts +20 -0
- package/dist/toast.service.js +25 -0
- package/dist/user-error.d.ts +9 -0
- package/dist/user-error.js +12 -0
- package/package.json +97 -5
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 CompeanR
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,107 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @compean/core
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The shared core of every Compean app: controllers, services, errors, forms, feedback messages and bug reporting, plus the ESLint rules that keep apps consistent. React web (Vite + TanStack Router), React Native (Expo) and a Hono API on Node all build on it.
|
|
4
|
+
|
|
5
|
+
**Read [`RULES.md`](RULES.md) before writing code in an app that uses this package.** It holds the conventions; this README only lists what the package exports.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
pnpm add @compean/core zod
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Optional peers, by entry point: `react` for `/react`; `prettier` for `/prettier`; `hono` and `@hono/zod-validator` for `/server`; `@tanstack/query-core` for `changed()`; `eslint` for `/eslint`. Requires Node 22.12 or later.
|
|
14
|
+
|
|
15
|
+
Bug reports from `load()` and `run()` name the controller by its class name, so production builds must keep class names: in Vite, set `esbuild: { keepNames: true }`.
|
|
16
|
+
|
|
17
|
+
## Entry points
|
|
18
|
+
|
|
19
|
+
| Import | Exports | Used in |
|
|
20
|
+
| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
|
|
21
|
+
| `@compean/core` | `Observable`, `Controller`, `$S`, `register`, `UserError`, `ToastService`, `Form`, `ErrorReporter`, `ConsoleReporter`, `readJson`, `changed` | everything |
|
|
22
|
+
| `@compean/core/react` | `useController`, `Toaster` | web and mobile screens |
|
|
23
|
+
| `@compean/core/server` | `handleErrors`, `validator`, `loadConfig` | the API |
|
|
24
|
+
| `@compean/core/testing` | `resetServices`, `RecordingReporter`, `recordingNavigation` | tests |
|
|
25
|
+
| `@compean/core/eslint` | `compeanRules` | each app's `eslint.config.js` |
|
|
26
|
+
| `@compean/core/prettier` | the shared Prettier config (4 spaces, width 130, single quotes) | each app's `package.json`: `"prettier": "@compean/core/prettier"` |
|
|
27
|
+
|
|
28
|
+
## A screen in four pieces
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
// packages/client: the controller
|
|
32
|
+
export class NotesController extends Controller<NotesState> {
|
|
33
|
+
private notes: Note[] = [];
|
|
34
|
+
public readonly form = new Form(AddNoteSchema, { body: '' });
|
|
35
|
+
|
|
36
|
+
public initialize(): void {
|
|
37
|
+
this.track(this.form.subscribe(() => this.notify()));
|
|
38
|
+
void this.load(async () => {
|
|
39
|
+
this.notes = await $S(NotesService).list();
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
public get state(): NotesState {
|
|
44
|
+
return { status: this.status, loadError: this.loadError, notes: this.notes, form: this.form.view() };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
public addNote(): Promise<void> {
|
|
48
|
+
return this.run(() =>
|
|
49
|
+
this.form.submit(async (input) => {
|
|
50
|
+
await $S(NotesService).add(input);
|
|
51
|
+
this.form.reset();
|
|
52
|
+
$S(ToastService).show('Note added');
|
|
53
|
+
}),
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
```tsx
|
|
60
|
+
// apps/web: the screen
|
|
61
|
+
const { state, controller } = useController(() => new NotesController());
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
// apps/api: errors become HTTP in one place
|
|
66
|
+
app.onError(handleErrors());
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
// eslint.config.js
|
|
71
|
+
export default [...typescriptConfig, ...compeanRules()];
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Design notes
|
|
75
|
+
|
|
76
|
+
Decisions that span several modules, with the alternatives that were rejected. Code comments point here instead of repeating them.
|
|
77
|
+
|
|
78
|
+
### Controller status: `load()` and `run()`
|
|
79
|
+
|
|
80
|
+
A screen is `'loading'`, `'ready'` or `'error'`. Only `load()` decides that status, and only until the screen is first ready. Several first loads are allowed: the status settles when they have all finished, as `'error'` with the first failure's message if any failed; after that a load is a refresh, so its failure shows as a toast and the data stays on screen. `run()` never touches the status. `start()` (called by `useController`) marks a screen that started no load as ready.
|
|
81
|
+
|
|
82
|
+
- Rejected: one `run()` for everything, where the first run counted as the load. A form screen with nothing to load stayed `'loading'`, and its first failed submit replaced the whole screen with an error.
|
|
83
|
+
- Rejected: `run(task, { load: true })`. A flag that changes behaviour is a special case every caller has to remember.
|
|
84
|
+
|
|
85
|
+
### One `UserError` class
|
|
86
|
+
|
|
87
|
+
Everything the user should read is a `UserError(message, status)`; everything else is a bug that is reported and shown generically. `readJson` rebuilds the same class on the client from a 4xx `{ message }`, so `load()`, `run()` and `handleErrors` share one rule.
|
|
88
|
+
|
|
89
|
+
- Rejected: a subclass per status (`NotFoundError`, `ConflictError`). The client only asks "may I show this?", which the class answers; the status carries the rest.
|
|
90
|
+
|
|
91
|
+
### Reporting never breaks error handling
|
|
92
|
+
|
|
93
|
+
`load()`, `run()` and `handleErrors` report through one internal `reportSafely`, which falls back to the console when the registered reporter throws. A vendor outage must never turn a handled error into an unhandled one.
|
|
94
|
+
|
|
95
|
+
### Defaults in the registry
|
|
96
|
+
|
|
97
|
+
`$S(ToastService)` and `$S(ErrorReporter)` work without wiring (a fresh `ToastService`, a `ConsoleReporter`), so tests and local runs need no boot code. Every other service must be registered; a missing one is a wiring bug and throws.
|
|
98
|
+
|
|
99
|
+
### `useController` and React StrictMode
|
|
100
|
+
|
|
101
|
+
StrictMode runs an effect, disposes, then runs it again on the same controller. The hook never starts a disposed controller; it mounts a fresh one. On a key change it creates the new controller during render, so the screen never shows the new controller with the old one's state.
|
|
102
|
+
|
|
103
|
+
- Rejected: `useSyncExternalStore` on `controller.state`. The getter builds a new object on every read, which makes React re-render forever.
|
|
104
|
+
|
|
105
|
+
### Lint rules are core's own
|
|
106
|
+
|
|
107
|
+
The four checks are small ESLint rules shipped by core, not stacked `no-restricted-imports` entries. ESLint lets a later config entry for the same rule replace an earlier one, so overlapping file globs would silently cancel each other.
|
package/RULES.md
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# Framework rules
|
|
2
|
+
|
|
3
|
+
Conventions for every app built on `@compean/core`. They apply on top of the `code-rules` skill, which holds the design principles (from _A Philosophy of Software Design_) these rules are derived from; read that first. Each rule here is written once; the section number in brackets points to the principle behind it.
|
|
4
|
+
|
|
5
|
+
A rule a tool can check is enforced by `@compean/core/eslint` and listed in [Enforced by lint](#enforced-by-lint). A new kind of file, a new dependency direction or an exception to a rule needs a design decision before code, not a local workaround.
|
|
6
|
+
|
|
7
|
+
## Comments
|
|
8
|
+
|
|
9
|
+
Comments follow `code-rules/comments.md`. Decisions that span modules go in the README's design notes, with a pointer from the code. [ch. 12, ch. 13]
|
|
10
|
+
|
|
11
|
+
## Glossary
|
|
12
|
+
|
|
13
|
+
| Term | Meaning |
|
|
14
|
+
| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
15
|
+
| **Domain** | The pure TypeScript that both client and server agree on: models, schemas and policies in `packages/domain`. No I/O, no runtime imports, no display text. |
|
|
16
|
+
| **Model** | A domain concept: its readonly Zod schema, its type, and the pure functions that define its terms (`isKept`, `isPathComplete`). File: `*.model.ts`. |
|
|
17
|
+
| **Schema** | A validation shape that isn't a domain concept, such as a request body or a form's input. File: `*.schema.ts`. |
|
|
18
|
+
| **Policy** | A pure behaviour decision: "what should the app do?" (`shouldPromptForReview`, `recommendNextPaths`). Never creates, changes or stores anything. File: `*.policy.ts`. |
|
|
19
|
+
| **Service** | A class that performs operations and may hold shared state, reached through `$S`. Exists on the client and on the server. File: `*.service.ts`. |
|
|
20
|
+
| **Controller** | Client-only. Owns one screen's logic and state; its `state` is the screen's view model. Runs on web and mobile unchanged. File: `*.controller.ts`. |
|
|
21
|
+
| **View model** | The display-ready values a screen renders. Normally a controller's `state`; a `*.view-model.ts` file exists only when two or more controllers build the same display shape. |
|
|
22
|
+
| **Screen** | The component a route renders. Calls `useController` once and renders its state. |
|
|
23
|
+
| **Routes** | Server-only. A chained Hono definition of a feature's HTTP endpoints. File: `*.routes.ts`. The word "controller" is never used for this. |
|
|
24
|
+
| **Repository** | Server-only. Holds queries only when they are deep (joins, mapping, transactions) or shared by two or more services. File: `*.repository.ts`. |
|
|
25
|
+
| **Port / adapter** | The boundary with something we don't control (device storage, a third-party SDK) and its implementation. Files: `*.port.ts`, `*.adapter.ts`. |
|
|
26
|
+
| **`$S`** | The service registry. `register(Class, instance)` in `boot.ts`; `$S(Class)` everywhere else it's allowed. |
|
|
27
|
+
| **`boot.ts`** | The composition root of each app: the one place that builds services and wires their dependencies. |
|
|
28
|
+
| **`UserError`** | An error the user can fix or should be told about. Its message is shown verbatim. Everything else is a bug. |
|
|
29
|
+
| **Resource** | A name for a kind of server data (`'applications'`), used to mark cached data stale. Resources are a typed union in `packages/domain`, so a typo is a compile error. |
|
|
30
|
+
|
|
31
|
+
## File roles
|
|
32
|
+
|
|
33
|
+
These eleven file roles are the only ones. The folder is the feature; the suffix is the role.
|
|
34
|
+
|
|
35
|
+
| Where | Role |
|
|
36
|
+
| -------------------------------- | -------------------------------------------------------------- |
|
|
37
|
+
| `packages/domain` | `*.model.ts`, `*.schema.ts`, `*.policy.ts` |
|
|
38
|
+
| `packages/client` | `*.controller.ts`, `*.service.ts`, `*.view-model.ts` |
|
|
39
|
+
| `apps/api` | `*.routes.ts`, `*.service.ts`, `*.repository.ts` |
|
|
40
|
+
| `packages/client` and `apps/api` | `*.port.ts` (an interface a service depends on) |
|
|
41
|
+
| any app | `*.adapter.ts` (a platform or vendor implementation of a port) |
|
|
42
|
+
| entry points | `boot.ts` in every app; `app.ts` and `server.ts` in `apps/api` |
|
|
43
|
+
|
|
44
|
+
Tests sit next to the file they cover as `*.test.ts`.
|
|
45
|
+
|
|
46
|
+
Code that seems to need another suffix goes where its knowledge lives:
|
|
47
|
+
|
|
48
|
+
| Instead of | Put it in |
|
|
49
|
+
| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
|
50
|
+
| `runtime`, `engine` | a `*.service.ts` (with `start()` if it listens to events), or a `*.policy.ts` if it's pure |
|
|
51
|
+
| `mapper`, `codec` | the module that knows the format: the adapter, repository or controller that uses it; a domain format goes in its `*.model.ts` |
|
|
52
|
+
| `factory` | the service that owns the object, or a `*.policy.ts` if it's a pure decision |
|
|
53
|
+
| `handler` | a method on the owning service |
|
|
54
|
+
| `config` | `loadConfig` in `boot.ts` |
|
|
55
|
+
| `store` | private state inside a service |
|
|
56
|
+
| `types`, `entity`, `value` | a `*.model.ts` |
|
|
57
|
+
| `sequence` (animation) | the view |
|
|
58
|
+
| `labels`, `content` (display text) | the controller, or a shared `*.view-model.ts` |
|
|
59
|
+
|
|
60
|
+
## Layout
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
apps/web/ Vite + TanStack Router: routes/, screens/, adapters/, boot.ts
|
|
64
|
+
apps/mobile/ Expo Router, same shape (added when needed)
|
|
65
|
+
apps/api/ Hono on Node: features/<feature>/, migrations/, app.ts, server.ts, boot.ts
|
|
66
|
+
packages/domain/ models, schemas, policies
|
|
67
|
+
packages/client/ features/<feature>/: controllers, client services, shared view models
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
- Formatting comes from `@compean/core/prettier` (`"prettier": "@compean/core/prettier"` in the root `package.json`), with an `.editorconfig` for editors that don't read Prettier. No project keeps its own copy.
|
|
71
|
+
- **pnpm workspaces**, pinned with `packageManager`. **Node** is the only runtime, for the server, tests and scripts.
|
|
72
|
+
- Dependencies point one way: `apps → packages/client → packages/domain`, and `apps/api → packages/domain`. `packages/client` imports only `type AppType` from the API, never its code. [§9.4]
|
|
73
|
+
- Folders are grouped by feature inside each app or package, never by role. [ch. 9]
|
|
74
|
+
- `@compean/core`'s entry points and their exports are listed in its README.
|
|
75
|
+
|
|
76
|
+
## Domain
|
|
77
|
+
|
|
78
|
+
- Every domain type is a readonly Zod schema in a `*.model.ts`; its type comes from `z.infer`. [§5.3, §9.1]
|
|
79
|
+
- A definition of a domain term lives with its model once two modules, or both client and server, need it. Until then it stays a private function in the module that uses it. [§9.3]
|
|
80
|
+
- A policy is a pure behaviour decision in the book's sense: mechanism and policy kept apart. [§6, §8.2]
|
|
81
|
+
- Display text and constants that only one screen uses stay out of the domain.
|
|
82
|
+
|
|
83
|
+
## Controllers
|
|
84
|
+
|
|
85
|
+
- A controller extends `Controller<TState>`. It exposes `state`, `initialize()` and its actions; the base class provides `start()`, `subscribe`, `dispose`, `notify()`, `track(cleanup)`, `load(task)` and `run(task)`.
|
|
86
|
+
- `state` holds display-ready values only. Building them is private mapping inside the controller. Screens and components import only view-model types, never the functions that build them. Decisions call domain rules, not view-model fields. [§7.4, §9.1]
|
|
87
|
+
- Loading the screen's data goes through `load()`: before the screen is first ready, the status settles when every load started so far has finished ('ready', or 'error' with a retry); once ready it's a refresh whose failure shows as a toast. A screen that loads nothing never calls it and starts ready. Every action goes through `run()`, which never changes the status and shows failures as toasts. Both show a `UserError`'s message, show a generic message for anything else, and report bugs. [§10.7, §18]
|
|
88
|
+
- An action must add something: `run()`, a state change, or combining services. One that only forwards a call is a pass-through. [§7.1]
|
|
89
|
+
- Every decision that reads app state is made in the controller, in a method called directly where the data arrives (for example at the end of `load()`). Subscribe to a service only when the change comes from outside. [§18.2]
|
|
90
|
+
- Controllers import no React, React Native or router package. They hold no animation values, springs, animation timers or haptics; they expose meaningful state (`phase`, `isRevealed`) and the view animates it. Timing that is behaviour (auto-advance after 3 s) stays in the controller and never copies an animation's duration. When the flow waits for an animation, the view calls a method named by what the user saw (`planRevealed()`). [§5.2, §9.4]
|
|
91
|
+
- Services come from `$S`. The constructor takes only what the screen owns, in this order: route params, then navigation, each only when needed. [§7.5]
|
|
92
|
+
- Success messages: `$S(ToastService).show('Moved to Technical')` after the action succeeds; `show(message, tone)` takes `'error'` for the rare message that isn't from `run()`. Only actions announce success; loading data never does.
|
|
93
|
+
- Forms: the controller holds a `Form` built from a domain schema, exposed as `public readonly`, and subscribes to it in `initialize()`. The screen binds inputs to it directly, so the controller writes no setter per field. Submit with `this.run(() => this.form.submit(async (input) => { ... }))`: invalid input shows every field's error and skips the action. Clear the form only after the action succeeds. Field errors show on blur or submit. A rule only the server can check (an email already taken) arrives as a toast through `run()`. [§19.6, §10.1]
|
|
94
|
+
|
|
95
|
+
### Navigation
|
|
96
|
+
|
|
97
|
+
- When the destination is knowledge the screen owns (a row's detail, a menu item, back), the screen uses `<Link>` or its router directly.
|
|
98
|
+
- When it comes from a rule, a result or a flow order, the controller decides. It declares a small navigation interface named by destination (`toResumeVersion(id)`, `back()`), and the screen implements it with its platform's router and passes it in. The order of a flow lives in a policy. [§9.4, §6.2]
|
|
99
|
+
- A navigation implementation only navigates. Controllers never see URLs or router APIs.
|
|
100
|
+
- Every navigation interface has a one-line comment: `/** Where this screen can lead after an action. Implemented by each platform's screen. */` [§13.5]
|
|
101
|
+
|
|
102
|
+
## Screens
|
|
103
|
+
|
|
104
|
+
- The screen calls `useController` once and renders `state`. Its sections receive `state` and `controller` as props; avoid passing them through components that don't use them. [§7.5]
|
|
105
|
+
- `useEffect`, `useFocusEffect` and `useState` are for view work only: focus, gesture timing, scroll and animation values, keyboard listeners, view-only toggles.
|
|
106
|
+
- Screens show no toasts or alerts.
|
|
107
|
+
- Animation, haptics and sound live in the view. `<Toaster />` at the app root owns how toasts look.
|
|
108
|
+
|
|
109
|
+
## Web routes
|
|
110
|
+
|
|
111
|
+
- Vite + TanStack Router with file-based routes. A route file holds the path, `validateSearch` with a domain schema, and the screen.
|
|
112
|
+
- Routes never use loaders or the router cache; controllers load data through services.
|
|
113
|
+
- `beforeLoad` is used only for access checks, reading `$S(AuthService)`.
|
|
114
|
+
|
|
115
|
+
## Services
|
|
116
|
+
|
|
117
|
+
- A service extends `Observable` only when screens must react to its changes live; otherwise it's a plain class. [§19.1]
|
|
118
|
+
- Its interface speaks the domain: one named snapshot for displayed data, intent methods instead of setters, question methods only for domain rules several callers share. A getter per field is a red flag. [§19.6, §5.1]
|
|
119
|
+
- A library (Zustand, query-core, Better Auth) may live inside a service, never in screens or controllers. [§5.1]
|
|
120
|
+
- Dependencies come through the constructor, every argument named, no `undefined` placeholders. A default must be what production wants, so only `{ clock, random }` default (to the real ones) and a dependency never defaults. Product constants live in the module that owns the rule. [§8.2, §10.9]
|
|
121
|
+
|
|
122
|
+
### Server data on the client
|
|
123
|
+
|
|
124
|
+
- A client service calls the typed `hc<AppType>` client directly; there's no port around our own API. It caches with a private `QueryClient` from `@tanstack/query-core`. Query keys, cache options and request status never leave the service. [ch. 8, §7.3]
|
|
125
|
+
- Queries declare what they're built from: `meta: { dependsOn: ['applications'] }`. After a write, the service calls `changed(cache, 'applications')`. A writer never knows who reads. [§5.2]
|
|
126
|
+
- Temporary network failures are retried inside the cache. [§10.6]
|
|
127
|
+
- `readJson` turns a 4xx `{ message }` into a `UserError` and a 5xx into a plain `Error`.
|
|
128
|
+
|
|
129
|
+
## Server
|
|
130
|
+
|
|
131
|
+
- A feature's routes are one chained `Hono` export: path, `validator` from `@compean/core/server` with a domain schema, one service call, an explicit status code. No route classes, no rules, no database calls. [ch. 7]
|
|
132
|
+
- `app.ts` chains every feature's routes and exports `AppType`. `server.ts` mounts it under `/api` with `@hono/node-server`.
|
|
133
|
+
- Services are classes with the feature's rules. They never read the HTTP context; routes pass `c.var.user.id` and validated input explicitly. [§5.1]
|
|
134
|
+
- A service holds its own queries by default. A `*.repository.ts` exists only when its abstraction differs (joins, mapping rows into shapes, transactions) or two or more services share it. Writes that touch several tables run in one transaction inside the module that knows the tables. There's no interface around the database. [§9.6, ch. 8, §7.2]
|
|
135
|
+
- Services return model types, never rows typed by the database schema, so the schema can't leak into `AppType`. [§5.2]
|
|
136
|
+
- PostgreSQL, raw SQL through `postgres`, checked by SafeQL against the real schema, so lint needs a database locally and in CI. Migrations are plain `.sql` files run by dbmate, reviewed before merge. A local-first app with SQLite on the device picks its own tool.
|
|
137
|
+
- Auth: Better Auth mounted in `app.ts`, with a `withSession` middleware that sets `c.var.user`. Its tables arrive as a reviewed migration. Who may do what is decided in services or policies; something the user may not see is reported as not found (`UserError(..., 404)`).
|
|
138
|
+
|
|
139
|
+
## Errors
|
|
140
|
+
|
|
141
|
+
- One class, `UserError(message, status)`, on both sides. Write the message where the cause is known. [§10.7]
|
|
142
|
+
- First try to define the error away: deleting something already gone succeeds, moving to the current stage does nothing. [§10.3]
|
|
143
|
+
- `app.onError` is the only place errors become HTTP: a `UserError` becomes `{ message }` with its status; an `HTTPException` below 500 (from Hono middleware) keeps its own response; anything else is reported and answered with a 500 and a generic message. Input validation errors take the `UserError` path through `validator`.
|
|
144
|
+
- Unrecoverable problems at startup (missing config, no database) crash `boot.ts` immediately with a clear message. [§10.8]
|
|
145
|
+
- No `try/catch` in routes, controllers or screens.
|
|
146
|
+
|
|
147
|
+
### Reporting bugs
|
|
148
|
+
|
|
149
|
+
- `ErrorReporter.report(error, { where, userId? })`, registered in each app's `boot.ts`: `ConsoleReporter` locally and in tests, a `SentryReporter` adapter in production. All apps report to one Sentry project. [§7.2]
|
|
150
|
+
- Only `load()`, `run()` and `app.onError` call it, and never for a `UserError`.
|
|
151
|
+
- A reporter may throw; core's callers then fall back to the console, so reporting never breaks error handling. [§10.6]
|
|
152
|
+
|
|
153
|
+
## Tests
|
|
154
|
+
|
|
155
|
+
- Vitest on Node for everything except end-to-end flows.
|
|
156
|
+
- Models and policies: plain unit tests.
|
|
157
|
+
- Controllers: unit tests with typed fakes registered in `$S`, no React rendering.
|
|
158
|
+
- Services and repositories: integration tests against a real Postgres, a fresh database per test file cloned from a migrated template. Setup fails loudly when `TEST_DATABASE_URL` is missing.
|
|
159
|
+
- Routes: a few `app.request()` tests per feature, which also exercise `app.onError`.
|
|
160
|
+
- Screens: a few Playwright flows for the key journeys.
|
|
161
|
+
- Every test starts with `resetServices()`; tests in one file don't run concurrently. [§7.5]
|
|
162
|
+
- Test-first and bug-fix order follow `code-rules/process.md`.
|
|
163
|
+
|
|
164
|
+
## Enforced by lint
|
|
165
|
+
|
|
166
|
+
`@compean/core/eslint` checks:
|
|
167
|
+
|
|
168
|
+
- Controllers and `packages/client` import no React, React Native, `@tanstack/react-router` or `expo-router`.
|
|
169
|
+
- `packages/domain` imports nothing outside itself and Zod.
|
|
170
|
+
- Route files define no `loader`.
|
|
171
|
+
- Only the auth service imports `better-auth`; only client services import `@tanstack/query-core`.
|
|
172
|
+
- SQL in `apps/api` passes SafeQL.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { QueryClient } from '@tanstack/query-core';
|
|
2
|
+
/** Query metadata naming the resources a cached query is built from. */
|
|
3
|
+
export interface DependsOnMeta<R extends string = string> {
|
|
4
|
+
dependsOn?: readonly R[];
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Marks stale every cached query whose `meta.dependsOn` lists `resource`, so screens showing it
|
|
8
|
+
* refresh. A writer calls this after a change and never needs to know who reads.
|
|
9
|
+
*/
|
|
10
|
+
export declare function changed<R extends string>(cache: Pick<QueryClient, 'invalidateQueries'>, resource: R): Promise<void>;
|
package/dist/changed.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Marks stale every cached query whose `meta.dependsOn` lists `resource`, so screens showing it
|
|
3
|
+
* refresh. A writer calls this after a change and never needs to know who reads.
|
|
4
|
+
*/
|
|
5
|
+
export function changed(cache, resource) {
|
|
6
|
+
return cache.invalidateQueries({
|
|
7
|
+
predicate: (query) => query.meta?.dependsOn?.includes(resource) ?? false,
|
|
8
|
+
});
|
|
9
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Observable } from './observable.js';
|
|
2
|
+
/**
|
|
3
|
+
* What the screen shows as a whole: a loading view, the content, or a full-screen error with retry.
|
|
4
|
+
* See "Controller status" in the README's design notes.
|
|
5
|
+
*/
|
|
6
|
+
export type ControllerStatus = 'loading' | 'ready' | 'error';
|
|
7
|
+
/**
|
|
8
|
+
* One screen's logic and state. `state` is what the screen renders; actions are public methods.
|
|
9
|
+
* A controller runs unchanged on web and mobile, so it never imports React, React Native or a
|
|
10
|
+
* router. Subclasses override `initialize()` and `state`, load data with `load()` and perform
|
|
11
|
+
* actions with `run()`; neither needs a try/catch.
|
|
12
|
+
*/
|
|
13
|
+
export declare abstract class Controller<TState> extends Observable {
|
|
14
|
+
/** Undone on `dispose()` in reverse order, so the last thing tracked is undone first. */
|
|
15
|
+
private readonly cleanups;
|
|
16
|
+
private currentStatus;
|
|
17
|
+
/** The message for the 'error' status; null in any other status. */
|
|
18
|
+
private currentLoadError;
|
|
19
|
+
/** Loads started before the screen was first ready and not yet finished. The status settles at zero. */
|
|
20
|
+
private pendingFirstLoads;
|
|
21
|
+
/** The first failure among the pending first loads; it becomes the load error once they all finish. */
|
|
22
|
+
private firstLoadFailure;
|
|
23
|
+
/** Read by the screen after every `notify()`. Build it from fields; never fetch or do slow work here. */
|
|
24
|
+
abstract get state(): TState;
|
|
25
|
+
/**
|
|
26
|
+
* Subclass hook, called once by `start()` when the screen mounts. Start loading with `load()`
|
|
27
|
+
* and subscribe to services here, registering each unsubscribe with `track()`.
|
|
28
|
+
*/
|
|
29
|
+
initialize(): void;
|
|
30
|
+
/**
|
|
31
|
+
* Mounts the controller: calls `initialize()`, then marks a screen that started no load as
|
|
32
|
+
* ready. `useController` calls this; tests call it in place of mounting a screen.
|
|
33
|
+
*/
|
|
34
|
+
start(): void;
|
|
35
|
+
/** Safe to call more than once: each cleanup runs only the first time. */
|
|
36
|
+
dispose(): void;
|
|
37
|
+
protected get status(): ControllerStatus;
|
|
38
|
+
/** The message to show when status is 'error': a UserError's own words, or a generic one. */
|
|
39
|
+
protected get loadError(): string | null;
|
|
40
|
+
/** Runs `cleanup` (an unsubscribe, a clearTimeout) when the controller is disposed. */
|
|
41
|
+
protected track(cleanup: () => void): void;
|
|
42
|
+
/**
|
|
43
|
+
* Loads the screen's data. Before the screen is first ready, the status settles once every
|
|
44
|
+
* load started so far has finished: 'ready' if all succeeded, otherwise 'error' with the first
|
|
45
|
+
* failure's message (shown full screen; retry by calling `load()` again). Once ready, a load is
|
|
46
|
+
* a refresh: the data stays on screen and a failure shows as a toast. Never rejects.
|
|
47
|
+
*/
|
|
48
|
+
protected load(task: () => Promise<void>): Promise<void>;
|
|
49
|
+
/**
|
|
50
|
+
* Performs an action. It never changes the screen's status: a failure shows as an error toast,
|
|
51
|
+
* with a UserError's own message or a generic one, and bugs are reported. Never rejects.
|
|
52
|
+
*/
|
|
53
|
+
protected run(task: () => Promise<void>): Promise<void>;
|
|
54
|
+
/** The message the user sees for this error; reports it first unless it's a UserError. */
|
|
55
|
+
private describeAndReport;
|
|
56
|
+
private settle;
|
|
57
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { Observable } from './observable.js';
|
|
2
|
+
import { $S } from './registry.js';
|
|
3
|
+
import { reportSafely } from './report-safely.js';
|
|
4
|
+
import { ToastService } from './toast.service.js';
|
|
5
|
+
import { UserError } from './user-error.js';
|
|
6
|
+
const GENERIC_MESSAGE = 'Something went wrong. Try again.';
|
|
7
|
+
/**
|
|
8
|
+
* One screen's logic and state. `state` is what the screen renders; actions are public methods.
|
|
9
|
+
* A controller runs unchanged on web and mobile, so it never imports React, React Native or a
|
|
10
|
+
* router. Subclasses override `initialize()` and `state`, load data with `load()` and perform
|
|
11
|
+
* actions with `run()`; neither needs a try/catch.
|
|
12
|
+
*/
|
|
13
|
+
export class Controller extends Observable {
|
|
14
|
+
/** Undone on `dispose()` in reverse order, so the last thing tracked is undone first. */
|
|
15
|
+
cleanups = [];
|
|
16
|
+
currentStatus = 'loading';
|
|
17
|
+
/** The message for the 'error' status; null in any other status. */
|
|
18
|
+
currentLoadError = null;
|
|
19
|
+
/** Loads started before the screen was first ready and not yet finished. The status settles at zero. */
|
|
20
|
+
pendingFirstLoads = 0;
|
|
21
|
+
/** The first failure among the pending first loads; it becomes the load error once they all finish. */
|
|
22
|
+
firstLoadFailure = null;
|
|
23
|
+
/**
|
|
24
|
+
* Subclass hook, called once by `start()` when the screen mounts. Start loading with `load()`
|
|
25
|
+
* and subscribe to services here, registering each unsubscribe with `track()`.
|
|
26
|
+
*/
|
|
27
|
+
initialize() { }
|
|
28
|
+
/**
|
|
29
|
+
* Mounts the controller: calls `initialize()`, then marks a screen that started no load as
|
|
30
|
+
* ready. `useController` calls this; tests call it in place of mounting a screen.
|
|
31
|
+
*/
|
|
32
|
+
start() {
|
|
33
|
+
this.initialize();
|
|
34
|
+
if (this.pendingFirstLoads === 0 && this.currentStatus === 'loading') {
|
|
35
|
+
this.settle('ready', null);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** Safe to call more than once: each cleanup runs only the first time. */
|
|
39
|
+
dispose() {
|
|
40
|
+
for (const cleanup of this.cleanups.splice(0).reverse()) {
|
|
41
|
+
cleanup();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
get status() {
|
|
45
|
+
return this.currentStatus;
|
|
46
|
+
}
|
|
47
|
+
/** The message to show when status is 'error': a UserError's own words, or a generic one. */
|
|
48
|
+
get loadError() {
|
|
49
|
+
return this.currentLoadError;
|
|
50
|
+
}
|
|
51
|
+
/** Runs `cleanup` (an unsubscribe, a clearTimeout) when the controller is disposed. */
|
|
52
|
+
track(cleanup) {
|
|
53
|
+
this.cleanups.push(cleanup);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Loads the screen's data. Before the screen is first ready, the status settles once every
|
|
57
|
+
* load started so far has finished: 'ready' if all succeeded, otherwise 'error' with the first
|
|
58
|
+
* failure's message (shown full screen; retry by calling `load()` again). Once ready, a load is
|
|
59
|
+
* a refresh: the data stays on screen and a failure shows as a toast. Never rejects.
|
|
60
|
+
*/
|
|
61
|
+
async load(task) {
|
|
62
|
+
if (this.currentStatus === 'ready') {
|
|
63
|
+
await this.run(task);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (this.currentStatus === 'error') {
|
|
67
|
+
this.settle('loading', null);
|
|
68
|
+
}
|
|
69
|
+
this.pendingFirstLoads += 1;
|
|
70
|
+
try {
|
|
71
|
+
await task();
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
this.firstLoadFailure ??= this.describeAndReport(error);
|
|
75
|
+
}
|
|
76
|
+
finally {
|
|
77
|
+
this.pendingFirstLoads -= 1;
|
|
78
|
+
if (this.pendingFirstLoads === 0) {
|
|
79
|
+
const failure = this.firstLoadFailure;
|
|
80
|
+
this.firstLoadFailure = null;
|
|
81
|
+
this.settle(failure ? 'error' : 'ready', failure);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Performs an action. It never changes the screen's status: a failure shows as an error toast,
|
|
87
|
+
* with a UserError's own message or a generic one, and bugs are reported. Never rejects.
|
|
88
|
+
*/
|
|
89
|
+
async run(task) {
|
|
90
|
+
try {
|
|
91
|
+
await task();
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
$S(ToastService).show(this.describeAndReport(error), 'error');
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/** The message the user sees for this error; reports it first unless it's a UserError. */
|
|
98
|
+
describeAndReport(error) {
|
|
99
|
+
if (error instanceof UserError) {
|
|
100
|
+
return error.message;
|
|
101
|
+
}
|
|
102
|
+
reportSafely(error, { where: this.constructor.name });
|
|
103
|
+
return GENERIC_MESSAGE;
|
|
104
|
+
}
|
|
105
|
+
settle(status, loadError) {
|
|
106
|
+
this.currentStatus = status;
|
|
107
|
+
this.currentLoadError = loadError;
|
|
108
|
+
this.notify();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Linter } from 'eslint';
|
|
2
|
+
/**
|
|
3
|
+
* The framework rules a tool can check, as an ESLint flat config. Add it after your TypeScript
|
|
4
|
+
* parser setup: `export default [...typescriptConfig, ...compeanRules()]`.
|
|
5
|
+
*/
|
|
6
|
+
export declare function compeanRules(): Linter.Config[];
|