OverviewFilesCommitsIssuesDocsReleasesPeers

favela-bridge / lib / tid.js

71 lines · javascript ref:
// The rkey. ⛔ A TID, because bsky requires one and says nothing when it does not get one.
//
// MEASURED 2026-09-07: our PDS served 74 posts, three with non-TID rkeys; bsky's AppView showed the
// other 71 and dropped those three silently. `app.bsky.feed.post` is keyed "tid" in the lexicon.
//
// ⛔ PURE, unlike atproto-store's `tidNow`, which keeps module-level state to guarantee monotonicity
// and therefore returns a different rkey each time it is called for the same record. This bridge
// holds no record→rkey map — it asks the sink which rkeys it already has — so the mapping has to be
// a function of the record alone or every run posts everything again.
//
// A favela record name is milliseconds since the epoch, and a TID is microseconds shifted left by a
// 10-bit clock id, so the conversion is exact and the ordering survives.

const S32 = '234567abcdefghijklmnopqrstuvwxyz'

// favela-peer/lib/name.js: 10 base32hex characters of milliseconds, then 16 of randomness.
// ⛔ The digit-stripping path below is WRONG for this shape — a-v are dropped, so the remainder is a
// meaningless number and two different names can share one. Measured on the shape room.js minted:
// 65,536 distinct names collapsed to 11,111 distinct TIDs. That is not cosmetic, because
// index.js:101 reads `have.has(tidFor(name))` as "already there" and silently never sends the post.
const B32HEX = '0123456789abcdefghijklmnopqrstuv'
const NAME_RE = /^[0-9a-v]{26}$/

const b32hex = (s) => {
  let n = 0n
  for (const ch of s) n = n * 32n + BigInt(B32HEX.indexOf(ch))
  return n
}

/** @param {string} name a favela record name */
export function tidFor (name) {
  const s = String(name == null ? '' : name)

  if (NAME_RE.test(s)) {
    // The timestamp becomes the TID's microseconds, so order across milliseconds is preserved
    // exactly. The random half fills BOTH remaining fields: the 999 sub-millisecond microseconds we
    // would otherwise waste, and the 10-bit clock identifier — which is what that field is for.
    // That is ~1.02M distinct keys per millisecond rather than the 1,024 the clock id alone gives.
    //
    // ⚠ AND IT IS LOSSY, unavoidably. A name carries 80 bits of randomness; a TID has room for about
    // 20. Two records minted in the same millisecond still collide with probability ~1/1.02M, and at
    // human posting rates that never happens — but the native name remains the identity, and this is
    // a projection of it. Do not reason backwards from an rkey.
    const r = b32hex(s.slice(10))
    const micros = b32hex(s.slice(0, 10)) * 1000n + (r % 1000n)
    return encode((micros << 10n) | ((r / 1000n) % 1024n))
  }

  // Legacy: names were milliseconds since the epoch. Records written before favela-peer 0.0.38 are
  // live on three machines and must keep mapping to the rkeys bsky already holds.
  const digits = s.replace(/\D/g, '')
  // ⚠ A name that is neither still has to produce a conformant key, or the record is dropped by
  // bsky with no error. Falling back to a hash of the name keeps it deterministic.
  return encode(((digits ? BigInt(digits) : hash53(s)) * 1000n) << 10n)
}

function encode (n) {
  let s = ''
  for (let i = 0; i < 13; i++) { s = S32[Number(n % 32n)] + s; n /= 32n }
  return s
}

// 53 bits, deterministic, no crypto — this only has to be stable and in range, never unguessable.
function hash53 (s) {
  let h = 0n
  for (let i = 0; i < s.length; i++) h = (h * 131n + BigInt(s.charCodeAt(i))) % 9007199254740993n
  return h
}

export default { tidFor }