Skip to content

Commit 68a69f5

Browse files
committed
fix(node): address Twigpine#465 review findings
1 parent 6fa4b26 commit 68a69f5

6 files changed

Lines changed: 92 additions & 17 deletions

File tree

‎CONTRIBUTING.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ time once updated.
8181

8282
**Requirements:**
8383
- Rust stable (≥ 1.91) — install via [rustup](https://rustup.rs)
84-
- PostgreSQL — required for the node. Use the bundled `docker-compose.yml` for local dev.
84+
- PostgreSQL 16+ — required for the node (it uses `pg_input_is_valid`, which exists only on PostgreSQL 16 and later). Use the bundled `docker-compose.yml` for local dev.
8585
- Docker (optional, for full-stack local testing)
8686

8787
**Environment variables:**

‎crates/gitlawb-node/src/api/events.rs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ use crate::state::AppState;
1111

1212
/// Hard ceiling on rows any ref-update feed returns in one request. Shared by the
1313
/// shared collector's clamp and the per-handler request caps so they can't drift.
14-
const MAX_VISIBLE_REF_UPDATES: i64 = 200;
14+
/// `pub(crate)` so the GraphQL complexity meter can price against the same bound.
15+
pub(crate) const MAX_VISIBLE_REF_UPDATES: i64 = 200;
1516

1617
/// Collect up to `limit` ref-update rows visible to `caller`, newest first,
1718
/// paging past rows the feed gate drops. Filtering after a plain SQL `LIMIT`

‎crates/gitlawb-node/src/db/mod.rs‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1549,6 +1549,20 @@ impl Db {
15491549
/// mirrors `visibility::listable_at_root`; a differential test pins it.
15501550
/// Malformed reader JSON denies that repo to non-owners without failing the page.
15511551
/// Cursors only select a position and never confer read authority.
1552+
///
1553+
/// Performance note: the page orders and keyset-filters on
1554+
/// `(owner_key, name)` under `COLLATE "C"`, but the existing
1555+
/// `idx_repos_owner_key_name` index is built on the database default
1556+
/// collation and cannot serve that ordering, so each page sorts the full
1557+
/// deduped repo set before the keyset filter applies: O(total repos) work
1558+
/// per page. This is accepted (pages are capped at
1559+
/// `MAX_VISIBLE_REPO_PAGE_SIZE` rows over a narrow projection). A
1560+
/// C-collation expression index was deliberately not added in a migration:
1561+
/// versions 27-35 are claimed by other in-flight branches (Gitlawb/node#384
1562+
/// claims 27-35, #464 claims 27-28) and the runner keys applied migrations
1563+
/// on the version integer alone, so a colliding entry would be silently
1564+
/// skipped on whichever side merges second. Revisit once that range lands
1565+
/// or clears.
15521566
pub async fn list_visible_repos_page(
15531567
&self,
15541568
caller: Option<&str>,

‎crates/gitlawb-node/src/graphql/mod.rs‎

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -580,7 +580,7 @@ mod tests {
580580
let schema = build_schema(Arc::new(Db::for_testing(pool)), ref_tx, task_tx);
581581

582582
// refUpdates(limit: 200):
583-
// Single-field selection has child_complexity 1 -> cost 50 + 200 * 1 = 250 <= 400.
583+
// Single-field selection has child_complexity 1 -> cost 100 + 200 + 1 = 301 <= 400.
584584
// It passes validation and reaches the resolver (which yields db error on lazy pool).
585585
let single_ref = schema.execute("{ refUpdates(limit: 200) { repo } }").await;
586586
assert!(
@@ -600,13 +600,29 @@ mod tests {
600600
single_ref.errors
601601
);
602602

603-
// Two-field selection has child_complexity 2 -> cost 50 + 200 * 2 = 450 > 400 (rejected before resolver).
603+
// Two-field selection has child_complexity 2 -> cost 100 + 200 + 2 = 302 <= 400.
604+
// The documented max-200 request stays reachable with multiple fields
605+
// (the old multiplicative price rejected it at 450); it passes
606+
// validation and reaches the resolver like the single-field case.
604607
let multi_ref = schema
605608
.execute("{ refUpdates(limit: 200) { repo refName } }")
606609
.await;
607-
assert_eq!(multi_ref.data, async_graphql::Value::Null);
608-
assert_eq!(multi_ref.errors.len(), 1);
609-
assert_eq!(multi_ref.errors[0].message, "Query is too complex.");
610+
assert!(
611+
!multi_ref
612+
.errors
613+
.iter()
614+
.any(|e| e.message == "Query is too complex."),
615+
"two-field refUpdates at max limit 200 must pass complexity validation: {:?}",
616+
multi_ref.errors
617+
);
618+
assert!(
619+
multi_ref
620+
.errors
621+
.iter()
622+
.any(|e| e.message == GRAPHQL_DB_ERROR_MESSAGE),
623+
"two-field refUpdates at max limit 200 must reach the resolver: {:?}",
624+
multi_ref.errors
625+
);
610626

611627
// tasks(limit: 200):
612628
// Single-field selection has child_complexity 1 -> cost 50 + 200 * 1 = 250 <= 400.

‎crates/gitlawb-node/src/graphql/query.rs‎

Lines changed: 53 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use async_graphql::{Context, Object, Result};
22
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
33
use std::sync::Arc;
44

5+
use crate::api::events::MAX_VISIBLE_REF_UPDATES;
56
use crate::db::{Db, RepoRecord, MAX_VISIBLE_REPO_PAGE_SIZE};
67

78
use super::types::{AgentTaskType, RefUpdateType, RepoPageType, RepoType};
@@ -49,8 +50,9 @@ impl QueryRoot {
4950
// DB-backed roots carry a base cost so aliases consume the request budget
5051
// even when each alias selects only one inexpensive response field.
5152
#[graphql(complexity = "50 + child_complexity")]
52-
/// Complete visible repository list, up to 200 entries. Larger lists must
53-
/// use reposPage; this field returns an error instead of truncating silently.
53+
/// Complete visible repository list, up to MAX_VISIBLE_REPO_PAGE_SIZE entries.
54+
/// Larger lists must use reposPage; this field returns an error instead of
55+
/// truncating silently.
5456
async fn repos(&self, ctx: &Context<'_>) -> Result<Vec<RepoType>> {
5557
let db = ctx.data_unchecked::<Arc<Db>>();
5658
let caller = ctx
@@ -62,9 +64,10 @@ impl QueryRoot {
6264
.await
6365
.map_err(crate::graphql::graphql_db_err)?;
6466
if repos.len() > MAX_VISIBLE_REPO_PAGE_SIZE {
65-
return Err(async_graphql::Error::new(
66-
"repository list exceeds 200 entries; use reposPage with limit and after",
67-
));
67+
return Err(async_graphql::Error::new(format!(
68+
"repository list exceeds {} entries; use reposPage with limit and after",
69+
MAX_VISIBLE_REPO_PAGE_SIZE
70+
)));
6871
}
6972
// Preserve the legacy activity ordering for complete, small lists.
7073
repos.sort_by_key(|repo| std::cmp::Reverse(repo.updated_at));
@@ -73,19 +76,24 @@ impl QueryRoot {
7376

7477
/// Bounded visible repositories ordered by owner and name. Continue with
7578
/// endCursor while hasNextPage is true. Pages are not a database snapshot.
76-
#[graphql(complexity = "50 + (limit.clamp(1, 200) as usize) + child_complexity")]
79+
#[graphql(
80+
complexity = "50 + (limit.clamp(1, MAX_VISIBLE_REPO_PAGE_SIZE as i64) as usize) + child_complexity"
81+
)]
7782
async fn repos_page(
7883
&self,
7984
ctx: &Context<'_>,
8085
#[graphql(
8186
default = 50,
82-
desc = "Page size from 1 to 200; other values are rejected."
87+
desc = "Page size from 1 to MAX_VISIBLE_REPO_PAGE_SIZE; other values are rejected."
8388
)]
8489
limit: i64,
8590
after: Option<String>,
8691
) -> Result<RepoPageType> {
8792
if !(1..=MAX_VISIBLE_REPO_PAGE_SIZE as i64).contains(&limit) {
88-
return Err(async_graphql::Error::new("limit must be between 1 and 200"));
93+
return Err(async_graphql::Error::new(format!(
94+
"limit must be between 1 and {}",
95+
MAX_VISIBLE_REPO_PAGE_SIZE
96+
)));
8997
}
9098
let after = after.as_deref().map(parse_repo_cursor).transpose()?;
9199
let caller = ctx
@@ -113,7 +121,15 @@ impl QueryRoot {
113121
})
114122
}
115123

116-
#[graphql(complexity = "50 + (limit.clamp(0, 200) as usize) * child_complexity")]
124+
// Complexity is additive with a base reflecting the collector's fixed scan:
125+
// every request loads the full deduped repo set, the quarantined set, and
126+
// the visibility rules, then walks up to max(limit, 2048) event rows past
127+
// withheld ones, regardless of `limit`. A multiplicative price both
128+
// undercharged limit:1 (51 for that fixed work) and made the documented
129+
// max-200 request unreachable (limit:200 with two fields cost 450 > 400).
130+
#[graphql(
131+
complexity = "100 + (limit.clamp(0, MAX_VISIBLE_REF_UPDATES) as usize) + child_complexity"
132+
)]
117133
async fn ref_updates(
118134
&self,
119135
ctx: &Context<'_>,
@@ -453,6 +469,8 @@ mod tests {
453469
("root-tie", true),
454470
("odd-star", true),
455471
("malformed-readers", true),
472+
("non-array-readers", true),
473+
("non-string-reader", true),
456474
("quarantined", true),
457475
] {
458476
db.create_repo(&repo(id, OWNER, id, public)).await.unwrap();
@@ -478,19 +496,40 @@ mod tests {
478496
("root-tie", "/**", vec![]),
479497
("odd-star", "/*", vec![]),
480498
("malformed-readers", "/", vec![]),
499+
("non-array-readers", "/", vec![]),
500+
("non-string-reader", "/", vec![]),
481501
] {
482502
db.set_visibility_rule(id, glob, VisibilityMode::B, &readers, OWNER)
483503
.await
484504
.unwrap();
485505
}
486506
// The typed setter cannot create malformed JSON, but existing TEXT rows
487507
// can contain it. Such a rule must deny this repo, not abort the page.
508+
// All three deny arms of the reader_dids predicate are pinned here:
509+
// (a) invalid JSON, (b) valid JSON that is not an array, (c) an array
510+
// containing a non-string member. The Rust gate parses reader_dids as
511+
// Vec<String> with unwrap_or_default(), so (b) and (c) also fail to
512+
// parse and deny; the SQL CASE must agree on every shape.
488513
sqlx::query("UPDATE visibility_rules SET reader_dids = $1 WHERE repo_id = $2")
489514
.bind("not JSON")
490515
.bind("malformed-readers")
491516
.execute(db.pool())
492517
.await
493518
.unwrap();
519+
sqlx::query("UPDATE visibility_rules SET reader_dids = $1 WHERE repo_id = $2")
520+
.bind("\"just-a-string\"")
521+
.bind("non-array-readers")
522+
.execute(db.pool())
523+
.await
524+
.unwrap();
525+
// The named reader is listed, but the non-string member poisons the
526+
// whole list: both sides must still deny them.
527+
sqlx::query("UPDATE visibility_rules SET reader_dids = $1 WHERE repo_id = $2")
528+
.bind("[\"did:key:zReader\", 42]")
529+
.bind("non-string-reader")
530+
.execute(db.pool())
531+
.await
532+
.unwrap();
494533
let all = db.list_all_repos_deduped().await.unwrap();
495534
for caller in [
496535
None,
@@ -691,8 +730,12 @@ mod tests {
691730
.await
692731
.unwrap();
693732
assert_eq!(response.errors.len(), 1);
733+
let expected_limit_message = format!(
734+
"limit must be between 1 and {}",
735+
crate::db::MAX_VISIBLE_REPO_PAGE_SIZE
736+
);
694737
assert!(
695-
response.errors[0].message == "limit must be between 1 and 200"
738+
response.errors[0].message == expected_limit_message
696739
|| response.errors[0].message == "invalid repository cursor"
697740
);
698741
}

‎docs/RUN-A-NODE.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ Step-by-step guide to staking $GITLAWB, registering your node on-chain, and earn
88

99
- A wallet with at least **10,000 $GITLAWB** (minimum stake) plus a small amount of ETH on Base for gas
1010
- Docker or Rust 1.91+ (for running the node process)
11+
- PostgreSQL 16+ — the node uses `pg_input_is_valid`, which exists only on PostgreSQL 16 and later. The bundled `docker-compose.yml` pins `postgres:16-alpine`; if you supply your own `DATABASE_URL`, the server must be 16+.
1112
- A public HTTP URL (your-host.com) — can be a VPS, Fly.io app, or anything reachable. A Fly.io config is provided at `infra/fly/fly.toml` (deploy from the repo root with `fly deploy -c infra/fly/fly.toml`)
1213

1314
---

0 commit comments

Comments
 (0)