gemi 0.59.0 → 0.60.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/dist/bin/gemi.js +500 -16
  2. package/dist/bin/gemi.js.map +10 -5
  3. package/dist/{chunk-n412aa9s.js → chunk-khf9xda6.js} +1 -1
  4. package/dist/{chunk-gfma8e03.js → chunk-x8beq9c4.js} +1 -1
  5. package/dist/chunk-y3zz410b.js +6 -0
  6. package/dist/{chunk-txhcx69q.js.map → chunk-y3zz410b.js.map} +2 -2
  7. package/dist/config/index.d.ts +2 -0
  8. package/dist/config/index.d.ts.map +1 -1
  9. package/dist/config/index.js +2 -2
  10. package/dist/config/index.js.map +3 -3
  11. package/dist/server/index.js +1 -1
  12. package/package.json +3 -2
  13. package/skills/gemi-react-best-practices/SKILL.md +231 -0
  14. package/skills/gemi-react-best-practices/rules/_sections.md +56 -0
  15. package/skills/gemi-react-best-practices/rules/_template.md +28 -0
  16. package/skills/gemi-react-best-practices/rules/bundle-deep-imports.md +48 -0
  17. package/skills/gemi-react-best-practices/rules/bundle-mount-gate-heavy-panels.md +63 -0
  18. package/skills/gemi-react-best-practices/rules/client-form-vs-mutation-hooks.md +57 -0
  19. package/skills/gemi-react-best-practices/rules/client-loading-error-exports.md +54 -0
  20. package/skills/gemi-react-best-practices/rules/client-no-effect-data-flow.md +68 -0
  21. package/skills/gemi-react-best-practices/rules/client-typed-links.md +51 -0
  22. package/skills/gemi-react-best-practices/rules/controller-authorize-every-tenant-read.md +65 -0
  23. package/skills/gemi-react-best-practices/rules/controller-parse-request-at-the-boundary.md +54 -0
  24. package/skills/gemi-react-best-practices/rules/controller-redirect-facade-throws.md +68 -0
  25. package/skills/gemi-react-best-practices/rules/controller-request-schema.md +61 -0
  26. package/skills/gemi-react-best-practices/rules/controller-throw-framework-errors.md +57 -0
  27. package/skills/gemi-react-best-practices/rules/i18n-define-dictionary-inline.md +58 -0
  28. package/skills/gemi-react-best-practices/rules/orm-analytics-connection.md +53 -0
  29. package/skills/gemi-react-best-practices/rules/orm-include-not-n-plus-one.md +56 -0
  30. package/skills/gemi-react-best-practices/rules/orm-paginate-helper.md +69 -0
  31. package/skills/gemi-react-best-practices/rules/orm-plain-rows-by-default.md +55 -0
  32. package/skills/gemi-react-best-practices/rules/orm-select-narrow.md +58 -0
  33. package/skills/gemi-react-best-practices/rules/orm-transaction-no-io.md +54 -0
  34. package/skills/gemi-react-best-practices/rules/orm-transaction-sequential.md +64 -0
  35. package/skills/gemi-react-best-practices/rules/payload-dont-overprefetch.md +54 -0
  36. package/skills/gemi-react-best-practices/rules/payload-instant-vs-prefetch.md +58 -0
  37. package/skills/gemi-react-best-practices/rules/payload-minimal-view-props.md +51 -0
  38. package/skills/gemi-react-best-practices/rules/payload-parallel-controller-work.md +56 -0
  39. package/skills/gemi-react-best-practices/rules/payload-prefetch-late-queries.md +58 -0
  40. package/skills/gemi-react-best-practices/rules/payload-prefetch-mirrors-usequery.md +52 -0
  41. package/skills/gemi-react-best-practices/rules/query-debounce-search-variant.md +52 -0
  42. package/skills/gemi-react-best-practices/rules/query-keep-previous-data.md +40 -0
  43. package/skills/gemi-react-best-practices/rules/query-lazy-vs-mount-gate.md +54 -0
  44. package/skills/gemi-react-best-practices/rules/query-mutate-over-refetch.md +55 -0
  45. package/skills/gemi-react-best-practices/rules/query-no-hand-rolled-fetch.md +60 -0
  46. package/skills/gemi-react-best-practices/rules/query-revalidate-on-focus.md +44 -0
  47. package/skills/gemi-react-best-practices/rules/query-share-cache-key.md +51 -0
  48. package/skills/gemi-react-best-practices/rules/query-suspense-default.md +52 -0
  49. package/skills/gemi-react-best-practices/rules/routing-cache-policy-constants.md +53 -0
  50. package/skills/gemi-react-best-practices/rules/routing-middleware-dsl.md +60 -0
  51. package/skills/gemi-react-best-practices/rules/routing-resource-routes.md +59 -0
  52. package/skills/gemi-react-best-practices/rules/routing-routers-are-classes.md +55 -0
  53. package/skills/gemi-react-best-practices/rules/service-lazy-not-module-scope.md +63 -0
  54. package/skills/gemi-react-best-practices/rules/service-queue-is-in-memory.md +52 -0
  55. package/skills/gemi-react-best-practices/rules/service-static-token-and-name.md +52 -0
  56. package/skills/gemi-react-best-practices/rules/structure-discovered-vs-registered.md +71 -0
  57. package/skills/gemi-react-best-practices/rules/structure-do-not-reinvent-the-framework.md +58 -0
  58. package/skills/gemi-react-best-practices/rules/testing-assert-behaviour-over-markup.md +54 -0
  59. package/skills/gemi-react-best-practices/rules/testing-match-the-suite.md +57 -0
  60. package/skills/gemi-react-best-practices/rules/testing-page-seeds-real-inputs.md +65 -0
  61. package/dist/chunk-txhcx69q.js +0 -6
  62. /package/dist/{chunk-n412aa9s.js.map → chunk-khf9xda6.js.map} +0 -0
  63. /package/dist/{chunk-gfma8e03.js.map → chunk-x8beq9c4.js.map} +0 -0
