chadtmiller.com/knotless
Git hosted in your atproto account, on any stock PDS, fast enough for agents.
git clone knotless://chadtmiller.com/knotlessneeds the knotless git helper. set it up
cov: both runs side by side, in about two minutes; getFeed pages
harness/cov.mjs builds the tests once, instrumented, and runs every test binary against a stock PDS and a spaces one together, several at a time, each harness shared as `just pds` is, clearing only the last run's counts: from about six minutes to under two and a half. Sharing a PDS showed what the tests leaned on running alone, and each is fixed: dev.knotless.getFeed pages with a cursor, by time and URI as listRepos does, so the feed test reads back to where it began; the proxy's answer to too big a body can close the connection under the rest of it; the forge test's repository has a name no other test uses; and a pooled connection is let go before a Node server's five seconds of keep-alive end, rather than at ninety, as a PDS's are. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Chad Miller committed on Oct 21, 2026 parent 5026daf commit a4ed85c
Showing 11 changed files with 366 additions and 95 deletions
CLAUDE.md+3−1
| @@ -26,7 +26,9 @@ work is done. | ||
a block or component beside it splits the text into nodes, which shapes | ||
differently from the same text whole and moves pixels. | ||
- Done: `just lint` (it runs svelte-check too), `just cov` (100% lines and | ||
functions over the stock and spaces-alpha runs; slow, so once) and, if | ||
functions over the stock and spaces-alpha runs, side by side, in under | ||
two minutes; `COV_CLEAN=1` rebuilds everything first, for a miss that | ||
makes no sense) and, if | ||
pages changed, `just shots-check` and `just shots-live`. | ||
| ||
`cargo fmt` can put a `?` on a line of its own, which coverage then counts | ||
Justfile+1−4
| @@ -42,10 +42,7 @@ spaces := env_var_or_default("KNOTLESS_DEV_ENV", "../atproto-space-ref/packages/ | ||
# Fails under 100% of lines or functions, over runs against the stock | ||
# reference PDS and the spaces alpha together. | ||
cov: | ||
{{cargo}} llvm-cov clean --workspace | ||
KNOTLESS_DEV_ENV= {{cargo}} llvm-cov --workspace --no-report | ||
KNOTLESS_DEV_ENV={{spaces}} {{cargo}} llvm-cov --workspace --no-report | ||
{{cargo}} llvm-cov report --ignore-filename-regex '{{ignore}}' --fail-under-lines 100 --fail-under-functions 100 --show-missing-lines | ||
node harness/cov.mjs '{{spaces}}' '{{ignore}}' | ||
| ||
# Coverage over the stock run alone, failing on nothing: quicker than | ||
# `cov`, but what only the spaces alpha reaches shows as missing. | ||
README.md+1−1
| @@ -346,7 +346,7 @@ covered across the two runs, and CI fails below that. | ||
just setup # npm install in harness/ and web/, llvm-tools for coverage | ||
just test # every test | ||
just test-spaces # against the alpha: KNOTLESS_DEV_ENV names its dev-env | ||
just cov # both runs, failing under 100% lines or functions | ||
just cov # both runs side by side, failing under 100% lines or functions | ||
just lint # rustfmt, clippy and svelte-check | ||
just bench 50 # the table above, at 50 ms RTT | ||
just bench-cards # what a social card costs to draw, with the PNGs | ||
crates/knotless-serve/src/index.rs+66−34
| @@ -31,11 +31,16 @@ pub struct Listed { | ||
#[serde(skip_serializing_if = "Option::is_none")] | ||
pub fork_of: Option<String>, | ||
pub created_at: String, | ||
/// Its record, which pages are cut after. | ||
#[serde(skip)] | ||
pub uri: String, | ||
} | ||
| ||
/// A ref moving, as a claim recorded it. | ||
#[derive(Debug, Clone, PartialEq)] | ||
pub struct Push { | ||
/// The claim, which pages are cut after. | ||
pub uri: String, | ||
/// The repository's record. | ||
pub repo: String, | ||
pub did: String, | ||
| @@ -424,11 +429,7 @@ impl Index { | ||
) -> rusqlite::Result<(Vec<Listed>, Option<String>)> { | ||
let db = self.db.lock().unwrap(); | ||
let pattern = format!("%{}%", query.replace(['%', '_'], "")); | ||
// Newest first, ties broken by URI, so a page ends at a point the | ||
// next can start strictly after. | ||
let (at, uri) = cursor | ||
.and_then(|c| c.split_once(' ')) | ||
.unwrap_or(("\u{10ffff}", "")); | ||
let (at, uri) = after(cursor); | ||
let mut stmt = db.prepare_cached( | ||
"SELECT did, name, description, fork_of, created_at, uri FROM repo | ||
WHERE (name LIKE ?1 OR description LIKE ?1) | ||
| @@ -436,27 +437,32 @@ impl Index { | ||
AND did NOT IN down AND uri NOT IN down | ||
ORDER BY created_at DESC, uri DESC LIMIT ?4", | ||
)?; | ||
let rows: Vec<(Listed, String)> = stmt | ||
.query_map(params![pattern, at, uri, limit as i64], paged)? | ||
let rows: Vec<Listed> = stmt | ||
.query_map(params![pattern, at, uri, limit as i64], listed)? | ||
.collect::<rusqlite::Result<_>>()?; | ||
let next = (rows.len() == limit) | ||
.then(|| { | ||
rows.last() | ||
.map(|(r, uri)| format!("{} {uri}", r.created_at)) | ||
}) | ||
.then(|| rows.last().map(|r| cursor_at(&r.created_at, &r.uri))) | ||
.flatten(); | ||
Ok((rows.into_iter().map(|(r, _)| r).collect(), next)) | ||
Ok((rows, next)) | ||
} | ||
| ||
/// Pulls, issues, comments and changes of state on any repository, | ||
/// newest first, with the owner and key of the repository each is on: | ||
/// without what its owner hides, with what was said about that, or | ||
/// what those its owner blocks wrote, as `forge` leaves them out. | ||
pub fn recent(&self, limit: usize) -> rusqlite::Result<Vec<(Entry, String, String)>> { | ||
/// what those its owner blocks wrote, as `forge` leaves them out. After | ||
/// `cursor`, as [`cursor_at`] makes one, if given. | ||
pub fn recent( | ||
&self, | ||
limit: usize, | ||
cursor: Option<&str>, | ||
) -> rusqlite::Result<Vec<(Entry, String, String)>> { | ||
let (at, uri) = after(cursor); | ||
let db = self.db.lock().unwrap(); | ||
let mut stmt = db.prepare_cached( | ||
"SELECT f.uri, f.author, f.value, f.repo_did, f.repo_key FROM forge f | ||
WHERE f.uri NOT LIKE '%/dev.knotless.hide/%' AND f.uri NOT LIKE '%/dev.knotless.block/%' | ||
AND (json_extract(f.value, '$.createdAt') < ?2 | ||
OR (json_extract(f.value, '$.createdAt') = ?2 AND f.uri < ?3)) | ||
AND f.uri NOT IN down AND f.author NOT IN down | ||
AND coalesce(json_extract(f.value, '$.subject'), '') NOT IN down | ||
AND f.author NOT IN (SELECT subject FROM block WHERE did = f.repo_did) | ||
| @@ -467,21 +473,26 @@ impl Index { | ||
OR (o.uri LIKE '%/dev.knotless.hide/%' | ||
AND json_extract(o.value, '$.subject') | ||
IN (f.uri, coalesce(json_extract(f.value, '$.subject'), ''))))) | ||
ORDER BY json_extract(f.value, '$.createdAt') DESC LIMIT ?1", | ||
ORDER BY json_extract(f.value, '$.createdAt') DESC, f.uri DESC LIMIT ?1", | ||
)?; | ||
stmt.query_map([limit as i64], recent)?.collect() | ||
stmt.query_map(params![limit as i64, at, uri], recent)? | ||
.collect() | ||
} | ||
| ||
/// Refs that moved on repositories that stand, newest first. | ||
pub fn pushes(&self, limit: usize) -> rusqlite::Result<Vec<Push>> { | ||
/// Refs that moved on repositories that stand, newest first; after | ||
/// `cursor`, as [`cursor_at`] makes one, if given. | ||
pub fn pushes(&self, limit: usize, cursor: Option<&str>) -> rusqlite::Result<Vec<Push>> { | ||
let db = self.db.lock().unwrap(); | ||
let (at, uri) = after(cursor); | ||
let mut stmt = db.prepare_cached( | ||
"SELECT r.uri, p.did, r.name, r.fork_of, p.ref, p.before, p.after, p.created_at FROM push p | ||
JOIN repo r ON r.did = p.did AND r.key = p.key | ||
"SELECT r.uri, p.did, r.name, r.fork_of, p.ref, p.before, p.after, p.created_at, p.uri | ||
FROM push p JOIN repo r ON r.did = p.did AND r.key = p.key | ||
WHERE p.did NOT IN down AND r.uri NOT IN down | ||
ORDER BY p.created_at DESC LIMIT ?1", | ||
AND (p.created_at < ?2 OR (p.created_at = ?2 AND p.uri < ?3)) | ||
ORDER BY p.created_at DESC, p.uri DESC LIMIT ?1", | ||
)?; | ||
stmt.query_map([limit as i64], push)?.collect() | ||
stmt.query_map(params![limit as i64, at, uri], push)? | ||
.collect() | ||
} | ||
| ||
/// Where the repository at `repo` had `refname` just before `at`, as | ||
| @@ -508,7 +519,7 @@ impl Index { | ||
pub fn forks(&self, did: &str, key: &str) -> rusqlite::Result<Vec<Listed>> { | ||
let db = self.db.lock().unwrap(); | ||
let mut stmt = db.prepare_cached( | ||
"SELECT did, name, description, fork_of, created_at FROM repo | ||
"SELECT did, name, description, fork_of, created_at, uri FROM repo | ||
WHERE fork_did = ?1 AND fork_key = ?2 AND did NOT IN down AND uri NOT IN down | ||
ORDER BY created_at", | ||
)?; | ||
| @@ -550,6 +561,7 @@ fn push(row: &rusqlite::Row) -> rusqlite::Result<Push> { | ||
from: row.get(5)?, | ||
to: row.get(6)?, | ||
created_at: row.get(7)?, | ||
uri: row.get(8)?, | ||
}) | ||
} | ||
| ||
| @@ -557,9 +569,17 @@ fn recent(row: &rusqlite::Row) -> rusqlite::Result<(Entry, String, String)> { | ||
Ok((entry(row)?, row.get(3)?, row.get(4)?)) | ||
} | ||
| ||
/// A listed repository, and its URI to page from. | ||
fn paged(row: &rusqlite::Row) -> rusqlite::Result<(Listed, String)> { | ||
Ok((listed(row)?, row.get(5)?)) | ||
/// Where a page cut after the record `uri`, made at `at`, starts the next: | ||
/// newest first, ties broken by URI, so the next starts strictly after it. | ||
pub fn cursor_at(at: &str, uri: &str) -> String { | ||
format!("{at} {uri}") | ||
} | ||
| ||
/// The time and URI a cursor is after, or past everything without one. | ||
fn after(cursor: Option<&str>) -> (&str, &str) { | ||
cursor | ||
.and_then(|c| c.split_once(' ')) | ||
.unwrap_or(("\u{10ffff}", "")) | ||
} | ||
| ||
fn listed(row: &rusqlite::Row) -> rusqlite::Result<Listed> { | ||
| @@ -569,6 +589,7 @@ fn listed(row: &rusqlite::Row) -> rusqlite::Result<Listed> { | ||
description: row.get(2)?, | ||
fork_of: row.get(3)?, | ||
created_at: row.get(4)?, | ||
uri: row.get(5)?, | ||
}) | ||
} | ||
| ||
| @@ -606,8 +627,8 @@ mod tests { | ||
assert!(index.forks("did:a", "k").is_err()); | ||
assert!(index.forge("did:a", "k").is_err()); | ||
assert!(index.name("did:a", "k").is_err()); | ||
assert!(index.recent(1).is_err()); | ||
assert!(index.pushes(1).is_err()); | ||
assert!(index.recent(1, None).is_err()); | ||
assert!(index.pushes(1, None).is_err()); | ||
} | ||
| ||
#[test] | ||
| @@ -783,11 +804,12 @@ mod tests { | ||
&json!({"repo": "x", "name": "refs/heads/main", "createdAt": "2026-01-04T00:00:00Z"}), | ||
) | ||
.unwrap(); | ||
let pushes = index.pushes(10).unwrap(); | ||
let pushes = index.pushes(10, None).unwrap(); | ||
assert_eq!( | ||
pushes, | ||
[ | ||
Push { | ||
uri: "at://did:a/dev.knotless.claim/k.m.1".into(), | ||
repo: "at://did:a/dev.knotless.repo/r".into(), | ||
did: "did:a".into(), | ||
name: "r".into(), | ||
| @@ -798,6 +820,7 @@ mod tests { | ||
created_at: "2026-01-03T00:00:00Z".into(), | ||
}, | ||
Push { | ||
uri: "at://did:a/dev.knotless.claim/k.m.0".into(), | ||
repo: "at://did:a/dev.knotless.repo/r".into(), | ||
did: "did:a".into(), | ||
name: "r".into(), | ||
| @@ -819,8 +842,11 @@ mod tests { | ||
index.delete("did:a", nsid::CLAIM, "k.m.2").unwrap(); | ||
// Compaction drops claims; the history stays. | ||
index.delete("did:a", nsid::CLAIM, "k.m.0").unwrap(); | ||
assert_eq!(index.pushes(10).unwrap().len(), 3); | ||
assert_eq!(index.pushes(1).unwrap().len(), 1); | ||
assert_eq!(index.pushes(10, None).unwrap().len(), 3); | ||
assert_eq!(index.pushes(1, None).unwrap().len(), 1); | ||
let all = index.pushes(10, None).unwrap(); | ||
let at = cursor_at(&all[0].created_at, &all[0].uri); | ||
assert_eq!(index.pushes(10, Some(&at)).unwrap(), all[1..]); | ||
| ||
// Pulls, issues and what was said, newest first, without what the | ||
// owner hides or blocks, or those records themselves. | ||
| @@ -875,18 +901,24 @@ mod tests { | ||
index | ||
.put("did:a", nsid::BLOCK, "12", &json!({"subject": "did:e"})) | ||
.unwrap(); | ||
let recent = index.recent(10).unwrap(); | ||
let recent = index.recent(10, None).unwrap(); | ||
let kinds: Vec<&str> = recent | ||
.iter() | ||
.map(|(e, _, _)| e.uri.rsplit('/').nth(1).unwrap()) | ||
.collect(); | ||
assert_eq!(kinds, [nsid::COMMENT, nsid::PULL, nsid::ISSUE]); | ||
assert_eq!((recent[0].1.as_str(), recent[0].2.as_str()), ("did:a", "k")); | ||
assert_eq!(index.recent(1).unwrap().len(), 1); | ||
assert_eq!(index.recent(1, None).unwrap().len(), 1); | ||
// Paged by time and URI, the next page starting strictly after. | ||
let first = &index.recent(1, None).unwrap()[0].0; | ||
let at = cursor_at(first.value["createdAt"].as_str().unwrap(), &first.uri); | ||
let rest = index.recent(10, Some(&at)).unwrap(); | ||
assert_eq!(rest.len(), 2); | ||
assert_eq!(rest[0].0.uri, recent[1].0.uri); | ||
| ||
// Gone with the repository. | ||
index.delete("did:a", nsid::REPO, "r").unwrap(); | ||
assert!(index.pushes(10).unwrap().is_empty()); | ||
assert!(index.pushes(10, None).unwrap().is_empty()); | ||
} | ||
| ||
#[test] | ||
crates/knotless-serve/src/web/feed.rs+83−31
| @@ -2,7 +2,7 @@ | ||
//! knows, newest first. The front page shows it. | ||
| ||
use super::*; | ||
use crate::index::Index; | ||
use crate::index::{Index, cursor_at}; | ||
use knotless::records::nsid; | ||
use std::collections::HashSet; | ||
| ||
| @@ -18,6 +18,10 @@ const LISTED: usize = 10; | ||
#[ts(export, rename = "Feed")] | ||
pub struct Output { | ||
events: Vec<Event>, | ||
/// Where the next page starts, when there may be one. | ||
#[serde(skip_serializing_if = "Option::is_none")] | ||
#[ts(optional)] | ||
cursor: Option<String>, | ||
} | ||
| ||
/// Something an account did to a repository. | ||
| @@ -107,6 +111,7 @@ pub enum Action { | ||
#[derive(Deserialize, Default)] | ||
pub struct Params { | ||
limit: Option<usize>, | ||
cursor: Option<String>, | ||
} | ||
| ||
pub async fn get_feed( | ||
| @@ -118,8 +123,8 @@ pub async fn get_feed( | ||
"this server keeps no index".into(), | ||
))?; | ||
let limit = p.limit.unwrap_or(LIMIT).clamp(1, 100); | ||
let events = events(&app, index, limit).await?; | ||
Ok(axum::Json(Output { events })) | ||
let (events, cursor) = events(&app, index, limit, p.cursor.as_deref()).await?; | ||
Ok(axum::Json(Output { events, cursor })) | ||
} | ||
| ||
/// The owner and name of the repository `uri` names. | ||
| @@ -128,21 +133,63 @@ fn owner_and_name(uri: &str) -> Option<(String, String)> { | ||
Some((did.to_string(), name.to_string())) | ||
} | ||
| ||
/// What happened, newest first: repositories made, refs moved, and what | ||
/// was opened, said and decided on them. | ||
async fn events(app: &App, index: &Index, limit: usize) -> Result<Vec<Event>> { | ||
let repos = index | ||
.repos("", limit) | ||
/// Where a page of `limit` ends, from the time and URI of each record it | ||
/// could hold, newest `limit` of each kind: the last it holds, if it is | ||
/// full. | ||
fn cut<'a>(mut keys: Vec<(&'a str, &'a str)>, limit: usize) -> Option<(&'a str, &'a str)> { | ||
keys.sort_by(|a, b| b.cmp(a)); | ||
keys.get(limit - 1).copied() | ||
} | ||
| ||
/// What happened, newest first, after `cursor`: repositories made, refs | ||
/// moved, and what was opened, said and decided on them; and where the | ||
/// next page starts. A page is the newest `limit` records of all three | ||
/// kinds, by time and then URI, less those not shown. | ||
async fn events( | ||
app: &App, | ||
index: &Index, | ||
limit: usize, | ||
cursor: Option<&str>, | ||
) -> Result<(Vec<Event>, Option<String>)> { | ||
let (mut repos, _) = index | ||
.repos_page("", limit, cursor) | ||
.map_err(anyhow::Error::from) | ||
.map_err(AppError::internal)?; | ||
let pushes = index | ||
.pushes(limit) | ||
let mut pushes = index | ||
.pushes(limit, cursor) | ||
.map_err(anyhow::Error::from) | ||
.map_err(AppError::internal)?; | ||
let said = index | ||
.recent(limit) | ||
let mut said = index | ||
.recent(limit, cursor) | ||
.map_err(anyhow::Error::from) | ||
.map_err(AppError::internal)?; | ||
let at = |e: &forge::Entry| { | ||
e.value["createdAt"] | ||
.as_str() | ||
.unwrap_or_default() | ||
.to_string() | ||
}; | ||
let keys = repos | ||
.iter() | ||
.map(|r| (r.created_at.as_str(), r.uri.as_str())) | ||
.chain( | ||
pushes | ||
.iter() | ||
.map(|p| (p.created_at.as_str(), p.uri.as_str())), | ||
) | ||
.chain(said.iter().map(|(e, _, _)| { | ||
let at = e.value["createdAt"].as_str().unwrap_or_default(); | ||
(at, e.uri.as_str()) | ||
})) | ||
.collect(); | ||
let end = cut(keys, limit).map(|(at, uri)| (at.to_string(), uri.to_string())); | ||
let next = end.as_ref().map(|(at, uri)| cursor_at(at, uri)); | ||
if let Some(end) = &end { | ||
let held = |at: &str, uri: &str| (at, uri) >= (end.0.as_str(), end.1.as_str()); | ||
repos.retain(|r| held(&r.created_at, &r.uri)); | ||
pushes.retain(|p| held(&p.created_at, &p.uri)); | ||
said.retain(|(e, _, _)| held(&at(e), &e.uri)); | ||
} | ||
| ||
// Each account named, resolved together. | ||
let parents: Vec<(String, String)> = repos | ||
| @@ -174,20 +221,25 @@ async fn events(app: &App, index: &Index, limit: usize) -> Result<Vec<Event>> { | ||
name: name.to_string(), | ||
}; | ||
| ||
let mut events: Vec<Event> = repos | ||
let mut events: Vec<(String, Event)> = repos | ||
.iter() | ||
.map(|r| Event { | ||
created_at: r.created_at.clone(), | ||
actor: actor(&r.did), | ||
repo: repo(&r.did, &r.name), | ||
action: Action::Created { | ||
description: r.description.clone(), | ||
fork_of: r | ||
.fork_of | ||
.as_deref() | ||
.and_then(owner_and_name) | ||
.map(|(did, name)| repo(&did, &name)), | ||
}, | ||
.map(|r| { | ||
( | ||
r.uri.clone(), | ||
Event { | ||
created_at: r.created_at.clone(), | ||
actor: actor(&r.did), | ||
repo: repo(&r.did, &r.name), | ||
action: Action::Created { | ||
description: r.description.clone(), | ||
fork_of: r | ||
.fork_of | ||
.as_deref() | ||
.and_then(owner_and_name) | ||
.map(|(did, name)| repo(&did, &name)), | ||
}, | ||
}, | ||
) | ||
}) | ||
.collect(); | ||
| ||
| @@ -259,12 +311,13 @@ async fn events(app: &App, index: &Index, limit: usize) -> Result<Vec<Event>> { | ||
} | ||
} | ||
}; | ||
events.push(Event { | ||
let event = Event { | ||
created_at: p.created_at.clone(), | ||
actor: actor(&p.did), | ||
repo: repo(&p.did, &p.name), | ||
action, | ||
}); | ||
}; | ||
events.push((p.uri.clone(), event)); | ||
} | ||
| ||
// Pulls and issues, as their repositories' owners let them stand, by | ||
| @@ -326,11 +379,10 @@ async fn events(app: &App, index: &Index, limit: usize) -> Result<Vec<Event>> { | ||
repo: named[&(did.as_str(), key.as_str())].clone(), | ||
action, | ||
}; | ||
events.extend(shown.then_some(event)); | ||
events.extend(shown.then(|| (e.uri.clone(), event))); | ||
} | ||
events.sort_by(|a, b| b.created_at.cmp(&a.created_at)); | ||
events.truncate(limit); | ||
Ok(events) | ||
events.sort_by(|(a, x), (b, y)| (&y.created_at, b).cmp(&(&x.created_at, a))); | ||
Ok((events.into_iter().map(|(_, e)| e).collect(), next)) | ||
} | ||
| ||
#[cfg(test)] | ||
crates/knotless-serve/tests/forge.rs+11−9
| @@ -92,13 +92,13 @@ fn forks_pulls_reviews_and_merges() { | ||
let av = appview(); | ||
let owner = person(account(), &av); | ||
let contrib = person(account(), &av); | ||
let url = owner.url("project"); | ||
let url = owner.url("forkable"); | ||
let work = owner.work("w", 1); | ||
owner.git(&work, &["push", "-q", &url, "main"]); | ||
| ||
// A fork in the contributor's account, with a branch to offer. | ||
let fork = ok(&contrib, &["fork", &url]); | ||
assert_eq!(fork, format!("knotless://{}/project", contrib.account.did)); | ||
assert_eq!(fork, format!("knotless://{}/forkable", contrib.account.did)); | ||
let clone = contrib.path("clone"); | ||
contrib.git(contrib.dir.path(), &["clone", "-q", &fork, "clone"]); | ||
contrib.git(&clone, &["checkout", "-q", "-b", "feature"]); | ||
| @@ -263,16 +263,18 @@ fn forks_pulls_reviews_and_merges() { | ||
); | ||
| ||
// The index knows the fork, and finds repositories by name. | ||
let found: Value = | ||
reqwest::blocking::get(format!("{}/xrpc/dev.knotless.listRepos?q=proj", av.base)) | ||
.unwrap() | ||
.json() | ||
.unwrap(); | ||
let found: Value = reqwest::blocking::get(format!( | ||
"{}/xrpc/dev.knotless.listRepos?q=forkable", | ||
av.base | ||
)) | ||
.unwrap() | ||
.json() | ||
.unwrap(); | ||
assert_eq!(found["repos"].as_array().unwrap().len(), 2); | ||
assert!(found.get("cursor").is_none()); | ||
// A page at a time, the same two. | ||
let list = |cursor: Option<&str>| -> Value { | ||
let mut query = vec![("q", "proj"), ("limit", "1")]; | ||
let mut query = vec![("q", "forkable"), ("limit", "1")]; | ||
query.extend(cursor.map(|c| ("cursor", c))); | ||
reqwest::blocking::Client::new() | ||
.get(format!("{}/xrpc/dev.knotless.listRepos", av.base)) | ||
| @@ -294,7 +296,7 @@ fn forks_pulls_reviews_and_merges() { | ||
.unwrap() | ||
.is_empty() | ||
); | ||
let uri = format!("at://{}/dev.knotless.repo/project", owner.account.did); | ||
let uri = format!("at://{}/dev.knotless.repo/forkable", owner.account.did); | ||
let indexed = av.app.index.as_ref().unwrap(); | ||
let key = indexed.repo(&uri).unwrap().unwrap()["key"] | ||
.as_str() | ||
crates/knotless-serve/tests/web.rs+46−11
| @@ -67,8 +67,22 @@ impl Site { | ||
self.json(&format!("/_data{path}")) | ||
} | ||
| ||
fn feed(&self) -> Value { | ||
self.json("/xrpc/dev.knotless.getFeed?limit=100")["events"].clone() | ||
/// The feed back to `since`, page by page. | ||
fn feed(&self, since: &str) -> Value { | ||
let mut events = Vec::new(); | ||
let mut cursor = String::new(); | ||
loop { | ||
let page = self.json(&format!( | ||
"/xrpc/dev.knotless.getFeed?limit=100&cursor={}", | ||
cursor.replace(' ', "%20") | ||
)); | ||
events.extend(page["events"].as_array().unwrap().iter().cloned()); | ||
let last = events.last().map(|e| e["createdAt"].as_str().unwrap()); | ||
match page["cursor"].as_str() { | ||
Some(next) if last.is_some_and(|at| at >= since) => cursor = next.into(), | ||
_ => return Value::Array(events), | ||
} | ||
} | ||
} | ||
| ||
fn status(&self, path: &str) -> (u16, String) { | ||
| @@ -685,12 +699,20 @@ fn the_feed() { | ||
let owner = person(account(), &site); | ||
let other = person(account(), &site); | ||
let (o, t) = (&owner.account.handle, &other.account.handle); | ||
// Other tests' pushes and pulls share the feed, so each part is looked | ||
// for while it is recent: once the feed shows `last`, it must show | ||
// each of `wants`. | ||
// Other tests' pushes and pulls share the feed, so it is read back to | ||
// where it was as this began: once it shows `last`, it must show each | ||
// of `wants`. | ||
let newest = site.json("/xrpc/dev.knotless.getFeed?limit=1"); | ||
let since = newest["events"][0]["createdAt"].as_str().unwrap_or(""); | ||
let shows = |handle: &str, last: (&str, Value), wants: &[(&str, Value)]| { | ||
wait_until(|| did(&about(&site.feed(), handle, "fed"), last.0, last.1.clone())); | ||
let feed = site.feed(); | ||
wait_until(|| { | ||
did( | ||
&about(&site.feed(since), handle, "fed"), | ||
last.0, | ||
last.1.clone(), | ||
) | ||
}); | ||
let feed = site.feed(since); | ||
let events = about(&feed, handle, "fed"); | ||
for (kind, fields) in wants { | ||
assert!( | ||
| @@ -782,7 +804,7 @@ fn the_feed() { | ||
let (pull, issue) = (pull.trim(), issue.trim()); | ||
wait_until(|| { | ||
did( | ||
&about(&site.feed(), o, "fed"), | ||
&about(&site.feed(since), o, "fed"), | ||
"changed", | ||
json!({"subject": issue, "state": "open"}), | ||
) | ||
| @@ -835,6 +857,15 @@ fn the_feed() { | ||
}; | ||
assert!(events("") <= 40); | ||
assert_eq!((events("?limit=1"), events("?limit=0")), (1, 1)); | ||
// Page by page, each starting strictly after the last. | ||
let first = site.json("/xrpc/dev.knotless.getFeed?limit=2"); | ||
let cursor = first["cursor"].as_str().unwrap().replace(' ', "%20"); | ||
let next = site.json(&format!( | ||
"/xrpc/dev.knotless.getFeed?limit=1&cursor={cursor}" | ||
)); | ||
let (shown, after) = (first["events"].as_array().unwrap(), &next["events"][0]); | ||
assert!(after["createdAt"].as_str() <= shown.last().unwrap()["createdAt"].as_str()); | ||
assert!(!shown.contains(after)); | ||
let found = site.json("/_data/search?q=FED"); | ||
assert_eq!(found["query"], "FED"); | ||
assert!(names(&found["repos"]).contains(&"fed")); | ||
| @@ -920,12 +951,16 @@ fn pages_drawn_elsewhere() { | ||
.unwrap(); | ||
assert!(ours.text().unwrap().contains("keeps no index")); | ||
// Too much to hand on, or no app to hand it to. | ||
// The answer can come before all of it is sent, which closes the | ||
// connection under the rest. | ||
let big = client | ||
.post(format!("{base}/x")) | ||
.body(vec![b'x'; 2 << 20]) | ||
.send() | ||
.unwrap(); | ||
assert_eq!(big.status().as_u16(), 502); | ||
.send(); | ||
match big { | ||
Ok(res) => assert_eq!(res.status().as_u16(), 502), | ||
Err(e) => assert!(e.is_request(), "{e}"), | ||
} | ||
let (gone, nowhere) = listen(); | ||
drop(gone); | ||
let res = client.get(front(nowhere)).send().unwrap(); | ||
crates/knotless/src/env.rs+5−1
| @@ -142,7 +142,11 @@ impl Ctx { | ||
.user_agent(concat!("knotless/", env!("CARGO_PKG_VERSION"))) | ||
.connect_timeout(Duration::from_secs(10)) | ||
.timeout(env.timeout) | ||
.pool_idle_timeout(Duration::from_secs(90)) | ||
// Under the 5s Node servers keep an idle connection by default, | ||
// as a PLC directory does: past it, one can close under the | ||
// request reusing it. A PDS keeps one 90s, which ties with any | ||
// longer wait here. | ||
.pool_idle_timeout(Duration::from_secs(4)) | ||
.tcp_nodelay(true) | ||
.build() | ||
.expect("the HTTP client configuration is static"); | ||
harness/cov.mjsnew+141−0
| @@ -0,0 +1,141 @@ | ||
// Coverage over the stock reference PDS and the spaces alpha together, | ||
// failing under 100% of lines or functions: what `just cov` runs. | ||
// | ||
// node harness/cov.mjs <spaces dev-env> <ignore regex> | ||
// | ||
// The tests are built once, instrumented, and each test binary is run | ||
// against both PDSes, several at a time, rather than one after another | ||
// as `cargo test` would: one harness for each PDS, shared by every binary | ||
// run against it, as `just pds` is. COV_JOBS sets how many run at once; | ||
// COV_CLEAN=1 builds every workspace crate again first. | ||
| ||
import { spawn, spawnSync } from 'node:child_process' | ||
import { mkdirSync, openSync, writeFileSync } from 'node:fs' | ||
import { availableParallelism } from 'node:os' | ||
import { dirname, join, resolve } from 'node:path' | ||
import { fileURLToPath } from 'node:url' | ||
| ||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') | ||
const [spaces, ignore] = process.argv.slice(2) | ||
const cargo = ['run', 'stable', 'cargo'] | ||
const jobs = Number(process.env.COV_JOBS) || Math.max(2, Math.floor(availableParallelism() / 2)) | ||
const started = Date.now() | ||
const took = () => `${((Date.now() - started) / 1000).toFixed(0)}s` | ||
| ||
/// Runs `rustup run stable cargo <args>`, its output shown, failing with it. | ||
const run = (args, env = process.env, opts = {}) => { | ||
const r = spawnSync('rustup', [...cargo, ...args], { cwd: root, env, stdio: 'inherit', ...opts }) | ||
if (r.status !== 0) process.exit(r.status ?? 1) | ||
return r | ||
} | ||
| ||
// What cargo-llvm-cov sets to build and run instrumented, in its own | ||
// target directory, as `cargo llvm-cov` uses. | ||
const target = join(root, 'target/llvm-cov-target') | ||
const shown = spawnSync('rustup', [...cargo, 'llvm-cov', 'show-env', '--sh'], { | ||
cwd: root, | ||
env: { ...process.env, CARGO_TARGET_DIR: target }, | ||
encoding: 'utf8', | ||
}) | ||
const env = { ...process.env, CARGO_TARGET_DIR: target } | ||
for (const [, k, v] of shown.stdout.matchAll(/^export ([A-Z_0-9]+)=(.*)$/gm)) env[k] = v.replace(/^'(.*)'$/, '$1') | ||
| ||
// Only the last run's counts are cleared, so only what changed is built | ||
// again. A binary left from an older build adds no counts, only regions | ||
// none ran, so it can fail the gate but never pass it: COV_CLEAN=1 clears | ||
// them too, as after a miss that makes no sense. | ||
run(['llvm-cov', 'clean', process.env.COV_CLEAN ? '--workspace' : '--profraw-only']) | ||
| ||
// The harnesses start while the tests build. | ||
mkdirSync(join(root, 'target'), { recursive: true }) | ||
const harness = (name, devEnv) => { | ||
const file = join(root, `target/cov-net-${name}.json`) | ||
// What the PDS logs, which tests provoke on purpose, is kept out of the way. | ||
const log = openSync(join(root, `target/cov-net-${name}.log`), 'w') | ||
const child = spawn('node', [join(root, 'harness/pds.mjs'), '--accounts', 'seed'], { | ||
cwd: root, | ||
env: { ...process.env, KNOTLESS_DEV_ENV: devEnv }, | ||
stdio: ['pipe', 'pipe', log], | ||
}) | ||
const up = new Promise((ok, fail) => { | ||
let out = '' | ||
child.stdout.on('data', (d) => { | ||
out += d | ||
if (!out.includes('\n')) return | ||
writeFileSync(file, out) | ||
ok(file) | ||
}) | ||
child.on('exit', (code) => fail(new Error(`the ${name} harness exited with ${code}: target/cov-net-${name}.log`))) | ||
}) | ||
return { up, stop: () => child.stdin.end() } | ||
} | ||
const nets = { stock: harness('stock', ''), spaces: harness('spaces', spaces) } | ||
const stop = () => Object.values(nets).forEach((n) => n.stop()) | ||
process.on('exit', stop) | ||
| ||
// Every test binary, and the package it is in: cargo runs each there. | ||
const built = spawnSync('rustup', [...cargo, 'test', '--workspace', '--no-run', '--message-format=json'], { | ||
cwd: root, | ||
env, | ||
encoding: 'utf8', | ||
stdio: ['ignore', 'pipe', 'inherit'], | ||
maxBuffer: 1 << 28, | ||
}) | ||
if (built.status !== 0) process.exit(built.status ?? 1) | ||
const tests = built.stdout | ||
.split('\n') | ||
.filter((l) => l.startsWith('{')) | ||
.map((l) => JSON.parse(l)) | ||
.filter((m) => m.reason === 'compiler-artifact' && m.profile?.test && m.executable) | ||
.map((m) => ({ exe: m.executable, dir: dirname(m.manifest_path), name: `${m.target.name} (${m.target.kind[0]})` })) | ||
console.log(`built ${tests.length} test binaries in ${took()}`) | ||
| ||
const files = Object.fromEntries(await Promise.all(Object.entries(nets).map(async ([k, n]) => [k, await n.up]))) | ||
// The integration tests first: they take longest, so start soonest. | ||
const slow = (t) => (t.name.endsWith('(test)') ? 0 : 1) | ||
const runs = Object.keys(files) | ||
.flatMap((net) => tests.map((t) => ({ ...t, net }))) | ||
.sort((a, b) => slow(a) - slow(b)) | ||
| ||
/// One binary against one harness, its output kept to show if it fails. | ||
const one = (t) => | ||
new Promise((ok) => { | ||
const child = spawn(t.exe, [], { | ||
cwd: t.dir, | ||
env: { | ||
...env, | ||
KNOTLESS_TEST_NET: files[t.net], | ||
KNOTLESS_DEV_ENV: t.net === 'spaces' ? spaces : '', | ||
CARGO_MANIFEST_DIR: t.dir, | ||
// As .cargo/config.toml sets them for cargo: where the answers' | ||
// TypeScript types are written. | ||
TS_RS_EXPORT_DIR: join(root, 'web/src/lib/api'), | ||
TS_RS_LARGE_INT: 'number', | ||
}, | ||
stdio: ['ignore', 'pipe', 'pipe'], | ||
}) | ||
let out = '' | ||
child.stdout.on('data', (d) => (out += d)) | ||
child.stderr.on('data', (d) => (out += d)) | ||
child.on('exit', (code) => ok({ ...t, code, out })) | ||
}) | ||
| ||
const failed = [] | ||
let next = 0 | ||
await Promise.all( | ||
Array.from({ length: jobs }, async () => { | ||
while (next < runs.length) { | ||
const r = await one(runs[next++]) | ||
const line = r.out.match(/^test result: .*$/m)?.[0] ?? `exited with ${r.code}` | ||
console.log(`${r.code === 0 ? 'ok ' : 'FAIL'} ${r.net.padEnd(6)} ${r.name}: ${line}`) | ||
if (r.code !== 0) failed.push(r) | ||
} | ||
}), | ||
) | ||
stop() | ||
for (const r of failed) console.log(`\n---- ${r.net} ${r.name}\n${r.out}`) | ||
if (failed.length) process.exit(1) | ||
console.log(`ran ${runs.length} in ${took()}`) | ||
| ||
run(['llvm-cov', 'report', '--ignore-filename-regex', ignore, '--fail-under-lines', '100', '--fail-under-functions', '100', '--show-missing-lines']) | ||
console.log(`coverage in ${took()}`) | ||
lexicons/dev/knotless/getFeed.json+4−2
| @@ -9,7 +9,8 @@ | ||
"parameters": { | ||
"type": "params", | ||
"properties": { | ||
"limit": { "type": "integer", "minimum": 1, "maximum": 100, "default": 40 } | ||
"limit": { "type": "integer", "minimum": 1, "maximum": 100, "default": 40 }, | ||
"cursor": { "type": "string", "description": "Where the page starts: a previous page's cursor." } | ||
} | ||
}, | ||
"output": { | ||
| @@ -18,7 +19,8 @@ | ||
"type": "object", | ||
"required": ["events"], | ||
"properties": { | ||
"events": { "type": "array", "items": { "type": "ref", "ref": "#eventView" } } | ||
"events": { "type": "array", "items": { "type": "ref", "ref": "#eventView" } }, | ||
"cursor": { "type": "string", "description": "Where the next page starts, when there may be one." } | ||
} | ||
} | ||
} | ||
web/src/lib/api/Feed.ts+5−1
| @@ -1,4 +1,8 @@ | ||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. | ||
import type { FeedEvent } from "./FeedEvent"; | ||
| ||
export type Feed = { events: Array<FeedEvent>, }; | ||
export type Feed = { events: Array<FeedEvent>, | ||
/** | ||
* Where the next page starts, when there may be one. | ||
*/ | ||
cursor?: string, }; | ||