Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
d910ea5
fix(node): Bound GraphQL query cost.
euxaristia Sep 7, 2026
1d1ec25
test(node): Pin the GraphQL depth boundary.
euxaristia Sep 7, 2026
e77bfda
fix(node): Charge GraphQL mutations and list sizes against query limits.
euxaristia Sep 9, 2026
c975747
fix(node): paginate visible GraphQL repository lists
euxaristia Sep 9, 2026
9d38e04
fix(node): charge subscription roots and test query boundaries
euxaristia Sep 9, 2026
ebafeae
test(graphql): verify unauthorized caller, legacy bound, and producti…
euxaristia Sep 10, 2026
99426ca
fix(graphql): serve the advertised repository page range
euxaristia Sep 12, 2026
0e5ea50
test(graphql): cover every annotated root cost
euxaristia Sep 12, 2026
8d665c7
test(graphql): isolate the repository cursor length guard
euxaristia Sep 12, 2026
9d53e13
fix(node): pin GraphQL introspection depth and pagination contracts
euxaristia Sep 12, 2026
a06cb43
test(graphql): anti-correlate legacy repos sort and pin keyset tuple …
euxaristia Sep 13, 2026
e91b1ea
test(graphql): pin normalized owner ordering across page cursors
euxaristia Sep 14, 2026
f564ec0
test(node): make Windows regression fixtures portable
euxaristia Sep 14, 2026
1de5eb9
fix(sync): use a supported Windows promisor blob filter
euxaristia Sep 14, 2026
e9998a7
test(graphql): anti-correlate pagination ids and cover full terminal …
euxaristia Sep 15, 2026
ac6c134
revert(sync): split Windows promisor filter out of GraphQL query cost PR
euxaristia Sep 15, 2026
4e6488d
test(graphql): pin case ordering and test documented max page limit
euxaristia Sep 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ Good today:

