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:
pnpm add @lupinum/better-convex-nuxt@nextA plain Vue application installs @lupinum/better-convex-vue@next
instead. An application with MCP also installs the MCP package:
pnpm add @lupinum/better-convex-mcp@next @modelcontextprotocol/server@2.1.0Better 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:
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
issuervalue 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:
pnpm exec better-convex auth schema \
--config convex/betterAuth/schemaOptions.ts \
--output convex/betterAuthAfter you deploy 1.0, check for accounts that now share one key. Add this internal action:
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:
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:
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
| Removed | Use instead |
|---|---|
workforce: true and createWorkforceAuthSchemaOptions | The twoFactor option. See Better Auth plugin support. |
Workforce session functions (workforceSessions.*) | Better Auth session endpoints through useConvexAuth().client |
@lupinum/better-convex-mcp/vue and useMcpApp | The App class from @modelcontextprotocol/ext-apps. See MCP Apps. |
oauthPopupClient() in defineConvexAuthClient | client.signIn.social(). The server never supported popup sign-in. |
verifyOAuthBearerToken | auth.createMcpAccessVerifier(ctx) |
The beta.3 user migration (migrateBeta3UserGeneration) | Nothing. Finish that migration on a beta release before you upgrade. |
convexAuth, createAuthComponent, createConvexAuthRateLimitStorage, requireWritableAuthCtx | createBetterConvexAuth |
auth.authComponent | The helpers on auth itself (section 5 below) |
Types UseConvexCall, UploadStatus | UseConvexMutationReturn, UseConvexActionReturn, ConvexCallStatus |
Types UseConvexQueryOptions, UseConvexPaginatedQueryOptions | UseNuxtConvexQueryOptions, UseNuxtConvexPaginatedQueryOptions |
4. Update the local auth component exports
If you have convex/betterAuth/adapter.ts, export exactly these functions:
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:
// 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'.
// 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.
// 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:
| Option | Default | Allowed |
|---|---|---|
expiresIn | 7 days | 1 hour to 30 days |
updateAge | 1 day | 5 minutes to expiresIn |
cookieCache.maxAge | 300 | 1 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:
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.
// 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.
<!-- 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:
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.
// Before
const storageId = await upload(file)
const uploadedId = data.value
// After
const { storageId } = await upload(file)
const uploadedId = data.value?.storageIdIf 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:
// 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.
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().
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:
<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:
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:
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:
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()) }) }.
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
McpAccessVerifiercan returnprincipalnext toaccessandexpiresAt.handleMcpRequestpasses it toconfigureServer. - The verifier reads signing keys from the auth component. The
jwksUrloption is gone. runMcpToolstill works.registerMcpToolanddefineMcpToolset the annotations, scopes, and error handling for you.- Add error codes that the model may see to
exposeErrorCodes. - Requests must send the
MCP-Protocol-Versionheader. Without it, the SDK answers HTTP400. - Let users disconnect hosts with
auth.oauthConnections.listandauth.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-mcpdeclares@modelcontextprotocol/serveras 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:
// 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
callshasargs,identity,state,resolve(), andreject(). Query calls haveidentity, and theirkind'refresh'is now'query'. - New
respond(fn)andnextCall()on every control.reset()no longer rejects pending requests. - The upload control uses the real upload code:
progress(loaded, total?)instead ofprogress({ loaded, total, percent }), and newfail(status)andnextCall().reject(error)now fails the upload-URL mutation. - Plain Vue tests import
setupBetterConvexTestfrom@lupinum/better-convex-vue/test.
New options you can use
These are not breaking, but they often replace application code:
definePageMeta({ convexAuth: 'guest' })for sign-in pages,useConvexAuthReturnTo(), andnormalizeLocalRedirectPath(). See Route protection.convex.auth.routes: 'protected'andconvex.auth.defaultQueryAuthfor application-wide defaults.getConvexUser(event),requireConvexUser(event), andtoConvexH3Error(error)in Nitro handlers.blockedBy,immediate,lazy, andexecute()on queries.useConvexOperation(work)for several calls and uploads that must run for one signed-in user. See Multi-step operations.signInAsfrom@lupinum/better-convex-nuxt/better-auth/test.