Skip to main content

Upgrade to 1.0

Move an application from Better Convex Nuxt 1.0.0-beta.7 and Better Convex MCP 1.0.0-beta.2 to 1.0.

This page lists every breaking change between @lupinum/better-convex-nuxt 1.0.0-beta.7, @lupinum/better-convex-mcp 1.0.0-beta.2, and 1.0. Each change shows the old code and the new code. 1.0 has no compatibility layer: the old names are gone, and TypeScript reports each place that you must change.

Work through the sections in order. Then run your type check, your Convex tests, and a sign-in in the browser.

1. Update the packages

Install the release candidate from the next dist-tag:

bash
pnpm add @lupinum/better-convex-nuxt@next

A plain Vue application installs @lupinum/better-convex-vue@next instead. An application with MCP also installs the MCP package:

bash
pnpm add @lupinum/better-convex-mcp@next @modelcontextprotocol/server@2.1.0

Better Auth moves from 1.7.2 to 1.7.6. Every application with auth installs all three Better Auth packages, including the OAuth provider, even without MCP:

bash
pnpm add better-auth@1.7.6 @better-auth/core@1.7.6 @better-auth/oauth-provider@1.7.6

@lupinum/better-convex-mcp now uses @modelcontextprotocol/server 2.1.0 (before: 2.0.0). Install that exact version next to it, so the application resolves one copy. For MCP Apps, use @modelcontextprotocol/ext-apps 2.x.

2. Check the auth component data

Deploy 1.0 with your normal deploy command. Convex checks every stored auth row against the new component schema, and the beta rows pass: you do not export, import, or clear any auth table.

If your application uses MCP or the Better Auth OAuth provider, beta refresh tokens stop working. 1.0 binds every refresh token to the consent that issued it. Beta refresh tokens have no such binding, so 1.0 answers a refresh with one with invalid_grant and issues no new token. Connected MCP hosts and other OAuth clients sign in again, and then receive a bound token. The beta rows stay in the oauthRefreshToken table, but 1.0 never accepts them. You do not need to delete them.

1.0 identifies an auth account by its provider and the provider's account ID (providerId and accountId). The beta releases used issuer and accountId. Your existing account rows keep working:

  • Beta rows keep their issuer value in an optional column. Better Convex never reads or writes it.
  • Users, account IDs, passwords, and linked sign-in providers stay the same. You do not need a new auth component, and users do not sign up again.

If you use a local auth component, regenerate its schema:

bash
pnpm exec better-convex auth schema \
  --config convex/betterAuth/schemaOptions.ts \
  --output convex/betterAuth

After you deploy 1.0, check for accounts that now share one key. Add this internal action:

convex/authUpgrade.ts
import { findAccountKeyCollisions } from '@lupinum/better-convex-nuxt/better-auth/server'
import { v } from 'convex/values'
import { components } from './_generated/api'
import { internalAction } from './_generated/server'

export const checkAccountKeys = internalAction({
  args: { cursor: v.optional(v.union(v.string(), v.null())) },
  handler: (ctx, { cursor }) => findAccountKeyCollisions(ctx, components.betterAuth, { cursor }),
})

Run it:

bash
pnpm exec better-convex convex run authUpgrade:checkAccountKeys '{}'

The result lists each group of accounts with the same providerId and accountId. One run reads at most 10,000 accounts. When the result has isDone: false, run it again and pass the returned continueCursor:

bash
pnpm exec better-convex convex run authUpgrade:checkAccountKeys '{"cursor":"<continueCursor>"}'

When isDone is true and no run reported a collision, you are done. You can then delete the file.

A collision is possible only when one provider stored the same user under two issuers during the beta. This can happen with Microsoft Entra ID, Cognito, Paybin, genericOAuth providers (including the Keycloak, Okta, and Auth0 helpers), or a custom provider, when the issuer changed. It cannot happen for email and password, Google, Apple, Facebook, LINE, or providers without their own issuer, such as GitHub or Discord.

Better Auth does not sign in a user whose account key has more than one row. Other users are not affected. For each reported group, merge the rows into one or delete the extra rows. Then that user can sign in again.

This upgrade path is tested on a real local Convex backend. The repository suite pnpm test:integration deploys the 1.0.0-beta.7 auth component with beta users, accounts, sessions, and OAuth rows, then pushes 1.0 over it. It checks that user IDs stay the same, that password sign-in and account lookups work, that beta refresh tokens are rejected, and that findAccountKeyCollisions finds a planted collision and nothing else.

