fix(auth): assign first UNUSED palette color (AUTH-03 distinct colors)

Gate 2 A3 fail: a second member (amelia) got the same color as the first (luc),
both #E8734A. Color was assigned by COUNT(*) % palette; a deleted spike user
shifted the count so two live members landed on the same slot. Replace with
'first palette color not already in use by another user' (fall back to count
round-robin only once the palette is exhausted) — guarantees distinct, stable
colors for up to palette length members. +1 regression test (deletion frees a
slot → next member fills it, no collision).
This commit is contained in:
Lucas Berger
2026-06-07 18:14:20 -04:00
parent cdb097c5b3
commit f700182674
2 changed files with 52 additions and 13 deletions
+13 -8
View File
@@ -8,7 +8,7 @@
* Source: RESEARCH.md § "User upsert with color assignment"
*/
import { and, eq, sql } from 'drizzle-orm'
import { and, eq } from 'drizzle-orm'
import { db } from '../db/client.js'
import { users } from '../db/schema.js'
@@ -101,13 +101,18 @@ export async function upsertUser(
return existing[0]
}
// 2. Count existing users to determine round-robin color slot
const countResult = await db
.select({ count: sql<number>`COUNT(*)` })
.from(users)
const count = Number(countResult[0]?.count ?? 0)
const color = COLOR_PALETTE[count % COLOR_PALETTE.length]
// 2. Assign the first palette color NOT already in use by another member.
// A plain COUNT(*) % palette collides under deletions: a deleted user
// shifts the count so the next insert reuses an in-use slot (observed in
// Gate 2 — two members both got #E8734A). Selecting the first unused color
// guarantees distinct, stable colors for up to COLOR_PALETTE.length members
// (AUTH-03). Falls back to round-robin by count only once the palette is
// exhausted (more members than colors).
const usedRows = await db.select({ color: users.color }).from(users)
const usedColors = new Set(usedRows.map((r) => r.color))
const color =
COLOR_PALETTE.find((c) => !usedColors.has(c)) ??
COLOR_PALETTE[usedColors.size % COLOR_PALETTE.length]
// 3. Insert new user row
// mysql2 has no RETURNING clause — use $returningId() then re-select