gemi 0.58.1 → 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/ai/Agent.d.ts +33 -0
- package/dist/ai/Agent.d.ts.map +1 -0
- package/dist/ai/AgentController.d.ts +8 -0
- package/dist/ai/AgentController.d.ts.map +1 -0
- package/dist/ai/useChat.d.ts +12 -0
- package/dist/ai/useChat.d.ts.map +1 -0
- package/dist/app/index.js +1 -1
- package/dist/bin/gemi.js +500 -16
- package/dist/bin/gemi.js.map +10 -5
- package/dist/broadcasting/index.js +1 -1
- package/dist/bun/plugin.js +1 -1
- package/dist/bun/preload.js +1 -1
- package/dist/{chunk-szss069z.js → chunk-06j6rsew.js} +2 -2
- package/dist/{chunk-szss069z.js.map → chunk-06j6rsew.js.map} +1 -1
- package/dist/{chunk-yjzs247s.js → chunk-0fm6jh9b.js} +2 -2
- package/dist/{chunk-yjzs247s.js.map → chunk-0fm6jh9b.js.map} +1 -1
- package/dist/chunk-23h0dmx2.js +5 -0
- package/dist/{chunk-87qab82w.js.map → chunk-23h0dmx2.js.map} +3 -3
- package/dist/{chunk-m3xy5xyf.js → chunk-2cwcfwg3.js} +2 -2
- package/dist/{chunk-m3xy5xyf.js.map → chunk-2cwcfwg3.js.map} +1 -1
- package/dist/{chunk-xzk827r3.js → chunk-2khdxyjb.js} +2 -2
- package/dist/{chunk-xzk827r3.js.map → chunk-2khdxyjb.js.map} +1 -1
- package/dist/{chunk-sy7jbdeb.js → chunk-3296fwss.js} +2 -2
- package/dist/{chunk-sy7jbdeb.js.map → chunk-3296fwss.js.map} +1 -1
- package/dist/{chunk-xdv1b8mr.js → chunk-3gvjn3q4.js} +2 -2
- package/dist/{chunk-xdv1b8mr.js.map → chunk-3gvjn3q4.js.map} +1 -1
- package/dist/{chunk-jhkjz9jr.js → chunk-4dan69s4.js} +2 -2
- package/dist/{chunk-3zxwscmf.js.map → chunk-4dan69s4.js.map} +1 -1
- package/dist/{chunk-hxf1re93.js → chunk-4mcyyh1v.js} +2 -2
- package/dist/{chunk-hxf1re93.js.map → chunk-4mcyyh1v.js.map} +1 -1
- package/dist/{chunk-w62m5f0n.js → chunk-5gdv4n4a.js} +3 -3
- package/dist/{chunk-w62m5f0n.js.map → chunk-5gdv4n4a.js.map} +1 -1
- package/dist/{chunk-j0c6ytkj.js → chunk-8kj3zrm9.js} +3 -3
- package/dist/{chunk-j0c6ytkj.js.map → chunk-8kj3zrm9.js.map} +1 -1
- package/dist/{chunk-0a2xgcj3.js → chunk-98a3k7bp.js} +2 -2
- package/dist/{chunk-0a2xgcj3.js.map → chunk-98a3k7bp.js.map} +1 -1
- package/dist/{chunk-5athahgr.js → chunk-9gsdcjt7.js} +2 -2
- package/dist/{chunk-5athahgr.js.map → chunk-9gsdcjt7.js.map} +1 -1
- package/dist/{chunk-pmhd6zfc.js → chunk-b0t4zp6b.js} +2 -2
- package/dist/{chunk-pmhd6zfc.js.map → chunk-b0t4zp6b.js.map} +1 -1
- package/dist/{chunk-3xadx444.js → chunk-bn1v4sfs.js} +2 -2
- package/dist/{chunk-3xadx444.js.map → chunk-bn1v4sfs.js.map} +1 -1
- package/dist/{chunk-9m2tbf3n.js → chunk-c40n5r4v.js} +2 -2
- package/dist/{chunk-9m2tbf3n.js.map → chunk-c40n5r4v.js.map} +1 -1
- package/dist/{chunk-a2sgjpvq.js → chunk-cejf873g.js} +2 -2
- package/dist/{chunk-a2sgjpvq.js.map → chunk-cejf873g.js.map} +1 -1
- package/dist/{chunk-tss5svjr.js → chunk-cw9y6k15.js} +2 -2
- package/dist/{chunk-tss5svjr.js.map → chunk-cw9y6k15.js.map} +1 -1
- package/dist/{chunk-5n2rvfh3.js → chunk-cyrxdj6a.js} +2 -2
- package/dist/{chunk-5n2rvfh3.js.map → chunk-cyrxdj6a.js.map} +1 -1
- package/dist/chunk-dgasxgsm.js +19 -0
- package/dist/{chunk-hwa5sqw5.js.map → chunk-dgasxgsm.js.map} +6 -6
- package/dist/{chunk-3g5bjvdf.js → chunk-f6dd4gd8.js} +2 -2
- package/dist/{chunk-3g5bjvdf.js.map → chunk-f6dd4gd8.js.map} +1 -1
- package/dist/{chunk-qgxr0g36.js → chunk-fb4cy6sq.js} +2 -2
- package/dist/{chunk-qgxr0g36.js.map → chunk-fb4cy6sq.js.map} +1 -1
- package/dist/{chunk-7ef5n8k2.js → chunk-fbvvqf9b.js} +2 -2
- package/dist/{chunk-7ef5n8k2.js.map → chunk-fbvvqf9b.js.map} +1 -1
- package/dist/{chunk-zbxgbr12.js → chunk-fxy42w6n.js} +2 -2
- package/dist/{chunk-zbxgbr12.js.map → chunk-fxy42w6n.js.map} +1 -1
- package/dist/{chunk-xey9cbap.js → chunk-get4mkx8.js} +2 -2
- package/dist/{chunk-xey9cbap.js.map → chunk-get4mkx8.js.map} +1 -1
- package/dist/{chunk-3zxwscmf.js → chunk-gwchvzdp.js} +2 -2
- package/dist/{chunk-jhkjz9jr.js.map → chunk-gwchvzdp.js.map} +1 -1
- package/dist/{chunk-k0fvsyeh.js → chunk-hsfjt13m.js} +1 -1
- package/dist/{chunk-yf7vz71n.js → chunk-hwhw98hc.js} +1 -1
- package/dist/{chunk-fjm4y8bn.js → chunk-hxb1eg2z.js} +2 -2
- package/dist/{chunk-fjm4y8bn.js.map → chunk-hxb1eg2z.js.map} +1 -1
- package/dist/{chunk-7b0x860b.js → chunk-j06g4sqc.js} +2 -2
- package/dist/{chunk-7b0x860b.js.map → chunk-j06g4sqc.js.map} +1 -1
- package/dist/{chunk-dgsgjg53.js → chunk-j23cengk.js} +2 -2
- package/dist/{chunk-dgsgjg53.js.map → chunk-j23cengk.js.map} +1 -1
- package/dist/{chunk-vj6f2bmb.js → chunk-khf9xda6.js} +3 -3
- package/dist/{chunk-vj6f2bmb.js.map → chunk-khf9xda6.js.map} +1 -1
- package/dist/{chunk-437085pe.js → chunk-p3qd0gqa.js} +2 -2
- package/dist/{chunk-437085pe.js.map → chunk-p3qd0gqa.js.map} +1 -1
- package/dist/{chunk-gw6agevz.js → chunk-pkjq9833.js} +3 -3
- package/dist/{chunk-gw6agevz.js.map → chunk-pkjq9833.js.map} +1 -1
- package/dist/{chunk-7j6wbv12.js → chunk-q0y0j3ne.js} +2 -2
- package/dist/{chunk-7j6wbv12.js.map → chunk-q0y0j3ne.js.map} +1 -1
- package/dist/chunk-q6qghsy7.js +5 -0
- package/dist/{chunk-b35e128b.js.map → chunk-q6qghsy7.js.map} +1 -1
- package/dist/{chunk-wbrj0gya.js → chunk-qva4841r.js} +2 -2
- package/dist/{chunk-wbrj0gya.js.map → chunk-qva4841r.js.map} +1 -1
- package/dist/{chunk-86jebsm4.js → chunk-rkbv3df7.js} +3 -3
- package/dist/{chunk-86jebsm4.js.map → chunk-rkbv3df7.js.map} +1 -1
- package/dist/{chunk-keehyx51.js → chunk-rpjsmr41.js} +2 -2
- package/dist/{chunk-keehyx51.js.map → chunk-rpjsmr41.js.map} +1 -1
- package/dist/{chunk-mwpdp09e.js → chunk-spbgpndn.js} +2 -2
- package/dist/{chunk-mwpdp09e.js.map → chunk-spbgpndn.js.map} +1 -1
- package/dist/{chunk-yy0eb9wn.js → chunk-stq96kya.js} +2 -2
- package/dist/{chunk-yy0eb9wn.js.map → chunk-stq96kya.js.map} +1 -1
- package/dist/{chunk-d125j8t0.js → chunk-tds3xq6b.js} +3 -3
- package/dist/{chunk-d125j8t0.js.map → chunk-tds3xq6b.js.map} +1 -1
- package/dist/{chunk-3337e5g0.js → chunk-tey1xayb.js} +2 -2
- package/dist/{chunk-3337e5g0.js.map → chunk-tey1xayb.js.map} +1 -1
- package/dist/{chunk-w7rf99w6.js → chunk-vj9538yn.js} +2 -2
- package/dist/{chunk-w7rf99w6.js.map → chunk-vj9538yn.js.map} +1 -1
- package/dist/{chunk-yed5whgs.js → chunk-wcv6qtpq.js} +3 -3
- package/dist/{chunk-yed5whgs.js.map → chunk-wcv6qtpq.js.map} +1 -1
- package/dist/{chunk-vr90r27j.js → chunk-wzvs3sym.js} +2 -2
- package/dist/{chunk-vr90r27j.js.map → chunk-wzvs3sym.js.map} +1 -1
- package/dist/{chunk-cyaz97p5.js → chunk-x6y1z4cq.js} +2 -2
- package/dist/{chunk-cyaz97p5.js.map → chunk-x6y1z4cq.js.map} +1 -1
- package/dist/chunk-x8beq9c4.js +55 -0
- package/dist/chunk-x8beq9c4.js.map +11 -0
- package/dist/{chunk-6235kb30.js → chunk-xecj8025.js} +3 -3
- package/dist/{chunk-6235kb30.js.map → chunk-xecj8025.js.map} +1 -1
- package/dist/chunk-y3zz410b.js +6 -0
- package/dist/{chunk-4sz6pwtn.js.map → chunk-y3zz410b.js.map} +2 -2
- package/dist/{chunk-eejmhtnc.js → chunk-y64j80v9.js} +2 -2
- package/dist/{chunk-eejmhtnc.js.map → chunk-y64j80v9.js.map} +1 -1
- package/dist/{chunk-zh2egcyb.js → chunk-ygfwtgvm.js} +2 -2
- package/dist/{chunk-zh2egcyb.js.map → chunk-ygfwtgvm.js.map} +1 -1
- package/dist/{chunk-4yt5x8s2.js → chunk-ys904esh.js} +2 -2
- package/dist/{chunk-4yt5x8s2.js.map → chunk-ys904esh.js.map} +1 -1
- package/dist/{chunk-y6a8r2bn.js → chunk-z1e55w67.js} +3 -3
- package/dist/{chunk-y6a8r2bn.js.map → chunk-z1e55w67.js.map} +1 -1
- package/dist/{chunk-qb5mv6pj.js → chunk-z2tcxwyr.js} +3 -3
- package/dist/{chunk-qb5mv6pj.js.map → chunk-z2tcxwyr.js.map} +1 -1
- package/dist/{chunk-hppagzz4.js → chunk-zhbrkpb3.js} +4 -4
- package/dist/{chunk-hppagzz4.js.map → chunk-zhbrkpb3.js.map} +1 -1
- package/dist/{chunk-grdahng8.js → chunk-zqsfanvk.js} +2 -2
- package/dist/{chunk-grdahng8.js.map → chunk-zqsfanvk.js.map} +1 -1
- 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/console/run.js +2 -2
- package/dist/console/run.js.map +1 -1
- package/dist/container/index.js +2 -2
- package/dist/container/index.js.map +1 -1
- package/dist/database/index.js +2 -2
- package/dist/database/index.js.map +1 -1
- package/dist/email/index.js +2 -2
- package/dist/email/index.js.map +1 -1
- package/dist/facades/Features.d.ts +67 -1
- package/dist/facades/Features.d.ts.map +1 -1
- package/dist/facades/index.js +2 -2
- package/dist/facades/index.js.map +1 -1
- package/dist/foundation/index.js +2 -2
- package/dist/foundation/index.js.map +1 -1
- package/dist/http/index.js +2 -2
- package/dist/http/index.js.map +1 -1
- package/dist/i18n/dictionaryRuntime.js +2 -2
- package/dist/i18n/dictionaryRuntime.js.map +1 -1
- package/dist/i18n/index.js +2 -2
- package/dist/i18n/index.js.map +2 -2
- package/dist/kernel/index.js +2 -2
- package/dist/kernel/index.js.map +1 -1
- package/dist/orm/index.js +2 -2
- package/dist/orm/index.js.map +1 -1
- package/dist/server/Server.d.ts.map +1 -1
- package/dist/server/httpDev.d.ts.map +1 -1
- package/dist/server/index.js +2 -2
- package/dist/server/index.js.map +3 -3
- package/dist/services/events/Event.d.ts +6 -0
- package/dist/services/events/Event.d.ts.map +1 -1
- package/dist/services/features/FeatureFlagStore.d.ts +56 -0
- package/dist/services/features/FeatureFlagStore.d.ts.map +1 -1
- package/dist/services/features/FeatureManager.d.ts +35 -1
- package/dist/services/features/FeatureManager.d.ts.map +1 -1
- package/dist/services/features/types.d.ts +62 -0
- package/dist/services/features/types.d.ts.map +1 -1
- package/dist/services/index.d.ts +2 -2
- package/dist/services/index.d.ts.map +1 -1
- package/dist/services/index.js +8 -8
- package/dist/services/index.js.map +3 -3
- package/dist/services/logging/LogManager.d.ts +10 -1
- package/dist/services/logging/LogManager.d.ts.map +1 -1
- package/dist/services/logging/LogServiceProvider.d.ts +10 -0
- package/dist/services/logging/LogServiceProvider.d.ts.map +1 -1
- package/dist/support/index.js +2 -2
- package/dist/support/index.js.map +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-01e3gk86.js +0 -55
- package/dist/chunk-01e3gk86.js.map +0 -11
- package/dist/chunk-4sz6pwtn.js +0 -6
- package/dist/chunk-87qab82w.js +0 -5
- package/dist/chunk-b35e128b.js +0 -5
- package/dist/chunk-hwa5sqw5.js +0 -19
- /package/dist/{chunk-k0fvsyeh.js.map → chunk-hsfjt13m.js.map} +0 -0
- /package/dist/{chunk-yf7vz71n.js.map → chunk-hwhw98hc.js.map} +0 -0
|
@@ -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>
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Routes Are Declared on Router Classes, Not by File Location
|
|
3
|
+
impact: HIGH
|
|
4
|
+
impactDescription: the only place a URL is defined
|
|
5
|
+
tags: routing, structure, views
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Routes Are Declared on Router Classes, Not by File Location
|
|
9
|
+
|
|
10
|
+
Routing is **class-based**. A `ViewRouter` or `ApiRouter` subclass carries a `routes`
|
|
11
|
+
object mapping a path to a handler, and routers nest by assigning one as a route
|
|
12
|
+
value. Putting a file in `app/views` registers nothing — **the view file name has no
|
|
13
|
+
relation to the URL**, and the mapping in `app/http/routes/view.ts` is the only thing
|
|
14
|
+
that makes a page reachable.
|
|
15
|
+
|
|
16
|
+
**Incorrect (creating the file and expecting a URL):**
|
|
17
|
+
|
|
18
|
+
```tsx
|
|
19
|
+
// app/views/customer/Billing.tsx — reachable at… nothing.
|
|
20
|
+
export default function Billing() { /* … */ }
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
**Correct (register it, and bind its server data):**
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
// app/http/routes/view.ts
|
|
27
|
+
class CustomerRouter extends ViewRouter {
|
|
28
|
+
middlewares = ["cache:private,0,no-store", "auth"];
|
|
29
|
+
routes = {
|
|
30
|
+
"/billing": this.view("customer/Billing", [BillingController, "view"]),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The pieces worth knowing:
|
|
36
|
+
|
|
37
|
+
- **`this.view(name, handler?)`** — the handler is an inline callback or a
|
|
38
|
+
`[Controller, "method"]` tuple; its return value becomes the component's props.
|
|
39
|
+
- **`this.layout(name, handler?, routes)`** — nests a layout around child routes.
|
|
40
|
+
A layout handler **does not re-run** while navigating within the same layout unless
|
|
41
|
+
it is marked `.alwaysRun()`.
|
|
42
|
+
- **`:param`** dynamic, **`:param?`** optional, **`(group)/`** groups routes for
|
|
43
|
+
shared middleware or a layout **without adding a URL segment**.
|
|
44
|
+
- **`this.redirect(() => ({ destination }))`** for a static redirect.
|
|
45
|
+
|
|
46
|
+
On the API side: `this.get/post/put/patch/delete(Controller, "method")`,
|
|
47
|
+
`this.file(...)`, `this.stream(...)` (handles 206/416 and `Content-Range` for
|
|
48
|
+
range requests), `this.proxy(...)`, and an object of lowercase method keys to bind
|
|
49
|
+
several verbs to one path.
|
|
50
|
+
|
|
51
|
+
**A view component must be a default export; a controller must be a named export.**
|
|
52
|
+
The router imports each by that convention.
|
|
53
|
+
|
|
54
|
+
Reference: `app/http/routes/view.ts`, `app/http/routes/api.ts`
|
|
55
|
+
<https://nstfkc.github.io/gemi/routing.md>
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Construct Clients Lazily, Never at Module Scope
|
|
3
|
+
impact: HIGH
|
|
4
|
+
impactDescription: keeps boot fast and command discovery working
|
|
5
|
+
tags: service, boot, module-scope, commands
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Construct Clients Lazily, Never at Module Scope
|
|
9
|
+
|
|
10
|
+
Work at module scope runs whenever the module is *imported*, which is not the same as
|
|
11
|
+
when it is *used*. Three places in a gemi app punish that:
|
|
12
|
+
|
|
13
|
+
1. **Command discovery imports every file under `app/commands` just to list them.**
|
|
14
|
+
A `new Stripe(key)` beside the handler throws on an empty key during
|
|
15
|
+
`gemi run` — with no command actually invoked.
|
|
16
|
+
2. **Service `boot()` runs on every application start**, including per-test and
|
|
17
|
+
per-CLI-command. Validate settings there; open connections lazily.
|
|
18
|
+
3. **Ports are installed at boot, after every module in the graph has evaluated**, so
|
|
19
|
+
reading one at module scope gets `undefined`.
|
|
20
|
+
|
|
21
|
+
**Incorrect (constructed on import; `boot()` opens a connection):**
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
const stripe = new Stripe(process.env.STRIPE_KEY!); // throws at import time
|
|
25
|
+
|
|
26
|
+
export class SearchIndex extends Service {
|
|
27
|
+
static token = "searchIndex";
|
|
28
|
+
async boot() {
|
|
29
|
+
this.client = await Client.connect(process.env.SEARCH_URL!); // every start
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
**Correct (validate at boot, connect on first use, construct in the handler):**
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
export class SearchIndex extends Service {
|
|
38
|
+
static token = "searchIndex";
|
|
39
|
+
url = process.env.SEARCH_URL;
|
|
40
|
+
private ready?: Promise<Client>;
|
|
41
|
+
|
|
42
|
+
async boot() {
|
|
43
|
+
if (!this.url) throw new Error("SEARCH_URL is not set");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private connect() {
|
|
47
|
+
return (this.ready ??= Client.connect(this.url!));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export default defineCommand("sync-prices").handle(async ({ line }) => {
|
|
52
|
+
const stripe = new Stripe(process.env.STRIPE_KEY!); // inside the handler
|
|
53
|
+
});
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
**`Service.inject()` must not be called at module top level either** — it throws
|
|
57
|
+
because the kernel has not booted. Inject as a constructor default
|
|
58
|
+
(`constructor(private billing = Billing.inject())`), which resolves per request and
|
|
59
|
+
lets a test pass a double without touching the container.
|
|
60
|
+
|
|
61
|
+
**Read a runtime port inside a function or behind a getter**, never at module scope.
|
|
62
|
+
|
|
63
|
+
Reference: <https://nstfkc.github.io/gemi/services.md>
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: The Queue Is In-Process and In-Memory — Do Not Trust It With Durable Work
|
|
3
|
+
impact: HIGH
|
|
4
|
+
impactDescription: enqueued work is lost on restart
|
|
5
|
+
tags: service, jobs, queue, durability
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## The Queue Is In-Process and In-Memory — Do Not Trust It With Durable Work
|
|
9
|
+
|
|
10
|
+
Jobs live in the server process's memory. **Enqueued jobs do not survive a restart**,
|
|
11
|
+
and there is no cross-machine queue — a job dispatched on one instance runs on that
|
|
12
|
+
instance or not at all. Use jobs for best-effort work: warming a cache, sending a
|
|
13
|
+
non-critical email, kicking off media processing that the user can retry.
|
|
14
|
+
|
|
15
|
+
Anything that must not be lost needs a durable record: write the row first, then let
|
|
16
|
+
the job (or a cron sweep) act on it, so a restart leaves work to pick up rather than
|
|
17
|
+
a gap.
|
|
18
|
+
|
|
19
|
+
**Incorrect (the only record of the charge lives in the queue):**
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
async store(req: HttpRequest) {
|
|
23
|
+
const order = await Order.create({ data });
|
|
24
|
+
SettleOrderJob.dispatch({ orderId: order.publicId }); // lost on deploy
|
|
25
|
+
return { order };
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
**Correct (durable state first; the job is an accelerator):**
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
async store(req: HttpRequest) {
|
|
33
|
+
const order = await Order.create({ data: { ...data, settlementStatus: "pending" } });
|
|
34
|
+
SettleOrderJob.dispatch({ orderId: order.publicId });
|
|
35
|
+
return { order };
|
|
36
|
+
}
|
|
37
|
+
// A cron sweeps `settlementStatus: "pending"` rows, so a lost dispatch self-heals.
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
**Two deployment-shaped constraints that follow:**
|
|
41
|
+
|
|
42
|
+
- **The `queue` and `schedule` config slices declare their `jobs` lists
|
|
43
|
+
explicitly, and must keep doing so.** Discovery is a *runtime* filesystem walk, and
|
|
44
|
+
the release image ships only `dist/` — there is no `app/jobs` or `app/cron` on disk
|
|
45
|
+
in production. Discovery there warns once and registers nothing, so every job and
|
|
46
|
+
cron would stop running while dev and CI stayed green. Note `jobs: []` is
|
|
47
|
+
*present* and disables everything; omitting the key is what enables discovery.
|
|
48
|
+
- **A command dispatching a Job may exit before it runs.** The queue runs in-process
|
|
49
|
+
and the cron scheduler does not start under `gemi run` — do that work inline in the
|
|
50
|
+
command instead.
|
|
51
|
+
|
|
52
|
+
Reference: <https://nstfkc.github.io/gemi/jobs-and-queues.md>
|