3. Removed features

RemovedUse instead
workforce: true and createWorkforceAuthSchemaOptionsThe twoFactor option. See Better Auth plugin support.
Workforce session functions (workforceSessions.*)Better Auth session endpoints through useConvexAuth().client
@lupinum/better-convex-mcp/vue and useMcpAppThe App class from @modelcontextprotocol/ext-apps. See MCP Apps.
oauthPopupClient() in defineConvexAuthClientclient.signIn.social(). The server never supported popup sign-in.
verifyOAuthBearerTokenauth.createMcpAccessVerifier(ctx)
The beta.3 user migration (migrateBeta3UserGeneration)Nothing. Finish that migration on a beta release before you upgrade.
convexAuth, createAuthComponent, createConvexAuthRateLimitStorage, requireWritableAuthCtxcreateBetterConvexAuth
auth.authComponentThe helpers on auth itself (section 5 below)
Types UseConvexCall, UploadStatusUseConvexMutationReturn, UseConvexActionReturn, ConvexCallStatus
Types UseConvexQueryOptions, UseConvexPaginatedQueryOptionsUseNuxtConvexQueryOptions, UseNuxtConvexPaginatedQueryOptions

4. Update the local auth component exports

If you have convex/betterAuth/adapter.ts, export exactly these functions:

convex/betterAuth/adapter.ts
import { defineAuthAdapterFunctions } from '@lupinum/better-convex-nuxt/better-auth/server'

import schema from './schema'
import schemaMetadata from './schemaMetadata'

export const {
  consumeOne,
  consumeRateLimit,
  count,
  create,
  deleteMany,
  deleteOne,
  expireSession,
  findMany,
  findOne,
  incrementOne,
  oauthLiveAccess,
  pruneSigningKeys,
  rotateSigningKey,
  sessionAdmission,
  updateMany,
  updateOne,
} = defineAuthAdapterFunctions({ metadata: schemaMetadata, schema })

assertProfile and the five workforce functions are gone. expireSession, oauthLiveAccess, and pruneSigningKeys are new.

In convex/auth.ts, also export pruneSigningKeys, and schedule it, for example as a daily cron:

convex/auth.ts
// Before
export const { ensureSigningKey, rotateSigningKey } = auth.jwksOperatorFunctions()

// After
export const { ensureSigningKey, pruneSigningKeys, rotateSigningKey } = auth.jwksOperatorFunctions()

5. Read the signed-in user in Convex

The user helpers moved from auth.authComponent to auth. requireUser throws a ConvexError with code UNAUTHENTICATED instead of the string 'Unauthenticated'.

convex/projects.ts
// Before
export const { authComponent } = auth
const user = await authComponent.safeGetAuthUser(ctx)
const required = await authComponent.getAuthUser(ctx)
const { auth: betterAuth, headers } = await authComponent.getAuth(createAuth, ctx)

// After
const user = await auth.getUser(ctx)
const required = await auth.requireUser(ctx)
const { auth: betterAuth, headers } = await auth.getAuth(ctx)

For an HTTP action that must act as the signed-in user, use the new auth.sessionHttpAction(handler). See Backend authorization.

6. Send auth email through one hook

The per-feature email callbacks are replaced by one email hook. The emailAndPassword, emailVerification, and emailOTP options no longer accept a function of ctx; pass plain objects. Password reset is now off until you set passwordReset: true.

convex/auth.ts
// Before
export const auth = createBetterConvexAuth<DataModel>(components.betterAuth, {
  emailAndPassword: (ctx) => ({
    requireEmailVerification: true,
    sendResetPassword: async ({ user, url }) => {
      await ctx.runMutation(internal.mail.send, { to: user.email, url })
    },
  }),
  emailVerification: {
    sendVerificationEmail: async ({ user, url }) => {
      /* ... */
    },
  },
})

// After
export const auth = createBetterConvexAuth<DataModel>(components.betterAuth, {
  emailAndPassword: { requireEmailVerification: true, passwordReset: true },
  emailVerification: { sendOnSignUp: true },
  email: async (ctx, message) => {
    switch (message.type) {
      case 'verify-email':
      case 'reset-password':
        await ctx.runMutation(internal.mail.send, { to: message.to, url: message.url })
        return
      default:
        throw new Error(`Unsupported auth email: ${message.type}`)
    }
  },
})

sendResetPassword, sendVerificationEmail, sendVerificationOTP, twoFactor.otpOptions.sendOTP, and organization.sendInvitationEmail now fail with AUTH_CONFIG_INVALID. See Transactional auth email for every message type.

