All files / src/utils unique-id-counter.js

95.45% Statements 21/22
96.87% Branches 31/32
100% Functions 4/4
100% Lines 20/20

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64                                      86x 14x   72x 6x 6x   66x             63x 63x 63x 63x                         26x 23x   23x 23x 23x 23x 23x 23x 23x       25x  
/**
 * Type-immune helpers for the shared counters/uniqueId document.
 *
 * Root cause (2026-07-10): a teardown "restore" read the top doc of
 * orderBy('uniqueId','desc') — Firestore type-orders strings AFTER numbers,
 * so a single string-typed uniqueId doc anywhere poisons the counter with a
 * string; `+ 1` on it then CONCATENATES ("33331" + 1 === "333311") and every
 * subsequently minted user carries a string uniqueId that the number-typed
 * searches can never match. THREE call sites shared the same document with
 * copy-pasted arithmetic (the test-helpers allocator + teardown, the
 * production signup mint in routes/users.js, and cron/testDataCleanup); this
 * module is their single arbiter so a fourth divergent copy cannot appear.
 */
 
// A trustworthy uniqueId-ish value: a positive safe integer, or a pure-digit
// string of one (coerced for sequence continuity). Anything else — booleans,
// null, NaN, Infinity, fractions, exponent strings, unsafe-range values —
// returns null and the caller picks its fallback.
function coercePositiveSafeInteger(raw) {
  if (typeof raw === 'number') {
    return Number.isSafeInteger(raw) && raw > 0 ? raw : null;
  }
  if (typeof raw === 'string' && /^[1-9][0-9]*$/.test(raw)) {
    const n = Number(raw);
    return Number.isSafeInteger(n) ? n : null;
  }
  return null;
}
 
// Next id from a raw counter value. `base` is the restart point when the raw
// value is untrusted; `floor` (optional) additionally clamps trusted-but-low
// values up (the signup mint never issues below MIN_UNIQUE_ID).
function nextUniqueIdFrom(raw, { base, floor = 0 }) {
  let current = coercePositiveSafeInteger(raw);
  if (current === null) current = base;
  Iif (current < floor) current = floor;
  return current + 1;
}
 
// Repair/restore the counter after deleting users: raise-only, doc-only
// transaction. The candidate comes from the highest remaining user; a
// string-typed top doc gives NO information about the true numeric max
// (strings sort after ALL numbers in the desc order), so only a genuine
// positive safe integer is trusted — otherwise the base. The transaction
// never lowers a live counter below what a concurrent allocation already
// advanced it to (lowering would reissue an id the concurrent setup just
// handed out). Deliberately reads ONLY the counter doc inside the tx — the
// users query stays outside so the conflict set stays a single document.
async function restoreUniqueIdCounter(db, { base = 100000000 } = {}) {
  const maxSnap = await db.collection('users').orderBy('uniqueId', 'desc').limit(1).get();
  const rawMax = maxSnap.empty ? undefined : maxSnap.docs[0].data().uniqueId;
  const candidate =
    typeof rawMax === 'number' && Number.isSafeInteger(rawMax) && rawMax > 0 ? rawMax : base;
  const counterRef = db.doc('counters/uniqueId');
  await db.runTransaction(async (t) => {
    const snap = await t.get(counterRef);
    const live = coercePositiveSafeInteger(snap.exists ? snap.data().value : undefined);
    const value = live !== null && live > candidate ? live : candidate;
    t.set(counterRef, { value }, { merge: true });
  });
}
 
module.exports = { coercePositiveSafeInteger, nextUniqueIdFrom, restoreUniqueIdCounter };