Skip to content

fix(orm): stop re-reading per row in FK, CLEARING, and relation probes - #420

Merged
zbeyens merged 3 commits into
mainfrom
fix/orm-per-transaction-memo-lifetime
Aug 26, 2026
Merged

zbeyens merged 3 commits into
mainfrom
fix/orm-per-transaction-memo-lifetime

Conversation

@MikeyZhang75

@MikeyZhang75 MikeyZhang75 commented Aug 21, 2026 •

Copy link
Copy Markdown
Collaborator
  • Auto release

🐛 Fixes #406
🧭 Task plan: docs/plans/2026-08-21-406-orm-per-transaction-memo-lifetime.md
🟢 95-100% confidence

Phase 🧪 Tests 🌐 Browser
Reproduced 🔴 8 reads for 8 rows sharing one FK, 12 CLEARING probes for 12 rows, 50 target reads for 2 distinct targets ➖ N/A
Verified 🟢 bun check, 4 new read-bound test files ➖ N/A

✅ Outcome

  • insert() probes each distinct foreign id once per statement instead of once per row (8 → 1).
  • The aggregate write barrier scans the CLEARING range once per transaction instead of once per written row (12 → 1).
  • A relation where reads each distinct target document once per query instead of once per scanned row (50 → 2).
  • New orm/transaction-cache.ts owns the per-transaction memo lifetime the ORM was missing.

⚠️ Caveat

  • The issue assumes all three consumers want per-transaction lifetime; two of them do not, and following that would ship bugs. insert() is per-statement because insert -> delete parent -> insert in one transaction would otherwise write a dangling foreign key with no hook and no warning. Relation targets are per-execution because a query ctx never has an inner writer, so the proposed anchor is unreachable there.
  • The issue also misnames the third site: relationDefinition.where is a synchronous column-only predicate and issues zero reads. The amplification is matchesPostFetchMembership handing _applyRelationsFilterToRows a one-row array, which starves every downstream de-duplication map.

🏗️ Design

  • The memo is keyed on getOrmLifecycleInnerDb(db) ?? db. The fallback is required, not defensive: the lifecycle refuses to wrap readers and no-ops for schemas without triggers or aggregate indexes, so the inner-db symbol is absent for every query and most mutations. That formula converges on the same raw writer from the main scope, skipRules, withoutTriggers, and the scheduled workers.
  • Raw ctx.db is a fresh literal per UDF invocation, so the key can never leak across transactions; a nested ctx.runMutation just starts a fresh memo, which costs extra reads rather than staleness.
  • setCountState is the only writer that can move a state row into CLEARING, so it bumps an isolate generation counter that retires cached clean answers even across the nested-invocation boundary no transaction-scoped invalidation could reach. The counter only ever invalidates, so it cannot reintroduce the closure trap the issue warns about.
  • The insert memo stores ids only, is limited to the bare _id existence check, and switches off when the table has lifecycle hooks, which are the only path that runs user code between rows.
  • New module is dependency-free so orm/aggregate-index/runtime.ts can use it without dragging lifecycle.ts into the aggregate entry's bundle; import-graph.test.ts still passes.

🧪 Verified

  • bun check passes: lint, typecheck 5/5, 1312 bun tests, 864 vitest tests, CLI, concave smoke, fixture parity, test:verify, test:runtime.
  • Each read bound was proven failing first by re-running it with only its source file stashed.
  • Every memo has a paired negative test: parent deleted between statements, trigger deleting the parent mid-statement, setCountState flipping CLEARING mid-transaction, and a target patched between two reads.
  • Autoreview clean twice; its one sub-P0 finding was applied because the docblock claimed an invariant the code did not yet hold.

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

Add a per-transaction memo lifetime keyed on the stable inner writer, the
only scope the aggregate write barrier can reach: its closure is built at
module scope, so a flag there lives as long as the isolate.

- insert(): probe each distinct foreign id once per statement, not per row
- aggregate barrier: scan the CLEARING range once per transaction, retired
  by a generation counter bumped in setCountState, the sole CLEARING writer
- relation `where`: memoize _getById per query execution, so a residual
  stream reads each distinct target once instead of once per scanned row

Consumers 1 and 3 deliberately do not use the per-transaction memo:
insert -> delete parent -> insert would write a dangling key, and a query
ctx never has an inner writer.
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Repo admins can enable using credits for code reviews in their settings.

@changeset-bot

