mirror of
https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit-grasp.git
synced 2026-10-05 15:08:24 +00:00
feat(sync): probe participant NIP-65 mailboxes
Repository conversations can continue in the read or write relays advertised by authors of accepted replies, reactions, zaps, and other descendants. Root-author inbox discovery alone therefore leaves valid indirect descendants undiscovered. Carry exact accepted-root provenance through the existing bounded descendant frontier, admit identity events only for those participants, and derive read/write mailbox ownership from accepted kind 10002 events. Probe one byte-bounded historic filter at a time with the existing per-relay fetch_events pagination, pacing, ledger, timeout, policy, and persistence paths. Starts are paced globally, but progress and terminal state remain independent per relay. Require both an established socket and the sync actor committed lifecycle before starting a probe. Prefer lifecycle-active due relays while falling back to the existing oldest-due dial order, so hundreds of unavailable mailbox sources cannot starve already-ready work. Drain completed mailbox probes before accepting more connection results so a busy startup queue cannot delay cursor progress or resource release. Production canaries exposed these startup conditions without requiring cross-relay coordination. The recursive descendant limit bounds which indirect IDs remain query roots; direct root references remain complete. Mailbox filter cursors are intentionally best-effort in-memory state: restart reconstructs ownership from LMDB and safely begins historic coverage again. This does not add permanent participant live subscriptions or a cross-relay completion coordinator. Validated with cargo check, strict all-target Clippy, 767 library tests including lifecycle and ready-selection regressions, and the three proactive Sync+ integration scenarios, including a write-mailbox child that references only a participant reaction.
This commit is contained in:
@@ -109,9 +109,9 @@ Explanation documentation helps you **understand concepts** and design decisions
|
||||
---
|
||||
|
||||
### [GRASP-03 Proactive Sync Plus](grasp-03-proactive-sync-plus.md)
|
||||
**NIP-65 inbox discovery for accepted repository conversations**
|
||||
**NIP-65 inbox/outbox discovery for accepted repository conversations**
|
||||
|
||||
**Read when:** You want to understand how accepted conversations are recovered from root-author inboxes
|
||||
**Read when:** You want to understand how accepted conversations are recovered from participant mailboxes
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -593,8 +593,15 @@ For full design details, see [grasp-02-proactive-sync.md](grasp-02-proactive-syn
|
||||
|
||||
GRASP-03 Sync+ is a default-on mailbox-discovery overlay on this manager. Set
|
||||
`NGIT_SYNC_PLUS_ENABLED=false` to retain GRASP-02 sync without discovering
|
||||
root-author NIP-65 inboxes; NIP-11 advertises `GRASP-03` only while the overlay
|
||||
is enabled.
|
||||
accepted root and descendant participants' NIP-65 read/write mailboxes; NIP-11
|
||||
advertises `GRASP-03` only while the overlay is enabled. Exact thread
|
||||
provenance scopes each mailbox to its accepted roots. Accepted root authors'
|
||||
read/unmarked inboxes retain ordinary live/rotating GRASP-02 coverage. Wider
|
||||
participant mailboxes use independent, history-only `fetch_events` workers:
|
||||
at most one per relay and one new start per maintenance pass. They reuse the
|
||||
connection's ordinary pacing, subscription ledger, pagination and 30-second
|
||||
per-page terminal timeout without installing permanent participant
|
||||
subscriptions or coupling progress between relays.
|
||||
|
||||
### Rejected Events Index
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
# GRASP-03 proactive sync plus
|
||||
|
||||
GRASP-03 extends repository-declared GRASP-02 coverage to the Nostr outbox
|
||||
model. An accepted issue, patch or pull request may have replies in the root
|
||||
author's inbox even when those events are absent from repository relays.
|
||||
model. An accepted issue, patch or pull request may have replies, reactions or
|
||||
zaps in a conversation participant's inbox or outbox even when those events
|
||||
are absent from repository relays.
|
||||
|
||||
The overlay is enabled by default and can be disabled with
|
||||
`NGIT_SYNC_PLUS_ENABLED=false`. Because it extends the proactive GRASP-02
|
||||
@@ -12,14 +13,19 @@ manager, it is effective only while that manager is running. NIP-11 advertises
|
||||
## Minimal approach
|
||||
|
||||
The implementation adds a narrow discovery control-plane, not a second sync
|
||||
engine: derive accepted root IDs and authors locally; ask a small existing
|
||||
index set for each author's latest profile kind `0` and NIP-65 kind `10002`;
|
||||
retain those replaceable events locally; treat `read` and unmarked relays as
|
||||
inboxes; and merge inbox-to-root mappings into ordinary per-relay sync targets.
|
||||
engine: derive accepted root IDs and participant authors locally; ask a small
|
||||
existing index set for each author's latest profile kind `0` and NIP-65 kind
|
||||
`10002`; retain those replaceable events locally; keep accepted root authors'
|
||||
`read` and unmarked inboxes in ordinary GRASP-02 coverage; and probe accepted
|
||||
participants' `read`, `write` and unmarked conversation mailboxes with
|
||||
repository and thread-reference filters.
|
||||
|
||||
GRASP-02 then owns all transport. Its current root tiers, historic and
|
||||
live/rotating coverage, grouping, subscription ledger, rate-limit recovery,
|
||||
pagination and per-relay connection lifecycle apply unchanged.
|
||||
Mailbox work reuses GRASP-02's connection safety, filter byte packing,
|
||||
pagination, subscription ledger, request pacing, event pipeline and write
|
||||
policy. The participant expansion is history-only: a non-root participant
|
||||
relay never becomes a permanent live source merely because an accepted author
|
||||
advertised it. Root-author inboxes retain the pre-existing live/rotating Sync+
|
||||
behavior.
|
||||
|
||||
Authors without an accepted stored relay list are queried through the configured
|
||||
user-index set plus the bootstrap relay. Once a list is retained, discovery
|
||||
@@ -28,21 +34,26 @@ to converge without making arbitrary repository relays identity sources.
|
||||
Discovery remains best effort and bounded to this operator-visible source graph.
|
||||
|
||||
If a successful user-index query returns no accepted kind `10002`, accepted
|
||||
roots by that author temporarily use the operator-configured Sync+ fallback
|
||||
relay set as inboxes. This is the same root-only overlay consumed by ordinary
|
||||
GRASP-02 historic, live and rotating coverage; it creates no separate
|
||||
subscription scheduler. An accepted relay list removes the author from desired
|
||||
fallback coverage and installs its declared inboxes. Existing shared live
|
||||
subscriptions still drain naturally, as with any other relay-list replacement.
|
||||
roots associated with that author temporarily use the operator-configured
|
||||
Sync+ fallback relay set as mailboxes. Root-author fallback roots continue
|
||||
through ordinary GRASP-02 historic, live and rotating coverage; non-root
|
||||
participant roots use the paced history probe. An accepted relay list removes
|
||||
the author from fallback coverage and schedules its declared relays instead.
|
||||
|
||||
The self-subscriber builds a compact root-candidate inventory during its
|
||||
existing startup load and maintains it incrementally as roots arrive. StateOnly
|
||||
candidates remain inert; promotion of their repository to Full makes them
|
||||
eligible without rescanning retained events. Eligible identity authors are the
|
||||
owners and declared maintainers of accepted Full announcements plus accepted
|
||||
root authors—not every author retained by the relay. A once-per-minute
|
||||
reconciliation derives sources from this index. Remote queries contain at most 100 authors and
|
||||
only one discovery batch may be in flight globally. Admission samples immediate
|
||||
eligible without rescanning retained root events. A bounded recursive scan of
|
||||
locally accepted root threads adds the authors of replies, reactions, zaps and
|
||||
other descendants. Root provenance is carried through event-ID and address
|
||||
references, so each participant is associated only with accepted threads in
|
||||
which their events occur, including indirect descendants that expose only an
|
||||
immediate parent. Eligible identity authors are
|
||||
the owners and declared maintainers of accepted Full announcements plus
|
||||
accepted root and descendant authors—not every author retained by the relay. A
|
||||
once-per-minute reconciliation derives sources from this index. Remote queries
|
||||
contain at most 100 authors and only one discovery batch may be in flight
|
||||
globally. Admission samples immediate
|
||||
transient capacity; if historic work wins the small race before the permit is
|
||||
acquired, that single batch may wait but discovery can never build a waiter
|
||||
queue. Its SDK-owned REQ draws from the same pacer and subscription ledger as
|
||||
@@ -51,7 +62,7 @@ reconnection machinery, but are a distinct control-plane role: connecting to a
|
||||
user index or outbox does not start ordinary announcement, repository, or
|
||||
descendant sync against it. At most one new discovery source is dialled per
|
||||
maintenance pass, and an exclusively discovery connection retires after its
|
||||
currently due author batches drain. A relay that independently becomes a
|
||||
currently due identity or mailbox work drains. A relay that independently becomes a
|
||||
repository source is promoted to the ordinary lifecycle without opening a
|
||||
duplicate connection. Authors returned by a successful query refresh after 24 hours;
|
||||
missing authors and failed queries retry after five minutes. Configured user
|
||||
@@ -61,25 +72,64 @@ write/unmarked outboxes are then followed additively for newer replacements;
|
||||
new outboxes discovered by a replacement join the same bounded round until no
|
||||
unvisited source remains. Identity events
|
||||
pass through the ordinary write policy, persistence and broadcast path. Only a
|
||||
stored, accepted kind `10002` can change inbox ownership. On startup, retained
|
||||
relay lists for eligible authors rebuild inbox ownership from the local
|
||||
stored, accepted kind `10002` can change mailbox ownership. On startup, retained
|
||||
relay lists for eligible authors rebuild mailbox ownership from the local
|
||||
database before any network refresh, so serving established coverage does not
|
||||
depend on an external index remaining available. Remote discovery then refreshes
|
||||
that retained state on the normal cadence.
|
||||
|
||||
Inbox replacement/removal changes desired ownership immediately. Additions are
|
||||
derived promptly. Removals prevent future rotating and historic work, but do
|
||||
not eagerly CLOSE live descendants or abort shared in-flight batches: existing
|
||||
coverage drains on its natural EOSE/CLOSED/disconnect or an ordinary later
|
||||
consolidation rebuild. This avoids interrupting unrelated roots and subscription
|
||||
churn merely because one author replaced a relay list. Root deletion follows
|
||||
the existing GRASP-02 root-index lifecycle and is reconstructed from retained
|
||||
accepted events on restart; this change does not add a second deletion graph.
|
||||
Root-author read/unmarked inboxes still use ordinary GRASP-02 coverage. Wider
|
||||
participant mailboxes use history-only workers. A maintenance pass starts at
|
||||
most one due relay, while each relay may have at most one worker in flight.
|
||||
Relays do not share a mailbox lane or terminal state: a slow or unavailable
|
||||
relay cannot prevent another relay from progressing on a later pass.
|
||||
|
||||
Each worker selects one stable-sorted, byte-bounded filter and delegates its
|
||||
REQ lifecycle to the existing `RelayConnection::fetch_events` path. That path
|
||||
owns per-relay request pacing, background priority, subscription-ledger
|
||||
capacity, EOSE/CLOSED handling and a 30-second timeout. The worker reuses the
|
||||
ordinary pagination state to continue through full historic pages until the
|
||||
filter is exhausted or a page makes no new progress. It then passes events
|
||||
through the normal write policy and persistence pipeline. No mailbox-specific
|
||||
pending-batch kind, EOSE hook, close API, watchdog or cross-relay coordinator
|
||||
is added.
|
||||
|
||||
The probe covers accepted repository coordinates, root IDs, bounded recursive
|
||||
descendant IDs, and descendant address coordinates using `a`/`A`/`q` and
|
||||
`e`/`E`/`q`. A numeric in-memory cursor gives each filter group a turn. A
|
||||
successful complete rotation refreshes after 24 hours; a failed group advances
|
||||
the cursor after a five-minute delay and is retried on a later rotation. This
|
||||
is deliberately best effort rather than a durable exactly-once schedule. A
|
||||
relay already needed for ordinary repository sync shares its connection; an
|
||||
exclusively control-plane connection retires when its identity and mailbox
|
||||
work is idle.
|
||||
|
||||
On restart, accepted roots and retained kind `10002` events rebuild participant
|
||||
and mailbox ownership from LMDB. The in-memory group cursor is intentionally
|
||||
not restored, so historic mailbox probing starts again from the first current
|
||||
filter and remains safe to repeat. Inventory changes keep the cursor modulo the
|
||||
new stable-sorted filter set; they do not coordinate with a worker on another
|
||||
relay.
|
||||
|
||||
The fixed-cardinality retained-state metric reports eligible authors, desired
|
||||
history-probe relays, allocated filter cursors, and active per-relay workers.
|
||||
This makes a stuck worker or unexpected inventory expansion visible without
|
||||
putting peer URLs or public keys into metric labels.
|
||||
|
||||
Mailbox replacement/removal changes desired ownership immediately. Root-author
|
||||
inbox additions use ordinary coverage, while removal-only changes let existing
|
||||
shared live subscriptions drain naturally. History-probe additions become due
|
||||
promptly; removals prevent future groups while an already in-flight
|
||||
`fetch_events` worker may finish naturally. Root deletion follows the existing
|
||||
GRASP-02 root-index lifecycle and is reconstructed from retained accepted
|
||||
events on restart; this change does not add a second deletion graph.
|
||||
|
||||
## Deliberately excluded
|
||||
|
||||
- the old recursive `SyncScope` graph and response-author fan-out;
|
||||
- mailbox expansion for authors who have not produced locally accepted
|
||||
repository-thread events;
|
||||
- maintainer mailbox expansion unrelated to an accepted root;
|
||||
- identity storage for authors other than accepted roots;
|
||||
- identity storage for authors outside accepted repositories and their
|
||||
locally accepted threads;
|
||||
- per-user fallback configuration knobs; and
|
||||
- a separate GRASP-03 subscription scheduler.
|
||||
- permanent non-root participant-mailbox live subscriptions.
|
||||
|
||||
@@ -39,7 +39,7 @@ larger retained set; it is not an excuse to copy arbitrary wire input.
|
||||
| Purgatory event maps and sync queue | write policy; promotion/cleanup owns removal | Time/external: entries are keyed by admitted event/repository identity, normally expire at 30 minutes, and soft-expired announcements at 24 hours. Queue entries deduplicate by identifier and disappear on completion or event expiry. | purgatory counts, queue and Git-process metrics |
|
||||
| Per-domain Git throttle queues | incomplete purgatory fetch; throttle manager owns drain | External: one entry per purgatory identifier/domain, merged on repeat. Completion, URL exhaustion, or purgatory expiry removes useful work. Request history is time-windowed. | domain/fetch logs and Git-process metrics |
|
||||
| Dependency retry attempts and temporary relays | rejected/purgatory dependency discovery; maintenance owns expiry | Time/external: event IDs are pruned against current purgatory input; temporary relays have explicit deadlines. Repeats overwrite timestamps. | retained dependency gauges |
|
||||
| NIP-65 discovery state/results | accepted root authors; discovery scheduler owns completion | External plus static work: author/source maps derive from accepted roots, one discovery query is in flight, and its result channel has capacity one. Missing results time out and clear in-flight state through result handling/disconnect refresh. | discovery logs and relay gauges |
|
||||
| NIP-65 discovery and mailbox probes | accepted root and descendant authors; ordinary root-inbox coverage plus discovery/probe scheduler own completion | External/session work: root-author read inboxes remain ordinary derived sync targets, while participant maps derive from accepted root threads. Identity discovery is globally single-flight. Mailbox history has at most one ordinary `fetch_events` worker per accepted relay; each page consumes that relay's pacing and ledger capacity and has a 30-second timeout. Starts are paced one per maintenance pass, progress on different relays is independent, and exclusive connections retire when idle. Numeric filter cursors and due times are in memory and bounded by desired mailbox relays. | discovery/probe logs and relay gauges |
|
||||
| Deferred consolidation | capacity refusal; final batch/reset/disconnect owns removal | External and deduplicated by relay. Final batch completion processes the set directly; no self-addressed notification queue remains. | retained deferred-consolidation gauge |
|
||||
| Descendant rotations and auxiliary live coverage | accepted root coverage; EOSE/CLOSED/disconnect/daily reset own transition | Session/external: one state object per derived relay and at most one rotating request in flight per relay. Coverage IDs consume ledger slots. | retained rotation gauge and terminal logs |
|
||||
| Health and naughty-list entries | connection failures; health checker owns recovery/expiry | External/time: one entry per canonical target, ordinary failures back off, persistent entries expire after 12 hours. Metrics expose only three fixed categories. | health gauges and aggregate naughty metrics |
|
||||
|
||||
+11
-9
@@ -28,7 +28,8 @@ use crate::nostr::persistence::{EventPersistence, SaveContext};
|
||||
use crate::nostr::policy::{
|
||||
accepted_purgatory, duplicate, reject_error, reject_invalid, reject_restricted,
|
||||
AnnouncementPolicy, AnnouncementResult, IdentityAdmission, PolicyContext, PrEventPolicy,
|
||||
ReferenceResult, RelatedEventPolicy, SharedProactiveRootAuthorIndex, StatePolicy, StateResult,
|
||||
ReferenceResult, RelatedEventPolicy, SharedProactiveParticipantAuthorIndex, StatePolicy,
|
||||
StateResult,
|
||||
};
|
||||
use crate::nostr::SharedDatabase;
|
||||
use crate::purgatory::promotion_hooks::NostrPurgatoryPromotionHooks;
|
||||
@@ -84,7 +85,7 @@ pub struct Nip34WritePolicy {
|
||||
state_policy: StatePolicy,
|
||||
pr_event_policy: PrEventPolicy,
|
||||
related_event_policy: RelatedEventPolicy,
|
||||
proactive_root_authors: SharedProactiveRootAuthorIndex,
|
||||
proactive_participant_authors: SharedProactiveParticipantAuthorIndex,
|
||||
deletion: DeletionService,
|
||||
rejected_events_index: Arc<RwLock<Option<Arc<RejectedEventsIndex>>>>,
|
||||
}
|
||||
@@ -133,23 +134,24 @@ impl Nip34WritePolicy {
|
||||
state_policy: StatePolicy::new(ctx.clone()),
|
||||
pr_event_policy: PrEventPolicy::new(ctx.clone(), repo_init_locks),
|
||||
related_event_policy: RelatedEventPolicy::new(ctx.clone()),
|
||||
proactive_root_authors: crate::nostr::policy::ProactiveRootAuthorIndex::shared(),
|
||||
proactive_participant_authors:
|
||||
crate::nostr::policy::ProactiveParticipantAuthorIndex::shared(),
|
||||
deletion: DeletionService::new(deletion_ctx),
|
||||
rejected_events_index: Arc::new(RwLock::new(None)),
|
||||
ctx,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn proactive_root_authors(&self) -> SharedProactiveRootAuthorIndex {
|
||||
self.proactive_root_authors.clone()
|
||||
pub fn proactive_participant_authors(&self) -> SharedProactiveParticipantAuthorIndex {
|
||||
self.proactive_participant_authors.clone()
|
||||
}
|
||||
|
||||
async fn handle_proactive_identity(&self, event: &Event) -> WritePolicyResult {
|
||||
match self.proactive_root_authors.admit(event).await {
|
||||
match self.proactive_participant_authors.admit(event).await {
|
||||
IdentityAdmission::Accept => WritePolicyResult::Accept,
|
||||
IdentityAdmission::IrrelevantAuthor => {
|
||||
reject_restricted("Kind 0/10002 author must own an accepted repository root")
|
||||
}
|
||||
IdentityAdmission::IrrelevantAuthor => reject_restricted(
|
||||
"Kind 0/10002 author must participate in an accepted repository thread",
|
||||
),
|
||||
IdentityAdmission::NotIdentity => {
|
||||
reject_invalid("Proactive identity policy received a non-identity event")
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Narrow GRASP-03 identity admission for accepted root authors.
|
||||
//! Narrow GRASP-03 identity admission for accepted repository participants.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
@@ -6,7 +6,7 @@ use std::sync::Arc;
|
||||
use nostr_sdk::prelude::{Event, Kind, PublicKey};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
pub type SharedProactiveRootAuthorIndex = Arc<ProactiveRootAuthorIndex>;
|
||||
pub type SharedProactiveParticipantAuthorIndex = Arc<ProactiveParticipantAuthorIndex>;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum IdentityAdmission {
|
||||
@@ -15,15 +15,15 @@ pub enum IdentityAdmission {
|
||||
NotIdentity,
|
||||
}
|
||||
|
||||
/// Derived admission set. Accepted repository roots remain authoritative; an
|
||||
/// identity event can never add its own author here.
|
||||
/// Derived admission set. Accepted repository threads remain authoritative;
|
||||
/// an identity event can never add its own author here.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ProactiveRootAuthorIndex {
|
||||
pub struct ProactiveParticipantAuthorIndex {
|
||||
authors: RwLock<HashSet<PublicKey>>,
|
||||
}
|
||||
|
||||
impl ProactiveRootAuthorIndex {
|
||||
pub fn shared() -> SharedProactiveRootAuthorIndex {
|
||||
impl ProactiveParticipantAuthorIndex {
|
||||
pub fn shared() -> SharedProactiveParticipantAuthorIndex {
|
||||
Arc::new(Self::default())
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ mod tests {
|
||||
async fn identity_cannot_make_its_own_author_relevant() {
|
||||
let author = Keys::generate();
|
||||
let other = Keys::generate();
|
||||
let index = ProactiveRootAuthorIndex::shared();
|
||||
let index = ProactiveParticipantAuthorIndex::shared();
|
||||
let metadata = EventBuilder::new(Kind::Metadata, "{}")
|
||||
.finalize(&author)
|
||||
.unwrap();
|
||||
|
||||
@@ -18,7 +18,9 @@ pub(crate) use result::{
|
||||
};
|
||||
|
||||
pub use announcement::{AnnouncementPolicy, AnnouncementResult};
|
||||
pub use identity::{IdentityAdmission, ProactiveRootAuthorIndex, SharedProactiveRootAuthorIndex};
|
||||
pub use identity::{
|
||||
IdentityAdmission, ProactiveParticipantAuthorIndex, SharedProactiveParticipantAuthorIndex,
|
||||
};
|
||||
pub use pr_event::PrEventPolicy;
|
||||
pub use related::{ReferenceResult, RelatedEventPolicy};
|
||||
pub use state::{StatePolicy, StateResult};
|
||||
|
||||
@@ -175,6 +175,25 @@ pub fn accepted_repository_authors<'a>(
|
||||
authors
|
||||
}
|
||||
|
||||
/// Associate each accepted root author with their own roots and each accepted
|
||||
/// descendant author with the root provenance derived for their events.
|
||||
pub fn mailbox_author_roots(
|
||||
roots: &[AcceptedRoot],
|
||||
participant_roots: impl IntoIterator<Item = (PublicKey, HashSet<EventId>)>,
|
||||
) -> HashMap<PublicKey, HashSet<EventId>> {
|
||||
let mut author_roots: HashMap<PublicKey, HashSet<EventId>> = HashMap::new();
|
||||
for root in roots {
|
||||
author_roots.entry(root.author).or_default().insert(root.id);
|
||||
}
|
||||
for (author, accepted_roots) in participant_roots {
|
||||
author_roots
|
||||
.entry(author)
|
||||
.or_default()
|
||||
.extend(accepted_roots);
|
||||
}
|
||||
author_roots
|
||||
}
|
||||
|
||||
pub fn merge_inbox_roots(
|
||||
targets: &mut HashMap<String, RelaySyncNeeds>,
|
||||
inbox_roots: &HashMap<String, HashSet<EventId>>,
|
||||
@@ -220,6 +239,15 @@ pub fn build_inbox_root_overlay(
|
||||
overlay
|
||||
}
|
||||
|
||||
/// Relays on which an author may receive or publish conversation events.
|
||||
/// Unmarked entries serve both roles under NIP-65.
|
||||
pub fn mailbox_relays(event: &Event) -> HashSet<String> {
|
||||
inbox_relays(event)
|
||||
.into_iter()
|
||||
.chain(outbox_relays(event))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -312,6 +340,29 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relay_list_maps_read_write_and_unmarked_participant_mailboxes() {
|
||||
let keys = Keys::generate();
|
||||
let relay_list = event(
|
||||
&keys,
|
||||
Kind::RelayList,
|
||||
vec![
|
||||
Tag::custom("r", ["wss://read.example", "read"]),
|
||||
Tag::custom("r", ["wss://write.example", "write"]),
|
||||
Tag::custom("r", ["wss://both.example"]),
|
||||
],
|
||||
1,
|
||||
);
|
||||
assert_eq!(
|
||||
mailbox_relays(&relay_list),
|
||||
HashSet::from([
|
||||
"wss://read.example".to_string(),
|
||||
"wss://write.example".to_string(),
|
||||
"wss://both.example".to_string(),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_authors_are_bounded_to_full_repo_owners_and_maintainers() {
|
||||
let owner = Keys::generate();
|
||||
@@ -356,6 +407,40 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn descendant_authors_receive_only_their_derived_root_provenance() {
|
||||
let first_root_author = Keys::generate().public_key();
|
||||
let second_root_author = Keys::generate().public_key();
|
||||
let participant = Keys::generate().public_key();
|
||||
let first_root = EventId::from_byte_array([12; 32]);
|
||||
let second_root = EventId::from_byte_array([13; 32]);
|
||||
let roots = vec![
|
||||
AcceptedRoot {
|
||||
id: first_root,
|
||||
author: first_root_author,
|
||||
repository: "30617:owner:first".to_string(),
|
||||
},
|
||||
AcceptedRoot {
|
||||
id: second_root,
|
||||
author: second_root_author,
|
||||
repository: "30617:owner:second".to_string(),
|
||||
},
|
||||
];
|
||||
|
||||
let author_roots =
|
||||
mailbox_author_roots(&roots, [(participant, HashSet::from([second_root]))]);
|
||||
|
||||
assert_eq!(
|
||||
author_roots[&first_root_author],
|
||||
HashSet::from([first_root])
|
||||
);
|
||||
assert_eq!(
|
||||
author_roots[&second_root_author],
|
||||
HashSet::from([second_root])
|
||||
);
|
||||
assert_eq!(author_roots[&participant], HashSet::from([second_root]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latest_relay_list_uses_nip01_replacement_order() {
|
||||
let keys = Keys::generate();
|
||||
|
||||
+843
-72
File diff suppressed because it is too large
Load Diff
@@ -670,11 +670,9 @@ impl SelfSubscriber {
|
||||
// state, avoiding an O(all repositories × all relays) rebuild for
|
||||
// every historic batch.
|
||||
for relay_url in dirty_relays {
|
||||
// Skip our own relay URL (we're subscribed to ourselves via self-subscription)
|
||||
if relay_url.contains(&self.relay_domain) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Keep our own relay's dirty signal: SyncManager rejects it as a
|
||||
// sync target, but uses the signal to refresh participant mailbox
|
||||
// inventory after a newly accepted local root arrives.
|
||||
let action = AddFilters {
|
||||
relay_url: relay_url.clone(),
|
||||
items: crate::sync::PendingItems::default(),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Minimal GRASP-03 mailbox discovery scenario.
|
||||
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use nostr_sdk::prelude::*;
|
||||
@@ -9,6 +10,23 @@ use crate::common::{
|
||||
setup_announcement_on_relay, wait_for_event_on_relay, MockRelay, TestClient, TestRelay,
|
||||
};
|
||||
|
||||
async fn wait_for_log_line<F>(path: &Path, timeout: Duration, predicate: F) -> bool
|
||||
where
|
||||
F: Fn(&str) -> bool,
|
||||
{
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
loop {
|
||||
let log = std::fs::read_to_string(path).unwrap_or_default();
|
||||
if log.lines().any(&predicate) {
|
||||
return true;
|
||||
}
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
return false;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_author_inbox_reuses_existing_root_sync_pipeline() {
|
||||
let index = MockRelay::start().await;
|
||||
@@ -207,6 +225,91 @@ async fn root_author_inbox_reuses_existing_root_sync_pipeline() {
|
||||
inbox.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn participant_write_mailbox_fetches_child_of_direct_reaction() {
|
||||
let index = MockRelay::start().await;
|
||||
let mailbox = MockRelay::start().await;
|
||||
let owner = Keys::generate();
|
||||
let root_author = Keys::generate();
|
||||
let participant = Keys::generate();
|
||||
let child_author = Keys::generate();
|
||||
let identifier = "participant-write-mailbox";
|
||||
|
||||
let relay_list = EventBuilder::new(Kind::RelayList, "")
|
||||
.tag(Tag::custom("r", vec![mailbox.url(), "write"]))
|
||||
.finalize(&participant)
|
||||
.expect("build participant relay list");
|
||||
send_to_relay_url(index.url(), &relay_list)
|
||||
.await
|
||||
.expect("seed participant relay list");
|
||||
|
||||
let syncing_git_dir = tempfile::tempdir().expect("create persistent git directory");
|
||||
let syncing_relay_dir = tempfile::tempdir().expect("create persistent relay directory");
|
||||
let syncing = TestRelay::start_on_reservation_persistent_sync(
|
||||
reserve_port(),
|
||||
Some(index.url().to_string()),
|
||||
false,
|
||||
syncing_git_dir.path().to_path_buf(),
|
||||
syncing_relay_dir.path().to_path_buf(),
|
||||
)
|
||||
.await;
|
||||
let syncing_domain = syncing.domain();
|
||||
let (_announcement, _git_dir) =
|
||||
setup_announcement_on_relay(&syncing, &owner, &[&syncing_domain], identifier).await;
|
||||
let issue = build_layer2_issue_event(
|
||||
&root_author,
|
||||
&repo_coord(&owner, identifier),
|
||||
"root with a reaction whose child is mailbox-only",
|
||||
)
|
||||
.expect("build accepted root");
|
||||
let root_client = TestClient::new(syncing.url(), root_author)
|
||||
.await
|
||||
.expect("connect root author");
|
||||
root_client.send_event(&issue).await.expect("publish root");
|
||||
|
||||
let reaction = EventBuilder::new(Kind::Reaction, "+")
|
||||
.tag(Tag::custom("e", vec![issue.id.to_hex()]))
|
||||
.finalize(&participant)
|
||||
.expect("build direct participant reaction");
|
||||
let participant_client = TestClient::new(syncing.url(), participant)
|
||||
.await
|
||||
.expect("connect participant");
|
||||
participant_client
|
||||
.send_event(&reaction)
|
||||
.await
|
||||
.expect("publish direct reaction");
|
||||
|
||||
let child = EventBuilder::new(Kind::TextNote, "reply to a reaction")
|
||||
.tag(Tag::custom("e", vec![reaction.id.to_hex()]))
|
||||
.finalize(&child_author)
|
||||
.expect("build mailbox-only child");
|
||||
send_to_relay_url(mailbox.url(), &child)
|
||||
.await
|
||||
.expect("seed participant write mailbox");
|
||||
|
||||
assert!(
|
||||
wait_for_event_on_relay(
|
||||
syncing.url(),
|
||||
Filter::new().id(child.id),
|
||||
Duration::from_secs(30),
|
||||
)
|
||||
.await,
|
||||
"history probing should fetch a child that only names the participant's direct reaction"
|
||||
);
|
||||
let sync_log = std::fs::read_to_string(syncing.log_path()).expect("read syncing relay log");
|
||||
assert!(
|
||||
sync_log.lines().any(|line| {
|
||||
line.contains("Started bounded participant mailbox fetch")
|
||||
&& line.contains(mailbox.url())
|
||||
}),
|
||||
"participant mailbox transport should remain observable"
|
||||
);
|
||||
|
||||
syncing.stop().await;
|
||||
mailbox.stop().await;
|
||||
index.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_relay_list_uses_bounded_fallback_coverage() {
|
||||
let index = MockRelay::start().await;
|
||||
@@ -259,7 +362,7 @@ async fn missing_relay_list_uses_bounded_fallback_coverage() {
|
||||
let sync_log = std::fs::read_to_string(syncing.log_path()).expect("read syncing relay log");
|
||||
assert!(
|
||||
sync_log.lines().any(|line| {
|
||||
line.contains("Updated proactive inbox coverage from NIP-65")
|
||||
line.contains("Updated proactive participant mailbox coverage from NIP-65")
|
||||
&& line.contains("fallback_authors=1")
|
||||
}),
|
||||
"fallback activation should remain observable"
|
||||
@@ -288,12 +391,11 @@ async fn missing_relay_list_uses_bounded_fallback_coverage() {
|
||||
.await,
|
||||
"a later accepted relay list should replace desired fallback coverage"
|
||||
);
|
||||
let sync_log = std::fs::read_to_string(syncing.log_path()).expect("read updated relay log");
|
||||
assert!(
|
||||
sync_log.lines().any(|line| {
|
||||
line.contains("Updated proactive inbox coverage from NIP-65")
|
||||
&& line.contains("fallback_authors=0")
|
||||
}),
|
||||
wait_for_log_line(&syncing.log_path(), Duration::from_secs(20), |line| {
|
||||
line.contains("proactive participant mailbox") && line.contains("fallback_authors=0")
|
||||
})
|
||||
.await,
|
||||
"accepted NIP-65 ownership should retire the author from desired fallback coverage"
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user