@@ -0,0 +1,56 @@
1
+ ---
2
+ title: One include Tree Beats a Loop of Queries
3
+ impact: HIGH
4
+ impactDescription: N+1 round trips to 1
5
+ tags: orm, n-plus-one, include, relations
6
+ ---
7
+
8
+ ## One include Tree Beats a Loop of Queries
9
+
10
+ On Postgres, gemi's default **lateral** strategy folds an entire `include` /
11
+ nested-`select` tree into the root statement using `LATERAL` joins and `json_agg` —
12
+ one round trip for the whole tree. A loop that queries per row gets none of that.
13
+
14
+ **Incorrect (1 + N round trips):**
15
+
16
+ ```ts
17
+ const products = await Product.findMany({ where: { organizationId } });
18
+ const withMedia = await Promise.all(
19
+ products.map(async (product) => ({
20
+ ...product,
21
+ media: await Media.findMany({ where: { productId: product.id } }),
22
+ })),
23
+ );
24
+ ```
25
+
26
+ **Correct (one statement):**
27
+
28
+ ```ts
29
+ const products = await Product.findMany({
30
+ where: { organizationId },
31
+ include: { media: true },
32
+ });
33
+ ```
34
+
35
+ **Know when the lateral strategy declines**, because those nodes fall back to
36
+ batched (one query per include node) and a deep tree can quietly become several
37
+ round trips. It declines for: implicit many-to-many relations, self-relations, nodes
38
+ with `_count`, nodes ordered by a relation, and **any descendant of a declined
39
+ node**. If a read is hot and its tree contains one of these, consider restructuring
40
+ the tree rather than accepting the fallback.
41
+
42
+ **`take` inside a to-many is per-parent, not total** — `take: 10` on an included
43
+ relation means ten per parent. That requires the lateral strategy; batched mode
44
+ refuses it.
45
+
46
+ **Index the foreign keys.** Relation filters, counts and orderings compile to
47
+ correlated subqueries that run once per parent row; without an index on the child's
48
+ FK each run scans the table.
49
+
50
+ Override per call when you need the other strategy:
51
+
52
+ ```ts
53
+ await User.findMany({ include: { accounts: true } }, { strategy: "batched" });
54
+ ```
55
+
56
+ Reference: <https://nstfkc.github.io/gemi/orm.md>
@@ -0,0 +1,69 @@
1
+ ---
2
+ title: Use paginate() From gemi/orm, and Know Its Ceiling
3
+ impact: MEDIUM
4
+ impactDescription: bounded reads, no unbounded scans
5
+ tags: orm, pagination, validation
6
+ ---
7
+
8
+ ## Use paginate() From gemi/orm, and Know Its Ceiling
9
+
10
+ An unbounded list read is a slow query waiting for the row count to grow. `paginate`
11
+ validates and clamps request input in one place rather than each controller
12
+ hand-rolling `parseInt` and a guard.
13
+
14
+ ```ts
15
+ import { paginate } from "gemi/orm";
16
+
17
+ const { take, skip } = paginate({
18
+ page: req.search.get("page"),
19
+ perPage: req.search.get("perPage"),
20
+ });
21
+ ```
22
+
23
+ Its behaviour, which you must design around:
24
+
25
+ - Absent, blank or non-finite values are treated as absent.
26
+ - `perPage` **defaults to 25 and is capped at 100**.
27
+ - `page` clamps to a minimum of 1.
28
+
29
+ **The 100-row cap is the trap.** A caller that asks for `limit=200` silently gets
30
+ 100 — half its data, no error. If a surface genuinely needs more, it must paginate
31
+ rather than raise the number.
32
+
33
+ **Incorrect (unbounded, and it will not stay fast):**
34
+
35
+ ```ts
36
+ const products = await Product.findMany({ where: { organizationId } });
37
+ ```
38
+
39
+ **Incorrect (hand-rolled, and unvalidated `take` is refused rather than coerced):**
40
+
41
+ ```ts
42
+ const take = Number(req.search.get("limit"));
43
+ const products = await Product.findMany({ where, take });
44
+ ```
45
+
46
+ **Correct:**
47
+
48
+ ```ts
49
+ const { take, skip } = paginate({
50
+ page: req.search.get("page"),
51
+ perPage: req.search.get("perPage"),
52
+ });
53
+ const products = await Product.findMany({
54
+ where: { organizationId },
55
+ take,
56
+ skip,
57
+ orderBy: { createdAt: "desc" },
58
+ });
59
+ ```
60
+
61
+ Two related ORM behaviours: `take` and `skip` must be integers and are **refused,
62
+ not coerced** — a negative `take` means "the last N rows". And an `update` with
63
+ `data: {}` reads rather than writes, returning the row unchanged.
64
+
65
+ A deliberately unpaginated read is a decision worth a comment saying why (see
66
+ `CustomerProductsV2Controller`).
67
+
68
+ Reference: <https://nstfkc.github.io/gemi/orm.md>
69
+ `app/http/controllers/AdminMediaController.ts`
@@ -0,0 +1,55 @@
1
+ ---
2
+ title: Plain Rows Are the Default — Opt Into Tracking Deliberately
3
+ impact: MEDIUM
4
+ impactDescription: ~100% read overhead when tracking is on
5
+ tags: orm, rows, tracking, performance
6
+ ---
7
+
8
+ ## Plain Rows Are the Default — Opt Into Tracking Deliberately
9
+
10
+ Queries return plain objects. No proxying, no hydration, no identity map — so a row
11
+ can be spread, serialized, or handed to a view freely, and `select` narrows the
12
+ **type** as well as the columns. This costs nothing, which is why it is the default.
13
+
14
+ Two opt-ins exist above it, and both have a price:
15
+
16
+ - **`{ track: true }`** enables `Model.save(row)` differential updates — only changed
17
+ columns are written, and an unchanged row produces no SQL at all. It costs roughly
18
+ **100% overhead on a 1,000-row read** (WeakMap insertion plus snapshot cloning), so
19
+ it does not belong on a list read.
20
+ - **`Model.wrap(row)`** adds model methods and getters. It **requires a complete
21
+ row** — a partial `select` is a compile error — and tracks automatically.
22
+
23
+ **Incorrect (tracking a list read that is only being serialized):**
24
+
25
+ ```ts
26
+ const products = await Product.findMany(
27
+ { where: { organizationId } },
28
+ { track: true }, // pure overhead; nothing here is saved
29
+ );
30
+ return { products };
31
+ ```
32
+
33
+ **Correct (plain for reads; track the single row you intend to mutate):**
34
+
35
+ ```ts
36
+ const products = await Product.findMany({
37
+ where: { organizationId },
38
+ select: { publicId: true, name: true },
39
+ });
40
+
41
+ const product = await Product.findUnique({ where: { id } }, { track: true });
42
+ product.name = nextName;
43
+ await Product.save(product); // writes only `name`
44
+ ```
45
+
46
+ **Provenance is keyed on object identity via a WeakMap**, so spreading, cloning or
47
+ serializing loses it — `Model.save({ ...product })` throws. It also cannot assign to
48
+ a column the query did not fetch, and a tracked row remembers which connection
49
+ produced it, so `save()` returns to the right pool.
50
+
51
+ **There is no identity map and no lazy loading.** Two reads of the same row return
52
+ two distinct objects that do not synchronize. Coordinate saves inside a
53
+ `Model.transaction`, sequentially (`orm-transaction-sequential`).
54
+
55
+ Reference: <https://nstfkc.github.io/gemi/orm-rows-and-entities.md>
@@ -0,0 +1,58 @@
1
+ ---
2
+ title: Select the Columns the Response Actually Serializes
3
+ impact: MEDIUM
4
+ impactDescription: less data over two wires
5
+ tags: orm, select, payload, serialization
6
+ ---
7
+
8
+ ## Select the Columns the Response Actually Serializes
9
+
10
+ Every unselected column crosses two wires: database → server, then server → browser
11
+ in the SSR payload or the API response. Narrowing at the query removes both, and
12
+ prevents internal columns from leaking into a payload anyone can read.
13
+
14
+ This is the query-side half of `payload-minimal-view-props`.
15
+
16
+ **Incorrect (loads every column of every row, then renders three fields):**
17
+
18
+ ```ts
19
+ const products = await Product.findMany({
20
+ where: { organizationId },
21
+ include: { media: true },
22
+ });
23
+ return products.map((p) => ({ id: p.publicId, name: p.name, cover: p.media[0] }));
24
+ ```
25
+
26
+ **Correct (narrow the tree at the query, including inside the relation):**
27
+
28
+ ```ts
29
+ const products = await Product.findMany({
30
+ where: { organizationId },
31
+ select: {
32
+ publicId: true,
33
+ name: true,
34
+ media: { select: { url: true }, take: 1 },
35
+ },
36
+ });
37
+ ```
38
+
39
+ Note `take: 1` inside the relation is **per parent** (`orm-include-not-n-plus-one`),
40
+ which is what you want for a cover image.
41
+
42
+ **Prefer `count` / `aggregate` over loading rows to count them:**
43
+
44
+ ```ts
45
+ // Incorrect
46
+ const orders = await Order.findMany({ where: { status: "pending" } });
47
+ const pending = orders.length;
48
+
49
+ // Correct
50
+ const pending = await Order.count({ where: { status: "pending" } });
51
+ ```
52
+
53
+ But note the counterpart from the ORM docs: a `_count` node makes the lateral
54
+ strategy decline for that node and its descendants, and an unindexed `_count`
55
+ compiles to a correlated subquery per parent row. A separate `count` query is often
56
+ the cheaper shape for a hot read.
57
+
58
+ Reference: <https://nstfkc.github.io/gemi/orm.md>
@@ -0,0 +1,54 @@
1
+ ---
2
+ title: Keep Network I/O Out of a Transaction Callback
3
+ impact: HIGH
4
+ impactDescription: frees a pooled connection held for a network round-trip
5
+ tags: orm, transactions, pooling, io
6
+ ---
7
+
8
+ ## Keep Network I/O Out of a Transaction Callback
9
+
10
+ The reserved connection is held for the **entire** duration of the callback. An HTTP
11
+ call, an upload, a queue push or an LLM request inside a transaction holds a pooled
12
+ connection for as long as that request takes — and under load, unrelated queries
13
+ elsewhere in the app queue behind it. The pools here are small and budgeted
14
+ (your database config's pool sizes are asserted against a per-instance
15
+ budget at boot), so one slow callback is felt app-wide.
16
+
17
+ Do the I/O first, or after. Keep the transaction to the writes that must be atomic.
18
+
19
+ **Incorrect (a connection held across a third-party API call):**
20
+
21
+ ```ts
22
+ await Order.transaction(async () => {
23
+ const order = await Order.create({ data });
24
+ const charge = await stripe.charges.create({ amount }); // network
25
+ await Storage.put(`receipts/${order.publicId}.pdf`, pdf); // network
26
+ await Order.update({ where: { id: order.id }, data: { chargeId: charge.id } });
27
+ });
28
+ ```
29
+
30
+ **Correct (I/O outside; the transaction is the atomic write only):**
31
+
32
+ ```ts
33
+ const charge = await stripe.charges.create({ amount });
34
+
35
+ const order = await Order.transaction(async () => {
36
+ const created = await Order.create({ data });
37
+ await Order.update({
38
+ where: { id: created.id },
39
+ data: { chargeId: charge.id },
40
+ });
41
+ return created;
42
+ });
43
+
44
+ await Storage.put(`receipts/${order.publicId}.pdf`, pdf);
45
+ ```
46
+
47
+ **Deferred work belongs in a Job**, not in the callback. `Job.dispatch(...)` from
48
+ `app/jobs` enqueues and returns `void`, so the request path never waits on it.
49
+
50
+ One caveat from `CLAUDE.md`: the queue runs in-process, and the cron
51
+ scheduler does not start under `gemi run`. A **command** that dispatches a Job may
52
+ exit before it runs — do that work inline instead.
53
+
54
+ Reference: <https://nstfkc.github.io/gemi/orm.md>
@@ -0,0 +1,64 @@
1
+ ---
2
+ title: Never Promise.all ORM Calls Inside a Transaction
3
+ impact: HIGH
4
+ impactDescription: prevents deadlock and lost writes
5
+ tags: orm, transactions, concurrency, correctness
6
+ ---
7
+
8
+ ## Never Promise.all ORM Calls Inside a Transaction
9
+
10
+ `Model.transaction(fn)` is **ambient** — it uses `AsyncLocalStorage`, so nothing
11
+ takes a `tx` parameter and every ORM query in the async subtree joins the
12
+ transaction automatically. That convenience has a hard constraint: the transaction
13
+ holds **one reserved connection**, so concurrent queries inside the callback are not
14
+ safe. Await them in sequence.
15
+
16
+ This is the one place that overrides `payload-parallel-controller-work`: everywhere
17
+ else independent awaits belong in a `Promise.all`, but inside a transaction callback
18
+ sequential is correct.
19
+
20
+ **Incorrect (concurrent queries on one reserved connection):**
21
+
22
+ ```ts
23
+ await Order.transaction(async () => {
24
+ await Promise.all([
25
+ Order.update({ where: { id }, data: { status } }),
26
+ OrderItem.createMany({ data: items }),
27
+ AuditLog.create({ data: entry }),
28
+ ]);
29
+ });
30
+ ```
31
+
32
+ **Correct (sequential):**
33
+
34
+ ```ts
35
+ await Order.transaction(async () => {
36
+ await Order.update({ where: { id }, data: { status } });
37
+ await OrderItem.createMany({ data: items });
38
+ await AuditLog.create({ data: entry });
39
+ });
40
+ ```
41
+
42
+ **A failed statement aborts the whole Postgres transaction block.** Catching an
43
+ error and continuing loses everything after it. Wrap a fallible step in a nested
44
+ transaction — nesting creates a savepoint, so an inner failure rolls back to the
45
+ savepoint and leaves the outer transaction usable:
46
+
47
+ ```ts
48
+ await Model.transaction(async () => {
49
+ try {
50
+ await Model.transaction(() => User.create({ data: { email } }));
51
+ } catch {
52
+ // recovery
53
+ }
54
+ await Audit.create({ data: entry }); // still safe
55
+ });
56
+ ```
57
+
58
+ **A transaction cannot span connections.** Crossing pools inside one raises
59
+ `CrossConnectionTransactionError`, and it cannot span the ORM and Prisma clients
60
+ either — they are separate pools. That is why the credit-settlement cluster stays
61
+ entirely on Prisma; see `CLAUDE.md` → "The Prisma boundary is
62
+ transactional, not directory-shaped."
63
+
64
+ Reference: <https://nstfkc.github.io/gemi/orm.md>
@@ -0,0 +1,54 @@
1
+ ---
2
+ title: Do Not Prefetch What the Page May Never Read
3
+ impact: HIGH
4
+ impactDescription: keeps navigation payloads small
5
+ tags: payload, prefetch, bandwidth, tradeoffs
6
+ ---
7
+
8
+ ## Do Not Prefetch What the Page May Never Read
9
+
10
+ `Query.prefetch` is not free. Primed data is added to the payload of **every**
11
+ navigation to that route — including navigations where the client already has the
12
+ value cached. Priming a heavy collection to save one round-trip can cost more bytes
13
+ than it saves, on every visit.
14
+
15
+ Prefetch a read when the surface reads it **on mount, always**. Do not prefetch:
16
+
17
+ - **Reads behind a closed popover, dialog, or tab.** Nobody has opened it. Gate them
18
+ by mounting (`bundle-mount-gate-heavy-panels`) or with `{ lazy: true }` +
19
+ `trigger()`.
20
+ - **Heavy collections that revalidate well.** A large list benefits more from
21
+ cache-then-revalidate than from eager priming.
22
+ - **Anything behind a feature flag or role gate** most visitors fail.
23
+
24
+ **Incorrect (priming a catalogue for a picker most sessions never open):**
25
+
26
+ ```ts
27
+ async view(req: HttpRequest) {
28
+ Query.prefetch("/app/:orgId/products/search", {
29
+ params: { orgId },
30
+ search: { q: null, limit: 25 },
31
+ });
32
+ return {};
33
+ }
34
+ ```
35
+
36
+ **Correct (the read lives inside the popover content, which Radix unmounts while closed):**
37
+
38
+ ```tsx
39
+ function CatalogSearchPanel({ orgId }: { orgId: string }) {
40
+ const { data: products = [], loading } = useQuery(
41
+ "/app/:orgId/products/search",
42
+ { params: { orgId }, search: { q: debouncedQuery || null, limit } },
43
+ { suspense: false },
44
+ );
45
+ // …
46
+ }
47
+
48
+ <PopoverContent>
49
+ <CatalogSearchPanel orgId={orgId} />
50
+ </PopoverContent>
51
+ ```
52
+
53
+ For a route where the un-prefetched reads are deliberate, `Query.noPrefetch()`
54
+ silences gemi's dev hint so the hints that remain stay meaningful.
@@ -0,0 +1,58 @@
1
+ ---
2
+ title: Query.instant Blocks, Query.prefetch Does Not
3
+ impact: CRITICAL
4
+ impactDescription: avoids blocking TTFB on a slow read
5
+ tags: payload, prefetch, instant, ttfb
6
+ ---
7
+
8
+ ## Query.instant Blocks, Query.prefetch Does Not
9
+
10
+ Both reuse an API handler server-side and prime the client cache. They differ in
11
+ what they do to the response:
12
+
13
+ - **`Query.instant(path, opts)`** — awaits the handler and blocks the response until
14
+ it resolves. The data ships in the initial payload. Use it when the view cannot
15
+ render anything meaningful without the data, or when the handler's return value is
16
+ needed to build the view's props.
17
+ - **`Query.prefetch(path, opts)`** — starts the handler immediately and in parallel,
18
+ without blocking. Use it for everything else: the shell ships now, the data lands
19
+ behind it.
20
+
21
+ The default choice is `prefetch`. Reach for `instant` only when you actually need
22
+ the value on the server.
23
+
24
+ **Incorrect (a slow, non-essential read holds up the whole document):**
25
+
26
+ ```ts
27
+ async view(req: HttpRequest) {
28
+ const orgId = req.params.orgId;
29
+ // The credits widget lives in a corner of the nav. Blocking TTFB on it
30
+ // delays the entire page for every visitor.
31
+ const credits = await Query.instant("/app/:orgId/ai-credits", {
32
+ params: { orgId },
33
+ });
34
+ return { credits };
35
+ }
36
+ ```
37
+
38
+ **Correct (shell first, widget data behind it):**
39
+
40
+ ```ts
41
+ view(req: HttpRequest) {
42
+ const orgId = req.params.orgId;
43
+ Query.prefetch("/app/:orgId/ai-credits", { params: { orgId } });
44
+ return {};
45
+ }
46
+ ```
47
+
48
+ **Also correct — `instant` when the server needs the value:**
49
+
50
+ ```ts
51
+ async view(req: HttpRequest) {
52
+ const store = await Query.instant("/app/:orgId/store", { params });
53
+ Meta.title(`${store.name} — Acme`);
54
+ return { storeName: store.name };
55
+ }
56
+ ```
57
+
58
+ Reference: <https://nstfkc.github.io/gemi/data-fetching.md>
@@ -0,0 +1,51 @@
1
+ ---
2
+ title: Return the Shape the View Renders, Not the Row You Loaded
3
+ impact: HIGH
4
+ impactDescription: smaller payload, no leaked columns
5
+ tags: payload, serialization, ssr, security
6
+ ---
7
+
8
+ ## Return the Shape the View Renders, Not the Row You Loaded
9
+
10
+ A controller's return value is serialized into the HTML payload and shipped to every
11
+ visitor. Returning a whole model row inflates the document and can leak columns the
12
+ UI never shows — internal ids, timestamps, provider tokens, soft-delete flags.
13
+
14
+ It applies to `this.view(...)` handler returns and to API controller responses
15
+ alike.
16
+
17
+ **Incorrect (ships every column, including ones the UI never renders):**
18
+
19
+ ```ts
20
+ async view(req: HttpRequest) {
21
+ const store = await Store.findUniqueOrThrow({
22
+ where: { publicId: req.params.storeId },
23
+ });
24
+ return { store };
25
+ }
26
+ ```
27
+
28
+ **Correct (select what the view renders):**
29
+
30
+ ```ts
31
+ async view(req: HttpRequest) {
32
+ const store = await Store.findUniqueOrThrow({
33
+ where: { publicId: req.params.storeId },
34
+ select: { publicId: true, name: true, slug: true, logoUrl: true },
35
+ });
36
+ return { store };
37
+ }
38
+ ```
39
+
40
+ Two related habits:
41
+
42
+ - **Do not ship the same data twice.** If a `Query.prefetch` already primes a read,
43
+ the view handler need not also return that data as props — pick one.
44
+ - **Narrow at the query, not after it.** `select` in the ORM call keeps the columns
45
+ off the wire from the database too (`orm-select-narrow`), rather than loading them
46
+ and dropping them in JavaScript.
47
+
48
+ Type safety follows the narrowing for free: `ViewProps<"/path">` infers the exact
49
+ shape the handler returned.
50
+
51
+ Reference: <https://nstfkc.github.io/gemi/views-and-layouts.md>
@@ -0,0 +1,56 @@
1
+ ---
2
+ title: Parallelize Independent Work in a Controller
3
+ impact: CRITICAL
4
+ impactDescription: 2-5x on multi-read endpoints
5
+ tags: payload, async, promises, controllers, waterfalls
6
+ ---
7
+
8
+ ## Parallelize Independent Work in a Controller
9
+
10
+ Server-side fetching lives in the controller method, not in the component tree, so
11
+ there is nothing to restructure in the view — the parallelism is expressed where the
12
+ awaits are. Independent awaits belong in a `Promise.all`.
13
+
14
+ Each sequential `await` on an independent read adds a full database round-trip to
15
+ the response.
16
+
17
+ **Incorrect (three serial round-trips):**
18
+
19
+ ```ts
20
+ export class OrganizationController extends Controller {
21
+ async show(req: HttpRequest) {
22
+ const org = await Organization.findUniqueOrThrow({ where: { publicId } });
23
+ const members = await Account.findMany({ where: { organizationId: org.id } });
24
+ const stores = await Store.findMany({ where: { organizationId: org.id } });
25
+ return { org, members, stores };
26
+ }
27
+ }
28
+ ```
29
+
30
+ **Correct (the dependent read first, then the two independent ones together):**
31
+
32
+ ```ts
33
+ export class OrganizationController extends Controller {
34
+ async show(req: HttpRequest) {
35
+ const org = await Organization.findUniqueOrThrow({ where: { publicId } });
36
+ const [members, stores] = await Promise.all([
37
+ Account.findMany({ where: { organizationId: org.id } }),
38
+ Store.findMany({ where: { organizationId: org.id } }),
39
+ ]);
40
+ return { org, members, stores };
41
+ }
42
+ }
43
+ ```
44
+
45
+ **Two hard exceptions:**
46
+
47
+ 1. **Never `Promise.all` ORM calls inside `Model.transaction`** — see
48
+ `orm-transaction-sequential`. One reserved connection makes it unsafe.
49
+ 2. **A batch over the analytics pool is bounded**, not unbounded — the admin
50
+ aggregations cap their in-flight count at the pool size so a batch
51
+ cannot queue more work than the pool can serve.
52
+
53
+ Two habits that compound with this: move an `await` into the branch that actually
54
+ uses it, and check cheap synchronous conditions (a param, a flag already in memory)
55
+ *before* awaiting anything — an early return costs nothing once the round-trip has
56
+ already started.
@@ -0,0 +1,58 @@
1
+ ---
2
+ title: Prefetch Reads That Render Discovers Late
3
+ impact: CRITICAL
4
+ impactDescription: removes a full serial round-trip
5
+ tags: payload, prefetch, waterfall, suspense
6
+ ---
7
+
8
+ ## Prefetch Reads That Render Discovers Late
9
+
10
+ gemi starts every query it can *reach* on the initial render, in parallel — so the
11
+ page costs the slowest query, not their sum. The exception is a query render cannot
12
+ reach yet. Two shapes cause it:
13
+
14
+ 1. **Nested under a suspending query.** A layout or parent that suspends blocks its
15
+ children from mounting, so their queries are not discovered until the parent
16
+ resolves — a textbook waterfall.
17
+ 2. **Conditionally rendered.** `{data.hasReports && <Reports />}` cannot start
18
+ `Reports`' query until `data` arrives.
19
+
20
+ `Query.prefetch` in the controller starts these at request time instead of at
21
+ discovery time, collapsing the waterfall.
22
+
23
+ **Incorrect (the inner read cannot start until the outer one resolves):**
24
+
25
+ ```tsx
26
+ function Dashboard() {
27
+ const { data: summary } = useQuery("/app/:orgId/summary", { params });
28
+ // Discovered only after `summary` resolves — two serial round-trips.
29
+ return summary.hasOrders ? <PendingOrders /> : null;
30
+ }
31
+
32
+ function PendingOrders() {
33
+ const { data } = useQuery("/app/:orgId/orders/pending-count", { params });
34
+ return <Badge>{data.count}</Badge>;
35
+ }
36
+ ```
37
+
38
+ **Correct (both start at request time, in parallel):**
39
+
40
+ ```ts
41
+ export class DashboardController extends Controller {
42
+ view(req: HttpRequest) {
43
+ const orgId = req.params.orgId;
44
+ Query.prefetch("/app/:orgId/summary", { params: { orgId } });
45
+ Query.prefetch("/app/:orgId/orders/pending-count", { params: { orgId } });
46
+ return {};
47
+ }
48
+ }
49
+ ```
50
+
51
+ Matching prefetched data wakes a suspended query immediately, and an in-flight
52
+ result is not overwritten by a later prefetch — so priming is safe to add.
53
+
54
+ Weigh it against `payload-dont-overprefetch` first: priming costs bandwidth on
55
+ every navigation. For a route where you deliberately want no priming,
56
+ `Query.noPrefetch()` silences the dev hint.
57
+
58
+ Reference: <https://nstfkc.github.io/gemi/data-fetching.md>