| … | … | @@ -13,13 +13,48 @@ |
| 13 | 13 | |
| 14 | 14 | const S32 = '234567abcdefghijklmnopqrstuvwxyz' |
| 15 | 15 | |
| 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. | |
| 21 | const B32HEX = '0123456789abcdefghijklmnopqrstuv' | |
| 22 | const NAME_RE = /^[0-9a-v]{26}$/ | |
| 23 | ||
| 24 | const b32hex = (s) => { | |
| 25 | let n = 0n | |
| 26 | for (const ch of s) n = n * 32n + BigInt(B32HEX.indexOf(ch)) | |
| 27 | return n | |
| 28 | } | |
| 29 | ||
| 16 | 30 | /** @param {string} name a favela record name */ |
| 17 | 31 | export 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 | ||
| 57 | function encode (n) { | |
| 23 | 58 | let s = '' |
| 24 | 59 | for (let i = 0; i < 13; i++) { s = S32[Number(n % 32n)] + s; n /= 32n } |
| 25 | 60 | return s |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "favela-bridge", |
| 3 | "version": "0.0.2", | |
| 3 | "version": "0.0.5", | |
| 4 | 4 | "description": "Projects favela records onto the networks a room may reach. One reader, one reach decision, one projection per network.", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "index.js", |
| … | … | @@ -14,7 +14,8 @@ |
| 14 | 14 | "favela-markdown": "file:../favela-markdown", |
| 15 | 15 | "hyper-svc": "file:../hyper-svc", |
| 16 | 16 | "bare-net": "^1.3.0", |
| 17 | "bare-process": "^4.0.0" | |
| 17 | "bare-process": "^4.0.0", | |
| 18 | "favela-identity": "file:../favela-identity" | |
| 18 | 19 | }, |
| 19 | 20 | "devDependencies": { |
| 20 | 21 | "brittle": "^3.5.0" |
| … | … | @@ -44,3 +44,45 @@ test('⛔ a name that is not a timestamp still yields a valid tid', async (t) => |
| 44 | 44 | t.ok(isValidTid(k), JSON.stringify(odd) + ' -> ' + k) |
| 45 | 45 | } |
| 46 | 46 | }) |
| 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. | |
| 61 | const NEW_A = '01k1v297vo008i4cq4alj7f24p' // at 1788822724600, bytes 0011..99 | |
| 62 | const NEW_B = '01k1v297vovvndrj5rlacogtr6' // SAME millisecond, different bytes | |
| 63 | const NEW_LATER = '01k1v297vp008i4cq4alj7f24p' // one millisecond later | |
| 64 | ||
| 65 | test('★★ 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 | ||
| 69 | test('★★ 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 | ||
| 75 | test('★★ 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 | ||
| 80 | test('★ 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 | ||
| 86 | test('⛔ 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 | }) |