Transactional user creation (NestAuthUser + AppUser in one rollback)
Wrap signup-style flows in a TypeORM transaction so a failure halfway through doesn't leave a half-created user in `nest_auth_users`.
The problem
A typical "create portal user" flow does several writes that need to succeed together:
- Create the
NestAuthUserrow (auth identity). - Hash and store the password.
- Create email/phone identity rows in
nest_auth_identities. - Bind the user to a tenant via
nest_auth_user_accessesand assign roles. - (Or, for admin users) bind to
nest_auth_platform_accessesand assign admin roles. - Create the corresponding row in your application's
AppUsertable (linked viaauthUserId).
Without a transaction wrapping all six, a failure between step 1 and
step 6 leaves you with a NestAuthUser row but no AppUser row.
The next time the operator retries with the same email, signup fails
with EMAIL_ALREADY_EXISTS from nest-auth — even though the user
looks like it doesn't exist from your application's perspective. You
have to manually clean up nest_auth_users to recover.
What's transaction-aware now
Every method in the user-creation chain accepts an optional
manager: EntityManager parameter. Pass it, and the statement
participates in your transaction. Omit it, and behaviour is identical
to before (each statement uses the default datasource), so existing
non-transactional callers don't have to change.
UserService — top-level helper and CRUD methods
| Method | Notes |
|---|---|
runInTransaction(fn) | Convenience wrapper around dataSource.transaction(...). Use this if you don't already have your own DataSource injected. |
createUser(data, tenantId?, context?, manager?) | |
updateUser(id, data, manager?) | The same-tenant uniqueness check, the user UPDATE, and the identity UPSERTs run on the supplied connection. |
deleteUser(id, manager?) | Listener still runs outside the tx (see gotchas). |
verifyUser(id, type?, manager?) / unverifyUser(id, type?, manager?) | |
updateUserStatus(id, isActive, manager?) | |
updateUserMetadata(id, metadata, manager?) | |
getUserById(id, options?, manager?) | |
getUserByEmail(email, tenantId?, options?, manager?) | |
getUserByPhone(phone, tenantId?, options?, manager?) | |
getUsers(options?, manager?) | |
getUsersAndCount(options?, manager?) / countUsers(options?, manager?) | |
getUsersByRole(roleName, guard, tenantId?, manager?) | |
getUserTenants(userId, manager?) | |
isUserInTenant(userId, tenantId, manager?) | |
ensureUserAccess(userId, tenantId, manager?) | |
setUserAccessRoles(userId, tenantId, roleIds, manager?) | |
deleteUserAccess(userId, tenantId, manager?) |
Entity helpers (BaseEntity methods)
| Method | Where |
|---|---|
authUser.findOrCreateIdentity(provider, providerId, manager?) | NestAuthUser |
authUser.updateOrCreateIdentity(provider, data, manager?) | NestAuthUser |
authUser.updateEmail(newEmail, manager?) | NestAuthUser |
authUser.updatePhone(newPhone, manager?) | NestAuthUser |
authUser.getUserAccess(tenantId, createIfNotExists, manager?) | NestAuthUser |
authUser.getPlatformAccess(createIfNotExists, manager?) | NestAuthUser |
userAccess.assignRoles(roleIds, manager?) | NestAuthUserAccess |
platformAccess.assignRoles(roleIds, manager?) | NestAuthPlatformAccess |
Note that entity.save() is not transaction-aware — TypeORM's
BaseEntity.save() always uses the static datasource. Inside a
transaction, call manager.save(entity) instead.
Recipe — portal "create user" with full rollback
This is the canonical end-to-end shape. Compare against your existing
non-transactional version: every DB-touching call gained a manager
argument, and entity.save() became manager.save(entity).
What changes — diff against the original flow
Concretely: every existing call grew a final manager argument, and
the two entity.save() calls became manager.save(entity).
Gotchas
Event listeners run after commit, not inside the tx
UserCreatedEvent, UserRegisteredEvent, etc. are emitted via
@nestjs/event-emitter from inside createUser(). They fire while the
transaction is still open, but listeners that do their own DB work
will not see your uncommitted rows — they use the default datasource,
not the manager.
If a listener must participate in the same transaction, refactor it
into a synchronous user.afterCreate hook (which receives the user
and runs in the same call stack) instead.
entity.save() is not tx-aware
TypeORM's BaseEntity.save() always uses the static datasource. Inside
a transaction, call manager.save(entity). The new tx-aware methods on
NestAuthUser, NestAuthUserAccess and NestAuthPlatformAccess already
do this internally when you pass manager — but if you call
entity.save() directly anywhere in the flow, it will silently bypass
the transaction.
Don't open a transaction for read-only paths
runInTransaction opens a real DB transaction every time. Don't wrap
read-only paths or the simple getUserByEmail lookup in it — there's
no benefit and you pay the BEGIN/COMMIT cost.
The flow without a transaction still works
You don't have to migrate. createUser(data, tenantId) (no manager
arg) works exactly as it always did. Reach for runInTransaction only
when you need atomicity across multiple writes.
Updating a user atomically with AppUser
The same pattern works for updates. updateUser is itself multi-write
(a uniqueness pre-check, a user UPDATE, and identity UPSERTs for changed
email/phone), so wrapping it lets you mirror the changes onto your
AppUser row in the same transaction:
If the manager.update(AppUser, ...) throws (FK violation, validation,
network), the email/phone change on NestAuthUser rolls back too — no
mismatch between the two tables.
When you don't need this
- OAuth signup paths — handled internally by
AuthService.handleSocialLogin. Already safe (onecreateUsercall, identity link is best-effort and retryable). - Pure single-row updates — e.g.
updateUserStatus(id, true)is one UPDATE statement; row-level atomicity is automatic. - Read flows — never need a transaction.
The transactional pattern is for multi-write flows where the partial state is broken — typically:
- "Create portal/admin user with role assignment + AppUser row" (the recipe above).
- "Update email/phone on both
NestAuthUserandAppUser" — when you want both tables consistent or neither. - "Delete user + clean up tenant accesses + revoke sessions" — multiple writes where an aborted middle step leaves orphans.