Fail closed when guest progress cannot merge into an account

  • javascript
  • auth
  • reliability

I shipped accounts on a side project that already let people study as guests. Progress lived in localStorage keyed by lesson slug. Signing in with a magic link was supposed to make that work follow them. The first merge path I sketched was the optimistic one: walk every guest key, write what you can, clear the rest, stamp a “merged” flag, move on.

That version is how you quietly lose a week of reviews.

What “merge” actually has to do

A guest session and an account session are not the same shape. Guest state is a blob under a prefixed key. Account state is rows: one for the lesson, one for each card, maybe a couple of checkmarks. The merge has to:

  1. Find every guest lesson that has real progress.
  2. Resolve each slug to a database lesson id.
  3. Resolve each card key to a database item id.
  4. Upsert the card and checklist rows for that user.
  5. Only then decide the guest data is finished.

Steps 2 and 3 are where people get casual. If a lesson is not in the seed yet, or an item key does not match, you can “succeed” while dropping the rows that mattered. The UI still looks fine because it keeps reading localStorage. The account is empty. The next device has nothing.

Fail closed means the flag stays off

The important rule is not “try hard to merge.” It is “do not mark the merge complete unless every guest lesson you intended to move landed cleanly.”

Roughly:

async function mergeGuestProgress(db, userId) {
	const guests = readAllGuestLessons();
	if (!guests.length) return { ok: true, merged: 0 };

	let merged = 0;
	let skipped = 0;
	let errors = 0;

	for (const { slug, progress } of guests) {
		const lesson = await db.lessons.findBySlug(slug);
		if (!lesson) {
			skipped++;
			continue;
		}

		const items = await db.items.forLesson(lesson.id);
		if (!items.length) {
			skipped++;
			continue;
		}

		const byKey = new Map(items.map((i) => [i.key, i.id]));

		for (const [cardKey, state] of Object.entries(progress.cards)) {
			const itemId = byKey.get(parseItemKey(cardKey));
			if (!itemId) continue;

			try {
				await db.cardStates.upsert({ userId, itemId, ...state });
				merged++;
			} catch {
				errors++;
			}
		}
	}

	if (errors > 0 || skipped > 0) {
		return {
			ok: false,
			merged,
			skipped,
			errors,
			reason: errors ? 'writes failed' : 'lessons or items missing',
		};
	}

	markGuestMerged();
	return { ok: true, merged, skipped: 0, errors: 0 };
}

Two outcomes matter.

Writes failed. Leave the merged flag alone. The guest keys stay put. The next sign-in can retry. Partial upserts are annoying, but idempotent upserts make a retry safe, and a half-moved account beats a cleared guest with nothing to show for it.

Lessons or items were missing. Same rule. Skipping is not success. A content deploy that forgot a seed, or a rename that broke a key map, should surface as a failed merge, not as a quiet wipe. I treat skipped > 0 the same as a hard error for the purpose of the flag.

Empty guest storage is the one true no-op. That returns success with merged: 0 and does not need a flag at all.

Keep the local copy after a good merge

On success I mark the merge done, but I do not clear localStorage in the same breath. The study UI still reads the guest store for the current tab. Wiping it the moment the account rows land means a flicker or a cold reload until the account fetch catches up. Clearing can be a later cleanup once the account path is the source of truth for that browser. The flag is what stops a second merge from replaying the same upserts every visit.

The handler that runs after auth checks the flag first, then hasGuestProgress(), then merges. If anything returns ok: false, it leaves the flag off and stays out of the way. No toast that claims everything is synced. No delete. Just another chance next time.

What I deliberately did not do

I did not invent lesson rows from guest slugs. If the catalog does not know that slug, the merge has nothing honest to attach to. Creating shell lessons from client data would teach the database to trust the browser.

I did not trust a client-only “unlock” or plan field during the same pass. Entitlements live in rows the user cannot update. The merge only copies study progress, never billing state.

I did not treat “most cards merged” as good enough. Counts are for logs. The gate is binary: everything intended landed, or the flag stays false.

Why this is worth the strictness

Guest mode exists so someone can try the product before they create an account. The whole point fails if signing in is where their progress goes to die. Fail-open merge feels faster in the happy path. Fail-closed merge is the one that still has something to retry when the catalog is half-seeded, the network blips, or a key rename lands between the guest session and the first login.

If you only remember one check: never set the “guest merged” flag until skipped and errors are both zero. Everything else is details around that promise.