changeset-bot Bot commented Aug 21, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: ef07860

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
kitcn Patch
@kitcn/resend Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercel Bot commented Aug 21, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
better-convex Ready Ready Preview Aug 26, 2026 12:35am

Request Review

…n-memo-lifetime

# Conflicts:
#	packages/kitcn/src/orm/insert.read-amplification.vitest.ts
#	packages/kitcn/src/orm/insert.ts
@zbeyens

zbeyens commented Aug 26, 2026

Copy link
Copy Markdown
Member

Autoclosure terminal receipt

Exact head: ef07860692b3b86355c321c01d34e3aaea0de705

Head proof: local HEAD = fetched refs/pull/420/head = GitHub headRefOid. Baseline kitcn/main: d90c20987a5a94a29d3fcb57aee85e060146a2a8.

Per-PR task evidence

Proof

Feedback inventory

Final inventory before receipt: helper 0 unresolved threads, 2 included PR comments, 0 review bodies; raw API 3 issue comments and 0 reviews; GraphQL 0 threads. No deferred feedback.

@zbeyens
zbeyens merged commit 73741ed into main Aug 26, 2026
6 checks passed
MikeyZhang75 added a commit that referenced this pull request Sep 6, 2026
A memo entry is a snapshot shared by every read of that key in one execution,
but relation loading writes nested `with` results and `extras` straight onto
the target document it is handed, and `hydrateDateFieldsForRead` then copies
every own key it finds. So a relation asked for as `true` came back carrying a
nested relation only the `where` had requested.

Return a shallow copy per caller from both memo twins. The writes are all
top-level, so that is exactly as much isolation as they need.

This also fixes the same leak on the `_id` path, where `_documentByNormalizedId`
(#420) has it today: the new `_getById` regression test fails on main.

Reported by @chatgpt-codex-connector on #448.
zbeyens added a commit that referenced this pull request Sep 7, 2026
* fix(orm): memoize non-_id relation target reads per execution

A relation `where` runs as a post-fetch membership predicate over a residual
stream, one row at a time, so every per-batch dedup map inside the relation
loaders is handed a batch of one. #420 fixed this for joins on the primary id
with `_documentByNormalizedId`; a join on any other column resolves its target
with `.first()` and had no memo, so it re-read once per drain.

Add `_firstByFields`, an execution-scoped sibling of `_getById` keyed on
(table, index, columns, values), and route all three single-target `.first()`
sites through it: `_loadOneRelation`'s non-id branch, through-relation target
resolution, and the `_count` through path.

50-row scan with 2 distinct targets: 50 target reads -> 2.

Fixes #446

* docs(plans): record PR #448 in the 446 task plan

* fix(orm): encode relation memo keys losslessly

`JSON.stringify` renders every ArrayBuffer as `{}` and NaN/Infinity as
`null`, and throws on int64. The residual relation `where` hands the relation
loader one row at a time, so its own per-batch key map is a map of one and
never compares two join values — the memo key was the only discriminator, and
it aliased them.

A `bytes()` or float-special join value therefore matched the wrong parent:
`where: { teamByToken: { name: 'Alpha' } }` returned all 50 members instead of
Alpha's 25.

Key on `convexToJson`, the same wire encoding `internal/query-key.ts` already
uses, so every Convex value round-trips distinctly. A value it rejects is not a
Convex value and goes unmemoized rather than sharing an entry.

Reported by @chatgpt-codex-connector on #448.

* docs(plans): sync 446 plan to the lossless memo key

* fix(orm): hand each caller its own copy of a memoized relation target

A memo entry is a snapshot shared by every read of that key in one execution,
but relation loading writes nested `with` results and `extras` straight onto
the target document it is handed, and `hydrateDateFieldsForRead` then copies
every own key it finds. So a relation asked for as `true` came back carrying a
nested relation only the `where` had requested.

Return a shallow copy per caller from both memo twins. The writes are all
top-level, so that is exactly as much isolation as they need.

This also fixes the same leak on the `_id` path, where `_documentByNormalizedId`
(#420) has it today: the new `_getById` regression test fails on main.

Reported by @chatgpt-codex-connector on #448.

* docs(plans): record the memo isolation fix in the 446 plan

* chore: verify PR 448 against refreshed fixtures

---------

Co-authored-by: zbeyens <zbeyens@udecode.dev>

This branch was successfully deployed

1 active deployment
Preview — ef078606 Deployed Aug 26, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ORM: no per-transaction memo lifetime — FK probes, CLEARING probes, and relation targets re-read once per row

2 participants