Skip to content

fix: match unset optional fields against null where clauses - #425

Open
suhailopensource wants to merge 1 commit into
convex:mainfrom
suhailopensource:fix/eq-null-unset-optional-fields
Open

suhailopensource wants to merge 1 commit into
convex:mainfrom
suhailopensource:fix/eq-null-unset-optional-fields

Conversation

@suhailopensource

Copy link
Copy Markdown

Closes #399.

Problem

filterByWhere implemented eq as strict equality (value === w.value). Convex omits optional fields that were never written, so such a field reads back as undefined and never matched eq null. Every other first-party adapter treats an unset optional field as matching — SQL IS NULL, Mongo { field: null } — so plugins written against that contract broke only here. adapterWhereValidator already accepts v.null() for value, so the input was supported; nothing handled it for eq/ne.

Impact: @better-auth/oauth-provider creates oauthRefreshToken rows with no revoked key, then guards rotation with a CAS on [{ field: "id" }, { field: "revoked", operator: "eq", value: null }]. With no native incrementOne, core falls back to findMany({ where, limit: 1 }); the revoked eq null leg never matched the fresh row, so every refresh_token grant failed with 400 invalid_grant, making offline_access unusable. Device-authorization uses the same create-without-the-field-then-match-eq null pattern with userId.

Fix

Treat an absent field as null when the where value is null, for eq and the mirrored ne. This follows the local-predicate pattern already in filterByWhere — isLessThan for lt/lte, isGreaterThan for gt/gte, now isNullish for eq/ne; both existing helpers already coalesce null and undefined the same way. Behavior changes only when the where value is null.

Tests

Two cases in convexCustomTestSuite, beside the existing should preserve null to non-null range comparisons:

  • should match unset optional fields against eq null — an account created with no accessTokenExpiresAt key matches via findOne and via the findMany shape core's incrementOne fallback issues; an explicitly-null field still matches; a field with a value does not.
  • should not match unset optional fields against ne null — the mirror.

account.accessTokenExpiresAt is an unindexed nullable timestamp, the same shape as revoked, so the clause is applied as a static filter rather than pushed into an index range. Without the fix these two are the only failures in the adapter suite (2 failed | 131 passed); with it, 133 passed.

Upstream @better-auth/test-utils null coverage (eq operator with null value (single condition) should use IS NULL, the AND/OR group variants, update — ... where condition uses null value) passes both before and after this change — it exercises explicitly written nulls; the gap was only fields Convex omits.

Known gap (pre-existing, not addressed here)

A null-valued eq on an indexed field is pushed into the index range instead, and Convex index ranges match only explicit nulls:

// two user docs: one with username absent, one with username: null
await ctx.db.query("user").withIndex("username", (q) => q.eq("username", null)).collect();
// → only the explicitly-null doc

Closing it means truncating the index range at the first null-valued eq and re-applying it (and everything after) statically, which also touches paginate's unique short-circuit. That felt separate from this fix, and it isn't needed for #399: revoked is unindexed and the rotation CAS goes through the _id short-circuit, both landing in filterByWhere. Happy to fold it in if you'd prefer. in/not_in share the same asymmetry and are likewise unchanged.

Verification

On Node 24 (src/client/adapter.test.ts self-skips below it): npm test — 184 passed, 31 skipped, no type errors · npm run typecheck — clean, including all example projects · npm run lint — 0 errors (3 pre-existing react-hooks/exhaustive-deps warnings in src/react/index.tsx) · npm run build — clean.

@vercel

vercel Bot commented Aug 12, 2026

Copy link
Copy Markdown

@syedsuhail-07 is attempting to deploy a commit to the Convex Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai

coderabbitai Bot commented Aug 12, 2026 •

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository: get-convex/coderabbit/.coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 769233a8-e2e3-4207-8eb0-bb13e5d339c4

📥 Commits

Reviewing files that changed from the base of the PR and between 818c53d and 107db27.

📒 Files selected for processing (1)
  • src/client/adapter-utils.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/client/adapter-utils.ts

📝 Walkthrough

Walkthrough

The adapter’s eq null filter now matches fields with null or undefined values. The ne null filter excludes both values. Other comparisons retain strict behavior. Convex adapter tests cover unset, explicit-null, and non-null fields, compound predicates, ID lookups, query limits, and inequality filtering.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main fix for matching unset optional fields against null where clauses.
Description check ✅ Passed The description directly explains the bug, impact, fix, tests, verification, and known limitations.
Linked Issues check ✅ Passed The change satisfies issue #399 by matching undefined and null for eq and ne null filters and adding focused Convex tests.
Out of Scope Changes check ✅ Passed The changes are limited to the required filter behavior and related tests; indexed comparisons and unrelated operators remain unchanged.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint install failed: dependency version conflict. Check your lock file or package.json.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Convex omits optional fields that were never written, so they read back as
undefined and `eq null` never matched them. Every other first-party adapter
matches an unset optional field against `eq null` (SQL `IS NULL`, Mongo
`{ field: null }`), and plugins rely on it: @better-auth/oauth-provider creates
oauthRefreshToken rows with no `revoked` key, then rotates them with an
`eq null` compare-and-swap, so every refresh_token grant failed with 400
invalid_grant.

Treat an absent field as null for `eq` and the mirrored `ne`, following the
existing local-predicate pattern in filterByWhere (isLessThan for lt/lte,
isGreaterThan for gt/gte), both of which already coalesce null and undefined.
Behavior only changes when the where value is null.

Closes convex#399
@suhailopensource
suhailopensource force-pushed the fix/eq-null-unset-optional-fields branch from 818c53d to 107db27 Compare August 12, 2026 13:00
helixnebulatech added a commit to helixnebulatech/convex-better-auth that referenced this pull request Oct 8, 2026
Convex omits optional fields that were never written, and filterByWhere
compared them strictly, so eq null never matched them and ne null did.
Better Auth 1.7's incrementOne fallback guards on the row snapshot with
eq null for every absent field it touches, so for example the two factor
lockout (set lockedUntil) failed with a "contention" error.

Treat undefined as null for eq/ne null. Beyond convex#425,
also apply eq null statically instead of through an index range or a
unique-field lookup, which only match explicit nulls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sshkeda added a commit to sshkeda/better-auth that referenced this pull request Oct 13, 2026
Treat absent fields as null in static eq-null and ne-null comparisons.
Better Auth's refresh rotation guards an unset revoked field with eq null,
so valid tokens were rejected even with native incrementOne.

Add regression cases for absent, explicit-null and set fields, including
repeated guarded claims. Indexed null and in/not_in behavior remain separate.

Credit @suhailopensource for the same fix in convex#425,
addressing the report by @a-fatah in convex#399.

Co-authored-by: suhailopensource <114918598+suhailopensource@users.noreply.github.com>

This branch has not been deployed

No deployments
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.

eq null never matches absent optional fields — breaks oauth-provider refresh-token rotation (400 invalid_grant on every refresh)

1 participant