Skip to content
19 changes: 16 additions & 3 deletions .agents/skills/kitcn/references/features/orm.md
Original file line number Diff line number Diff line change
Expand Up @@ -262,9 +262,22 @@ Rules:
Relation `limit`/`orderBy` are pushed into the relation index when the index
already walks in that order. After the FK, `index('by_user').on(t.userId)`
orders by creation time, so `with: { posts: { limit: 5, orderBy: { createdAt:
'desc' } } }` reads 5 posts per parent. Sorting by any other column reads the
parent's whole child partition and sorts in memory β€” put the sort column in the
relation index (`index('by_user_rank').on(t.userId, t.rank)`) to stay bounded.
'desc' } } }` reads 5 posts per parent. Multi-field sorts are pushed down too
when the index carries all of them: `index('by_user_rank').on(t.userId, t.rank)`
serves `orderBy: { rank: 'asc', createdAt: 'asc' }`, since the FK pins `userId`,
`rank` is the next key and `createdAt` is Convex's implicit trailing one.
Non-null values use Convex value ordering on both index-backed and post-fetch
paths, including UTF-8 strings, signed zero, and NaN.
When the first requested field is equality-pinned but points opposite to the
moving fields, include `createdAt` in the moving direction as the final sort
field. Without that explicit tie-break, the ORM post-fetch sorts to preserve
which tied rows survive `limit`.
Every unpinned declared index key must be requested. An extra key after the
requested fields would break ties before Convex's implicit creation-time key,
so the ORM keeps the post-fetch sort to preserve which rows survive `limit`.
Sorting by any other column β€” or by one that can be missing or `null` β€” reads
the parent's whole child partition and sorts in memory; put the sort columns in
the relation index, in sort order, to stay bounded.

### Nested `with` depth

Expand Down
31 changes: 31 additions & 0 deletions .changeset/nine-planets-marry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
"kitcn": patch
---

## Patches

- Fix a multi-field `orderBy` reading the whole table even when a declared
compound index already produces that exact order. `orderBy: [asc(type),
asc(numLikes)]` with `limit: 5` against an index on `(type, numLikes)` now
reads 5 documents instead of every row, at any table size β€” previously the
read cost was the same whether you asked for 5 rows or 50. The same bound
applies to relations: `with: { posts: { orderBy: { numLikes: 'asc' },
limit: 2 } }` now reads 2 children per parent instead of all of them.
- Prefer an index that supplies more of the requested sort when several serve
the filter equally well, so `(orgId, createdAt, title)` is chosen over
`(orgId, createdAt)` for a sort on both `createdAt` and `title`. An index with
another unrequested key after `title` is not treated as an exact sort because
that key would change which tied rows survive `limit`.
- Stop warning that secondary `orderBy` fields are unstable across pages when
the index carries the whole sort. A Convex cursor is the index key, so those
pages are stable. The warning still fires β€” with corrected wording β€” when no
index serves the full sort and the extra fields really are dropped.
- Sorts that mix directions, skip an index key, or run over a column that can
be missing or null keep using the post-fetch sort, so row order and null
placement are unchanged.
- Use Convex value ordering for non-null post-fetch values, including UTF-8
strings, signed zero, and NaN, so an index-backed top-k and its post-fetch
fallback select the same rows.
- Preserve the existing implicit creation-time tie order when an
equality-pinned leading sort field points opposite to the moving fields.
Add `createdAt` in the moving direction to make that sort index-bounded.
Loading
Loading