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,57 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Reach for Form First, Mutation Hooks When You Need Control
|
|
3
|
+
impact: MEDIUM
|
|
4
|
+
impactDescription: CSRF, FormData and field errors for free
|
|
5
|
+
tags: client, forms, mutations, validation
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Reach for Form First, Mutation Hooks When You Need Control
|
|
9
|
+
|
|
10
|
+
`<Form>` wraps a native form and handles what is otherwise hand-written every time:
|
|
11
|
+
collecting fields into `FormData`, attaching the CSRF token, tracking pending state,
|
|
12
|
+
and exposing the server's per-field `validation_error` messages through context.
|
|
13
|
+
|
|
14
|
+
**Incorrect (re-implementing all of it, and losing field errors):**
|
|
15
|
+
|
|
16
|
+
```tsx
|
|
17
|
+
const { trigger, loading } = usePost("/admin/user");
|
|
18
|
+
const [name, setName] = useState("");
|
|
19
|
+
const [errors, setErrors] = useState({});
|
|
20
|
+
|
|
21
|
+
async function onSubmit(e) {
|
|
22
|
+
e.preventDefault();
|
|
23
|
+
const res = await trigger({ name });
|
|
24
|
+
if (res?.error?.kind === "validation_error") setErrors(res.error.messages);
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
**Correct:**
|
|
29
|
+
|
|
30
|
+
```tsx
|
|
31
|
+
import { Form, ValidationErrors } from "gemi/client";
|
|
32
|
+
|
|
33
|
+
<Form action="/admin/user" method="POST"
|
|
34
|
+
onSuccess={(_data, form) => { form.reset(); push("/admin/users"); }}>
|
|
35
|
+
<input name="name" />
|
|
36
|
+
<ValidationErrors name="name" />
|
|
37
|
+
<FormError />
|
|
38
|
+
<button type="submit">Create</button>
|
|
39
|
+
</Form>;
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Inside a `Form`, three hooks read its context: `useFormStatus()`
|
|
43
|
+
(`{ isPending, validationErrors, formError }`), `useMutationStatus()`
|
|
44
|
+
(`{ isPending }`), and `useFormData()` for live `FormData` as the user types. The
|
|
45
|
+
form also carries a `data-loading` attribute for styling.
|
|
46
|
+
|
|
47
|
+
**Use the mutation hooks (`usePost` / `usePut` / `usePatch` / `useDelete`) when the
|
|
48
|
+
write is not a form submission** — a button that toggles a flag, an action in a menu,
|
|
49
|
+
an optimistic list operation. Pair them with `useMutate` to update the reading
|
|
50
|
+
query's cache rather than refetching.
|
|
51
|
+
|
|
52
|
+
**File posts that need to be unit-testable use `usePost` with `FormData`.**
|
|
53
|
+
`useUpload` is XHR-based, which is what gives it progress events and also what makes
|
|
54
|
+
it uninterceptable by MSW under happy-dom — reach for it only when you need the
|
|
55
|
+
progress bar.
|
|
56
|
+
|
|
57
|
+
Reference: <https://nstfkc.github.io/gemi/forms.md>
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: A Route Module's Loading and Error Exports Are Its Suspense Boundary
|
|
3
|
+
impact: MEDIUM
|
|
4
|
+
impactDescription: correct fallback placement, no full-page blanking
|
|
5
|
+
tags: render, suspense, ssr, boundaries
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## A Route Module's Loading and Error Exports Are Its Suspense Boundary
|
|
9
|
+
|
|
10
|
+
A view module declares its own suspense boundary by exporting `Loading` and
|
|
11
|
+
`Error` **alongside** its default export — there is no separate file convention for
|
|
12
|
+
either. Since
|
|
13
|
+
`useQuery` suspends by default (`query-suspense-default`), a route with no `Loading`
|
|
14
|
+
export falls back to whatever boundary happens to be above it — often a bare
|
|
15
|
+
`<Suspense fallback={null}>`, which blanks the surface.
|
|
16
|
+
|
|
17
|
+
**Incorrect (no boundary of its own; suspending blanks whatever is above):**
|
|
18
|
+
|
|
19
|
+
```tsx
|
|
20
|
+
export default function Todos() {
|
|
21
|
+
const { data } = useQuery("/todos");
|
|
22
|
+
return <ul>{data.map((t) => <li key={t.id}>{t.title}</li>)}</ul>;
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
**Correct:**
|
|
27
|
+
|
|
28
|
+
```tsx
|
|
29
|
+
export default function Todos() {
|
|
30
|
+
const { data } = useQuery("/todos");
|
|
31
|
+
return <ul>{data.map((t) => <li key={t.id}>{t.title}</li>)}</ul>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function Loading() {
|
|
35
|
+
return <TodosSkeleton />;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function Error({ error, resetErrorBoundary }) {
|
|
39
|
+
return <button onClick={() => resetErrorBoundary()}>Retry</button>;
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
**Place the boundary where the blank is acceptable.** A route-level `Loading` is
|
|
44
|
+
right for the route's primary read. For a secondary widget, do not add a boundary —
|
|
45
|
+
give that query `{ suspense: false }` and render its loading state in place, so the
|
|
46
|
+
rest of the page keeps its content.
|
|
47
|
+
|
|
48
|
+
**Do not hand-roll a navigation spinner.** gemi already commits navigations in a
|
|
49
|
+
transition, keeping the previous page visible until the new page's queries resolve,
|
|
50
|
+
and the production shell preloads the chunks in the current route's component chain.
|
|
51
|
+
`Link` sets `data-pending` during navigation if you want to style the transition —
|
|
52
|
+
reach for that before adding state.
|
|
53
|
+
|
|
54
|
+
Reference: <https://nstfkc.github.io/gemi/data-fetching.md>
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Do Not Route Data Flow Through useEffect
|
|
3
|
+
impact: MEDIUM
|
|
4
|
+
impactDescription: removes a render pass and a class of stale-state bugs
|
|
5
|
+
tags: rerender, effects, derived-state, house-style
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Do Not Route Data Flow Through useEffect
|
|
9
|
+
|
|
10
|
+
An effect that computes state runs *after* a render commits, so it costs an extra
|
|
11
|
+
render pass and opens a window where the UI shows a stale value. This is a standing
|
|
12
|
+
house rule in most gemi apps, not a preference. Three replacements cover almost
|
|
13
|
+
every
|
|
14
|
+
case:
|
|
15
|
+
|
|
16
|
+
| Instead of an effect that… | Do this |
|
|
17
|
+
|---|---|
|
|
18
|
+
| resets state when a prop or selection changes | reset in the `onChange` handler — it is event-driven |
|
|
19
|
+
| computes a value from other state | derive it in `useMemo` from the source |
|
|
20
|
+
| debounces a value | a debounce hook, applied to the value before it is used |
|
|
21
|
+
|
|
22
|
+
**Incorrect (three effects doing data flow):**
|
|
23
|
+
|
|
24
|
+
```tsx
|
|
25
|
+
const [selected, setSelected] = useState(null);
|
|
26
|
+
const [query, setQuery] = useState("");
|
|
27
|
+
const [debounced, setDebounced] = useState("");
|
|
28
|
+
const [options, setOptions] = useState([]);
|
|
29
|
+
|
|
30
|
+
useEffect(() => { setQuery(""); }, [selected]); // reset
|
|
31
|
+
useEffect(() => { setOptions(items.filter(fn)); }, [items]); // derive
|
|
32
|
+
useEffect(() => { // debounce
|
|
33
|
+
const id = setTimeout(() => setDebounced(query), 250);
|
|
34
|
+
return () => clearTimeout(id);
|
|
35
|
+
}, [query]);
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
**Correct:**
|
|
39
|
+
|
|
40
|
+
```tsx
|
|
41
|
+
// gemi ships no debounce hook — this is your own, or one from a library.
|
|
42
|
+
import { useDebounced } from "@/app/hooks/useDebounced";
|
|
43
|
+
|
|
44
|
+
const [selected, setSelected] = useState(null);
|
|
45
|
+
const [query, setQuery] = useState("");
|
|
46
|
+
const [debouncedQuery] = useDebounced(query.trim(), 250);
|
|
47
|
+
|
|
48
|
+
const options = useMemo(() => items.filter(fn), [items]);
|
|
49
|
+
|
|
50
|
+
function onSelect(next) {
|
|
51
|
+
setSelected(next);
|
|
52
|
+
setQuery(""); // reset where the event happens
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
**Whether the memoization here is load-bearing depends on your build.** If your
|
|
57
|
+
`gemi.config.ts` enables the React plugin's `compiler` option — the default in
|
|
58
|
+
projects scaffolded from the gemi template, unless `GEMI_REACT_COMPILER=off` is set —
|
|
59
|
+
the React Compiler memoizes the client build for you, and a hand-written `useMemo` on a derived value is mostly redundant. Without it,
|
|
60
|
+
`useMemo` / `useCallback` on a value that feeds a query variant, an effect dependency,
|
|
61
|
+
or a memoized child is doing real work, not decoration. Check the config before you
|
|
62
|
+
add or remove one.
|
|
63
|
+
|
|
64
|
+
Either way the *shape* above is the point: derive in `useMemo`, do not compute state
|
|
65
|
+
in an effect. The compiler removes the boilerplate, not the extra render pass.
|
|
66
|
+
|
|
67
|
+
Effects remain correct for actual synchronization with the outside world:
|
|
68
|
+
subscriptions, event listeners, imperative DOM measurement, timers you own.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Navigate With a Typed Link, Not an Interpolated Path String
|
|
3
|
+
impact: MEDIUM-HIGH
|
|
4
|
+
impactDescription: a renamed route breaks the build, not production
|
|
5
|
+
tags: client, navigation, links, types
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Navigate With a Typed Link, Not an Interpolated Path String
|
|
9
|
+
|
|
10
|
+
`Link` and `useNavigate` take the **route pattern** plus `params`, both typed against
|
|
11
|
+
the routers. Build a URL by hand and the type layer cannot see it: renaming a route
|
|
12
|
+
leaves a template literal that compiles fine and 404s at runtime.
|
|
13
|
+
|
|
14
|
+
**Incorrect (a string the compiler cannot check):**
|
|
15
|
+
|
|
16
|
+
```tsx
|
|
17
|
+
<a href={`/app/${orgId}/products/${product.publicId}`}>{product.name}</a>
|
|
18
|
+
<Link href={`/app/${orgId}/chat`}>Chat</Link>
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
**Correct:**
|
|
22
|
+
|
|
23
|
+
```tsx
|
|
24
|
+
import { Link, useNavigate } from "gemi/client";
|
|
25
|
+
|
|
26
|
+
<Link href="/app/:orgId/products/:productId"
|
|
27
|
+
params={{ orgId, productId: product.publicId }}>
|
|
28
|
+
{product.name}
|
|
29
|
+
</Link>;
|
|
30
|
+
|
|
31
|
+
const { push } = useNavigate();
|
|
32
|
+
push("/app/:orgId/chat", { params: { orgId } });
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
- **`params` are inherited from the current route if omitted**, so a link within the
|
|
36
|
+
same org needs only the params that change.
|
|
37
|
+
- **`search` and `hash`** are typed props, not string concatenation.
|
|
38
|
+
- **`Link` sets `data-active`** when it matches the current URL and `data-pending`
|
|
39
|
+
during navigation — style those instead of tracking active state yourself.
|
|
40
|
+
- **`prefetch`** warms the target route (`hover`, `intent`, `viewport`, `render`).
|
|
41
|
+
A prefetch request carries a `Purpose: prefetch` header, so a handler with side
|
|
42
|
+
effects must check it before recording a visit.
|
|
43
|
+
- **`useSearchParams`** mutates and then navigates: `searchParams.set("page", next)`
|
|
44
|
+
then `searchParams.push()`.
|
|
45
|
+
- **`push(..., { shallow: true })`** updates the URL without re-running data loaders.
|
|
46
|
+
|
|
47
|
+
Prefer a typed `<Link>` over a shared route-string map for new navigation. Some
|
|
48
|
+
existing surfaces read templates from a constants module; match that when editing
|
|
49
|
+
them, but do not extend the pattern.
|
|
50
|
+
|
|
51
|
+
Reference: <https://nstfkc.github.io/gemi/navigation.md>
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Scope Every Tenant Read, in Middleware or a Policy
|
|
3
|
+
impact: HIGH
|
|
4
|
+
impactDescription: the difference between a bug and a data leak
|
|
5
|
+
tags: controller, authorization, tenancy, security, orm
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Scope Every Tenant Read, in Middleware or a Policy
|
|
9
|
+
|
|
10
|
+
A route carrying `:orgId` is not authorized by having the parameter — it is
|
|
11
|
+
authorized by checking the signed-in user belongs to that organization. Three layers
|
|
12
|
+
do this, and they compose:
|
|
13
|
+
|
|
14
|
+
1. **Middleware**, for the coarse gate on the whole router (`"auth"`, `"admin"`,
|
|
15
|
+
`"org"`, `"role:owner"`). This is where a whole surface is fenced off.
|
|
16
|
+
2. **`Auth.guard(predicate)`** in a controller, for a per-action decision. It throws
|
|
17
|
+
`InsufficientPermissionsError` on a falsy result; `Auth.guardSafe` returns a
|
|
18
|
+
boolean instead.
|
|
19
|
+
3. **An ORM policy** on the model — a `scope` that narrows every query, including
|
|
20
|
+
nested `include`s.
|
|
21
|
+
|
|
22
|
+
**Incorrect (the parameter is trusted; any signed-in user reads any org):**
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
class OrgRouter extends ApiRouter {
|
|
26
|
+
middlewares = ["auth"]; // authenticated, but not scoped
|
|
27
|
+
routes = { "/:orgId/products": this.get(ProductController, "list") };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async list(req: HttpRequest) {
|
|
31
|
+
return Product.findMany({ where: { organizationId: req.params.orgId } });
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
**Correct (a membership gate on the router):**
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
class OrgRouter extends ApiRouter {
|
|
39
|
+
middlewares = ["auth", "org"]; // OrganizationMiddleware checks membership
|
|
40
|
+
routes = { "/:orgId/products": this.get(ProductController, "list") };
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
**A policy is the strongest version**, because it reaches reads the controller did
|
|
45
|
+
not write — the `accounts` inside a `User.findMany({ include: { accounts: true } })`
|
|
46
|
+
are scoped too, and relation filters and counts only see scoped rows, so
|
|
47
|
+
"unscoped existence" cannot leak:
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
export class Account extends AccountModel {
|
|
51
|
+
static $policies: AccountPolicy[] = [
|
|
52
|
+
{
|
|
53
|
+
scope: (ctx) => ({ organizationId: ctx.user.organizationId }),
|
|
54
|
+
onCreate: (ctx, data) => ({ ...data, organizationId: ctx.user.organizationId }),
|
|
55
|
+
},
|
|
56
|
+
];
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
A policy only applies to a model **registered on `Kernel.models`** via the
|
|
61
|
+
`app/models` barrel — that registration is what makes it apply inside nested
|
|
62
|
+
`include`s. Policies concatenate base-first, so a subclass can narrow an inherited
|
|
63
|
+
policy but never widen it.
|
|
64
|
+
|
|
65
|
+
Reference: <https://nstfkc.github.io/gemi/authorization.md>
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Parse the Request at the Boundary, Keep Utils Framework-Free
|
|
3
|
+
impact: MEDIUM
|
|
4
|
+
impactDescription: testable helpers, no framework in the value layer
|
|
5
|
+
tags: controller, utils, structure, testing
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Parse the Request at the Boundary, Keep Utils Framework-Free
|
|
9
|
+
|
|
10
|
+
**Never pass an `HttpRequest` (or any framework object) into a util.** Read the raw
|
|
11
|
+
value in the controller, parse and clamp it there, and hand the util a plain value.
|
|
12
|
+
A util that takes a request cannot be unit-tested without constructing a request, and
|
|
13
|
+
it quietly becomes controller logic living in the wrong directory.
|
|
14
|
+
|
|
15
|
+
**Incorrect (the util now depends on the framework):**
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
// app/utils/productFilters.ts
|
|
19
|
+
export function buildProductFilter(req: HttpRequest) {
|
|
20
|
+
const limit = Number(req.search.get("limit") ?? 25);
|
|
21
|
+
return { take: Math.min(limit, 100) };
|
|
22
|
+
}
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
**Correct (controller parses; util stays pure):**
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
// controller
|
|
29
|
+
const { take, skip } = paginate({
|
|
30
|
+
page: req.search.get("page"),
|
|
31
|
+
perPage: req.search.get("perPage"),
|
|
32
|
+
});
|
|
33
|
+
const status = optionalNumber(req.search.get("status"));
|
|
34
|
+
|
|
35
|
+
// app/utils/productFilters.ts — plain values in, plain values out
|
|
36
|
+
export function buildProductFilter(status?: number) {
|
|
37
|
+
return status === undefined ? {} : { where: { status } };
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Where a helper belongs:
|
|
42
|
+
|
|
43
|
+
- **`app/utils`** — web-only, gemi-adjacent helpers (`optionalNumber`,
|
|
44
|
+
`safeParseInt`).
|
|
45
|
+
- **A shared workspace package** (`@acme/utils`) — value logic another app **also**
|
|
46
|
+
needs. Make it the single source of truth; reach for it before writing a local
|
|
47
|
+
copy, and add a new cross-app rule there rather than duplicating. A local
|
|
48
|
+
`app/utils/*` module may still exist as a thin facade that adds web-only
|
|
49
|
+
write-side validation.
|
|
50
|
+
- **A service** (`app/services`) — anything with I/O, a client, or state.
|
|
51
|
+
|
|
52
|
+
`req.search.get()` returns a string **or `string[]`** for a repeated key, which is
|
|
53
|
+
one more reason the parsing belongs at the boundary where you can decide what a
|
|
54
|
+
repeat means.
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Never Wrap the Redirect Facade in try/catch
|
|
3
|
+
impact: HIGH
|
|
4
|
+
impactDescription: silently breaks the redirect
|
|
5
|
+
tags: controller, redirect, facades, gotcha
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Never Wrap the Redirect Facade in try/catch
|
|
9
|
+
|
|
10
|
+
`Redirect` from `gemi/facades` works by **throwing** a special error that the
|
|
11
|
+
framework catches higher up to perform the redirect. A `try/catch` around it — or a
|
|
12
|
+
broad `catch` anywhere up the call stack — swallows that throw, and the redirect
|
|
13
|
+
silently does nothing. Nothing logs; the handler just continues.
|
|
14
|
+
|
|
15
|
+
Code after a `Redirect.to()` call is unreachable.
|
|
16
|
+
|
|
17
|
+
**Incorrect (the catch eats the redirect):**
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
async view(req: HttpRequest) {
|
|
21
|
+
try {
|
|
22
|
+
const store = await Store.findUnique({ where: { slug } });
|
|
23
|
+
if (!store) {
|
|
24
|
+
Redirect.to("/stores"); // throws…
|
|
25
|
+
}
|
|
26
|
+
return { store };
|
|
27
|
+
} catch (error) { // …and this swallows it
|
|
28
|
+
Log.error("store view failed", { error });
|
|
29
|
+
return { store: null };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
**Correct (redirect outside the guarded region):**
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
async view(req: HttpRequest) {
|
|
38
|
+
let store = null;
|
|
39
|
+
try {
|
|
40
|
+
store = await Store.findUnique({ where: { slug } });
|
|
41
|
+
} catch (error) {
|
|
42
|
+
Log.error("store lookup failed", { error });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (!store) {
|
|
46
|
+
Redirect.to("/stores");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return { store };
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
**Two Redirects, and they are not interchangeable:**
|
|
54
|
+
|
|
55
|
+
- **Facade** (`gemi/facades`) — server-side, in a handler or middleware, *before*
|
|
56
|
+
rendering. `Redirect.to(path, { params, search })` for internal routes,
|
|
57
|
+
`Redirect.external(url, status)` for absolute ones.
|
|
58
|
+
- **Component** (`gemi/client`) — client-side, redirects on mount from client state:
|
|
59
|
+
`if (!user) return <Redirect href="/auth/sign-in" action="replace" />;`
|
|
60
|
+
|
|
61
|
+
Prefer the facade when the decision is knowable on the server: the component ships
|
|
62
|
+
and renders a page first, then navigates.
|
|
63
|
+
|
|
64
|
+
`Auth.user()` throws `AuthenticationError` the same way, so the same rule applies to
|
|
65
|
+
it — do not bury an auth check inside a `try/catch`.
|
|
66
|
+
|
|
67
|
+
Reference: `app/utils/storefrontRedirect.ts`
|
|
68
|
+
<https://nstfkc.github.io/gemi/navigation.md>
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Validate With a Request Schema, Not Inline Checks
|
|
3
|
+
impact: HIGH
|
|
4
|
+
impactDescription: one boundary, localized messages, typed body
|
|
5
|
+
tags: controller, validation, requests
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Validate With a Request Schema, Not Inline Checks
|
|
9
|
+
|
|
10
|
+
Subclass `HttpRequest`, declare a `schema`, and type the controller method against
|
|
11
|
+
it. The framework validates before the method body runs and produces the standard
|
|
12
|
+
`validation_error` response that `<Form>` and the mutation hooks already know how to
|
|
13
|
+
render per field.
|
|
14
|
+
|
|
15
|
+
Built-in rules: `required`, `string`, `email`, `number`, `password`, `min:N`,
|
|
16
|
+
`max:N`, `file`, `fileType`, `fileSize`. Custom logic goes in a `refine()` override.
|
|
17
|
+
**A field with no value and no `required` rule is skipped**, so optional fields need
|
|
18
|
+
no special handling.
|
|
19
|
+
|
|
20
|
+
**Incorrect (hand-rolled, unlocalized, and it will drift from the client):**
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
async store(req: HttpRequest<{ name: string }>) {
|
|
24
|
+
const input = await req.input();
|
|
25
|
+
const name = input.get("name");
|
|
26
|
+
if (!name) return { error: "Name is required" }; // wrong shape entirely
|
|
27
|
+
if (name.length > 120) return { error: "Too long" };
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
**Correct:**
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
class CreateCustomerRequest extends HttpRequest<{ name: string; email?: string }> {
|
|
35
|
+
schema = {
|
|
36
|
+
name: { required: "Name is required", "max:120": "Too long" },
|
|
37
|
+
email: { email: "Email is invalid", "max:160": "Too long" },
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class CustomerController extends ResourceController {
|
|
42
|
+
async store(req: CreateCustomerRequest) {
|
|
43
|
+
const { name, email } = (await req.input()).toJSON();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
**Localize the messages** rather than hardcoding English — `Dictionary.text({...})`
|
|
49
|
+
from `gemi/i18n` translates an inline string without needing a client dictionary,
|
|
50
|
+
which is exactly what a schema message is:
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
schema = { name: { required: Dictionary.text({ "en-US": "Name is required", "tr-TR": "Ad gerekli" }) } };
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
**Use `refine()` for a cross-field or lookup rule** — the one place a guard covers
|
|
57
|
+
every path into an endpoint. `req.safeInput()` returns `{ isValid, errors, input }`
|
|
58
|
+
when you need to branch instead of throw.
|
|
59
|
+
|
|
60
|
+
Reference: `app/http/requests/SignUpRequest.ts` (a `refine()` guard covering every
|
|
61
|
+
sign-up path); <https://nstfkc.github.io/gemi/controllers.md>
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Throw the Framework's Errors, Do Not Invent a Response Shape
|
|
3
|
+
impact: HIGH
|
|
4
|
+
impactDescription: clients already handle these; a custom shape they do not
|
|
5
|
+
tags: controller, errors, validation, client-contract
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Throw the Framework's Errors, Do Not Invent a Response Shape
|
|
9
|
+
|
|
10
|
+
The client's mutation hooks and `<Form>` branch on a tagged error object —
|
|
11
|
+
`validation_error`, `form_error`, `server_error`, `not_authorized`,
|
|
12
|
+
`insufficient_permissions`. Returning an ad-hoc `{ error: "…" }` from a controller
|
|
13
|
+
produces a **200 with a body the client reads as success**, so the UI shows nothing
|
|
14
|
+
and the failure disappears.
|
|
15
|
+
|
|
16
|
+
| Throw | Meaning |
|
|
17
|
+
|---|---|
|
|
18
|
+
| `ValidationError({ field: ["msg"] })` | 400, per-field messages `<ValidationErrors>` renders |
|
|
19
|
+
| `AuthenticationError` | 401 — no identity (views redirect to sign-in) |
|
|
20
|
+
| `AuthorizationError("msg")` | 401 — known identity, refused this action |
|
|
21
|
+
| `InsufficientPermissionsError` | 401 — missing a role or permission |
|
|
22
|
+
|
|
23
|
+
**Incorrect (a 200 that the client cannot distinguish from success):**
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
async update(req: HttpRequest) {
|
|
27
|
+
const post = await Post.findUniqueOrThrow({ where: { publicId } });
|
|
28
|
+
if (post.authorId !== user.id) {
|
|
29
|
+
return { error: "You cannot edit this post", status: 403 };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
**Correct:**
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
import { AuthorizationError, ValidationError } from "gemi/http";
|
|
38
|
+
|
|
39
|
+
async update(req: HttpRequest) {
|
|
40
|
+
const post = await Post.findUniqueOrThrow({ where: { publicId } });
|
|
41
|
+
if (post.authorId !== user.id) {
|
|
42
|
+
throw new AuthorizationError("You cannot edit this post");
|
|
43
|
+
}
|
|
44
|
+
if (!slug) {
|
|
45
|
+
throw new ValidationError({ slug: ["Slug is required"] });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
**`ValidationError` already yields the right status and body** — do not wrap it, and
|
|
51
|
+
do not build a parallel error convention per endpoint.
|
|
52
|
+
|
|
53
|
+
**Do not construct a `Response` by hand** to set a status, header or redirect. The
|
|
54
|
+
facades cover it: `Redirect.to(...)`, `Cookie.set(...)`, `Meta.title(...)`. A handler
|
|
55
|
+
returns plain data and the framework serializes it.
|
|
56
|
+
|
|
57
|
+
Reference: <https://nstfkc.github.io/gemi/controllers.md>
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: The defineDictionary Literal Must Be Inline
|
|
3
|
+
impact: MEDIUM
|
|
4
|
+
impactDescription: passes tests, fails the build
|
|
5
|
+
tags: i18n, build, vite, gotcha
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## The defineDictionary Literal Must Be Inline
|
|
9
|
+
|
|
10
|
+
New i18n work uses `defineDictionary` (gemi 0.54): a `<Component>.i18n.ts` beside the
|
|
11
|
+
component, read with `useDictionary(theDict)`. gemi's Vite plugin **rewrites the call
|
|
12
|
+
at build time** and needs the object to be statically analyzable.
|
|
13
|
+
|
|
14
|
+
A helper call, variable, spread or template literal **fails the BUILD** — while
|
|
15
|
+
still resolving fine under test. That asymmetry is the trap: the refactor looks
|
|
16
|
+
green locally and breaks CI at the build step.
|
|
17
|
+
|
|
18
|
+
**Incorrect (factored behind a helper — resolves in tests, fails the build):**
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
const copy = (en: string, tr: string) => ({ "en-US": en, "tr-TR": tr });
|
|
22
|
+
|
|
23
|
+
export const dict = defineDictionary({
|
|
24
|
+
title: copy("Products", "Ürünler"),
|
|
25
|
+
...sharedKeys,
|
|
26
|
+
});
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
**Correct (inline literal, `en-US` first in every key):**
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
export const dict = defineDictionary({
|
|
33
|
+
title: {
|
|
34
|
+
"en-US": "Products",
|
|
35
|
+
"tr-TR": "Ürünler",
|
|
36
|
+
"de-DE": "Produkte",
|
|
37
|
+
// …
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Rules that come with it:
|
|
43
|
+
|
|
44
|
+
- **Write `en-US` first in every key.** The first locale is the source language and
|
|
45
|
+
the fallback when another locale is missing a key; if keys disagree on ordering,
|
|
46
|
+
the build stops.
|
|
47
|
+
- **No name string, no `app/i18n/index.ts` entry, no prefetch map, no `gemi.d.ts`
|
|
48
|
+
declaration.** Keys and `{{param}}` types are inferred from the literal.
|
|
49
|
+
- **Name a key after the copy it holds**, not its call site — `new`, not `button`.
|
|
50
|
+
A key named for where it is used drifts the moment the copy moves.
|
|
51
|
+
- Untransformed it holds every locale and resolves **synchronously**, so a component
|
|
52
|
+
test asserts the real copy with nothing seeded. Assert `en-US` only.
|
|
53
|
+
- **A missing translation degrades silently** (it falls back to the source locale)
|
|
54
|
+
rather than failing — completeness across locales wants one i18n-level check, not
|
|
55
|
+
an assertion in every component test.
|
|
56
|
+
|
|
57
|
+
The legacy `Dictionary.create` system is still the bulk of the app and both coexist.
|
|
58
|
+
Migrate per component when you touch it; there is no sweep planned.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Heavy Aggregations Run on a Separate Connection
|
|
3
|
+
impact: HIGH
|
|
4
|
+
impactDescription: keeps the request-path pool available
|
|
5
|
+
tags: orm, connections, pooling, admin, cron
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Heavy Aggregations Run on a Separate Connection
|
|
9
|
+
|
|
10
|
+
Every connection your app declares counts against the database server's connection
|
|
11
|
+
budget, and a long admin scan or cron aggregation on the same pool that serves
|
|
12
|
+
requests starves the every-request auth path. This is a common production incident
|
|
13
|
+
shape, not a hypothetical.
|
|
14
|
+
|
|
15
|
+
If your app declares a second connection for that work — the usual name is
|
|
16
|
+
`analytics` — route heavy reads to it. **Selection is per query, never per model:**
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
await AuditLog.on("analytics").count({ where });
|
|
20
|
+
await DB.connection("analytics").query(sql`…`);
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
**Incorrect (a full-table admin scan competing with request traffic):**
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
const rows = await AuditLog.findMany({
|
|
27
|
+
where: { createdAt: { gte: monthStart } },
|
|
28
|
+
});
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
**Correct:**
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
const rows = await AuditLog.on("analytics").findMany({
|
|
35
|
+
where: { createdAt: { gte: monthStart } },
|
|
36
|
+
});
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`Model.on(name)` and `DB.connection(name)` both take a connection your app has
|
|
40
|
+
declared in its database config. There is no framework-provided `analytics` pool —
|
|
41
|
+
check what your app actually declares before reaching for a name.
|
|
42
|
+
|
|
43
|
+
**Bound a batch's concurrency to the pool that serves it.** An unbounded
|
|
44
|
+
`Promise.all` over a three-connection pool is a queue, not parallelism, and it is a
|
|
45
|
+
queue that blocks anything else needing that pool. Cap the in-flight count at the
|
|
46
|
+
pool size rather than fanning out over the whole collection.
|
|
47
|
+
|
|
48
|
+
**Do not hardcode a pool size at a call site.** Read it from the same config that
|
|
49
|
+
declares the pool, so shrinking the pool cannot leave a batch fanning out wider than
|
|
50
|
+
it can serve.
|
|
51
|
+
|
|
52
|
+
Remember `orm-transaction-sequential`: a transaction cannot span connections, so
|
|
53
|
+
work inside `Model.transaction` stays on the connection that opened it.
|