gemi 0.59.0 → 0.60.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/gemi.js +500 -16
- package/dist/bin/gemi.js.map +10 -5
- package/dist/{chunk-n412aa9s.js → chunk-khf9xda6.js} +1 -1
- package/dist/{chunk-gfma8e03.js → chunk-x8beq9c4.js} +1 -1
- package/dist/chunk-y3zz410b.js +6 -0
- package/dist/{chunk-txhcx69q.js.map → chunk-y3zz410b.js.map} +2 -2
- package/dist/config/index.d.ts +2 -0
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +2 -2
- package/dist/config/index.js.map +3 -3
- package/dist/server/index.js +1 -1
- package/package.json +3 -2
- package/skills/gemi-react-best-practices/SKILL.md +231 -0
- package/skills/gemi-react-best-practices/rules/_sections.md +56 -0
- package/skills/gemi-react-best-practices/rules/_template.md +28 -0
- package/skills/gemi-react-best-practices/rules/bundle-deep-imports.md +48 -0
- package/skills/gemi-react-best-practices/rules/bundle-mount-gate-heavy-panels.md +63 -0
- package/skills/gemi-react-best-practices/rules/client-form-vs-mutation-hooks.md +57 -0
- package/skills/gemi-react-best-practices/rules/client-loading-error-exports.md +54 -0
- package/skills/gemi-react-best-practices/rules/client-no-effect-data-flow.md +68 -0
- package/skills/gemi-react-best-practices/rules/client-typed-links.md +51 -0
- package/skills/gemi-react-best-practices/rules/controller-authorize-every-tenant-read.md +65 -0
- package/skills/gemi-react-best-practices/rules/controller-parse-request-at-the-boundary.md +54 -0
- package/skills/gemi-react-best-practices/rules/controller-redirect-facade-throws.md +68 -0
- package/skills/gemi-react-best-practices/rules/controller-request-schema.md +61 -0
- package/skills/gemi-react-best-practices/rules/controller-throw-framework-errors.md +57 -0
- package/skills/gemi-react-best-practices/rules/i18n-define-dictionary-inline.md +58 -0
- package/skills/gemi-react-best-practices/rules/orm-analytics-connection.md +53 -0
- package/skills/gemi-react-best-practices/rules/orm-include-not-n-plus-one.md +56 -0
- package/skills/gemi-react-best-practices/rules/orm-paginate-helper.md +69 -0
- package/skills/gemi-react-best-practices/rules/orm-plain-rows-by-default.md +55 -0
- package/skills/gemi-react-best-practices/rules/orm-select-narrow.md +58 -0
- package/skills/gemi-react-best-practices/rules/orm-transaction-no-io.md +54 -0
- package/skills/gemi-react-best-practices/rules/orm-transaction-sequential.md +64 -0
- package/skills/gemi-react-best-practices/rules/payload-dont-overprefetch.md +54 -0
- package/skills/gemi-react-best-practices/rules/payload-instant-vs-prefetch.md +58 -0
- package/skills/gemi-react-best-practices/rules/payload-minimal-view-props.md +51 -0
- package/skills/gemi-react-best-practices/rules/payload-parallel-controller-work.md +56 -0
- package/skills/gemi-react-best-practices/rules/payload-prefetch-late-queries.md +58 -0
- package/skills/gemi-react-best-practices/rules/payload-prefetch-mirrors-usequery.md +52 -0
- package/skills/gemi-react-best-practices/rules/query-debounce-search-variant.md +52 -0
- package/skills/gemi-react-best-practices/rules/query-keep-previous-data.md +40 -0
- package/skills/gemi-react-best-practices/rules/query-lazy-vs-mount-gate.md +54 -0
- package/skills/gemi-react-best-practices/rules/query-mutate-over-refetch.md +55 -0
- package/skills/gemi-react-best-practices/rules/query-no-hand-rolled-fetch.md +60 -0
- package/skills/gemi-react-best-practices/rules/query-revalidate-on-focus.md +44 -0
- package/skills/gemi-react-best-practices/rules/query-share-cache-key.md +51 -0
- package/skills/gemi-react-best-practices/rules/query-suspense-default.md +52 -0
- package/skills/gemi-react-best-practices/rules/routing-cache-policy-constants.md +53 -0
- package/skills/gemi-react-best-practices/rules/routing-middleware-dsl.md +60 -0
- package/skills/gemi-react-best-practices/rules/routing-resource-routes.md +59 -0
- package/skills/gemi-react-best-practices/rules/routing-routers-are-classes.md +55 -0
- package/skills/gemi-react-best-practices/rules/service-lazy-not-module-scope.md +63 -0
- package/skills/gemi-react-best-practices/rules/service-queue-is-in-memory.md +52 -0
- package/skills/gemi-react-best-practices/rules/service-static-token-and-name.md +52 -0
- package/skills/gemi-react-best-practices/rules/structure-discovered-vs-registered.md +71 -0
- package/skills/gemi-react-best-practices/rules/structure-do-not-reinvent-the-framework.md +58 -0
- package/skills/gemi-react-best-practices/rules/testing-assert-behaviour-over-markup.md +54 -0
- package/skills/gemi-react-best-practices/rules/testing-match-the-suite.md +57 -0
- package/skills/gemi-react-best-practices/rules/testing-page-seeds-real-inputs.md +65 -0
- package/dist/chunk-txhcx69q.js +0 -6
- /package/dist/{chunk-n412aa9s.js.map → chunk-khf9xda6.js.map} +0 -0
- /package/dist/{chunk-gfma8e03.js.map → chunk-x8beq9c4.js.map} +0 -0
|
@@ -0,0 +1,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>
|