fix(onboarding): atomic DB append for step completion (deferred bug L3)

markOnboardingStepDone read space_members.onboarding_progress, appended the step
id in JS, and wrote the whole jsonb object back — a lost-update race where two
near-simultaneous completions (double-submit, or two steps ticked quickly) both
read the same base array and the second write clobbered the first, silently
dropping a step.

Replace it with mark_onboarding_step_done (migration 055): a single atomic
UPDATE that dedup-appends the step id into onboarding_progress.completed_step_ids
under the row lock — no update can be lost (READ COMMITTED re-evaluates the jsonb
expression against the latest tuple via EvalPlanQual), other keys are preserved,
and re-appending an existing id is a no-op. Ownership-guarded by auth.uid() so a
caller can only complete steps on their own membership row; the no-JWT service
path is trusted and scoped by p_member_id (mirrors class_signup_tx et al.).

integration/onboarding-progress.test.ts covers dedup/idempotency, key
preservation, the auth.uid ownership gate, and the point of the fix: 12
concurrent completions all survive.

Migration 055 folded into schema.sql; types/database.ts regenerated.
typecheck 0 · lint 0 errors · 645 unit · 58 integration · build ok
This commit is contained in:
Moheeb Zara
2026-08-04 22:36:12 -07:00
parent b2b463491a
commit 4aa6646499
5 changed files with 221 additions and 14 deletions
+9 -14
View File
@@ -130,20 +130,15 @@ export async function markOnboardingStepDone(stepId: string) {
if (!auth.ok) return { error: auth.error }
const { member } = auth
const { data: row } = await supabase
.from('space_members')
.select('onboarding_progress')
.eq('id', member.id)
.single()
const progress = (row?.onboarding_progress ?? {}) as { completed_step_ids?: string[] }
const done = new Set(progress.completed_step_ids ?? [])
done.add(v.data)
const { error } = await supabase
.from('space_members')
.update({ onboarding_progress: { ...progress, completed_step_ids: Array.from(done) } })
.eq('id', member.id)
// Atomic dedup-append in the DB (migration 055). The old read-modify-write on
// onboarding_progress had a lost-update race: two near-simultaneous
// completions both read the same base array and the second write clobbered
// the first, silently dropping a step. The RPC pins the write to the caller's
// own row via auth.uid().
const { error } = await supabase.rpc('mark_onboarding_step_done', {
p_member_id: member.id,
p_step_id: v.data,
})
if (error) return { error: error.message }
return { success: true as const }