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.
Files changed (231) hide show
  1. package/dist/ai/Agent.d.ts +33 -0
  2. package/dist/ai/Agent.d.ts.map +1 -0
  3. package/dist/ai/AgentController.d.ts +8 -0
  4. package/dist/ai/AgentController.d.ts.map +1 -0
  5. package/dist/ai/useChat.d.ts +12 -0
  6. package/dist/ai/useChat.d.ts.map +1 -0
  7. package/dist/app/index.js +1 -1
  8. package/dist/bin/gemi.js +500 -16
  9. package/dist/bin/gemi.js.map +10 -5
  10. package/dist/broadcasting/index.js +1 -1
  11. package/dist/bun/plugin.js +1 -1
  12. package/dist/bun/preload.js +1 -1
  13. package/dist/{chunk-szss069z.js → chunk-06j6rsew.js} +2 -2
  14. package/dist/{chunk-szss069z.js.map → chunk-06j6rsew.js.map} +1 -1
  15. package/dist/{chunk-yjzs247s.js → chunk-0fm6jh9b.js} +2 -2
  16. package/dist/{chunk-yjzs247s.js.map → chunk-0fm6jh9b.js.map} +1 -1
  17. package/dist/chunk-23h0dmx2.js +5 -0
  18. package/dist/{chunk-87qab82w.js.map → chunk-23h0dmx2.js.map} +3 -3
  19. package/dist/{chunk-m3xy5xyf.js → chunk-2cwcfwg3.js} +2 -2
  20. package/dist/{chunk-m3xy5xyf.js.map → chunk-2cwcfwg3.js.map} +1 -1
  21. package/dist/{chunk-xzk827r3.js → chunk-2khdxyjb.js} +2 -2
  22. package/dist/{chunk-xzk827r3.js.map → chunk-2khdxyjb.js.map} +1 -1
  23. package/dist/{chunk-sy7jbdeb.js → chunk-3296fwss.js} +2 -2
  24. package/dist/{chunk-sy7jbdeb.js.map → chunk-3296fwss.js.map} +1 -1
  25. package/dist/{chunk-xdv1b8mr.js → chunk-3gvjn3q4.js} +2 -2
  26. package/dist/{chunk-xdv1b8mr.js.map → chunk-3gvjn3q4.js.map} +1 -1
  27. package/dist/{chunk-jhkjz9jr.js → chunk-4dan69s4.js} +2 -2
  28. package/dist/{chunk-3zxwscmf.js.map → chunk-4dan69s4.js.map} +1 -1
  29. package/dist/{chunk-hxf1re93.js → chunk-4mcyyh1v.js} +2 -2
  30. package/dist/{chunk-hxf1re93.js.map → chunk-4mcyyh1v.js.map} +1 -1
  31. package/dist/{chunk-w62m5f0n.js → chunk-5gdv4n4a.js} +3 -3
  32. package/dist/{chunk-w62m5f0n.js.map → chunk-5gdv4n4a.js.map} +1 -1
  33. package/dist/{chunk-j0c6ytkj.js → chunk-8kj3zrm9.js} +3 -3
  34. package/dist/{chunk-j0c6ytkj.js.map → chunk-8kj3zrm9.js.map} +1 -1
  35. package/dist/{chunk-0a2xgcj3.js → chunk-98a3k7bp.js} +2 -2
  36. package/dist/{chunk-0a2xgcj3.js.map → chunk-98a3k7bp.js.map} +1 -1
  37. package/dist/{chunk-5athahgr.js → chunk-9gsdcjt7.js} +2 -2
  38. package/dist/{chunk-5athahgr.js.map → chunk-9gsdcjt7.js.map} +1 -1
  39. package/dist/{chunk-pmhd6zfc.js → chunk-b0t4zp6b.js} +2 -2
  40. package/dist/{chunk-pmhd6zfc.js.map → chunk-b0t4zp6b.js.map} +1 -1
  41. package/dist/{chunk-3xadx444.js → chunk-bn1v4sfs.js} +2 -2
  42. package/dist/{chunk-3xadx444.js.map → chunk-bn1v4sfs.js.map} +1 -1
  43. package/dist/{chunk-9m2tbf3n.js → chunk-c40n5r4v.js} +2 -2
  44. package/dist/{chunk-9m2tbf3n.js.map → chunk-c40n5r4v.js.map} +1 -1
  45. package/dist/{chunk-a2sgjpvq.js → chunk-cejf873g.js} +2 -2
  46. package/dist/{chunk-a2sgjpvq.js.map → chunk-cejf873g.js.map} +1 -1
  47. package/dist/{chunk-tss5svjr.js → chunk-cw9y6k15.js} +2 -2
  48. package/dist/{chunk-tss5svjr.js.map → chunk-cw9y6k15.js.map} +1 -1
  49. package/dist/{chunk-5n2rvfh3.js → chunk-cyrxdj6a.js} +2 -2
  50. package/dist/{chunk-5n2rvfh3.js.map → chunk-cyrxdj6a.js.map} +1 -1
  51. package/dist/chunk-dgasxgsm.js +19 -0
  52. package/dist/{chunk-hwa5sqw5.js.map → chunk-dgasxgsm.js.map} +6 -6
  53. package/dist/{chunk-3g5bjvdf.js → chunk-f6dd4gd8.js} +2 -2
  54. package/dist/{chunk-3g5bjvdf.js.map → chunk-f6dd4gd8.js.map} +1 -1
  55. package/dist/{chunk-qgxr0g36.js → chunk-fb4cy6sq.js} +2 -2
  56. package/dist/{chunk-qgxr0g36.js.map → chunk-fb4cy6sq.js.map} +1 -1
  57. package/dist/{chunk-7ef5n8k2.js → chunk-fbvvqf9b.js} +2 -2
  58. package/dist/{chunk-7ef5n8k2.js.map → chunk-fbvvqf9b.js.map} +1 -1
  59. package/dist/{chunk-zbxgbr12.js → chunk-fxy42w6n.js} +2 -2
  60. package/dist/{chunk-zbxgbr12.js.map → chunk-fxy42w6n.js.map} +1 -1
  61. package/dist/{chunk-xey9cbap.js → chunk-get4mkx8.js} +2 -2
  62. package/dist/{chunk-xey9cbap.js.map → chunk-get4mkx8.js.map} +1 -1
  63. package/dist/{chunk-3zxwscmf.js → chunk-gwchvzdp.js} +2 -2
  64. package/dist/{chunk-jhkjz9jr.js.map → chunk-gwchvzdp.js.map} +1 -1
  65. package/dist/{chunk-k0fvsyeh.js → chunk-hsfjt13m.js} +1 -1
  66. package/dist/{chunk-yf7vz71n.js → chunk-hwhw98hc.js} +1 -1
  67. package/dist/{chunk-fjm4y8bn.js → chunk-hxb1eg2z.js} +2 -2
  68. package/dist/{chunk-fjm4y8bn.js.map → chunk-hxb1eg2z.js.map} +1 -1
  69. package/dist/{chunk-7b0x860b.js → chunk-j06g4sqc.js} +2 -2
  70. package/dist/{chunk-7b0x860b.js.map → chunk-j06g4sqc.js.map} +1 -1
  71. package/dist/{chunk-dgsgjg53.js → chunk-j23cengk.js} +2 -2
  72. package/dist/{chunk-dgsgjg53.js.map → chunk-j23cengk.js.map} +1 -1
  73. package/dist/{chunk-vj6f2bmb.js → chunk-khf9xda6.js} +3 -3
  74. package/dist/{chunk-vj6f2bmb.js.map → chunk-khf9xda6.js.map} +1 -1
  75. package/dist/{chunk-437085pe.js → chunk-p3qd0gqa.js} +2 -2
  76. package/dist/{chunk-437085pe.js.map → chunk-p3qd0gqa.js.map} +1 -1
  77. package/dist/{chunk-gw6agevz.js → chunk-pkjq9833.js} +3 -3
  78. package/dist/{chunk-gw6agevz.js.map → chunk-pkjq9833.js.map} +1 -1
  79. package/dist/{chunk-7j6wbv12.js → chunk-q0y0j3ne.js} +2 -2
  80. package/dist/{chunk-7j6wbv12.js.map → chunk-q0y0j3ne.js.map} +1 -1
  81. package/dist/chunk-q6qghsy7.js +5 -0
  82. package/dist/{chunk-b35e128b.js.map → chunk-q6qghsy7.js.map} +1 -1
  83. package/dist/{chunk-wbrj0gya.js → chunk-qva4841r.js} +2 -2
  84. package/dist/{chunk-wbrj0gya.js.map → chunk-qva4841r.js.map} +1 -1
  85. package/dist/{chunk-86jebsm4.js → chunk-rkbv3df7.js} +3 -3
  86. package/dist/{chunk-86jebsm4.js.map → chunk-rkbv3df7.js.map} +1 -1
  87. package/dist/{chunk-keehyx51.js → chunk-rpjsmr41.js} +2 -2
  88. package/dist/{chunk-keehyx51.js.map → chunk-rpjsmr41.js.map} +1 -1
  89. package/dist/{chunk-mwpdp09e.js → chunk-spbgpndn.js} +2 -2
  90. package/dist/{chunk-mwpdp09e.js.map → chunk-spbgpndn.js.map} +1 -1
  91. package/dist/{chunk-yy0eb9wn.js → chunk-stq96kya.js} +2 -2
  92. package/dist/{chunk-yy0eb9wn.js.map → chunk-stq96kya.js.map} +1 -1
  93. package/dist/{chunk-d125j8t0.js → chunk-tds3xq6b.js} +3 -3
  94. package/dist/{chunk-d125j8t0.js.map → chunk-tds3xq6b.js.map} +1 -1
  95. package/dist/{chunk-3337e5g0.js → chunk-tey1xayb.js} +2 -2
  96. package/dist/{chunk-3337e5g0.js.map → chunk-tey1xayb.js.map} +1 -1
  97. package/dist/{chunk-w7rf99w6.js → chunk-vj9538yn.js} +2 -2
  98. package/dist/{chunk-w7rf99w6.js.map → chunk-vj9538yn.js.map} +1 -1
  99. package/dist/{chunk-yed5whgs.js → chunk-wcv6qtpq.js} +3 -3
  100. package/dist/{chunk-yed5whgs.js.map → chunk-wcv6qtpq.js.map} +1 -1
  101. package/dist/{chunk-vr90r27j.js → chunk-wzvs3sym.js} +2 -2
  102. package/dist/{chunk-vr90r27j.js.map → chunk-wzvs3sym.js.map} +1 -1
  103. package/dist/{chunk-cyaz97p5.js → chunk-x6y1z4cq.js} +2 -2
  104. package/dist/{chunk-cyaz97p5.js.map → chunk-x6y1z4cq.js.map} +1 -1
  105. package/dist/chunk-x8beq9c4.js +55 -0
  106. package/dist/chunk-x8beq9c4.js.map +11 -0
  107. package/dist/{chunk-6235kb30.js → chunk-xecj8025.js} +3 -3
  108. package/dist/{chunk-6235kb30.js.map → chunk-xecj8025.js.map} +1 -1
  109. package/dist/chunk-y3zz410b.js +6 -0
  110. package/dist/{chunk-4sz6pwtn.js.map → chunk-y3zz410b.js.map} +2 -2
  111. package/dist/{chunk-eejmhtnc.js → chunk-y64j80v9.js} +2 -2
  112. package/dist/{chunk-eejmhtnc.js.map → chunk-y64j80v9.js.map} +1 -1
  113. package/dist/{chunk-zh2egcyb.js → chunk-ygfwtgvm.js} +2 -2
  114. package/dist/{chunk-zh2egcyb.js.map → chunk-ygfwtgvm.js.map} +1 -1
  115. package/dist/{chunk-4yt5x8s2.js → chunk-ys904esh.js} +2 -2
  116. package/dist/{chunk-4yt5x8s2.js.map → chunk-ys904esh.js.map} +1 -1
  117. package/dist/{chunk-y6a8r2bn.js → chunk-z1e55w67.js} +3 -3
  118. package/dist/{chunk-y6a8r2bn.js.map → chunk-z1e55w67.js.map} +1 -1
  119. package/dist/{chunk-qb5mv6pj.js → chunk-z2tcxwyr.js} +3 -3
  120. package/dist/{chunk-qb5mv6pj.js.map → chunk-z2tcxwyr.js.map} +1 -1
  121. package/dist/{chunk-hppagzz4.js → chunk-zhbrkpb3.js} +4 -4
  122. package/dist/{chunk-hppagzz4.js.map → chunk-zhbrkpb3.js.map} +1 -1
  123. package/dist/{chunk-grdahng8.js → chunk-zqsfanvk.js} +2 -2
  124. package/dist/{chunk-grdahng8.js.map → chunk-zqsfanvk.js.map} +1 -1
  125. package/dist/config/index.d.ts +2 -0
  126. package/dist/config/index.d.ts.map +1 -1
  127. package/dist/config/index.js +2 -2
  128. package/dist/config/index.js.map +3 -3
  129. package/dist/console/run.js +2 -2
  130. package/dist/console/run.js.map +1 -1
  131. package/dist/container/index.js +2 -2
  132. package/dist/container/index.js.map +1 -1
  133. package/dist/database/index.js +2 -2
  134. package/dist/database/index.js.map +1 -1
  135. package/dist/email/index.js +2 -2
  136. package/dist/email/index.js.map +1 -1
  137. package/dist/facades/Features.d.ts +67 -1
  138. package/dist/facades/Features.d.ts.map +1 -1
  139. package/dist/facades/index.js +2 -2
  140. package/dist/facades/index.js.map +1 -1
  141. package/dist/foundation/index.js +2 -2
  142. package/dist/foundation/index.js.map +1 -1
  143. package/dist/http/index.js +2 -2
  144. package/dist/http/index.js.map +1 -1
  145. package/dist/i18n/dictionaryRuntime.js +2 -2
  146. package/dist/i18n/dictionaryRuntime.js.map +1 -1
  147. package/dist/i18n/index.js +2 -2
  148. package/dist/i18n/index.js.map +2 -2
  149. package/dist/kernel/index.js +2 -2
  150. package/dist/kernel/index.js.map +1 -1
  151. package/dist/orm/index.js +2 -2
  152. package/dist/orm/index.js.map +1 -1
  153. package/dist/server/Server.d.ts.map +1 -1
  154. package/dist/server/httpDev.d.ts.map +1 -1
  155. package/dist/server/index.js +2 -2
  156. package/dist/server/index.js.map +3 -3
  157. package/dist/services/events/Event.d.ts +6 -0
  158. package/dist/services/events/Event.d.ts.map +1 -1
  159. package/dist/services/features/FeatureFlagStore.d.ts +56 -0
  160. package/dist/services/features/FeatureFlagStore.d.ts.map +1 -1
  161. package/dist/services/features/FeatureManager.d.ts +35 -1
  162. package/dist/services/features/FeatureManager.d.ts.map +1 -1
  163. package/dist/services/features/types.d.ts +62 -0
  164. package/dist/services/features/types.d.ts.map +1 -1
  165. package/dist/services/index.d.ts +2 -2
  166. package/dist/services/index.d.ts.map +1 -1
  167. package/dist/services/index.js +8 -8
  168. package/dist/services/index.js.map +3 -3
  169. package/dist/services/logging/LogManager.d.ts +10 -1
  170. package/dist/services/logging/LogManager.d.ts.map +1 -1
  171. package/dist/services/logging/LogServiceProvider.d.ts +10 -0
  172. package/dist/services/logging/LogServiceProvider.d.ts.map +1 -1
  173. package/dist/support/index.js +2 -2
  174. package/dist/support/index.js.map +1 -1
  175. package/package.json +3 -2
  176. package/skills/gemi-react-best-practices/SKILL.md +231 -0
  177. package/skills/gemi-react-best-practices/rules/_sections.md +56 -0
  178. package/skills/gemi-react-best-practices/rules/_template.md +28 -0
  179. package/skills/gemi-react-best-practices/rules/bundle-deep-imports.md +48 -0
  180. package/skills/gemi-react-best-practices/rules/bundle-mount-gate-heavy-panels.md +63 -0
  181. package/skills/gemi-react-best-practices/rules/client-form-vs-mutation-hooks.md +57 -0
  182. package/skills/gemi-react-best-practices/rules/client-loading-error-exports.md +54 -0
  183. package/skills/gemi-react-best-practices/rules/client-no-effect-data-flow.md +68 -0
  184. package/skills/gemi-react-best-practices/rules/client-typed-links.md +51 -0
  185. package/skills/gemi-react-best-practices/rules/controller-authorize-every-tenant-read.md +65 -0
  186. package/skills/gemi-react-best-practices/rules/controller-parse-request-at-the-boundary.md +54 -0
  187. package/skills/gemi-react-best-practices/rules/controller-redirect-facade-throws.md +68 -0
  188. package/skills/gemi-react-best-practices/rules/controller-request-schema.md +61 -0
  189. package/skills/gemi-react-best-practices/rules/controller-throw-framework-errors.md +57 -0
  190. package/skills/gemi-react-best-practices/rules/i18n-define-dictionary-inline.md +58 -0
  191. package/skills/gemi-react-best-practices/rules/orm-analytics-connection.md +53 -0
  192. package/skills/gemi-react-best-practices/rules/orm-include-not-n-plus-one.md +56 -0
  193. package/skills/gemi-react-best-practices/rules/orm-paginate-helper.md +69 -0
  194. package/skills/gemi-react-best-practices/rules/orm-plain-rows-by-default.md +55 -0
  195. package/skills/gemi-react-best-practices/rules/orm-select-narrow.md +58 -0
  196. package/skills/gemi-react-best-practices/rules/orm-transaction-no-io.md +54 -0
  197. package/skills/gemi-react-best-practices/rules/orm-transaction-sequential.md +64 -0
  198. package/skills/gemi-react-best-practices/rules/payload-dont-overprefetch.md +54 -0
  199. package/skills/gemi-react-best-practices/rules/payload-instant-vs-prefetch.md +58 -0
  200. package/skills/gemi-react-best-practices/rules/payload-minimal-view-props.md +51 -0
  201. package/skills/gemi-react-best-practices/rules/payload-parallel-controller-work.md +56 -0
  202. package/skills/gemi-react-best-practices/rules/payload-prefetch-late-queries.md +58 -0
  203. package/skills/gemi-react-best-practices/rules/payload-prefetch-mirrors-usequery.md +52 -0
  204. package/skills/gemi-react-best-practices/rules/query-debounce-search-variant.md +52 -0
  205. package/skills/gemi-react-best-practices/rules/query-keep-previous-data.md +40 -0
  206. package/skills/gemi-react-best-practices/rules/query-lazy-vs-mount-gate.md +54 -0
  207. package/skills/gemi-react-best-practices/rules/query-mutate-over-refetch.md +55 -0
  208. package/skills/gemi-react-best-practices/rules/query-no-hand-rolled-fetch.md +60 -0
  209. package/skills/gemi-react-best-practices/rules/query-revalidate-on-focus.md +44 -0
  210. package/skills/gemi-react-best-practices/rules/query-share-cache-key.md +51 -0
  211. package/skills/gemi-react-best-practices/rules/query-suspense-default.md +52 -0
  212. package/skills/gemi-react-best-practices/rules/routing-cache-policy-constants.md +53 -0
  213. package/skills/gemi-react-best-practices/rules/routing-middleware-dsl.md +60 -0
  214. package/skills/gemi-react-best-practices/rules/routing-resource-routes.md +59 -0
  215. package/skills/gemi-react-best-practices/rules/routing-routers-are-classes.md +55 -0
  216. package/skills/gemi-react-best-practices/rules/service-lazy-not-module-scope.md +63 -0
  217. package/skills/gemi-react-best-practices/rules/service-queue-is-in-memory.md +52 -0
  218. package/skills/gemi-react-best-practices/rules/service-static-token-and-name.md +52 -0
  219. package/skills/gemi-react-best-practices/rules/structure-discovered-vs-registered.md +71 -0
  220. package/skills/gemi-react-best-practices/rules/structure-do-not-reinvent-the-framework.md +58 -0
  221. package/skills/gemi-react-best-practices/rules/testing-assert-behaviour-over-markup.md +54 -0
  222. package/skills/gemi-react-best-practices/rules/testing-match-the-suite.md +57 -0
  223. package/skills/gemi-react-best-practices/rules/testing-page-seeds-real-inputs.md +65 -0
  224. package/dist/chunk-01e3gk86.js +0 -55
  225. package/dist/chunk-01e3gk86.js.map +0 -11
  226. package/dist/chunk-4sz6pwtn.js +0 -6
  227. package/dist/chunk-87qab82w.js +0 -5
  228. package/dist/chunk-b35e128b.js +0 -5
  229. package/dist/chunk-hwa5sqw5.js +0 -19
  230. /package/dist/{chunk-k0fvsyeh.js.map → chunk-hsfjt13m.js.map} +0 -0
  231. /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>