AT Protocol PDS + AppView + Tauri Desktop Client, 160-char post limit. - PDS (Rust + axum + sqlx) - Auth: createAccount, createSession, refreshSession - Records: createRecord, deleteRecord (race-safe via SELECT FOR UPDATE) - Feed: feed.like.create, feed.repost.create - Sync: getRepo, getBlocks, getLatestCommit, getRecord (with MST proof), listRepos - Identity: resolveHandle - MST: spec-conformant (at-mst crate, 27 tests) - Repo: signed commits, TID counter (monotonic, 4096 wrap safe) - AppView (Rust + axum + sqlx) - Jetstream consumer (WebSocket, exponential backoff, 38k+ events indexed) - REST API: timeline/home (graph-aware), profile, search, post (with thread hydration) - Handle-sync worker (did:plc + did:web) - JSONB embed storage + thread columns (migration 0003) - Like/repost counter cache (migration 0004) - Tauri 2 + Svelte 5 Desktop Client - System tray (Show/Compose/Quit menu) - OS notifications (tauri-plugin-notification) - Auto-update (tauri-plugin-updater, placeholder endpoint) - Window-state (tauri-plugin-window-state) - 160-char compose with live counter - Image/Link embed rendering - LocalStorage-persisted like state - Timeline with poll (prepend new posts) - Custom TitleBar (transparent, no decorations) - Orange/IBM Plex Mono maarcade design Tests: 231 Rust + 9 vitest = 240 passed.
176 lines
5.8 KiB
Rust
176 lines
5.8 KiB
Rust
use std::sync::atomic::{AtomicU64, Ordering};
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
pub const TID_BASE32: &[u8] = b"234567abcdefghijklmnopqrstuvwxyz";
|
|
|
|
/// Process-local monotonic counter used to disambiguate TIDs that would
|
|
/// otherwise collide on the same microsecond.
|
|
///
|
|
/// The wall clock gives us 13 base32 chars of timestamp (≈52 bits of
|
|
/// micros). Two writes in the same microsecond on the same PDS would
|
|
/// otherwise produce the identical TID, and `put_record` would silently
|
|
/// overwrite the prior record in the MST (same rkey, but actually
|
|
/// different content — the value CIDs differ but the MST key is the
|
|
/// TID, so the prior record becomes unreachable from the head commit).
|
|
///
|
|
/// We tack 12 low bits of a `fetch_add` counter into the encoded value
|
|
/// so back-to-back calls — even inside the same microsecond — always
|
|
/// yield different TIDs. The counter starts at 0; the first call's
|
|
/// fetch_add returns 0 and produces a TID encoding
|
|
/// `(now_micros << 12) | 0`. The counter is monotonic per process,
|
|
/// not globally — a process restart will reset it to 0, which means
|
|
/// a TID emitted by the new process may sort *before* a TID emitted
|
|
/// by its predecessor on the same wall-clock microsecond. That's
|
|
/// acceptable because TIDs are only used as MST rkeys within a
|
|
/// single repo's history; the protocol doesn't require cross-process
|
|
/// monotonicity.
|
|
static TID_COUNTER: AtomicU64 = AtomicU64::new(0);
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
|
pub struct Tid {
|
|
pub raw: String,
|
|
}
|
|
|
|
impl Tid {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
raw: generate_tid(),
|
|
}
|
|
}
|
|
|
|
pub fn from_string(s: impl Into<String>) -> Self {
|
|
Self { raw: s.into() }
|
|
}
|
|
|
|
pub fn as_str(&self) -> &str {
|
|
&self.raw
|
|
}
|
|
}
|
|
|
|
impl Default for Tid {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Display for Tid {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.write_str(&self.raw)
|
|
}
|
|
}
|
|
|
|
pub fn generate_tid() -> String {
|
|
// Phase 5b H10 — the counter is 12 bits wide, so it would wrap after
|
|
// 4096 calls inside a single microsecond. To prevent that, we
|
|
// block until the wall clock advances whenever the low-12 counter
|
|
// has cycled back to 0 inside the same microsecond. In practice
|
|
// this never fires (4096 TIDs/µs ≈ 4 billion/sec from one process)
|
|
// but it's a cheap insurance policy.
|
|
let mut now = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_micros() as u64;
|
|
let counter = loop {
|
|
let prev = TID_COUNTER.fetch_add(1, Ordering::Relaxed);
|
|
// The counter is reset to 0 at process start; the low 12 bits
|
|
// are an in-microsecond disambiguator. If we've wrapped back to
|
|
// 0 mid-microsecond, spin until the clock advances.
|
|
if (prev & 0xFFF) == 0 && prev != 0 {
|
|
let next = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_micros() as u64;
|
|
if next == now {
|
|
std::hint::spin_loop();
|
|
continue;
|
|
}
|
|
now = next;
|
|
}
|
|
break prev;
|
|
};
|
|
|
|
// Combine timestamp + 12-bit disambiguator. The wall clock fits
|
|
// comfortably in 52 bits, so the counter never spills into the
|
|
// timestamp portion for any realistic process lifetime (~year 2400).
|
|
let combined: u64 = (now << 12) | (counter & 0xFFF);
|
|
|
|
let mut s = String::with_capacity(13);
|
|
let mut n = combined;
|
|
for _ in 0..13 {
|
|
let idx = (n & 0x1F) as usize;
|
|
s.push(TID_BASE32[idx] as char);
|
|
n >>= 5;
|
|
}
|
|
s.chars().rev().collect()
|
|
}
|
|
|
|
pub fn compare_tid(a: &str, b: &str) -> std::cmp::Ordering {
|
|
a.cmp(b)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::collections::HashSet;
|
|
|
|
#[test]
|
|
fn tid_increases() {
|
|
let t1 = generate_tid();
|
|
std::thread::sleep(std::time::Duration::from_millis(2));
|
|
let t2 = generate_tid();
|
|
// Strict ordering: t2 must be greater than t1. Accepting `is_le`
|
|
// would mask the very bug this test exists to catch.
|
|
assert!(compare_tid(&t1, &t2).is_lt());
|
|
}
|
|
|
|
#[test]
|
|
fn tid_uses_lowercase_base32() {
|
|
let t = generate_tid();
|
|
for c in t.chars() {
|
|
assert!(matches!(c, '2'..='7' | 'a'..='z'));
|
|
}
|
|
}
|
|
|
|
/// Phase 5b H10 — two writes in the same microsecond used to
|
|
/// produce identical TIDs, which caused `put_record` to silently
|
|
/// overwrite the prior record (different value CID, but the same
|
|
/// rkey, so the new MST entry eclipsed the old). Verify a tight
|
|
/// burst of N calls yields N distinct TIDs.
|
|
#[test]
|
|
fn generate_tid_is_monotonic_per_process() {
|
|
let n = 1_000;
|
|
let mut seen = HashSet::with_capacity(n);
|
|
let mut prev: Option<String> = None;
|
|
for _ in 0..n {
|
|
let t = generate_tid();
|
|
assert!(
|
|
seen.insert(t.clone()),
|
|
"duplicate TID produced in tight loop: {t}"
|
|
);
|
|
if let Some(p) = prev.as_ref() {
|
|
assert!(
|
|
compare_tid(p, &t).is_lt(),
|
|
"TID must strictly increase per process: {p} >= {t}"
|
|
);
|
|
}
|
|
prev = Some(t);
|
|
}
|
|
assert_eq!(seen.len(), n);
|
|
}
|
|
|
|
/// Same as above but explicitly constructs the "same microsecond"
|
|
/// worst case by sampling TIDs back-to-back without sleeping. The
|
|
/// counter overlay must keep them distinct even when the wall
|
|
/// clock doesn't tick.
|
|
#[test]
|
|
fn generate_tid_avoids_same_microsecond_collisions() {
|
|
let n = 100;
|
|
let mut seen = HashSet::with_capacity(n);
|
|
for _ in 0..n {
|
|
let t = generate_tid();
|
|
assert!(seen.insert(t.clone()), "collision at {t}");
|
|
}
|
|
assert_eq!(seen.len(), n);
|
|
}
|
|
}
|