7. Check the session and account options

session now accepts expiresIn and updateAge with fixed limits, and a limited cookieCache:

OptionDefaultAllowed
expiresIn7 days1 hour to 30 days
updateAge1 day5 minutes to expiresIn
cookieCache.maxAge3001 to 300 seconds; refreshCache is rejected

account.accountLinking.trustedProviders now accepts only names of configured social providers. Any other key under account fails. See Social sign-in.

Convex session tokens carry only the library claims. If you read name, email, emailVerified, or image from useConvexAuth().user or ctx.auth.getUserIdentity(), return them from defineSessionClaims, or use auth.getUser(ctx) in Convex functions:

convex/auth.ts
export const auth = createBetterConvexAuth<DataModel>(components.betterAuth, {
  defineSessionClaims: ({ user }) => ({ name: user.name, email: user.email }),
})

8. Destructure mutations and actions

useConvexMutation and useConvexAction return an object instead of a function with state attached. Destructure the verb: mutate for mutations, run for actions.

ts
// Before
const updateProject = useConvexMutation(api.projects.update)
await updateProject({ projectId, name })
const saving = updateProject.pending

const generate = useConvexAction(api.ai.generate)
await generate({ documentId })

// After
const { mutate: updateProject, pending: saving } = useConvexMutation(api.projects.update)
await updateProject({ projectId, name })

const { run: generate } = useConvexAction(api.ai.generate)
await generate({ documentId })

Both now also return data, status, error, and reset().

A second submit() of useConvexForm while one is pending now rejects with code SUBMIT_IN_PROGRESS. Disable the submit button while pending is true.

9. Update pagination state

pageStatus and cursor are no longer public. status and pending describe only the first page. Use isLoadingMore for later pages, and isExhausted for the end of the list. loadMore() returns a promise, never rejects, and does nothing while canLoadMore is false.

vue
<!-- Before -->
<button v-if="canLoadMore" :disabled="pending" @click="loadMore(20)">Load more</button>
<p v-else-if="status === 'success'">No more items.</p>

<!-- After -->
<button v-if="canLoadMore || isLoadingMore" :disabled="!canLoadMore" @click="loadMore(20)">
  {{ isLoadingMore ? 'Loading…' : 'Load more' }}
</button>
<p v-else-if="isExhausted">No more items.</p>

A failed later page now keeps the loaded items and sets error. The next loadMore() retries it. See Pagination.

10. Update error handling

ConvexCallError.message of a server error is now the text that your Convex function wrote: a string ConvexError data, or data.message. Before, it was always Convex application error. Check that these messages are safe to show to users.

Every error now has functionName. Errors raised by the library have a stable code. Use isConvexCallError instead of reading data yourself:

ts
import { isConvexCallError } from '@lupinum/better-convex-nuxt/errors'

// Before
catch (error) {
  const normalized = normalizeConvexError(error)
  if ((normalized.data as { code?: string } | undefined)?.code === 'PROJECT_ARCHIVED') showNotice()
}

// After
catch (error) {
  if (isConvexCallError(error, 'PROJECT_ARCHIVED')) showNotice()
}

The library codes include IDENTITY_CHANGED, CANCELLED, FILE_TOO_LARGE, FILE_TYPE_NOT_ALLOWED, UPLOAD_IN_PROGRESS, SUBMIT_IN_PROGRESS, UNAUTHENTICATED, CLIENT_UNAVAILABLE, NETWORK_ERROR, and TIMEOUT. Error types lists every code.

Browser errors also have outcome. 'not-sent' means that the request never reached Convex. 'unknown' means that it was sent and its result did not arrive, so it may have run. Before, IDENTITY_CHANGED always meant that the write may have run. See Error types.

11. Update file uploads

upload() now resolves with { storageId, prepared, completed } instead of the storage ID. data holds the same object.

ts
// Before
const storageId = await upload(file)
const uploadedId = data.value

// After
const { storageId } = await upload(file)
const uploadedId = data.value?.storageId

If you save the file in a second mutation after the upload, move that mutation into complete. It then runs for the same signed-in user, and pending and error cover it:

ts
// Before
const { upload } = useConvexFileUpload(api.files.generateUploadUrl)
const { mutate: attachFile } = useConvexMutation(api.documents.attachFile)
const storageId = await upload(file)
await attachFile({ documentId, storageId })

