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.
Files changed (63) hide show
  1. package/dist/bin/gemi.js +500 -16
  2. package/dist/bin/gemi.js.map +10 -5
  3. package/dist/{chunk-n412aa9s.js → chunk-khf9xda6.js} +1 -1
  4. package/dist/{chunk-gfma8e03.js → chunk-x8beq9c4.js} +1 -1
  5. package/dist/chunk-y3zz410b.js +6 -0
  6. package/dist/{chunk-txhcx69q.js.map → chunk-y3zz410b.js.map} +2 -2
  7. package/dist/config/index.d.ts +2 -0
  8. package/dist/config/index.d.ts.map +1 -1
  9. package/dist/config/index.js +2 -2
  10. package/dist/config/index.js.map +3 -3
  11. package/dist/server/index.js +1 -1
  12. package/package.json +3 -2
  13. package/skills/gemi-react-best-practices/SKILL.md +231 -0
  14. package/skills/gemi-react-best-practices/rules/_sections.md +56 -0
  15. package/skills/gemi-react-best-practices/rules/_template.md +28 -0
  16. package/skills/gemi-react-best-practices/rules/bundle-deep-imports.md +48 -0
  17. package/skills/gemi-react-best-practices/rules/bundle-mount-gate-heavy-panels.md +63 -0
  18. package/skills/gemi-react-best-practices/rules/client-form-vs-mutation-hooks.md +57 -0
  19. package/skills/gemi-react-best-practices/rules/client-loading-error-exports.md +54 -0
  20. package/skills/gemi-react-best-practices/rules/client-no-effect-data-flow.md +68 -0
  21. package/skills/gemi-react-best-practices/rules/client-typed-links.md +51 -0
  22. package/skills/gemi-react-best-practices/rules/controller-authorize-every-tenant-read.md +65 -0
  23. package/skills/gemi-react-best-practices/rules/controller-parse-request-at-the-boundary.md +54 -0
  24. package/skills/gemi-react-best-practices/rules/controller-redirect-facade-throws.md +68 -0
  25. package/skills/gemi-react-best-practices/rules/controller-request-schema.md +61 -0
  26. package/skills/gemi-react-best-practices/rules/controller-throw-framework-errors.md +57 -0
  27. package/skills/gemi-react-best-practices/rules/i18n-define-dictionary-inline.md +58 -0
  28. package/skills/gemi-react-best-practices/rules/orm-analytics-connection.md +53 -0
  29. package/skills/gemi-react-best-practices/rules/orm-include-not-n-plus-one.md +56 -0
  30. package/skills/gemi-react-best-practices/rules/orm-paginate-helper.md +69 -0
  31. package/skills/gemi-react-best-practices/rules/orm-plain-rows-by-default.md +55 -0
  32. package/skills/gemi-react-best-practices/rules/orm-select-narrow.md +58 -0
  33. package/skills/gemi-react-best-practices/rules/orm-transaction-no-io.md +54 -0
  34. package/skills/gemi-react-best-practices/rules/orm-transaction-sequential.md +64 -0
  35. package/skills/gemi-react-best-practices/rules/payload-dont-overprefetch.md +54 -0
  36. package/skills/gemi-react-best-practices/rules/payload-instant-vs-prefetch.md +58 -0
  37. package/skills/gemi-react-best-practices/rules/payload-minimal-view-props.md +51 -0
  38. package/skills/gemi-react-best-practices/rules/payload-parallel-controller-work.md +56 -0
  39. package/skills/gemi-react-best-practices/rules/payload-prefetch-late-queries.md +58 -0
  40. package/skills/gemi-react-best-practices/rules/payload-prefetch-mirrors-usequery.md +52 -0
  41. package/skills/gemi-react-best-practices/rules/query-debounce-search-variant.md +52 -0
  42. package/skills/gemi-react-best-practices/rules/query-keep-previous-data.md +40 -0
  43. package/skills/gemi-react-best-practices/rules/query-lazy-vs-mount-gate.md +54 -0
  44. package/skills/gemi-react-best-practices/rules/query-mutate-over-refetch.md +55 -0
  45. package/skills/gemi-react-best-practices/rules/query-no-hand-rolled-fetch.md +60 -0
  46. package/skills/gemi-react-best-practices/rules/query-revalidate-on-focus.md +44 -0
  47. package/skills/gemi-react-best-practices/rules/query-share-cache-key.md +51 -0
  48. package/skills/gemi-react-best-practices/rules/query-suspense-default.md +52 -0
  49. package/skills/gemi-react-best-practices/rules/routing-cache-policy-constants.md +53 -0
  50. package/skills/gemi-react-best-practices/rules/routing-middleware-dsl.md +60 -0
  51. package/skills/gemi-react-best-practices/rules/routing-resource-routes.md +59 -0
  52. package/skills/gemi-react-best-practices/rules/routing-routers-are-classes.md +55 -0
  53. package/skills/gemi-react-best-practices/rules/service-lazy-not-module-scope.md +63 -0
  54. package/skills/gemi-react-best-practices/rules/service-queue-is-in-memory.md +52 -0
  55. package/skills/gemi-react-best-practices/rules/service-static-token-and-name.md +52 -0
  56. package/skills/gemi-react-best-practices/rules/structure-discovered-vs-registered.md +71 -0
  57. package/skills/gemi-react-best-practices/rules/structure-do-not-reinvent-the-framework.md +58 -0
  58. package/skills/gemi-react-best-practices/rules/testing-assert-behaviour-over-markup.md +54 -0
  59. package/skills/gemi-react-best-practices/rules/testing-match-the-suite.md +57 -0
  60. package/skills/gemi-react-best-practices/rules/testing-page-seeds-real-inputs.md +65 -0
  61. package/dist/chunk-txhcx69q.js +0 -6
  62. /package/dist/{chunk-n412aa9s.js.map → chunk-khf9xda6.js.map} +0 -0
  63. /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.