User synchronization
Maintain a rebuildable application user projection without duplicating Better Auth session truth.
Most applications do not need a second user table. Add one only when Convex product data needs indexed display/search fields or application-owned profile fields.
Source-of-truth rule
Better Auth user
→ trigger
→ derived application user row
→ application queriesThe arrow is one-way for copied auth fields. The projection must be rebuildable from Better Auth.
Define the projection
users: defineTable({
authUserId: v.string(),
name: v.optional(v.string()),
image: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
}).index('by_auth_user_id', ['authUserId'])Create projection triggers
import {
createUserProjectionTriggers,
type BetterAuthUserProjectionSource,
} from '@lupinum/better-convex-nuxt/better-auth/server'
const userProjection = createUserProjectionTriggers<BetterAuthUserProjectionSource, Doc<'users'>>({
table: 'users',
index: 'by_auth_user_id',
authIdField: 'authUserId',
createDoc: ({ user, now }) => ({
name: user.name ?? undefined,
image: user.image ?? undefined,
createdAt: now,
updatedAt: now,
}),
patchDoc: ({ user, now }) => ({
name: user.name ?? undefined,
image: user.image ?? undefined,
updatedAt: now,
}),
rebuildDoc: ({ user, now }) => ({
name: user.name ?? undefined,
image: user.image ?? undefined,
updatedAt: now,
}),
})The helper sets authIdField: it injects the Better Auth user ID on
insert and prevents create, update, or rebuild callbacks from replacing it.
Callbacks set only the application projection fields.
now is captured once when each helper handler starts. It is suitable for
projection bookkeeping such as updatedAt. When an update finds no projection,
that update is the inserting event. createDoc receives the current update
snapshot and that handler's now. Copy the Better Auth createdAt
value from user when the projection must retain the auth user's original
creation time.
Pass the handlers to createBetterConvexAuth as user triggers, and export the
trigger functions:
const authFunctions: AuthFunctions = internal.auth
export const auth = createBetterConvexAuth<DataModel>(components.betterAuth, {
authFunctions,
triggers: {
user: {
onCreate: async (ctx, user) =>
userProjection.user.onCreate(ctx, user as BetterAuthUserProjectionSource),
onUpdate: async (ctx, user, previousUser) =>
userProjection.user.onUpdate(
ctx,
user as BetterAuthUserProjectionSource,
previousUser as BetterAuthUserProjectionSource,
),
onDelete: async (ctx, user) =>
userProjection.user.onDelete(ctx, user as BetterAuthUserProjectionSource),
},
},
})
export const { onCreate, onUpdate, onDelete } = auth.triggerFunctions()Import AuthFunctions from @lupinum/better-convex-nuxt/better-auth/server and
internal from ./_generated/api. starters/team/convex/auth.ts is a complete
example.
Rebuild and repair
The helper exposes user.rebuild(ctx, users) for one bounded page of Better Auth users. Run it from an internal function that only operators can call:
- Page through Better Auth users.
- Call
rebuildfor each page. - Repeat until the Better Auth query reports
isDone. - Record inserted, patched, and skipped counts.
Create, update, and rebuild read at most two rows for each auth ID. Zero or one
row is valid. Two rows are ambiguous, so the helper throws a ConvexError whose
data is exactly { code: 'AUTH_USER_PROJECTION_CONFLICT' } before it runs a
projection callback or writes that user's projection. The public auth HTTP
boundary converts this internal failure to its existing generic failure.
The helper does not guess which duplicate is correct and does not repair duplicates. Your application must repair them, because only your fields show which row is correct. The delete trigger is deliberately different: after the Better Auth user is deleted, it deletes every projection row matched by that auth ID in the same Convex transaction. If that exceeds Convex's transaction limits, the deletion fails atomically; the helper creates no repair state.
The helper does not schedule rebuilds and does not decide who may run them.
Do not expose the conflict code or projection values through a public route,
log, analytics event, or client error. createDoc, patchDoc, and rebuildDoc
run inside the calling Convex mutation and must not call external services.
Failure behavior
An update recreates a missing projection from the current user snapshot. This repairs drift from adoption, migration, or application-owned deletion. A later create sees that row and is idempotent.
Do not use the projection for authentication or as the sole source of revocable authorization.