// After
const { upload } = useConvexFileUpload(api.files.generateUploadUrl, {
  complete: (
    op,
    { storageId, context: documentId }: UploadCompleteContext<string, Id<'documents'>>,
  ) => op.mutation(api.documents.attachFile, { documentId, storageId }),
})
await upload(file, {}, { context: documentId })

The target passed as context is captured when upload() is called, so a later change to component state cannot attach the file to another record.

A prepare mutation can now return an object, such as an upload session. Pass url to select the upload URL. See Upload files.

upload() failures now have codes, a phase, and an outcome. cancel() makes the pending upload reject with CANCELLED; ignore that code when your own cancel caused it. The new reset() clears data, error, and progress.

ts
try {
  await upload(file)
} catch (error) {
  if (isConvexCallError(error, 'CANCELLED')) return
  throw error
}

Plain Vue applications now import useConvexFileUpload from @lupinum/better-convex-vue.

12. Remove the null check on the auth client

useConvexAuth().client is never null. During server rendering, its methods throw a ConvexCallError with code CLIENT_UNAVAILABLE, so keep calling them from event handlers or onMounted().

ts
const { client } = useConvexAuth()

// Before
if (!client) return
await client.signOut()

// After
await client.signOut()

13. Render browser-only queries as idle during SSR

A query with server: false now renders status: 'idle' on the server, and stays idle during hydration. It becomes pending when the browser starts it. Show your loading state for both values:

vue
<Skeleton v-if="status === 'idle' || status === 'pending'" />

14. Update the MCP server

The MCP OAuth setup moves into the auth factory. The verifier now returns a typed principal, and configureServer receives one object.

createBetterAuthMcpAccessVerifier, auth.validateOAuthAccess, and the OAuthLiveAccess type are removed. Use auth.createMcpAccessVerifier(ctx) in the HTTP action and auth.requireMcpPrincipal(ctx, principal, { scope }) in the Convex function that the tool calls. There is no other public path.

Before, the options held a hand-written oauthProvider: { ... } profile. Replace it with oauth.mcp:

convex/auth.ts
import { createBetterConvexAuth } from '@lupinum/better-convex-nuxt/better-auth/server'
import { components } from './_generated/api'
import type { DataModel } from './_generated/dataModel'

export const auth = createBetterConvexAuth<DataModel>(components.betterAuth, {
  oauth: {
    mcp: {
      scopes: { 'notes:read': 'Read your notes.' },
      hosts: ['chatgpt', 'claude'],
    },
  },
})

Before, the HTTP action built the verifier by hand:

convex/mcp.ts (before)
handleMcpRequest(request, {
  resource,
  authorization: {
    mode: 'oauth',
    issuer,
    verifier: createBetterAuthMcpAccessVerifier({
      allowedScopes: MCP_SCOPES,
      jwksUrl: `${issuer}/jwks`,
      maxLifetimeSeconds: 600,
      validateLiveAccess: (access) => authComponent.validateOAuthAccess(ctx, access),
    }),
    scopesSupported: MCP_SCOPES,
  },
  serverInfo,
  configureServer(access, server) {
    server.registerTool('list_notes', config, async () =>
      runMcpTool(async () => ({
        structuredContent: await ctx.runQuery(internal.notes.list, {
          access: {
            subject: access.subject,
            clientId: access.clientId,
            scopes: [...access.scopes],
          },
        }),
      })),
    )
  },
})

Now the auth factory provides the verifier and the addresses. The tool passes the typed principal to its internal function:

convex/mcp.ts
import { handleMcpRequest, registerMcpTool } from '@lupinum/better-convex-mcp'
import { z } from 'zod'
import { internal } from './_generated/api'
import { httpAction } from './_generated/server'
import { auth } from './auth'

export const handleMcp = httpAction((ctx, request) =>
  handleMcpRequest(request, {
    serverInfo: { name: 'notes', version: '1.0.0' },
    resource: auth.mcp.resource(),
    authorization: {
      mode: 'oauth',
      issuer: auth.mcp.issuer(),
      verifier: auth.createMcpAccessVerifier(ctx),
      scopesSupported: auth.mcp.scopesSupported(),
    },
    configureServer({ principal, server, tools }) {
      registerMcpTool(server, tools, {
        name: 'list_notes',
        description: 'List your newest notes.',
        risk: 'read',
        scopes: ['notes:read'],
        inputSchema: z.object({}).strict(),
        outputSchema: z.object({
          notes: z.array(z.object({ id: z.string(), title: z.string() })),
        }),
        handler: async () => ({
          structuredContent: { notes: await ctx.runQuery(internal.notes.list, { principal }) },
        }),
      })
    },
  }),
)

