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,52 @@
1
+ ---
2
+ title: A Prefetch Must Mirror Its useQuery Exactly
3
+ impact: CRITICAL
4
+ impactDescription: one saved round-trip per read, or zero
5
+ tags: payload, prefetch, cache-key, ssr
6
+ ---
7
+
8
+ ## A Prefetch Must Mirror Its useQuery Exactly
9
+
10
+ `Query.prefetch` primes the client cache, and the cache is keyed on **path + params
11
+ + search**. A prefetch that differs in any of the three lands in a different cache
12
+ slot: the SSR payload carries the bytes AND the client still fetches. That is worse
13
+ than not prefetching at all, and nothing fails — the page just quietly costs a
14
+ round-trip.
15
+
16
+ This makes prefetch lists a maintenance obligation. Adding a `useQuery` to a surface
17
+ without adding the matching prefetch silently costs a round-trip; leaving a prefetch
18
+ pointed at a path the surface no longer reads silently costs a query.
19
+
20
+ **Incorrect (search differs, so it primes a slot nothing reads):**
21
+
22
+ ```tsx
23
+ // Controller
24
+ Query.prefetch("/app/:orgId/lists-v2", { params: { orgId } });
25
+
26
+ // View — the cache key includes `search`, so this misses the primed entry
27
+ const { data } = useQuery("/app/:orgId/lists-v2", {
28
+ params: { orgId },
29
+ search: { limit: "100" },
30
+ });
31
+ ```
32
+
33
+ **Correct (path, params and search all match):**
34
+
35
+ ```tsx
36
+ // Controller
37
+ Query.prefetch("/app/:orgId/lists-v2", {
38
+ params: { orgId },
39
+ search: { limit: "100" },
40
+ });
41
+
42
+ // View
43
+ const { data } = useQuery("/app/:orgId/lists-v2", {
44
+ params: { orgId },
45
+ search: { limit: "100" },
46
+ });
47
+ ```
48
+
49
+ In dev, gemi logs a hint for a query that starts late, with its delay and payload
50
+ size — that hint is how you find a prefetch that stopped matching.
51
+
52
+ Reference: <https://nstfkc.github.io/gemi/data-fetching.md>
@@ -0,0 +1,52 @@
1
+ ---
2
+ title: Debounce a Value Before It Becomes a Query Variant
3
+ impact: MEDIUM-HIGH
4
+ impactDescription: one request per pause instead of per keystroke
5
+ tags: query, debounce, search, effects
6
+ ---
7
+
8
+ ## Debounce a Value Before It Becomes a Query Variant
9
+
10
+ Every distinct `search` object is a distinct cache key, so feeding raw input state
11
+ straight into `useQuery` fires a request per keystroke and fills the cache with
12
+ entries nobody will read again.
13
+
14
+ Debounce with a debounce hook — **not** a hand-rolled
15
+ `useEffect` + `setTimeout`. The house rule in most gemi apps is that data flow does
16
+ not go
17
+ through effects: derive in `useMemo`, reset in the change handler, debounce with the
18
+ hook (`rerender-no-effect-data-flow`).
19
+
20
+ **Incorrect (a request per keystroke, and a hand-rolled timer to boot):**
21
+
22
+ ```tsx
23
+ const [query, setQuery] = useState("");
24
+ const [debounced, setDebounced] = useState("");
25
+
26
+ useEffect(() => {
27
+ const id = setTimeout(() => setDebounced(query), 250);
28
+ return () => clearTimeout(id);
29
+ }, [query]);
30
+ ```
31
+
32
+ **Correct:**
33
+
34
+ ```tsx
35
+ // A debounce hook — your own, or one from a library.
36
+ import { useDebounceValue } from "@/app/hooks/useDebounceValue";
37
+
38
+ const [query, setQuery] = useState("");
39
+ const [debouncedQuery] = useDebounceValue(query.trim(), 250);
40
+
41
+ const { data = [], loading } = useQuery(
42
+ "/app/:orgId/products/search",
43
+ { params: { orgId }, search: { q: debouncedQuery || null, limit } },
44
+ { suspense: false },
45
+ );
46
+ ```
47
+
48
+ Two details worth copying from the call sites above:
49
+
50
+ - **`.trim()` before debouncing**, so trailing whitespace is not its own variant.
51
+ - **`|| null` rather than `""`** — pick one empty representation and use it
52
+ everywhere, or the empty-search variant splits in two.
@@ -0,0 +1,40 @@
1
+ ---
2
+ title: Keep the Previous Page Rendered While the Next Variant Loads
3
+ impact: MEDIUM-HIGH
4
+ impactDescription: removes layout collapse on every filter keystroke
5
+ tags: query, pagination, ux, keepPreviousData
6
+ ---
7
+
8
+ ## Keep the Previous Page Rendered While the Next Variant Loads
9
+
10
+ Changing `params` or `search` changes the cache key, so the query has no data for
11
+ the new variant. `keepPreviousData` (default `true`) holds the old variant's data on
12
+ screen until the new one arrives, which is what keeps a paginated table from
13
+ collapsing to a skeleton on every page click.
14
+
15
+ Because it is the default, the rule is mostly about **not breaking it**: setting
16
+ `keepPreviousData: false`, or remounting the component on variant change (a changing
17
+ `key`), throws the behaviour away.
18
+
19
+ **Incorrect (a changing key remounts, discarding the previous data):**
20
+
21
+ ```tsx
22
+ <ProductTable key={page} page={page} />
23
+ ```
24
+
25
+ **Correct (same instance, variant changes, previous page stays visible):**
26
+
27
+ ```tsx
28
+ const { data, loading } = useQuery(
29
+ "/app/:orgId/products",
30
+ { params: { orgId }, search: { page: String(page), limit: "25" } },
31
+ { keepPreviousData: true },
32
+ );
33
+
34
+ // `loading` is true while the next page is in flight; `data` is still the
35
+ // previous page. Dim the table rather than replacing it.
36
+ <Table className={loading ? "opacity-60" : undefined} rows={data} />
37
+ ```
38
+
39
+ Pair it with `query-debounce-search-variant`: without a debounce, every keystroke
40
+ is a new variant and `keepPreviousData` is doing far more work than it should.
@@ -0,0 +1,54 @@
1
+ ---
2
+ title: A Lazy Query Does Not Refetch When Its Variant Changes
3
+ impact: HIGH
4
+ impactDescription: prevents silently dead search and pagination
5
+ tags: query, lazy, correctness, mounting
6
+ ---
7
+
8
+ ## A Lazy Query Does Not Refetch When Its Variant Changes
9
+
10
+ `{ lazy: true }` defers a query until `trigger()` or `refetch()` is called. It is the
11
+ right tool for a read that fires on an explicit user action with **fixed** inputs.
12
+
13
+ It is the wrong tool for a read whose key changes — search text, page size, filters.
14
+ A lazy query does not refetch when its variant changes, so search and "load more"
15
+ keep rendering the first triggered result and appear to be broken. Nothing errors.
16
+
17
+ When a read is both expensive and variant-keyed, **gate it by mounting instead**.
18
+ Mounting is a real gate: an unmounted component runs no query, and remounting
19
+ re-establishes the subscription with the current variant.
20
+
21
+ **Incorrect (lazy on a variant-keyed read — search silently stops working):**
22
+
23
+ ```tsx
24
+ const { data, trigger } = useQuery(
25
+ "/app/:orgId/products/search",
26
+ { params: { orgId }, search: { q: debouncedQuery, limit } },
27
+ { lazy: true },
28
+ );
29
+
30
+ useEffect(() => { trigger(); }, [debouncedQuery, limit]); // fights the design
31
+ ```
32
+
33
+ **Correct (move the read into the subtree that only mounts when opened):**
34
+
35
+ ```tsx
36
+ // Radix unmounts PopoverContent while the popover is closed, so this query
37
+ // does not exist until the user opens the picker — and it re-keys normally
38
+ // on `debouncedQuery` and `limit` once it does.
39
+ <PopoverContent>
40
+ <CatalogSearchPanel orgId={orgId} />
41
+ </PopoverContent>
42
+ ```
43
+
44
+ **Also correct — lazy for a fixed-input, action-triggered read:**
45
+
46
+ ```tsx
47
+ const { data, trigger, loading } = useQuery(
48
+ "/app/:orgId/export/preview",
49
+ { params: { orgId } },
50
+ { lazy: true },
51
+ );
52
+
53
+ <Button onClick={() => trigger()}>Preview export</Button>
54
+ ```
@@ -0,0 +1,55 @@
1
+ ---
2
+ title: Write the Cache With mutate Instead of Refetching
3
+ impact: MEDIUM-HIGH
4
+ impactDescription: removes a round-trip from every write
5
+ tags: query, mutations, cache, optimistic
6
+ ---
7
+
8
+ ## Write the Cache With mutate Instead of Refetching
9
+
10
+ After a successful mutation the UI needs to reflect the new state. Refetching the
11
+ list costs a round-trip the client can often skip: `mutate` writes the cache
12
+ immediately, then reconciles with the server on its own.
13
+
14
+ - **`mutate(fn)`** from a `useQuery` — updates that component's variant.
15
+ - **`useMutate()`** — updates **any** variant by path, from outside the component
16
+ that owns it. This is the one to reach for after a mutation, since the writer is
17
+ rarely the reader.
18
+
19
+ The callback must return the complete next value — merge existing data yourself.
20
+ After the optimistic write, gemi refetches to reconcile, so a wrong guess
21
+ self-corrects rather than sticking.
22
+
23
+ **Incorrect (blank, then a full round-trip, before the row disappears):**
24
+
25
+ ```tsx
26
+ const { trigger } = useDelete("/app/:orgId/products/:id");
27
+
28
+ await trigger();
29
+ await refetch(); // user waits for the list again
30
+ ```
31
+
32
+ **Correct (row disappears immediately; reconciliation happens behind it):**
33
+
34
+ ```tsx
35
+ import { useMutate, useDelete } from "gemi/client";
36
+
37
+ const mutate = useMutate();
38
+ const { trigger } = useDelete("/app/:orgId/products/:id");
39
+
40
+ await trigger();
41
+ mutate(
42
+ { path: "/app/:orgId/products", params: { orgId } },
43
+ (products) => products.filter((p) => p.publicId !== id),
44
+ );
45
+ ```
46
+
47
+ **Refetch, don't guess, when the server derives the value.** If a write changes
48
+ counts, totals, credit balances or anything else computed server-side, call
49
+ `mutate()` with no callback — it refetches without an optimistic write, which is
50
+ still cheaper than remounting the surface.
51
+
52
+ Note the target must name the same variant as the reader (`query-share-cache-key`):
53
+ `mutate({ path, params, search })` misses if the search object differs.
54
+
55
+ Reference: <https://nstfkc.github.io/gemi/data-fetching.md>
@@ -0,0 +1,60 @@
1
+ ---
2
+ title: Read Server Data With useQuery, Never a Raw fetch
3
+ impact: CRITICAL
4
+ impactDescription: dedup, caching, SSR priming, types — all lost otherwise
5
+ tags: query, data-fetching, types
6
+ ---
7
+
8
+ ## Read Server Data With useQuery, Never a Raw fetch
9
+
10
+ A hand-rolled `fetch` in an effect opts out of everything gemi's network layer
11
+ provides: SSR priming from `Query.prefetch`, cross-component deduplication, the
12
+ cache, revalidation, suspense integration, and end-to-end types generated into
13
+ `.gemi/gemi.d.ts`. It also reintroduces the classic effect bugs — races on fast
14
+ navigation, no cancellation, a setState after unmount.
15
+
16
+ **Incorrect (no dedup, no cache, no priming, no types):**
17
+
18
+ ```tsx
19
+ function Products() {
20
+ const [products, setProducts] = useState([]);
21
+ useEffect(() => {
22
+ fetch(`/api/app/${orgId}/products`)
23
+ .then((r) => r.json())
24
+ .then(setProducts);
25
+ }, [orgId]);
26
+ }
27
+ ```
28
+
29
+ **Correct:**
30
+
31
+ ```tsx
32
+ import { useQuery } from "gemi/client";
33
+
34
+ function Products() {
35
+ const { data: products } = useQuery("/app/:orgId/products", {
36
+ params: { orgId },
37
+ });
38
+ }
39
+ ```
40
+
41
+ **Writes go through the mutation hooks or `<Form>`, for the same reason:**
42
+
43
+ ```tsx
44
+ import { usePost } from "gemi/client";
45
+
46
+ const { trigger, loading, error } = usePost("/app/:orgId/products");
47
+ await trigger({ name });
48
+ ```
49
+
50
+ Mutation errors arrive as tagged objects — `validation_error` (with per-field
51
+ `messages`), `form_error`, `server_error`, `not_authorized`,
52
+ `insufficient_permissions` — so a controller should `throw new ValidationError(...)`
53
+ rather than inventing a per-endpoint error shape.
54
+
55
+ **The one documented exception is file upload.** `useUpload` is XHR-based (it needs
56
+ progress events). That is also why it cannot be intercepted by MSW under happy-dom:
57
+ a client file post that needs to be unit-testable should use `usePost` with
58
+ `FormData` instead.
59
+
60
+ Reference: <https://nstfkc.github.io/gemi/data-fetching.md>
@@ -0,0 +1,44 @@
1
+ ---
2
+ title: revalidateOnFocus Is Opt-In, For Cross-Tab-Mutable Data Only
3
+ impact: MEDIUM
4
+ impactDescription: freshness without a request storm
5
+ tags: query, revalidation, focus, staleness
6
+ ---
7
+
8
+ ## revalidateOnFocus Is Opt-In, For Cross-Tab-Mutable Data Only
9
+
10
+ `revalidateOnFocus` defaults to `false` in gemi. Turn it on only for a value that can
11
+ change **without this tab doing anything** — a balance a webhook can credit, a status
12
+ another tab can flip, a quota a background job can consume. For everything else, the
13
+ 5s `staleTime` and the mutation-driven cache writes are enough.
14
+
15
+ When you do turn it on, two defaults keep it from becoming a request storm:
16
+ `staleTime` (5000ms) suppresses revalidation for data that is still fresh, and
17
+ `focusThrottleInterval` (5000ms) sets a floor between focus-triggered revalidations.
18
+ A tab return that fires both `focus` and `visibilitychange` collapses to one request.
19
+
20
+ **Incorrect (a long-lived widget goes stale for the whole session):**
21
+
22
+ ```tsx
23
+ // Mounted in the nav for the entire session. A purchase made in another tab,
24
+ // or a renewal landing via webhook, leaves this balance wrong until reload.
25
+ const { data: credits } = useQuery("/app/:orgId/ai-credits", { params: { orgId } });
26
+ ```
27
+
28
+ **Correct:**
29
+
30
+ ```tsx
31
+ const { data: credits, loading } = useQuery(
32
+ "/app/:orgId/ai-credits",
33
+ { params: { orgId } },
34
+ { revalidateOnFocus: true },
35
+ );
36
+ ```
37
+
38
+ **Do not reach for `refreshInterval` where focus revalidation would do.** Polling
39
+ runs while nobody is looking; focus revalidation runs when someone starts looking.
40
+ Reserve `refreshInterval` for genuinely live data (a job that is running now), and
41
+ stop polling when the surface unmounts or the work completes.
42
+
43
+ Remember `query-share-cache-key`: turning this on for a shared variant turns it on
44
+ for every reader of that variant.
@@ -0,0 +1,51 @@
1
+ ---
2
+ title: Identical Query Variants Dedupe For Free
3
+ impact: HIGH
4
+ impactDescription: N components, 1 request
5
+ tags: query, deduplication, cache
6
+ ---
7
+
8
+ ## Identical Query Variants Dedupe For Free
9
+
10
+ gemi's query cache is keyed on path + params + search. Two components reading the
11
+ same variant share one request, one cache entry, and one revalidation — there is no
12
+ caching library to add and no context to thread. Deduplication is the default, not
13
+ something you opt into.
14
+
15
+ The corollary is the useful part: **do not lift a query into a parent and prop-drill
16
+ it just to avoid a "duplicate" request.** There is no duplicate request. Reading it
17
+ where it is used keeps the component self-contained, and a mutation that refreshes
18
+ the variant refreshes every reader at once.
19
+
20
+ **Incorrect (prop-drilling to dedupe something already deduped):**
21
+
22
+ ```tsx
23
+ function Settings() {
24
+ const { data: credits } = useQuery("/app/:orgId/ai-credits", { params });
25
+ return (
26
+ <>
27
+ <CreditsPanel credits={credits} />
28
+ <Composer credits={credits} />
29
+ <NavBadge credits={credits} />
30
+ </>
31
+ );
32
+ }
33
+ ```
34
+
35
+ **Correct (each reads it; one request serves all three):**
36
+
37
+ ```tsx
38
+ function CreditsPanel() {
39
+ const { data: credits } = useQuery("/app/:orgId/ai-credits", { params });
40
+ // …
41
+ }
42
+ ```
43
+
44
+ Two things follow from the key being exact:
45
+
46
+ - **A cosmetic difference in `search` splits the cache.** `{ limit: 25 }` and
47
+ `{ limit: "25" }` are different variants; so are `{ q: "" }` and `{ q: null }`.
48
+ Normalize at one place — usually a shared constant — so readers agree.
49
+ - **Sharing a variant means sharing its config's effects.** A `revalidateOnFocus`
50
+ set by one reader refreshes the value every reader sees. That is usually what you
51
+ want; know that it is happening.
@@ -0,0 +1,52 @@
1
+ ---
2
+ title: suspense Is On By Default and Throws to the Nearest Boundary
3
+ impact: CRITICAL
4
+ impactDescription: prevents whole-surface blanking
5
+ tags: query, suspense, loading, ux
6
+ ---
7
+
8
+ ## suspense Is On By Default and Throws to the Nearest Boundary
9
+
10
+ `useQuery` defaults to `suspense: true`. Two consequences that surprise people:
11
+
12
+ 1. **A fresh fetch suspends the component**, which blanks everything up to the
13
+ nearest boundary — not just the widget doing the read. A query added deep inside
14
+ an interactive surface can blank the whole route behind it.
15
+ 2. **A failed fetch throws**, so `loading` and `error` are not what that path
16
+ returns. Under suspense, `data` is non-nullable and the loading/error states are
17
+ the boundary's job.
18
+
19
+ Keep the default for a route's primary read — that is what the route's `Loading` /
20
+ `Error` exports are for (`render-loading-error-exports`). Pass `{ suspense: false }`
21
+ for a secondary read that should render its own inline loading state in place.
22
+
23
+ **Incorrect (opening a picker blanks the chat behind it):**
24
+
25
+ ```tsx
26
+ // Inside a popover nested in the composer. The nearest boundary is the ROUTE's
27
+ // <Suspense fallback={null}>, so this suspends the entire surface.
28
+ const { data: products } = useQuery("/app/:orgId/products/search", {
29
+ params: { orgId },
30
+ search: { q: debouncedQuery },
31
+ });
32
+ ```
33
+
34
+ **Correct (the panel owns its loading state, nothing above it blanks):**
35
+
36
+ ```tsx
37
+ const { data: products = [], loading } = useQuery(
38
+ "/app/:orgId/products/search",
39
+ { params: { orgId }, search: { q: debouncedQuery || null, limit } },
40
+ { suspense: false },
41
+ );
42
+
43
+ if (loading) return <PanelSkeleton />;
44
+ ```
45
+
46
+ Note the third-argument position: options like `suspense`, `keepPreviousData`,
47
+ `staleTime` and `refreshInterval` are the **third** argument; `params` and `search`
48
+ are the second.
49
+
50
+ **When writing tests for this:** a non-lazy query suspends while its first page is
51
+ in flight and throws when it fails, so seed `<Page>`'s `fallback` and
52
+ `errorFallback` and assert those — `loading`/`error` are not what that path returns.
@@ -0,0 +1,53 @@
1
+ ---
2
+ title: Name Cache Policies Once, Reuse the Constant
3
+ impact: MEDIUM-HIGH
4
+ impactDescription: prevents a private page shipping a public cache header
5
+ tags: routing, caching, middleware, correctness
6
+ ---
7
+
8
+ ## Name Cache Policies Once, Reuse the Constant
9
+
10
+ `cache:` compiles to a `Cache-Control` header, so the difference between
11
+ `cache:public` and `cache:private,0,no-store` is the difference between a CDN
12
+ serving one customer's page to another and not. Spelling the policy inline at each
13
+ router invites a typo that is invisible in review and catastrophic in production.
14
+
15
+ This app hoists the policies it uses to named constants and reuses them.
16
+
17
+ **Incorrect (four routers, four hand-typed policies, one of them wrong):**
18
+
19
+ ```ts
20
+ class CustomerRouter extends ViewRouter {
21
+ middlewares = ["cache:private,0,no-store", "auth"];
22
+ }
23
+ class CustomerAuthRouter extends ViewRouter {
24
+ middlewares = ["cache:public"]; // signed-out, but now CDN-cacheable per-visitor
25
+ }
26
+ ```
27
+
28
+ **Correct (one named policy per audience):**
29
+
30
+ ```ts
31
+ const ANONYMOUS_VIEW_CACHE = "cache:private,0,no-store";
32
+ const LANDING_VIEW_CACHE = "cache:private,12840,must-revalidate";
33
+
34
+ class CustomerAuthRouter extends ViewRouter {
35
+ middlewares = [ANONYMOUS_VIEW_CACHE];
36
+ }
37
+ ```
38
+
39
+ What the DSL expands to:
40
+
41
+ | DSL | `Cache-Control` |
42
+ |---|---|
43
+ | `cache` / `cache:public` | `public, max-age=864000, stale-while-revalidate=300, stale-if-error=600` |
44
+ | `cache:private` | `private, max-age=0, stale-while-revalidate=300, stale-if-error=600` |
45
+ | `cache:private,0,no-store` | `private, max-age=0, no-store` |
46
+
47
+ The arguments are `scope`, `maxAge`, then directives, and the middleware only sets
48
+ headers on **GET** responses.
49
+
50
+ **Default to `no-store` for anything behind `auth`.** A per-user page that is
51
+ cacheable at all is a decision worth making explicitly, with a constant that says so.
52
+
53
+ Reference: `app/http/routes/view.ts`
@@ -0,0 +1,60 @@
1
+ ---
2
+ title: Middleware Is a String DSL, Applied at Router or Route Level
3
+ impact: HIGH
4
+ impactDescription: the app's actual auth boundary
5
+ tags: routing, middleware, auth, security
6
+ ---
7
+
8
+ ## Middleware Is a String DSL, Applied at Router or Route Level
9
+
10
+ Middleware attaches as strings — `"auth"`, `"admin"`, `"role:owner"`,
11
+ `"rate-limit:10,30"`, `"cache:private,0,no-store"` — resolved through the aliases in
12
+ `app/config/middleware.ts`. Everything after the colon is a comma-separated argument
13
+ list passed to the middleware's `run(...)`.
14
+
15
+ Declare it **router-level** (`middlewares = [...]`, inherited by nested routers) or
16
+ **per-route** (`.middleware([...])`, which stacks on top).
17
+
18
+ **Incorrect (hand-rolling an auth check that middleware already expresses):**
19
+
20
+ ```ts
21
+ export class ReportController extends Controller {
22
+ async index(req: HttpRequest) {
23
+ const user = await Auth.user();
24
+ if (!user || Number(user.globalRole) >= 10) {
25
+ throw new InsufficientPermissionsError();
26
+ }
27
+ // …
28
+ }
29
+ }
30
+ ```
31
+
32
+ **Correct (the boundary is declared where the route is):**
33
+
34
+ ```ts
35
+ class AdminRouter extends ApiRouter {
36
+ middlewares = ["cache:private,0,no-store", "auth", "admin"];
37
+ routes = {
38
+ "/reports": this.get(ReportController, "index"),
39
+ };
40
+ }
41
+ ```
42
+
43
+ **Cancel an inherited middleware with `-name`.** The framework keeps a de-duplicated
44
+ map keyed by alias, so a sign-in page inside an authenticated router opts out
45
+ explicitly rather than being moved:
46
+
47
+ ```ts
48
+ class AdminAuthViewRouter extends ViewRouter {
49
+ middlewares = [ANONYMOUS_VIEW_CACHE, "-auth", "-admin"];
50
+ routes = { "/sign-in": this.view("auth/SignIn") };
51
+ }
52
+ ```
53
+
54
+ **Rate-limit buckets are per client IP *and* route path**, so `/api/search` and
55
+ `/api/upload` hold separate budgets — a shared limit needs a configured `key`
56
+ function, and a budget outside a route uses the `RateLimiter` facade
57
+ (`RateLimiter.consume(key, { limit, window })`).
58
+
59
+ Reference: `app/config/middleware.ts`, `app/http/routes/view.ts`
60
+ <https://nstfkc.github.io/gemi/middleware.md>
@@ -0,0 +1,59 @@
1
+ ---
2
+ title: Use resource() for Standard REST, With Per-Method Middleware
3
+ impact: MEDIUM
4
+ impactDescription: five routes, one line, no drift
5
+ tags: routing, rest, controllers, middleware
6
+ ---
7
+
8
+ ## Use resource() for Standard REST, With Per-Method Middleware
9
+
10
+ `this.resource(Controller)` binds a `ResourceController`'s five methods to the
11
+ conventional REST shape in one line. The route key **must end with the item's id
12
+ parameter**; gemi splits it into the collection path and the item path itself.
13
+
14
+ | Method | Verb | Path |
15
+ |---|---|---|
16
+ | `list` | GET | collection |
17
+ | `store` | POST | collection |
18
+ | `show` | GET | item |
19
+ | `update` | PUT | item |
20
+ | `delete` | DELETE | item |
21
+
22
+ **Incorrect (five hand-wired routes that will drift apart):**
23
+
24
+ ```ts
25
+ routes = {
26
+ "/products": this.get(ProductsController, "list"),
27
+ "/products/new": this.post(ProductsController, "store"),
28
+ "/products/:productId": this.get(ProductsController, "show"),
29
+ "/product/:productId": this.put(ProductsController, "update"),
30
+ "/products/:productId/delete": this.delete(ProductsController, "delete"),
31
+ };
32
+ ```
33
+
34
+ **Correct:**
35
+
36
+ ```ts
37
+ routes = {
38
+ "/:orgId/products/:productId": this.resource(ProductsController).middleware({
39
+ store: ["auth"],
40
+ update: ["auth"],
41
+ delete: ["auth"],
42
+ }),
43
+ };
44
+ ```
45
+
46
+ **`.middleware({})` takes a per-method map**, which is how a resource exposes public
47
+ reads and authenticated writes without splitting into two routers.
48
+
49
+ **Reach for the explicit verbs when the shape is not REST.** A path that needs two
50
+ verbs bound to non-standard methods takes an object of lowercase method keys:
51
+
52
+ ```ts
53
+ "/conversations/:id": {
54
+ get: this.get(ConversationController, "restoreV2"),
55
+ delete: this.delete(ConversationController, "deleteV2"),
56
+ },
57
+ ```
58
+
59
+ Reference: `app/http/routes/api.ts`; <https://nstfkc.github.io/gemi/routing.md>