OverviewFilesCommitsIssuesDocsReleasesPeers

favela-bridge commits / b81a081

0.0.5: the rkey follows favela-peer's new record name
favela-peer 0.0.38 stopped naming records with a millisecond clock. A name is now 10 base32hex characters of timestamp plus 16 of randomness, and tidFor's digit-stripping was wrong for it: a-v are dropped, so the remainder is a meaningless number and two different names can share one. Measured on the shape room.js used to mint, 65,536 distinct names collapsed to 11,111 distinct TIDs — and a collision here is not cosmetic, because index.js:101 reads have.has(tidFor(name)) as "already there" and silently never sends the post. The new branch maps the timestamp half to the TID's microseconds, so ordering across milliseconds is exact, and spends the random half on both remaining fields: the 999 sub-millisecond microseconds that would otherwise be wasted, and the 10-bit clock identifier. Using the clock id alone gave 1,024 distinct keys per millisecond; using both gives ~1.02M. Measured: 20,000 names minted in one millisecond now produce 19,787 distinct TIDs, which is the birthday rate for that space and about 196 expected collisions. ⚠ It is lossy and says so. A name carries 80 bits; a TID has room for about 20. The name remains the identity — do not reason backwards from an rkey. Legacy all-digit names keep their existing mapping, so records already on three machines keep the rkeys bsky already holds. The new-format literals in the test are minted by favela-peer's generator with a fixed clock and fixed bytes. They are the cross-repo contract: this bridge must not import the peer to read its names, so the format is pinned here and any change to it breaks this test. favela-bridge 17/17 tests, 49/49 asserts (was 12/39) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
b81a081087b193b8f2d3ee2914d10a60cef56725
by plankatron <fleeky@gmail.com> · 2026-09-08T14:23:38.000Z

Changed files (3) +85 -7