- Local or Docker node startup.
- Postgres-backed repo metadata.
- Bounded GraphQL queries with [repository pagination](docs/graphql-pagination.md).
- Bare git repository storage.
- Git smart-HTTP clone/fetch/push.
- RFC 9421-signed writes.
Expand Down
18 changes: 13 additions & 5 deletions crates/gitlawb-node/src/api/ipfs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8338,6 +8338,7 @@ mod tests {
#[tokio::test]
async fn get_by_cid_per_source_cap_sheds_same_source_admits_other() {
let mut state = crate::test_support::test_state_lazy();
state.db.pool().close().await;
// Global pool has room; the per-source cap is 1.
state.git_ipfs_walk_semaphore = Arc::new(Semaphore::new(8));
state.git_ipfs_walk_per_caller = crate::rate_limit::PerCallerConcurrency::new(1, 100);
Expand All @@ -8364,16 +8365,23 @@ mod tests {
"a source at its per-source /ipfs walk cap must shed 503 with global capacity free"
);

let bytes = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap();
let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(body["error"], "overloaded");

// A DIFFERENT source is NOT shed by the per-source cap: it clears admission and
// proceeds (then errors on the lazy DB, which is not a 503).
// proceeds to the closed DB, which has a distinct db_unavailable error code.
let resp = ipfs_router(state)
.oneshot(get_cid(&cid, Some(other)))
.await
.unwrap();
assert_ne!(
resp.status(),
StatusCode::SERVICE_UNAVAILABLE,
"a different source must not be shed by the per-source cap"
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
let bytes = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap();
let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(
body["error"],
crate::error::DB_UNAVAILABLE_CODE,
"a different source must clear admission and reach the closed database"
);
}

Expand Down
14 changes: 14 additions & 0 deletions crates/gitlawb-node/src/api/repos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7124,6 +7124,7 @@ mod tests {
/// A pkt-line receive-pack body carrying one branch-create ref update, so the
/// handler's post-receive tail resolves a non-empty new-tip set (the delta
/// scan's git stages run).
#[cfg(unix)]
fn ref_update_body(new_sha: &str) -> axum::body::Bytes {
let line = format!("{ZERO_SHA} {new_sha} refs/heads/main");
axum::body::Bytes::from(format!("{:04x}{}0000", line.len() + 4, line))
Expand Down Expand Up @@ -9581,13 +9582,15 @@ mod tests {
)
}

#[cfg(unix)]
fn f2a_log(log: &std::path::Path) -> String {
std::fs::read_to_string(log).unwrap_or_default()
}

/// Withheld-walk children run so far. `ls-tree` is the walk's signature child
/// (`blob_paths` lists every reachable commit's tree); the delta scan and the
/// full-scan fallback use `rev-list` / `cat-file` instead.
#[cfg(unix)]
fn f2a_walks(log: &std::path::Path) -> usize {
f2a_log(log)
.lines()
Expand All @@ -9599,6 +9602,7 @@ mod tests {
/// the withheld walk actually runs rather than taking the no-rule shortcut).
/// The repo's on-disk path is passed to the tail directly, so no repo_store or
/// receive-pack plumbing is involved.
#[cfg(unix)]
async fn f2a_state(
pool: sqlx::PgPool,
git_bin: &str,
Expand Down Expand Up @@ -9630,6 +9634,7 @@ mod tests {
(state, rec)
}

#[cfg(unix)]
fn f2a_update(ref_name: &str, new_sha: &str) -> Vec<RefUpdate> {
vec![RefUpdate {
old_sha: ZERO_SHA.to_string(),
Expand All @@ -9638,6 +9643,7 @@ mod tests {
}]
}

#[cfg(unix)]
const F2A_PUSHER: &str = "did:key:z6MkF2aPusherAAAAAAAAAAAAAAAAAAAAAAAAAA";

/// Scenario 1 (the finding). A second rapid push to the same repo coalesces
Expand Down Expand Up @@ -9711,6 +9717,7 @@ mod tests {
}
/// Poll `cond` until it holds, with a bound so a regression fails the test
/// rather than hanging the suite.
#[cfg(unix)]
async fn f2a_wait_for(mut cond: impl FnMut() -> bool, what: &str) {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
while !cond() {
Expand All @@ -9725,6 +9732,7 @@ mod tests {
/// A `rev-list --objects` line names the tips a DELTA scan was asked to resolve,
/// so it attributes that scan to one push's tips. The withheld walk's own
/// `rev-list --all` / `ls-tree` lines never carry a tip as an argument this way.
#[cfg(unix)]
fn f2a_delta_scanned(log: &std::path::Path, tip: &str) -> bool {
f2a_log(log)
.lines()
Expand Down Expand Up @@ -9871,6 +9879,7 @@ mod tests {

/// Mount a Pinata upload endpoint that assigns every object the same CID, and
/// point the state at it. Returns the server (kept alive by the caller) and CID.
#[cfg(unix)]
async fn f2a_pinata(state: &mut AppState) -> (mockito::ServerGuard, String) {
let cid = "bafyf2acoalescedmapping".to_string();
let mut server = mockito::Server::new_async().await;
Expand All @@ -9890,6 +9899,7 @@ mod tests {

/// Poll the branch to CID table until the push's mapping lands (the Pinata
/// worker is detached), bounded so a regression fails rather than hangs.
#[cfg(unix)]
async fn f2a_wait_for_branch_cid(
db: &crate::db::Db,
slug: &str,
Expand All @@ -9909,6 +9919,7 @@ mod tests {
}
}

#[cfg(unix)]
fn f2a_slug(rec: &crate::db::RepoRecord) -> String {
format!(
"{}/{}",
Expand Down Expand Up @@ -10042,6 +10053,7 @@ mod tests {
// and `z6p2fail`), and owner-only push is on by default, so the identity has to
// follow the repo each push targets rather than being fixed for both.

#[cfg(unix)]
fn p2_push(
state: &AppState,
owner: &str,
Expand All @@ -10060,6 +10072,7 @@ mod tests {
)
}

#[cfg(unix)]
fn p2_logged(log: &std::path::Path, prefix: &str) -> bool {
f2a_log(log).lines().any(|l| l.starts_with(prefix))
}
Expand Down Expand Up @@ -10359,6 +10372,7 @@ mod tests {
/// runs exactly one `rev-list --all`, so this counts the walks that were attempted
/// (the `ls-tree` counter above cannot: a walk whose enumeration fails never gets
/// to `ls-tree`).
#[cfg(unix)]
fn f2b_walk_attempts(log: &std::path::Path) -> usize {
f2a_log(log)
.lines()
Expand Down
54 changes: 54 additions & 0 deletions crates/gitlawb-node/src/db/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ use std::time::Duration;
use tracing::info;
use uuid::Uuid;

/// Maximum visible repositories per page, plus one internal look-ahead row.
pub(crate) const MAX_VISIBLE_REPO_PAGE_SIZE: usize = 200;

// ── Public data types ─────────────────────────────────────────────────────────

#[derive(Debug, Clone, Serialize, Deserialize)]
Expand Down Expand Up @@ -1540,6 +1543,57 @@ impl Db {
Ok(rows.into_iter().map(row_to_repo).collect())
}

/// A bounded, mirror-deduplicated page ordered by owner key and repository
/// name. Apply root visibility before LIMIT: private rows must not consume
/// page slots or influence continuation metadata. The root-rule predicate
/// mirrors `visibility::listable_at_root`; a differential test pins it.
/// Cursors only select a position and never confer read authority.
pub async fn list_visible_repos_page(
&self,
caller: Option<&str>,
after: Option<(&str, &str)>,
limit: usize,
) -> Result<Vec<RepoRecord>> {
let sql = format!(
"{}
SELECT d.id, d.name, d.owner_did, d.description, d.is_public,
d.default_branch, d.created_at, d.updated_at, d.disk_path,
d.forked_from, d.machine_id
FROM deduped d
LEFT JOIN LATERAL (
SELECT reader_dids FROM visibility_rules v
WHERE v.repo_id = d.id AND v.path_glob ~ '^/*([*][*])*$'
ORDER BY v.path_glob DESC LIMIT 1
) root_rule ON TRUE
WHERE (
($2::text IS NOT NULL AND ({key}) = $2)
OR CASE WHEN root_rule.reader_dids IS NULL THEN d.is_public
WHEN jsonb_typeof(root_rule.reader_dids::jsonb) = 'array' THEN
COALESCE(root_rule.reader_dids::jsonb ? $3::text, FALSE)
AND NOT EXISTS (
SELECT 1 FROM jsonb_array_elements(root_rule.reader_dids::jsonb) reader
WHERE jsonb_typeof(reader) <> 'string'
)
ELSE FALSE END
)
AND ($4::text IS NULL OR (({key}), d.name) > ($4, $5::text))
ORDER BY ({key}), d.name
LIMIT $6",
Self::dedup_cte(),
key = OWNER_KEY_CASE_SQL,
);
let rows = sqlx::query(&sql)
.bind(None::<&str>)
.bind(caller.map(normalize_owner_key))
.bind(caller)
.bind(after.map(|(owner, _)| normalize_owner_key(owner)))
.bind(after.map(|(_, name)| name))
.bind(limit.min(MAX_VISIBLE_REPO_PAGE_SIZE + 1) as i64)
.fetch_all(&self.pool)
.await?;
Ok(rows.into_iter().map(row_to_repo).collect())
}

/// Repos currently quarantined (admitted as mirrors but withheld from every
/// listing surface). `list_all_repos_deduped` excludes these (its `DEDUP_CTE`
/// filters `quarantined = FALSE`), so a gate that resolves a slug against the
Expand Down
4 changes: 2 additions & 2 deletions crates/gitlawb-node/src/git/repo_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ impl RepoStore {
/// Test-only: every guard from this store parks in `release` right before the
/// `pg_advisory_unlock` await, until `gate` is notified. Dropping the future
/// while it is parked reproduces a client disconnect inside `release`.
#[cfg(test)]
#[cfg(all(test, unix))]
pub fn with_pre_unlock_gate(mut self, gate: Arc<tokio::sync::Notify>) -> Self {
self.pre_unlock_gate = Some(gate);
self
Expand All @@ -107,7 +107,7 @@ impl RepoStore {

/// Test-only: how many write guards from this store have reached the Tigris upload
/// site. See [`RepoStore::upload_site_reached`].
#[cfg(test)]
#[cfg(all(test, unix))]
pub fn tigris_upload_site_reached(&self) -> usize {
self.upload_site_reached
.load(std::sync::atomic::Ordering::SeqCst)
Expand Down
Loading
Loading