gemi 0.59.0 → 0.60.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/dist/bin/gemi.js +500 -16
- package/dist/bin/gemi.js.map +10 -5
- package/dist/{chunk-n412aa9s.js → chunk-khf9xda6.js} +1 -1
- package/dist/{chunk-gfma8e03.js → chunk-x8beq9c4.js} +1 -1
- package/dist/chunk-y3zz410b.js +6 -0
- package/dist/{chunk-txhcx69q.js.map → chunk-y3zz410b.js.map} +2 -2
- package/dist/config/index.d.ts +2 -0
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +2 -2
- package/dist/config/index.js.map +3 -3
- package/dist/server/index.js +1 -1
- package/package.json +3 -2
- package/skills/gemi-react-best-practices/SKILL.md +231 -0
- package/skills/gemi-react-best-practices/rules/_sections.md +56 -0
- package/skills/gemi-react-best-practices/rules/_template.md +28 -0
- package/skills/gemi-react-best-practices/rules/bundle-deep-imports.md +48 -0
- package/skills/gemi-react-best-practices/rules/bundle-mount-gate-heavy-panels.md +63 -0
- package/skills/gemi-react-best-practices/rules/client-form-vs-mutation-hooks.md +57 -0
- package/skills/gemi-react-best-practices/rules/client-loading-error-exports.md +54 -0
- package/skills/gemi-react-best-practices/rules/client-no-effect-data-flow.md +68 -0
- package/skills/gemi-react-best-practices/rules/client-typed-links.md +51 -0
- package/skills/gemi-react-best-practices/rules/controller-authorize-every-tenant-read.md +65 -0
- package/skills/gemi-react-best-practices/rules/controller-parse-request-at-the-boundary.md +54 -0
- package/skills/gemi-react-best-practices/rules/controller-redirect-facade-throws.md +68 -0
- package/skills/gemi-react-best-practices/rules/controller-request-schema.md +61 -0
- package/skills/gemi-react-best-practices/rules/controller-throw-framework-errors.md +57 -0
- package/skills/gemi-react-best-practices/rules/i18n-define-dictionary-inline.md +58 -0
- package/skills/gemi-react-best-practices/rules/orm-analytics-connection.md +53 -0
- package/skills/gemi-react-best-practices/rules/orm-include-not-n-plus-one.md +56 -0
- package/skills/gemi-react-best-practices/rules/orm-paginate-helper.md +69 -0
- package/skills/gemi-react-best-practices/rules/orm-plain-rows-by-default.md +55 -0
- package/skills/gemi-react-best-practices/rules/orm-select-narrow.md +58 -0
- package/skills/gemi-react-best-practices/rules/orm-transaction-no-io.md +54 -0
- package/skills/gemi-react-best-practices/rules/orm-transaction-sequential.md +64 -0
- package/skills/gemi-react-best-practices/rules/payload-dont-overprefetch.md +54 -0
- package/skills/gemi-react-best-practices/rules/payload-instant-vs-prefetch.md +58 -0
- package/skills/gemi-react-best-practices/rules/payload-minimal-view-props.md +51 -0
- package/skills/gemi-react-best-practices/rules/payload-parallel-controller-work.md +56 -0
- package/skills/gemi-react-best-practices/rules/payload-prefetch-late-queries.md +58 -0
- package/skills/gemi-react-best-practices/rules/payload-prefetch-mirrors-usequery.md +52 -0
- package/skills/gemi-react-best-practices/rules/query-debounce-search-variant.md +52 -0
- package/skills/gemi-react-best-practices/rules/query-keep-previous-data.md +40 -0
- package/skills/gemi-react-best-practices/rules/query-lazy-vs-mount-gate.md +54 -0
- package/skills/gemi-react-best-practices/rules/query-mutate-over-refetch.md +55 -0
- package/skills/gemi-react-best-practices/rules/query-no-hand-rolled-fetch.md +60 -0
- package/skills/gemi-react-best-practices/rules/query-revalidate-on-focus.md +44 -0
- package/skills/gemi-react-best-practices/rules/query-share-cache-key.md +51 -0
- package/skills/gemi-react-best-practices/rules/query-suspense-default.md +52 -0
- package/skills/gemi-react-best-practices/rules/routing-cache-policy-constants.md +53 -0
- package/skills/gemi-react-best-practices/rules/routing-middleware-dsl.md +60 -0
- package/skills/gemi-react-best-practices/rules/routing-resource-routes.md +59 -0
- package/skills/gemi-react-best-practices/rules/routing-routers-are-classes.md +55 -0
- package/skills/gemi-react-best-practices/rules/service-lazy-not-module-scope.md +63 -0
- package/skills/gemi-react-best-practices/rules/service-queue-is-in-memory.md +52 -0
- package/skills/gemi-react-best-practices/rules/service-static-token-and-name.md +52 -0
- package/skills/gemi-react-best-practices/rules/structure-discovered-vs-registered.md +71 -0
- package/skills/gemi-react-best-practices/rules/structure-do-not-reinvent-the-framework.md +58 -0
- package/skills/gemi-react-best-practices/rules/testing-assert-behaviour-over-markup.md +54 -0
- package/skills/gemi-react-best-practices/rules/testing-match-the-suite.md +57 -0
- package/skills/gemi-react-best-practices/rules/testing-page-seeds-real-inputs.md +65 -0
- package/dist/chunk-txhcx69q.js +0 -6
- /package/dist/{chunk-n412aa9s.js.map → chunk-khf9xda6.js.map} +0 -0
- /package/dist/{chunk-gfma8e03.js.map → chunk-x8beq9c4.js.map} +0 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Routes Are Declared on Router Classes, Not by File Location
|
|
3
|
+
impact: HIGH
|
|
4
|
+
impactDescription: the only place a URL is defined
|
|
5
|
+
tags: routing, structure, views
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Routes Are Declared on Router Classes, Not by File Location
|
|
9
|
+
|
|
10
|
+
Routing is **class-based**. A `ViewRouter` or `ApiRouter` subclass carries a `routes`
|
|
11
|
+
object mapping a path to a handler, and routers nest by assigning one as a route
|
|
12
|
+
value. Putting a file in `app/views` registers nothing — **the view file name has no
|
|
13
|
+
relation to the URL**, and the mapping in `app/http/routes/view.ts` is the only thing
|
|
14
|
+
that makes a page reachable.
|
|
15
|
+
|
|
16
|
+
**Incorrect (creating the file and expecting a URL):**
|
|
17
|
+
|
|
18
|
+
```tsx
|
|
19
|
+
// app/views/customer/Billing.tsx — reachable at… nothing.
|
|
20
|
+
export default function Billing() { /* … */ }
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
**Correct (register it, and bind its server data):**
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
// app/http/routes/view.ts
|
|
27
|
+
class CustomerRouter extends ViewRouter {
|
|
28
|
+
middlewares = ["cache:private,0,no-store", "auth"];
|
|
29
|
+
routes = {
|
|
30
|
+
"/billing": this.view("customer/Billing", [BillingController, "view"]),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The pieces worth knowing:
|
|
36
|
+
|
|
37
|
+
- **`this.view(name, handler?)`** — the handler is an inline callback or a
|
|
38
|
+
`[Controller, "method"]` tuple; its return value becomes the component's props.
|
|
39
|
+
- **`this.layout(name, handler?, routes)`** — nests a layout around child routes.
|
|
40
|
+
A layout handler **does not re-run** while navigating within the same layout unless
|
|
41
|
+
it is marked `.alwaysRun()`.
|
|
42
|
+
- **`:param`** dynamic, **`:param?`** optional, **`(group)/`** groups routes for
|
|
43
|
+
shared middleware or a layout **without adding a URL segment**.
|
|
44
|
+
- **`this.redirect(() => ({ destination }))`** for a static redirect.
|
|
45
|
+
|
|
46
|
+
On the API side: `this.get/post/put/patch/delete(Controller, "method")`,
|
|
47
|
+
`this.file(...)`, `this.stream(...)` (handles 206/416 and `Content-Range` for
|
|
48
|
+
range requests), `this.proxy(...)`, and an object of lowercase method keys to bind
|
|
49
|
+
several verbs to one path.
|
|
50
|
+
|
|
51
|
+
**A view component must be a default export; a controller must be a named export.**
|
|
52
|
+
The router imports each by that convention.
|
|
53
|
+
|
|
54
|
+
Reference: `app/http/routes/view.ts`, `app/http/routes/api.ts`
|
|
55
|
+
<https://nstfkc.github.io/gemi/routing.md>
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Construct Clients Lazily, Never at Module Scope
|
|
3
|
+
impact: HIGH
|
|
4
|
+
impactDescription: keeps boot fast and command discovery working
|
|
5
|
+
tags: service, boot, module-scope, commands
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Construct Clients Lazily, Never at Module Scope
|
|
9
|
+
|
|
10
|
+
Work at module scope runs whenever the module is *imported*, which is not the same as
|
|
11
|
+
when it is *used*. Three places in a gemi app punish that:
|
|
12
|
+
|
|
13
|
+
1. **Command discovery imports every file under `app/commands` just to list them.**
|
|
14
|
+
A `new Stripe(key)` beside the handler throws on an empty key during
|
|
15
|
+
`gemi run` — with no command actually invoked.
|
|
16
|
+
2. **Service `boot()` runs on every application start**, including per-test and
|
|
17
|
+
per-CLI-command. Validate settings there; open connections lazily.
|
|
18
|
+
3. **Ports are installed at boot, after every module in the graph has evaluated**, so
|
|
19
|
+
reading one at module scope gets `undefined`.
|
|
20
|
+
|
|
21
|
+
**Incorrect (constructed on import; `boot()` opens a connection):**
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
const stripe = new Stripe(process.env.STRIPE_KEY!); // throws at import time
|
|
25
|
+
|
|
26
|
+
export class SearchIndex extends Service {
|
|
27
|
+
static token = "searchIndex";
|
|
28
|
+
async boot() {
|
|
29
|
+
this.client = await Client.connect(process.env.SEARCH_URL!); // every start
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
**Correct (validate at boot, connect on first use, construct in the handler):**
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
export class SearchIndex extends Service {
|
|
38
|
+
static token = "searchIndex";
|
|
39
|
+
url = process.env.SEARCH_URL;
|
|
40
|
+
private ready?: Promise<Client>;
|
|
41
|
+
|
|
42
|
+
async boot() {
|
|
43
|
+
if (!this.url) throw new Error("SEARCH_URL is not set");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private connect() {
|
|
47
|
+
return (this.ready ??= Client.connect(this.url!));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export default defineCommand("sync-prices").handle(async ({ line }) => {
|
|
52
|
+
const stripe = new Stripe(process.env.STRIPE_KEY!); // inside the handler
|
|
53
|
+
});
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
**`Service.inject()` must not be called at module top level either** — it throws
|
|
57
|
+
because the kernel has not booted. Inject as a constructor default
|
|
58
|
+
(`constructor(private billing = Billing.inject())`), which resolves per request and
|
|
59
|
+
lets a test pass a double without touching the container.
|
|
60
|
+
|
|
61
|
+
**Read a runtime port inside a function or behind a getter**, never at module scope.
|
|
62
|
+
|
|
63
|
+
Reference: <https://nstfkc.github.io/gemi/services.md>
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: The Queue Is In-Process and In-Memory — Do Not Trust It With Durable Work
|
|
3
|
+
impact: HIGH
|
|
4
|
+
impactDescription: enqueued work is lost on restart
|
|
5
|
+
tags: service, jobs, queue, durability
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## The Queue Is In-Process and In-Memory — Do Not Trust It With Durable Work
|
|
9
|
+
|
|
10
|
+
Jobs live in the server process's memory. **Enqueued jobs do not survive a restart**,
|
|
11
|
+
and there is no cross-machine queue — a job dispatched on one instance runs on that
|
|
12
|
+
instance or not at all. Use jobs for best-effort work: warming a cache, sending a
|
|
13
|
+
non-critical email, kicking off media processing that the user can retry.
|
|
14
|
+
|
|
15
|
+
Anything that must not be lost needs a durable record: write the row first, then let
|
|
16
|
+
the job (or a cron sweep) act on it, so a restart leaves work to pick up rather than
|
|
17
|
+
a gap.
|
|
18
|
+
|
|
19
|
+
**Incorrect (the only record of the charge lives in the queue):**
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
async store(req: HttpRequest) {
|
|
23
|
+
const order = await Order.create({ data });
|
|
24
|
+
SettleOrderJob.dispatch({ orderId: order.publicId }); // lost on deploy
|
|
25
|
+
return { order };
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
**Correct (durable state first; the job is an accelerator):**
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
async store(req: HttpRequest) {
|
|
33
|
+
const order = await Order.create({ data: { ...data, settlementStatus: "pending" } });
|
|
34
|
+
SettleOrderJob.dispatch({ orderId: order.publicId });
|
|
35
|
+
return { order };
|
|
36
|
+
}
|
|
37
|
+
// A cron sweeps `settlementStatus: "pending"` rows, so a lost dispatch self-heals.
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
**Two deployment-shaped constraints that follow:**
|
|
41
|
+
|
|
42
|
+
- **The `queue` and `schedule` config slices declare their `jobs` lists
|
|
43
|
+
explicitly, and must keep doing so.** Discovery is a *runtime* filesystem walk, and
|
|
44
|
+
the release image ships only `dist/` — there is no `app/jobs` or `app/cron` on disk
|
|
45
|
+
in production. Discovery there warns once and registers nothing, so every job and
|
|
46
|
+
cron would stop running while dev and CI stayed green. Note `jobs: []` is
|
|
47
|
+
*present* and disables everything; omitting the key is what enables discovery.
|
|
48
|
+
- **A command dispatching a Job may exit before it runs.** The queue runs in-process
|
|
49
|
+
and the cron scheduler does not start under `gemi run` — do that work inline in the
|
|
50
|
+
command instead.
|
|
51
|
+
|
|
52
|
+
Reference: <https://nstfkc.github.io/gemi/jobs-and-queues.md>
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Services and Jobs Need a Static String Identifier
|
|
3
|
+
impact: HIGH
|
|
4
|
+
impactDescription: works in dev, silently breaks in the production build
|
|
5
|
+
tags: service, jobs, minification, production
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Services and Jobs Need a Static String Identifier
|
|
9
|
+
|
|
10
|
+
A `Service` needs `static token`; a `Job` needs `static name`. Both are string
|
|
11
|
+
literals because **minification renames classes in the production build** — the
|
|
12
|
+
container key and the queue's dispatch name must survive that rename. This is a
|
|
13
|
+
class of bug that cannot reproduce locally: dev is unminified, so everything works
|
|
14
|
+
until it is deployed.
|
|
15
|
+
|
|
16
|
+
**Incorrect (relies on the class name surviving the build):**
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
export class ProcessVideoJob extends Job {
|
|
20
|
+
async run(params: Params) { /* … */ }
|
|
21
|
+
}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
**Correct:**
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
export class ProcessVideoJob extends Job {
|
|
28
|
+
static name = "ProcessVideoJob";
|
|
29
|
+
maxAttempts = 3;
|
|
30
|
+
|
|
31
|
+
async run(params: Params) { /* … */ }
|
|
32
|
+
onFail(error: Error, params: Params) { /* … */ }
|
|
33
|
+
onDeadletter(error: Error, params: Params) { /* … */ }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class Billing extends Service {
|
|
37
|
+
static token = "Billing";
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
- **Tokens must be unique.** Two jobs claiming one name: the framework refuses the
|
|
42
|
+
second with a line on stderr rather than silently dropping it — rename one.
|
|
43
|
+
- **`maxAttempts` defaults to 3.** `run` throwing calls `onFail` and requeues;
|
|
44
|
+
the final failure calls `onDeadletter` and drops the job.
|
|
45
|
+
- **`worker: true`** runs the job in a separate Worker thread, for CPU-bound work.
|
|
46
|
+
- **Dispatch is fire-and-forget** — `Job.dispatch(payload)` returns `void`. Pass only
|
|
47
|
+
serializable data, never a class instance or a function.
|
|
48
|
+
|
|
49
|
+
**Register services explicitly on `Kernel.services`, and note that boot order is
|
|
50
|
+
load-bearing** — a service listed first boots before those after it.
|
|
51
|
+
|
|
52
|
+
<https://nstfkc.github.io/gemi/jobs-and-queues.md>
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Know Which Directories Are Discovered and Which Need Declaring
|
|
3
|
+
impact: HIGH
|
|
4
|
+
impactDescription: the difference between code that runs and code that silently never does
|
|
5
|
+
tags: structure, discovery, kernel, registration
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Know Which Directories Are Discovered and Which Need Declaring
|
|
9
|
+
|
|
10
|
+
`app/` has two kinds of directory, and confusing them produces the same symptom
|
|
11
|
+
either way: code that looks correct, imports cleanly, typechecks, and never runs.
|
|
12
|
+
|
|
13
|
+
**Discovered — writing the file *is* the registration:**
|
|
14
|
+
|
|
15
|
+
| Directory | Base class | Read at |
|
|
16
|
+
|---|---|---|
|
|
17
|
+
| `app/cron/` | `CronJob` | boot |
|
|
18
|
+
| `app/jobs/` | `Job` | boot |
|
|
19
|
+
| `app/listeners/` | `Listener` | boot |
|
|
20
|
+
| `app/commands/` | `defineCommand` chains | `gemi run` only |
|
|
21
|
+
|
|
22
|
+
Nothing anywhere names these. Adding a `Job` subclass under `app/jobs/` registers
|
|
23
|
+
it; there is no list to append to, and appending to one you invented does nothing.
|
|
24
|
+
|
|
25
|
+
`app/listeners/` is the one the scaffold does not create — its absence is not an
|
|
26
|
+
error, it means the app has no listeners yet.
|
|
27
|
+
|
|
28
|
+
**Declared — the file is not enough:**
|
|
29
|
+
|
|
30
|
+
- **Models** are declared on the Kernel. A model class under `app/models/` that
|
|
31
|
+
the Kernel's `models` does not reach is not registered, and `gemi check models`
|
|
32
|
+
exists to report exactly that gap for policied models.
|
|
33
|
+
- **Routers** are registered explicitly — `app/http/routes/view.ts` and `api.ts`
|
|
34
|
+
are the roots, and a new router is reached by being mounted from one of them.
|
|
35
|
+
- **Config slices** under `app/config/` are named by the Kernel's `config`.
|
|
36
|
+
- **Service providers** under `app/providers/` are named by the Kernel's
|
|
37
|
+
`providers`.
|
|
38
|
+
|
|
39
|
+
**Incorrect (a job that never runs, because a hand-rolled list is not the mechanism):**
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
// app/jobs/index.ts — invented; nothing reads this
|
|
43
|
+
export const jobs = [SendWelcomeEmail, ReindexProducts];
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
**Correct (the file is the registration):**
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
// app/jobs/SendWelcomeEmail.ts
|
|
50
|
+
export class SendWelcomeEmail extends Job {
|
|
51
|
+
static token = "SendWelcomeEmail";
|
|
52
|
+
// …
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
**Incorrect (a model that typechecks and is never registered):**
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
// app/models/Invoice.ts — written, but not reachable from the Kernel's `models`
|
|
60
|
+
export class Invoice extends Model {}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The four discovered directories have a cost worth knowing: **discovery imports
|
|
64
|
+
every file it walks**, because a class does not exist until its module has run. A
|
|
65
|
+
file in one of them that does work at import time does that work at boot. Keep
|
|
66
|
+
them to declarations — see `service-lazy-not-module-scope`.
|
|
67
|
+
|
|
68
|
+
The discovered directories can also be declared explicitly in their config slice
|
|
69
|
+
(`schedule`, `queue`, `events`, `command`), which turns the walk off and uses the
|
|
70
|
+
list verbatim. An empty array is a declaration too, and means "this app has none"
|
|
71
|
+
— not "fall back to walking".
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Do Not Build Structure the Framework Already Has
|
|
3
|
+
impact: HIGH
|
|
4
|
+
impactDescription: a parallel structure is one the framework's own tooling cannot see
|
|
5
|
+
tags: structure, container, services, conventions
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Do Not Build Structure the Framework Already Has
|
|
9
|
+
|
|
10
|
+
The reflex when a codebase needs somewhere to put shared code is to add `lib/`,
|
|
11
|
+
`utils/`, or a hand-rolled registry. In a gemi app most of those already exist
|
|
12
|
+
with a name, and the cost of the parallel one is not duplication — it is that the
|
|
13
|
+
framework's own machinery cannot see it.
|
|
14
|
+
|
|
15
|
+
| The reflex | What gemi already has |
|
|
16
|
+
|---|---|
|
|
17
|
+
| A `lib/` of shared classes with I/O | `app/services/`, classes extending `Service`, resolved from the container |
|
|
18
|
+
| A module-scope singleton client | A `Service` with a `static token`, injected where needed |
|
|
19
|
+
| A hand-rolled service locator or DI map | The container; `app/providers/` binds into it |
|
|
20
|
+
| A custom router, or routing by file path | Router classes under `app/http/routes/` |
|
|
21
|
+
| Ad-hoc `if (!req.body.name)` checks | An `HttpRequest` subclass under `app/http/requests/` |
|
|
22
|
+
| A `constants.ts` of env reads | A config slice under `app/config/` |
|
|
23
|
+
| A `scripts/` folder run with `bun x.ts` | `app/commands/`, run by `gemi run` |
|
|
24
|
+
| A barrel re-exporting a package's modules | Deep imports — see `bundle-deep-imports` |
|
|
25
|
+
|
|
26
|
+
**Incorrect (a registry the framework cannot see):**
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
// app/lib/services.ts — invented
|
|
30
|
+
export const billing = new Billing(process.env.STRIPE_KEY!);
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Constructed at module scope, so it runs whenever anything imports the file —
|
|
34
|
+
including a discovery walk that imports every file in a directory. Nothing can
|
|
35
|
+
rebind it in a test, and nothing resolves it by token.
|
|
36
|
+
|
|
37
|
+
**Correct:**
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
// app/services/Billing.ts
|
|
41
|
+
export class Billing extends Service {
|
|
42
|
+
static token = "Billing";
|
|
43
|
+
// Constructed lazily by the container, rebindable in a test.
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Two things follow from this that are easy to get backwards:
|
|
48
|
+
|
|
49
|
+
- **A view's file path is not its URL.** `app/views/` is organised however you
|
|
50
|
+
like; routing is declared on router classes. Moving a view does not change a
|
|
51
|
+
route, and creating one under a path that "looks like" a URL registers nothing.
|
|
52
|
+
See `routing-routers-are-classes`.
|
|
53
|
+
- **`app/models/generated/` is output.** Editing it is editing a build artifact —
|
|
54
|
+
the change disappears on the next generate.
|
|
55
|
+
|
|
56
|
+
When a rule here and an existing pattern in your app disagree, check the app's
|
|
57
|
+
`CLAUDE.md` and git history before "fixing" it: the app may have a reason, and
|
|
58
|
+
ground rule 2 says the app wins.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Mount and Query the DOM — Do Not Scrape Rendered Markup
|
|
3
|
+
impact: MEDIUM-HIGH
|
|
4
|
+
impactDescription: catches real regressions instead of string drift
|
|
5
|
+
tags: testing, testing-library, house-style
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Mount and Query the DOM — Do Not Scrape Rendered Markup
|
|
9
|
+
|
|
10
|
+
Asserting on `renderToStaticMarkup` / `renderToString` output — substring matches,
|
|
11
|
+
regex scrapes for `href=`, index arithmetic to prove ordering — tests the markup
|
|
12
|
+
rather than the component. It passes when the component is broken and fails when a
|
|
13
|
+
class name changes.
|
|
14
|
+
|
|
15
|
+
**Incorrect:**
|
|
16
|
+
|
|
17
|
+
```tsx
|
|
18
|
+
const html = renderToStaticMarkup(<ProductList products={products} />);
|
|
19
|
+
expect(html).toContain("Chair");
|
|
20
|
+
expect(html.indexOf("Chair")).toBeLessThan(html.indexOf("Desk"));
|
|
21
|
+
expect(html).toMatch(/href="\/app\/abc\/products\/1"/);
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
**Correct:**
|
|
25
|
+
|
|
26
|
+
```tsx
|
|
27
|
+
render(<Page {...}><ProductList /></Page>);
|
|
28
|
+
|
|
29
|
+
expect(screen.getByRole("link", { name: "Chair" })).toHaveAttribute(
|
|
30
|
+
"href", "/app/abc/products/1",
|
|
31
|
+
);
|
|
32
|
+
expect(
|
|
33
|
+
screen.getByText("Chair").compareDocumentPosition(screen.getByText("Desk")),
|
|
34
|
+
).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Use `getByRole` / `getByLabelText`, `compareDocumentPosition` for order, and
|
|
38
|
+
`label[for]` for input association. **The one exception is a test whose SUBJECT is
|
|
39
|
+
the server payload** — e.g. guarding that a virtualised grid does not ship an empty
|
|
40
|
+
shell. Say so in the file when you take it.
|
|
41
|
+
|
|
42
|
+
**Assert what goes over the wire, not how a query was configured.** Checking that
|
|
43
|
+
`lazy` or `refreshInterval` was passed tests the arguments; it cannot tell a working
|
|
44
|
+
pager from one that never fetched. A non-lazy query is proved by a request arriving
|
|
45
|
+
on mount, a lazy one by no request arriving until something asks.
|
|
46
|
+
|
|
47
|
+
**Mock HTTP with MSW, not by replacing `globalThis.fetch`.** `setupServer` +
|
|
48
|
+
`server.listen({ onUnhandledRequest: "error" })` makes an undeclared request fail
|
|
49
|
+
instead of silently resolving, and lets a case assert against the real `Request` —
|
|
50
|
+
resolved URL, method, headers, parsed body.
|
|
51
|
+
|
|
52
|
+
**Make a stub load-bearing.** A test that removes `crypto.randomUUID` to reach a
|
|
53
|
+
fallback, then asserts a shape both branches satisfy, passes either way. Assert the
|
|
54
|
+
exact value.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Match the Suite You Are In
|
|
3
|
+
impact: MEDIUM
|
|
4
|
+
impactDescription: a test the runner never selects is a test that does not exist
|
|
5
|
+
tags: testing, runner, conventions
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Match the Suite You Are In
|
|
9
|
+
|
|
10
|
+
Before adding a test, read the neighbours and the runner config. Copy the runner, the
|
|
11
|
+
import source, and the file-naming convention already in use in that directory. A
|
|
12
|
+
test written for the wrong runner does not fail loudly — the runner simply never
|
|
13
|
+
selects the file, and it sits green forever without executing.
|
|
14
|
+
|
|
15
|
+
**This is the whole rule for the common case.** Everything below is the gemi-specific
|
|
16
|
+
part: the two things that trip up a component test in a gemi app regardless of which
|
|
17
|
+
runner you picked.
|
|
18
|
+
|
|
19
|
+
**A component test needs a DOM.** gemi renders on the server, so a suite configured
|
|
20
|
+
as a plain Node project has no `window`. Mounting a view there fails on the first
|
|
21
|
+
DOM call, not with a useful message. Either point that suite at a DOM environment
|
|
22
|
+
(`jsdom`, `happy-dom`) or put component tests in the half of the suite that has one.
|
|
23
|
+
|
|
24
|
+
**A test of the server dictionary readers must drop `window` first.**
|
|
25
|
+
`Dictionary.render` and `Dictionary.reference` throw in a browser-shaped environment,
|
|
26
|
+
so a DOM-providing preload breaks them for the whole run. Reach for the server
|
|
27
|
+
globals helper at module scope — a dictionary is often read while the test module
|
|
28
|
+
evaluates, before any `beforeEach` has run.
|
|
29
|
+
|
|
30
|
+
**Incorrect (a component test in the Node-only half of the suite):**
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
// runs, finds no document, fails on render
|
|
34
|
+
import { render } from "@testing-library/react";
|
|
35
|
+
import { describe, it } from "vitest";
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
**Correct (the same test where a DOM exists):**
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
import { render } from "@testing-library/react";
|
|
42
|
+
import { describe, it } from "vitest"; // or "bun:test" — whichever this suite uses
|
|
43
|
+
|
|
44
|
+
// vitest.config.ts: environment: "jsdom"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Two notes if your repo runs more than one runner:
|
|
48
|
+
|
|
49
|
+
- **The file name is usually the selection mechanism.** `bun test` has no
|
|
50
|
+
include-pattern config, so a repo running both typically selects the bun half by a
|
|
51
|
+
path fragment (`<name>.bun.test.ts`) and excludes that same fragment from the other
|
|
52
|
+
runner's config. Renaming a file is what moves it between halves.
|
|
53
|
+
- **`vi.hoisted` / `vi.mock` do not port.** They rely on Vitest's AST hoisting, which
|
|
54
|
+
`bun:test` has no equivalent for. A file using only `describe`/`it`/`expect` is an
|
|
55
|
+
import swap; a file using those needs rewriting.
|
|
56
|
+
|
|
57
|
+
See also `testing-page-seeds-real-inputs` for how to mount a gemi view under test.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Mount With Page From gemi/testing, Never Mock gemi/client
|
|
3
|
+
impact: HIGH
|
|
4
|
+
impactDescription: tests the component instead of the mock
|
|
5
|
+
tags: testing, gemi-testing, house-style
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Mount With Page From gemi/testing, Never Mock gemi/client
|
|
9
|
+
|
|
10
|
+
`<Page>` supplies the inputs the framework normally provides — route params, search,
|
|
11
|
+
locale, user, and the query cache — so a view mounts for real. **Mocking
|
|
12
|
+
`gemi/client` is prohibited, and the rule is not scoped to component tests**: it
|
|
13
|
+
binds hook and model tests the same way, and spreading the real module through
|
|
14
|
+
(`...(await import("gemi/client"))`) is still mocking it.
|
|
15
|
+
|
|
16
|
+
**Incorrect (asserts the mock, not the component):**
|
|
17
|
+
|
|
18
|
+
```tsx
|
|
19
|
+
mock.module("gemi/client", () => ({
|
|
20
|
+
useQuery: () => ({ data: [{ id: 1, name: "Chair" }] }),
|
|
21
|
+
useParams: () => ({ orgId: "abc" }),
|
|
22
|
+
}));
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
**Correct (real hooks, seeded inputs):**
|
|
26
|
+
|
|
27
|
+
```tsx
|
|
28
|
+
import { Page } from "gemi/testing";
|
|
29
|
+
|
|
30
|
+
render(
|
|
31
|
+
<Page
|
|
32
|
+
pathname="/app/:orgId/products"
|
|
33
|
+
params={{ orgId: "abc" }}
|
|
34
|
+
searchParams="?tab=recent"
|
|
35
|
+
locale="en-US"
|
|
36
|
+
user={{ id: 1 }}
|
|
37
|
+
queryData={{ "/app/:orgId/products": [{ id: 1, name: "Chair" }] }}
|
|
38
|
+
fallback={<Skeleton />}
|
|
39
|
+
errorFallback={<Failed />}
|
|
40
|
+
onNavigate={onNavigate}
|
|
41
|
+
>
|
|
42
|
+
<ProductsRoute />
|
|
43
|
+
</Page>,
|
|
44
|
+
);
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
- **`queryData` keys are the path as `useQuery` writes it**, minus `/api`, with
|
|
48
|
+
`:params` resolved against the page's `params`. A seeded query renders on the first
|
|
49
|
+
pass and issues no request.
|
|
50
|
+
- **Navigation is reported, not performed** — `onNavigate` receives
|
|
51
|
+
`(resolvedHref, "push" | "replace")`. To test the destination, mount a second
|
|
52
|
+
`<Page>`.
|
|
53
|
+
- **Seed `fallback` and `errorFallback` and assert those** for a non-lazy query:
|
|
54
|
+
it suspends while in flight and throws on failure, so `loading`/`error` are not
|
|
55
|
+
what that path returns.
|
|
56
|
+
|
|
57
|
+
**If an assertion can only be made by mocking `gemi/client`** — `prefetch` leaves no
|
|
58
|
+
DOM trace, a synchronous `useMutate` throw — **drop the assertion, not the rule**, and
|
|
59
|
+
say in the file why it went.
|
|
60
|
+
|
|
61
|
+
**Do not mock anything else either** unless it is genuinely impossible otherwise. A
|
|
62
|
+
Radix dropdown rendering nothing until it opens is an argument for opening it with
|
|
63
|
+
`userEvent`, not for stubbing it.
|
|
64
|
+
|
|
65
|
+
Reference: <https://nstfkc.github.io/gemi/testing.md>
|
package/dist/chunk-txhcx69q.js
DELETED
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
// @bun
|
|
2
|
-
import{pa as h}from"./chunk-tkdxv6cq.js";import{createElement as x}from"react";function j(i,n){let s=Object.keys(n).sort((u,b)=>b.length-u.length).map((u)=>u.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")),r=new RegExp(s.join("|"),"g");return i.replace(r,(u)=>n[u])}async function J(i,n,s=[]){let r=[...s.map((t)=>`${i}/views/${t}.tsx`),`${i}/views/RootLayout.tsx`],u=new Set;for(let t of r){let e=n.moduleGraph.getModulesByFile(t);if(e)for(let d of e)u.add(d)}let b=[],p=[],c={};for(let t of u)if(t)for(let e of t.importedModules){if(e.file.includes("module.css"))c[e.file]=e.ssrTransformResult.map.sourcesContent.join("");if(e.file.includes(".css"))p.push(e.file)}for(let t of p){let e=await n.transformRequest(t+"?direct"),d=t.includes("module.css"),o="";if(d)o=j(c[t],e.default);b.push({isDev:!0,id:t,content:d?o:e.code})}return b.map((t,e)=>{return x("style",{key:e,type:"text/css","data-vite-dev-id":t.id,dangerouslySetInnerHTML:{__html:t.content}})})}async function L(i=[]){return i.map((n,s)=>{return x("style",{key:s,id:n?.id,type:"text/css",dangerouslySetInnerHTML:{__html:n.content}})})}import{existsSync as f}from"fs";import{basename as v}from"path";import{networkInterfaces as k}from"os";var y={name:"gemi",version:"0.59.0",private:!1,license:"MIT",author:"Enes Tufekci <enes@gemijs.dev>",repository:{type:"git",url:"git+https://github.com/nstfkc/gemi.git",directory:"packages/gemi"},bin:{gemi:"./dist/bin/gemi.js","gemi-orm-generator":"./dist/bin/orm-generator.js"},files:["dist/**/*","ide/typescript-plugin/package.json"],module:!0,exports:{"./http":"./http/index.ts","./client":"./client/index.ts","./testing":"./testing/index.ts","./app":"./app/index.ts","./facades":"./facades/index.ts","./email":"./email/index.ts","./vite":"./dist/vite/index.mjs","./kernel":"./kernel/index.ts","./services":"./services/index.ts","./broadcasting":"./broadcasting/index.ts","./i18n":"./i18n/index.ts","./dictionary":"./i18n/dictionaryRuntime.ts","./server":"./server/index.ts","./config":"./config/index.ts","./container":"./container/index.ts","./foundation":"./foundation/index.ts","./database":"./database/index.ts","./orm":"./orm/index.ts","./support":"./support/index.ts","./console/run":"./console/run.ts","./ide/typescript-plugin":"./ide/typescript-plugin/index.ts","./bun/preload":"./bun/preload.ts","./bun/plugin":"./bun/plugin.ts"},scripts:{lint:"oxlint",typecheck:"tsc --noEmit -p tsconfig.json && tsc --noEmit -p ide/typescript-plugin/tsconfig.json",build:"NODE_ENV=production bun run build:core && bun run build:bin && bun run build:types && bun run build:client && bun run build:plugin && bun run build:ts-plugin && bun run build:client-types && bun run build:augmentation","build:core":"bun ./scripts/build.ts","build:bin":"bun build --outdir=./dist/bin --target=bun --external=vite --external=react-dom --external=react/jsx-runtime --external=bun --external=sharp --external=@azure/storage-blob --sourcemap ./bin/gemi.ts ./bin/orm-generator.ts && bun ./scripts/prepare-bin.ts","build:client":"rm -rf dist/client dist/testing dist/chunks && vite build -c vite.client.config.mts","build:plugin":"vite build -c vite.plugin.config.mts","build:ts-plugin":"bun ./scripts/build-ts-plugin.ts","build:types":"tsc","build:client-types":"tsc -p tsconfig.browser.json",test:"bun --bun vitest","test:types":"vitest run --typecheck.only","test:packaging":"bun --bun vitest run --config vitest.packaging.config.ts","build:publish":"bun scripts/build-publish.ts",prepublishOnly:"echo 'Do not publish from this directory \u2014 its exports point at TS source. Run: bun run build:publish, then publish from .publish/' && exit 1","build:augmentation":"bun ./scripts/wire-augmentation.ts"},dependencies:{"@aws-sdk/client-s3":"^3.629.0","@aws-sdk/s3-request-presigner":"^3.629.0","ast-types":"^0.14.2",commander:"^12.1.0",history:"^5.3.0","jsx-email":"2.0.0-rc2.1","magic-string":"^0.30.17",open:"^10.1.0","react-error-boundary":"^6.0.0",recast:"^0.23.9",resend:"^6.12.3",satori:"^0.12.2","temporal-polyfill":"^0.2.5","twitter-api-v2":"^1.20.2","urlpattern-polyfill":"^10.0.0",uuid:"^9.0.1"},devDependencies:{"@babel/parser":"^7.25.4","@prisma/generator-helper":"^6.5.0","@repo/typescript-config":"*","@testing-library/react":"^16.3.2","@types/bun":"^1.3.14","@types/eslint":"^8.56.5","@types/node":"^20.11.24","@types/react":"^19.0.2","@types/react-dom":"^19.0.2",eslint:"^8.57.0",jsdom:"^30.0.1",oxlint:"^1.39.0",react:"19.2.3","react-dom":"19.2.3",typescript:"^5.7.3",vitest:"^4.0.0"},peerDependencies:{"@azure/storage-blob":"^12.28.0",react:">=19","react-dom":">=19",sharp:"^0.34.2",vite:"^8.0.0"},peerDependenciesMeta:{"@azure/storage-blob":{optional:!0}}};var z=[" \u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2557\u2588\u2588\u2557","\u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255D \u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255D\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2551\u2588\u2588\u2551","\u2588\u2588\u2551 \u2588\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2554\u2588\u2588\u2588\u2588\u2554\u2588\u2588\u2551\u2588\u2588\u2551","\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u255D \u2588\u2588\u2551\u255A\u2588\u2588\u2554\u255D\u2588\u2588\u2551\u2588\u2588\u2551","\u255A\u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255D\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2551 \u255A\u2550\u255D \u2588\u2588\u2551\u2588\u2588\u2551"," \u255A\u2550\u2550\u2550\u2550\u2550\u255D \u255A\u2550\u2550\u2550\u2550\u2550\u2550\u255D\u255A\u2550\u255D \u255A\u2550\u255D\u255A\u2550\u255D"],O=Boolean(process.stdout.isTTY)&&!process.env.NO_COLOR,g=(i,n)=>O?`\x1B[${i}m${n}\x1B[0m`:n,T=(i)=>g("1",i),l=(i)=>g("2",i),a=(i)=>g("36",i),q=(i)=>g("32",i),E=(i)=>g("35",i);function R(){return y.version??"?"}function _(i){for(let n of Object.values(k()))for(let s of n??[])if((s.family==="IPv4"||s.family===4)&&!s.internal)return`http://${s.address}:${i}`;return null}function G(i){return h(i).filter((n)=>f(n)).map((n)=>v(n))}function A(i){let{port:n,rootDir:s}=i,r=`http://localhost:${n}`,u=_(n),b=G(s),p=(t,e)=>` ${q("\u279C")} ${T(t.padEnd(9))}${e}`,c=["",...z.map((t)=>E(t)),"",` ${l("gemi")} ${a(`v${R()}`)}`,"",p("Local:",a(r)),p("Network:",u?a(u):l("unavailable")),p("Env:",b.length?b.join(", "):l("none")),""];console.log(c.join(`
|
|
3
|
-
`))}
|
|
4
|
-
export{J as Z,L as _,A as $};
|
|
5
|
-
|
|
6
|
-
//# debugId=729A5B226DC8C37F64756E2164756E21
|
|
File without changes
|
|
File without changes
|