···88//! here: the server binary loads `.env` directly via `dotenvy` and reads it into
99//! a typed `ServerConfig` struct (see `src/indexing/config.rs`).
10101111+use serde_json::Value;
1112use std::env;
1212-use std::fs::File;
1313+use std::fs::{self, File};
1314use std::io::Write;
1515+use std::path::{Path, PathBuf};
14161517fn main() {
1618 println!("cargo:rerun-if-changed=.env");
···2123 f.write_all(
2224 b"// This file is automatically generated by build.rs.\n\
2325 // Frontend (WASM) compile-time configuration.\n\
2424- // Do not edit; regenerate by rebuilding.\n\n",
2626+ // Do not edit; regenerate by rebuilding.\n",
2527 )
2628 .expect("write env.rs header");
2729···3739 value.replace('\\', "\\\\").replace('"', "\\\"")
3840 );
3941 f.write_all(line.as_bytes()).expect("write env.rs const");
4242+ }
4343+ }
4444+ generate_indexed_collections();
4545+}
4646+fn generate_indexed_collections() {
4747+ println!("cargo:rerun-if-changed=lexicons");
4848+ let lexicon_root = Path::new("lexicons");
4949+ let mut json_paths = Vec::new();
5050+ collect_json_paths(lexicon_root, &mut json_paths);
5151+ json_paths.sort();
5252+ let mut collections = Vec::new();
5353+ for path in json_paths {
5454+ println!("cargo:rerun-if-changed={}", path.display());
5555+ let contents = fs::read_to_string(&path)
5656+ .unwrap_or_else(|e| panic!("read authored lexicon {}: {e}", path.display()));
5757+ let lexicon: Value = serde_json::from_str(&contents)
5858+ .unwrap_or_else(|e| panic!("parse authored lexicon JSON {}: {e}", path.display()));
5959+ if lexicon.pointer("/defs/main/type").and_then(Value::as_str) != Some("record") {
6060+ continue;
6161+ }
6262+ let id = lexicon
6363+ .get("id")
6464+ .and_then(Value::as_str)
6565+ .unwrap_or_else(|| panic!("record lexicon {} has no string id", path.display()));
6666+ if id.starts_with("space.polymodel.") {
6767+ collections.push(id.to_owned());
6868+ }
6969+ }
7070+ collections.sort();
7171+ let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR set by Cargo"));
7272+ let out_path = out_dir.join("polymodel_record_collections.rs");
7373+ let mut out = File::create(&out_path)
7474+ .unwrap_or_else(|e| panic!("create generated collections {}: {e}", out_path.display()));
7575+ writeln!(out, "// Generated by build.rs from authored lexicons where defs.main.type == \"record\".\n// Do not edit.\npub const INDEXED_COLLECTIONS: &[&str] = &[").expect("write generated collections header");
7676+ for collection in collections {
7777+ writeln!(out, " {collection:?},").expect("write generated collection");
7878+ }
7979+ writeln!(out, "];").expect("write generated collections footer");
8080+}
8181+fn collect_json_paths(dir: &Path, paths: &mut Vec<PathBuf>) {
8282+ let entries =
8383+ fs::read_dir(dir).unwrap_or_else(|e| panic!("read lexicon dir {}: {e}", dir.display()));
8484+ for entry in entries {
8585+ let entry =
8686+ entry.unwrap_or_else(|e| panic!("read lexicon dir entry in {}: {e}", dir.display()));
8787+ let path = entry.path();
8888+ if path.is_dir() {
8989+ collect_json_paths(&path, paths);
9090+ } else if path.extension().and_then(|ext| ext.to_str()) == Some("json") {
9191+ paths.push(path);
4092 }
4193 }
4294}
+71-14
src/indexing/setup.rs
···11//! Hydrant ATProto indexer setup and configuration.
22//!
33-//! Initializes Hydrant from environment and applies a signal-mode filter so only
44-//! repos that publish `space.polymodel.*` records are indexed. The returned
55-//! handle is cheaply cloneable; the firehose/backfill driver is started later via
66-//! [`Hydrant::run`] in [`super::start_indexing`].
33+//! Initializes Hydrant from environment and applies exact Polymodel record
44+//! collection filters. Hydrant's by-collection crawler sends signal entries
55+//! directly to `listReposByCollection`, so wildcard NSIDs must not be used here.
66+//! The returned handle is cheaply cloneable; the firehose/backfill driver is started later via [`Hydrant::run`] in [`super::start_indexing`].
7788use hydrant::FilterMode;
99use hydrant::control::Hydrant;
1010-1111-/// NSID patterns to index: the whole Polymodel namespace, used for both
1212-/// signal-mode discovery and the collection allowlist.
1313-const COLLECTIONS: [&str; 1] = ["space.polymodel.*"];
1010+mod generated_collections {
1111+ include!(concat!(env!("OUT_DIR"), "/polymodel_record_collections.rs"));
1212+}
1313+/// Exact authored Polymodel repo record collections used for Hydrant discovery
1414+/// and storage. Keep this exact because Hydrant sends signal entries to listReposByCollection.
1515+const INDEXED_COLLECTIONS: &[&str] = generated_collections::INDEXED_COLLECTIONS;
14161517/// Initialize Hydrant from environment and configure signal-mode filtering.
1618///
···2123 .await
2224 .map_err(|e| anyhow::anyhow!("hydrant init failed: {e}"))?;
23252424- // Signal mode: only index repos that emit space.polymodel.* records, and
2525- // only store those collections. Code takes precedence over HYDRANT_FILTER_*
2626- // env vars, but the env vars are still documented in .env.example.
2626+ // Hydrant signal mode discovers repos publishing authored Polymodel records.
2727+ // In Hydrant's default ByCollection crawler, signals are also the exact
2828+ // values sent to listReposByCollection, so do not use wildcard patterns.
2729 hydrant
2830 .filter
2931 .set_mode(FilterMode::Filter)
3030- .set_signals(COLLECTIONS)
3131- .set_collections(COLLECTIONS)
3232+ .set_signals(INDEXED_COLLECTIONS.iter().copied())
3333+ .set_collections(INDEXED_COLLECTIONS.iter().copied())
3234 .apply()
3335 .await
3436 .map_err(|e| anyhow::anyhow!("hydrant filter apply failed: {e}"))?;
35373636- tracing::info!("hydrant initialized with signal filter for space.polymodel.*");
3838+ tracing::info!(
3939+ indexed_collections = INDEXED_COLLECTIONS.len(),
4040+ "hydrant initialized with exact authored record collection allowlist"
4141+ );
3742 Ok(hydrant)
3843}
4444+#[cfg(test)]
4545+mod tests {
4646+ use super::*;
4747+ #[test]
4848+ fn hydrant_filters_use_exact_record_collections() {
4949+ assert_eq!(
5050+ INDEXED_COLLECTIONS,
5151+ generated_collections::INDEXED_COLLECTIONS
5252+ );
5353+ for collection in INDEXED_COLLECTIONS {
5454+ assert!(
5555+ !collection.contains('*'),
5656+ "indexed collection must be exact, got {collection}"
5757+ );
5858+ assert!(
5959+ !collection.ends_with(".*"),
6060+ "indexed collection must not be a wildcard, got {collection}"
6161+ );
6262+ }
6363+ let expected_records = [
6464+ "space.polymodel.actor.profile",
6565+ "space.polymodel.graph.like",
6666+ "space.polymodel.graph.list",
6767+ "space.polymodel.graph.listitem",
6868+ "space.polymodel.graph.save",
6969+ "space.polymodel.graph.tag",
7070+ "space.polymodel.library.model",
7171+ "space.polymodel.library.part",
7272+ "space.polymodel.library.thing",
7373+ ];
7474+ for collection in expected_records {
7575+ assert!(
7676+ generated_collections::INDEXED_COLLECTIONS.contains(&collection),
7777+ "missing generated record collection {collection}"
7878+ );
7979+ }
8080+ let excluded_non_records = [
8181+ "space.polymodel.library.getFeed",
8282+ "space.polymodel.library.getThing",
8383+ "space.polymodel.library.getAuthorThings",
8484+ "space.polymodel.library.searchThings",
8585+ "space.polymodel.graph.getList",
8686+ "space.polymodel.library.defs",
8787+ ];
8888+ for collection in excluded_non_records {
8989+ assert!(
9090+ !generated_collections::INDEXED_COLLECTIONS.contains(&collection),
9191+ "non-record lexicon must not be indexed as a collection: {collection}"
9292+ );
9393+ }
9494+ }
9595+}