lib/tid.js +40 -5
……@@ -13,13 +13,48 @@
1313
1414const S32 = '234567abcdefghijklmnopqrstuvwxyz'
1515
16// favela-peer/lib/name.js: 10 base32hex characters of milliseconds, then 16 of randomness.
17// ⛔ The digit-stripping path below is WRONG for this shape — a-v are dropped, so the remainder is a
18// meaningless number and two different names can share one. Measured on the shape room.js minted:
19// 65,536 distinct names collapsed to 11,111 distinct TIDs. That is not cosmetic, because
20// index.js:101 reads `have.has(tidFor(name))` as "already there" and silently never sends the post.
21const B32HEX = '0123456789abcdefghijklmnopqrstuv'
22const NAME_RE = /^[0-9a-v]{26}$/
23
24const b32hex = (s) => {
25 let n = 0n
26 for (const ch of s) n = n * 32n + BigInt(B32HEX.indexOf(ch))
27 return n
28}
29
1630/** @param {string} name a favela record name */
1731export function tidFor (name) {
18 const digits = String(name == null ? '' : name).replace(/\D/g, '')
19 // ⚠ A name that is not a timestamp still has to produce a conformant key, or the record is
20 // dropped by bsky with no error. Falling back to a hash of the name keeps it deterministic.
21 const ms = digits ? BigInt(digits) : hash53(String(name))
22 let n = (ms * 1000n) << 10n
32 const s = String(name == null ? '' : name)
33
34 if (NAME_RE.test(s)) {
35 // The timestamp becomes the TID's microseconds, so order across milliseconds is preserved
36 // exactly. The random half fills BOTH remaining fields: the 999 sub-millisecond microseconds we
37 // would otherwise waste, and the 10-bit clock identifier — which is what that field is for.
38 // That is ~1.02M distinct keys per millisecond rather than the 1,024 the clock id alone gives.
39 //
40 // ⚠ AND IT IS LOSSY, unavoidably. A name carries 80 bits of randomness; a TID has room for about
41 // 20. Two records minted in the same millisecond still collide with probability ~1/1.02M, and at
42 // human posting rates that never happens — but the native name remains the identity, and this is
43 // a projection of it. Do not reason backwards from an rkey.
44 const r = b32hex(s.slice(10))
45 const micros = b32hex(s.slice(0, 10)) * 1000n + (r % 1000n)
46 return encode((micros << 10n) | ((r / 1000n) % 1024n))
47 }
48
49 // Legacy: names were milliseconds since the epoch. Records written before favela-peer 0.0.38 are
50 // live on three machines and must keep mapping to the rkeys bsky already holds.
51 const digits = s.replace(/\D/g, '')
52 // ⚠ A name that is neither still has to produce a conformant key, or the record is dropped by
53 // bsky with no error. Falling back to a hash of the name keeps it deterministic.
54 return encode(((digits ? BigInt(digits) : hash53(s)) * 1000n) << 10n)
55}
56
57function encode (n) {
2358 let s = ''
2459 for (let i = 0; i < 13; i++) { s = S32[Number(n % 32n)] + s; n /= 32n }
2560 return s
package.json +3 -2
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-bridge",
3 "version": "0.0.2",
3 "version": "0.0.5",
44 "description": "Projects favela records onto the networks a room may reach. One reader, one reach decision, one projection per network.",
55 "type": "module",
66 "main": "index.js",
……@@ -14,7 +14,8 @@
1414 "favela-markdown": "file:../favela-markdown",
1515 "hyper-svc": "file:../hyper-svc",
1616 "bare-net": "^1.3.0",
17 "bare-process": "^4.0.0"
17 "bare-process": "^4.0.0",
18 "favela-identity": "file:../favela-identity"
1819 },
1920 "devDependencies": {
2021 "brittle": "^3.5.0"
test/tid.test.js +42 -0
……@@ -44,3 +44,45 @@ test('⛔ a name that is not a timestamp still yields a valid tid', async (t) =>
4444 t.ok(isValidTid(k), JSON.stringify(odd) + ' -> ' + k)
4545 }
4646})
47
48// ── favela-peer 0.0.38 changed what a record is called ────────────────────────────────────────
49//
50// ⛔ THE OLD MAPPING BREAKS ON THE NEW NAME. `tidFor` stripped non-digits and read the rest as
51// milliseconds. That was right while every name WAS milliseconds. A name is now 10 base32hex
52// characters of timestamp plus 16 of randomness (favela-peer/lib/name.js), so the digit-subsequence
53// of one is a meaningless number — and worse, two different names share one, because a-v are all
54// dropped. Measured on the shape room.js used to mint: 65,536 distinct names collapsed to 11,111
55// distinct TIDs. A collision here is not cosmetic: index.js:101 treats `have.has(tidFor(name))` as
56// "already there" and never sends the post.
57//
58// These literals are minted by favela-peer's generator with a fixed clock and fixed bytes. They are
59// the cross-repo contract: this bridge must not need to import the peer to read its names, so the
60// FORMAT is pinned here and any change to it must break this test.
61const NEW_A = '01k1v297vo008i4cq4alj7f24p' // at 1788822724600, bytes 0011..99
62const NEW_B = '01k1v297vovvndrj5rlacogtr6' // SAME millisecond, different bytes
63const NEW_LATER = '01k1v297vp008i4cq4alj7f24p' // one millisecond later
64
65test('★★ a new-format name yields a valid tid', async (t) => {
66 for (const n of [NEW_A, NEW_B, NEW_LATER]) t.ok(isValidTid(tidFor(n)), n + ' -> ' + tidFor(n))
67})
68
69test('★★ two records minted in the SAME millisecond get DIFFERENT rkeys', async (t) => {
70 // The whole point. Under the old mapping these two collapse together and the second is silently
71 // reported "already there" and never delivered.
72 t.not(tidFor(NEW_A), tidFor(NEW_B))
73})
74
75test('★★ order survives the mapping', async (t) => {
76 t.ok(tidFor(NEW_A) < tidFor(NEW_LATER), 'a later record still sorts later')
77 t.is(Number(tidMicros(tidFor(NEW_A)) / 1000n), 1788822724600, 'and decodes to the moment it was minted')
78})
79
80test('★ legacy clock names keep working — records already on disk do not move', async (t) => {
81 t.ok(isValidTid(tidFor('1788822724600')))
82 t.is(Number(tidMicros(tidFor('1788822724600')) / 1000n), 1788822724600)
83 t.is(tidFor('1788822724600'), tidFor('1788822724600'), 'still pure')
84})
85
86test('⛔ the mapping stays PURE for the new format too', async (t) => {
87 t.is(tidFor(NEW_A), tidFor(NEW_A), 'a re-run posts nothing twice')
88})