Storyie lets you write a diary before you have an account. Open the app, start typing, and your entries are real rows in the database — attached to an anonymous user that Better Auth's anonymous plugin created for you silently. The friction of signing up comes later, if at all.
"Later" is the dangerous part. When a guest finally signs in with Google or Apple, Better Auth links the OAuth identity to a fresh user record and then deletes the anonymous user. Everything that guest wrote — diaries, notes, tags, likes — is keyed to an id that is about to disappear. If we do nothing, the sign-up that was supposed to save your work is the exact moment it gets erased.
This post is about the one function that stands in that gap: how we move a guest's data to their new account atomically, why it is written to fail rather than silently lose data, and what makes the "I already have an account" case surprisingly hairy.
TL;DR
- Better Auth's
anonymousplugin firesonLinkAccountwhen a guest links a real account, and deletes the anonymous user only after that callback resolves. That callback is our single chance to preserve data. migrateAccountData(anonymousId, newId)re-assigns every guest-owned row in one transaction. It must not catch its own errors: a thrown exception fails the link, keeps the anonymous user intact, and makes the link retryable — no partial loss.- Ownership migration splits into two groups: a plain UPDATE for tables with no unique constraint, and a delete-the-conflict-then-update pass for tables where the target account might already own a colliding row (the merge case).
- Tags are special-cased: same-name tags merge (re-point document links, sum usage counts) instead of one side being dropped.
- Profiles are the exception — guests never have them, so they are back-filled, not migrated.
A temporary identity that is a real database user
The anonymous plugin does not fake a session. A guest is a genuine row in the users table with is_anonymous = 1, and every diary they write carries their real user id as a foreign key. That is what makes the experience seamless — nothing is "pending upload"; it is already persisted — and also what makes the link risky, because that id is load-bearing.
Two design choices fall out of "a guest is a real user":
Guests get no profile. The hook that provisions public_profiles / private_profiles on sign-up returns early for anonymous users:
// apps/web/lib/auth/profile-hooks.ts (excerpt)
export const createProfilesAfterUserCreate = async (
user: User & Record<string, unknown>
): Promise<void> => {
if (user.isAnonymous) return;
const name = user.name || user.email.split("@")[0];
// ...insert public_profiles + private_profiles (onConflictDoNothing)
};A guest has no display name, no public page — nothing to render until they commit to an account. That keeps anonymous users out of the social surface entirely, and it means the migration never has to move a profile row (more on that below).
Guests are garbage-collected. Not every guest comes back. A daily cron deletes anonymous users that have been inactive past a threshold:
// packages/jobs/src/handlers/accounts/isGcEligible.ts (excerpt)
export const ANONYMOUS_GC_THRESHOLD_DAYS = 180;The GC and the self-serve account-deletion path both delegate to the same deleteUserData helper in packages/database, so the list of tables that make up "a user's data" lives in exactly one place. The migration below is the mirror image of that list — the same tables, moved instead of deleted.
Link-before-delete: the ordering that makes it safe
Here is the whole wiring, and the ordering guarantee it leans on is the entire point:
// apps/web/lib/auth/server.ts (excerpt)
anonymous({
// The anonymous plugin only deletes the anonymous user AFTER this resolves,
// so throwing here fails the link and leaves the anonymous user + its data
// intact (retryable).
onLinkAccount: async ({ anonymousUser, newUser }) => {
await accountQueries.migrateAccountData(anonymousUser.user.id, newUser.user.id);
},
}),Two things are easy to get wrong here, and both bite silently.
The first is the id. Better Auth hands you anonymousUser and newUser objects whose ids live at .user.id, not .id directly. We confirmed this against a live link before trusting it; reading .id compiles fine and migrates nothing.
The second is error handling — and it is deliberately inverted from what feels natural. The instinct in a data-migration callback is to wrap it in try/catch so a hiccup does not break the user's sign-in. That instinct is exactly wrong here. Because the plugin deletes the anonymous user only after onLinkAccount resolves, catching an error and returning normally would tell Better Auth "all done" — and it would then delete the guest, and its unmigrated data, for good. Letting the exception propagate does the opposite: the link fails, the anonymous user and every row survive, and the user can retry. We would rather a link occasionally fail loudly than succeed while dropping a month of diaries.
Ownership migration: one UPDATE per table, two strategies
migrateAccountData re-points every guest-owned row from the anonymous id to the new id, inside a single db.transaction. The tables split into two groups by one question: could a straight anonymousId → newId UPDATE hit a UNIQUE constraint?
Group A — plain UPDATE. Tables with no unique constraint that pairs userId with another column. A guest's diaries can always become the new user's diaries; there is nothing to collide with.
// apps/web/lib/db/queries/account.ts (excerpt)
await tx.update(diaries).set({ userId: newUserId })
.where(eq(diaries.userId, anonymousUserId));
await tx.update(diaryEntries).set({ userId: newUserId })
.where(eq(diaryEntries.userId, anonymousUserId));
await tx.update(notes).set({ userId: newUserId })
.where(eq(notes.userId, anonymousUserId));
// ...view logs, comments, activity log, email queue/logs, letters, reportsGroup B — delete-the-conflict, then update. This group only bites in the merge case: the guest is not creating a new account but signing into one they already have. Now the target user might already own a row that collides. If both accounts liked the same post, a blind UPDATE of the guest's likes row would violate the (user_id, content_type, content_id) unique constraint and abort the whole transaction. So for each such table we first delete the anon-side rows that would collide, then move the rest:
// apps/web/lib/db/queries/account.ts (excerpt — likes)
await tx.delete(likes).where(
and(
eq(likes.userId, anonymousUserId),
inArray(
sql`(${likes.contentType} || ':' || ${likes.contentId})`,
tx.select({ key: sql`(${likes.contentType} || ':' || ${likes.contentId})` })
.from(likes)
.where(eq(likes.userId, newUserId))
)
)
);
await tx.update(likes).set({ userId: newUserId })
.where(eq(likes.userId, anonymousUserId));The same delete-then-update shape covers userDevices (same FCM token on both accounts), monthlyReports, tenantMembers, and letterDeliveries. Losing a duplicate like or a duplicate device registration is a no-op — the target already has it.
Tags are the one place where dropping the conflict would lose real information. Two accounts can both have a tag named "work"; if we just deleted the guest's "work" tag, every diary tagged with it under the guest would come across untagged. So tags are merged: when a same-name tag exists on the target, we re-point the guest's document_tags links to the target tag, sum the usage counts, and only then delete the now-redundant guest tag.
// apps/web/lib/db/queries/account.ts (excerpt — tag merge)
if (existingTag) {
// re-point document_tags from anon tag → existing target tag, drop dup links,
await tx.update(documentTags).set({ tagId: existingTag.id })
.where(eq(documentTags.tagId, anonTag.id));
// usage_count is stored as TEXT — CAST to sum.
await tx.update(tags).set({
usageCount: sql`CAST(CAST(${existingTag.usageCount} AS INTEGER) + CAST(${anonTag.usageCount} AS INTEGER) AS TEXT)`,
}).where(eq(tags.id, existingTag.id));
await tx.delete(tags).where(eq(tags.id, anonTag.id));
} else {
await tx.update(tags).set({ userId: newUserId }).where(eq(tags.id, anonTag.id));
}The cascade that isn't there
There is a wrinkle hiding in Group B. letterDeliveries has an inbound foreign key from letterReports, declared with ON DELETE CASCADE. On Postgres you would drop a conflicting delivery and trust the cascade to clean up its reports. On libSQL/Turso that trust is misplaced: the driver does not enable PRAGMA foreign_keys by default, so every .onDelete("cascade") in the schema is inert at runtime. Deleting a delivery leaves its reports pointing at a row that no longer exists.
So before dropping any conflicting letterDeliveries row, we delete its letterReports by hand — the same "cascades are documentation, not enforcement" lesson we learned the hard way elsewhere. We wrote up that failure mode separately in When Cascades Don't Cascade; this migration is one of its downstream consequences.
The profile exception
Every other user-owned table is moved by UPDATE. Profiles are not, and it would be a bug to move them. Recall that guests never get a profile — so on the merge path the target already has one, and on the new-account path the user.create hook just created one. There is never a guest profile worth migrating.
The migration codifies that invariant defensively rather than assuming it. It deletes any anon-side profile (there should be none, but the delete is cheap insurance if that ever changes) and back-fills the target account's profile only if one is somehow missing:
// apps/web/lib/db/queries/account.ts (excerpt — profiles)
await tx.delete(privateProfiles).where(eq(privateProfiles.id, anonymousUserId));
await tx.delete(publicProfiles).where(eq(publicProfiles.id, anonymousUserId));
// Back-fill the target user's profiles if somehow missing (defensive).
const [newUser] = await tx.select(/* id, name, email */).from(authUsers)
.where(eq(authUsers.id, newUserId)).limit(1);
if (newUser) {
await tx.insert(publicProfiles)
.values({ id: newUser.id, name: newUser.name || newUser.email.split("@")[0] })
.onConflictDoNothing();
// ...private_profiles likewise, onConflictDoNothing
}The onConflictDoNothing is what keeps this safe to run over an account that already has a profile: back-fill is a no-op when there is nothing to fix. The reason profiles are read/created rather than blindly UPSERTed everywhere is a separate invariant we hold across the auth layer — some paths (like account-name setup) treat a missing profile as a hard failure, not something to silently create. That is its own story, but the through-line is: profiles have exactly one creator per path, and this migration is not sneaking in as a second one.
Idempotency: the whole thing is one retryable transaction
Put the pieces together and the safety story is simple, which is the point:
- Atomic. The entire migration runs inside one
db.transaction. A failure two-thirds of the way through — a constraint we did not anticipate, a dropped connection — rolls back every UPDATE and delete. There is no half-migrated state where some diaries moved and some did not. - Retryable. Because a throw rolls back and prevents Better Auth from deleting the guest, a failed link leaves the world exactly as it was before. The user tries again; the migration runs again from a clean slate.
- Merge-safe. The delete-conflict passes mean running the link into an already-populated account converges instead of colliding. Duplicate rows are dropped, mergeable rows (tags) are merged, and the target's data always wins where it has to.
What we consciously did not build is a resumable, partially-applied migration with its own bookkeeping. A transaction that either fully applies or fully rolls back is far easier to reason about than a checkpointed one, and "retry the whole thing" is a fine recovery story when the whole thing is cheap. The cost we accept is that a persistent failure blocks the link entirely rather than degrading — but for account linking, blocking loudly beats losing data quietly every time.
The logic is unit-tested directly (apps/web/tests/lib/db/queries/account.test.ts) even though the Better Auth wiring around it is not — importing the auth server into Jest trips over an ESM module, so we test migrateAccountData in isolation and keep the onLinkAccount glue thin enough to eyeball. The tests cover both the fresh-account path and every branch of the merge case, including the "link must fail, not silently lose data" contract.
Takeaways
- When a framework deletes the old identity after your migration callback, that callback is a one-shot with a hard deadline. Write it to throw, not to catch — a loud failure is recoverable, a swallowed one is data loss.
- "Move all the user's rows" is a plain UPDATE until a unique constraint says otherwise. The merge case — logging into an account that already exists — is where the real complexity lives; plan for delete-then-update and decide per-table whether a conflict should be dropped or merged.
- Know what your database actually enforces. On libSQL/Turso, declared cascades do nothing, so child rows are your responsibility.
If you want the other half of Storyie's auth story — how we swapped the provider underneath all of this without rewriting every caller — see Migrating Authentication Without Rewriting Every Caller.