A tool with an outputSchema may return only structuredContent. Without an outputSchema, return a complete tool result that includes content.

In the internal function, replace the access argument with the principal and check it live. Before, it declared args: { access: v.object({ subject: v.string(), clientId: v.string(), scopes: v.array(v.string()) }) }.

convex/notes.ts
import { mcpPrincipalValidator } from '@lupinum/better-convex-nuxt/better-auth/server'
import { internalQuery } from './_generated/server'
import { auth } from './auth'

export const list = internalQuery({
  args: { principal: mcpPrincipalValidator },
  handler: async (ctx, { principal }) => {
    const { user } = await auth.requireMcpPrincipal(ctx, principal, { scope: 'notes:read' })
    const notes = await ctx.db
      .query('notes')
      .withIndex('by_owner', (q) => q.eq('ownerId', user.id))
      .order('desc')
      .take(20)
    return notes.map((note) => ({ id: note._id, title: note.title }))
  },
})

Other MCP changes:

  • A custom McpAccessVerifier can return principal next to access and expiresAt. handleMcpRequest passes it to configureServer.
  • The verifier reads signing keys from the auth component. The jwksUrl option is gone.
  • runMcpTool still works. registerMcpTool and defineMcpTool set the annotations, scopes, and error handling for you.
  • Add error codes that the model may see to exposeErrorCodes.
  • Requests must send the MCP-Protocol-Version header. Without it, the SDK answers HTTP 400.
  • Let users disconnect hosts with auth.oauthConnections.list and auth.oauthConnections.revoke.
  • Every OAuth access token carries the ID of the consent that issued it, also with renewal: false. A token stops working when that consent is revoked, even if the person later grants the same access again. Tokens issued by a beta without this claim are rejected; hosts sign in again.
  • Disabling an OAuth resource now rejects access tokens that were already issued, not only refresh.
  • @lupinum/better-convex-mcp declares @modelcontextprotocol/server as an exact peer dependency. Install it in your application: pnpm add @lupinum/better-convex-mcp@next @modelcontextprotocol/server@2.1.0.

Add MCP to your application shows the complete new setup.

15. Update component tests

setupBetterConvexTest() from @lupinum/better-convex-nuxt/test now runs the real composables against an in-memory Convex connection. The composables map is gone. Install the runtime's plugin, and bind only useConvexAuth:

tests/component/client-screen.test.ts
// Before
mockNuxtImport(
  'useConvexQuery',
  () =>
    (...args) =>
      testState.call('useConvexQuery', args),
)
mockNuxtImport(
  'useConvexMutation',
  () =>
    (...args) =>
      testState.call('useConvexMutation', args),
)
mockNuxtImport(
  'useConvexAuth',
  () =>
    (...args) =>
      testState.call('useConvexAuth', args),
)

// After
mockNuxtImport('useConvexAuth', () => () => testState.convex!.auth)

const wrapper = await mountSuspended(ClientScreen, {
  global: { plugins: [testState.convex!.plugin] },
})

Other changes:

  • Every request stays pending until the test answers it. A one-shot query without an answer no longer throws.
  • Each entry of a mutation or action control's calls has args, identity, state, resolve(), and reject(). Query calls have identity, and their kind 'refresh' is now 'query'.
  • New respond(fn) and nextCall() on every control. reset() no longer rejects pending requests.
  • The upload control uses the real upload code: progress(loaded, total?) instead of progress({ loaded, total, percent }), and new fail(status) and nextCall(). reject(error) now fails the upload-URL mutation.
  • Plain Vue tests import setupBetterConvexTest from @lupinum/better-convex-vue/test.

See Test signed-in screens.

New options you can use

These are not breaking, but they often replace application code:

  • definePageMeta({ convexAuth: 'guest' }) for sign-in pages, useConvexAuthReturnTo(), and normalizeLocalRedirectPath(). See Route protection.
  • convex.auth.routes: 'protected' and convex.auth.defaultQueryAuth for application-wide defaults.
  • getConvexUser(event), requireConvexUser(event), and toConvexH3Error(error) in Nitro handlers.
  • blockedBy, immediate, lazy, and execute() on queries.
  • useConvexOperation(work) for several calls and uploads that must run for one signed-in user. See Multi-step operations.
  • signInAs from @lupinum/better-convex-nuxt/better-auth/test.