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,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>
|