blockwatcher-ports
blockwatcher-ports is the trait boundary that makes blockwatcher
chain-agnostic: it defines the
port traits every
module implements
(Source, Decoder, Matcher, Gate, Sink, Storage), their write-time and
runtime error types, the registration contract every module family enumerates itself
through, and, behind two features, the in-memory test doubles that let
consumers exercise a port’s contract without linking any real module at
all.
Its production dependencies are blockwatcher-types plus async plumbing
(tokio, tokio-util, futures, async-trait) and (de)serialization glue
(serde, serde_json, thiserror). No chain SDK, no HTTP client, no storage driver
appears anywhere in its allowlist or its transitive tree; that is what lets
blockwatcher-core, which depends on this crate directly, drive a pipeline
without ever importing one.
Key takeaways
blockwatcher-portsis the trait boundary that makes blockwatcher chain-agnostic: it defines the port traits (Source,Decoder,Matcher,Gate,Sink,Storage), their error types, and the module registration contract.- Behind the
fakesandtestingfeatures it also provides in-memory test doubles for every port, so a consumer can exercise a port’s contract without linking any real module. - No chain SDK, HTTP client, or storage driver appears anywhere in its
allowlist or transitive tree, which is what lets
blockwatcher-coredepend on it directly without importing one.
Responsibilities
- Define the port traits and the associated types each one needs:
Source(SourceCtx,SourceStatus,SourceOutcome,InterestSet,ScanRange),Decoder(CompiledSpec,CompiledSelector,DecodeOutcome,SpecSet),Matcher(CompiledPredicate,Explanation,ExplanationNode),Gate(CompiledGate,GateHit,GateDecision,GateAux,GateCtx),Sink, andStorage(BatchEntry). - Define the write-time and runtime error enum for each port
(
SourceError,SpecError,SelectorError,PredicateError,SinkError,GateError,StorageError) andErrorClass, the retry classification every one of them reports through theClassifytrait. - Define
ModuleRegistry, the trait a module family implements once per module to name itself and hand back a constructor, plus the*Factorytype alias beside each port trait that itsFactoryassociated type resolves to. - (
fakesfeature) Provide one in-memory implementation per port:FakeSource,FakeDecoder,FakeMatcher,PassthroughGate,FakeSink,MemoryStorage, andFlakyStorage(a fault-injecting wrapper aroundMemoryStorage), each registered through the sameModuleRegistrycontract a real module uses, so they are executable proof that a port hides no backend-specific detail, not just a convenience. - (
testingfeature) Re-exportmockall-generated mocks: one per port trait (MockSource,MockDecoder,MockMatcher,MockGate,MockSink), and one per storage facet (MockResourceStore,MockCheckpointStore,MockDeadLetterStore,MockPauseStore,MockDeliveryJournal,MockGateStore) for unit-level substitution.
Not this crate’s job: implementing any real module: blockwatcher-evm,
blockwatcher-rpc, blockwatcher-storage, blockwatcher-sinks, and blockwatcher-expr each
implement exactly one port over real chain, network, or storage logic;
blockwatcher-gates implements Gate the same way;
running a pipeline, retrying a failed delivery, or deciding when a
checkpoint advances (blockwatcher-core owns all of that: a Sink or Source
implementation never retries internally); the predicate language’s grammar
and evaluator (blockwatcher-expr defines those against the Matcher trait this
crate only declares).
Key types and traits
| Name | Kind | Role |
|---|---|---|
Source | trait | Pulls raw activity into a pipeline; owns its own cursor semantics and run loop |
Decoder | trait | Compiles a chain artifact into chain-agnostic schemas once, then decodes raw payloads against the compiled result |
Matcher | trait | Compiles predicate source against a SchemaSet, then evaluates it against decoded events |
Gate | trait | Compiles a monitor’s gate.config, then decides Retain/Discard/Emit over an engine-owned journal |
Sink | trait | Delivers one SinkEvent (Match or Retracted); the engine owns all retry/backoff/dead-letter policy |
Storage | trait | Resource CRUD, checkpoints, dead letters, and the bounded delivery journal, with optimistic concurrency on every write |
ModuleRegistry | trait | The NAME + Factory + factory() contract every module declares itself through |
Classify | trait | Reports one of four ErrorClass values for any port error |
ErrorClass | enum | Transient / Permanent / RateLimited / RetryNarrower: the whole retry-policy vocabulary |
SourceError, SpecError, SelectorError, PredicateError, SinkError, GateError, StorageError | enum | One thiserror enum per port, each with its own Classify impl |
CompiledSpec, CompiledSelector, CompiledPredicate, CompiledGate | struct | Type-erased compiled artifacts (downcast::<T>()), built once at write time and read on the hot path |
InterestSet, ScanRange | struct | Hints a source may use to narrow fetching, and the bounded range for a one-shot history scan |
SourceCtx, SourceStatus, SourceOutcome | struct/enum | Everything the engine hands a running source, its typed liveness status, and how run returns without a SourceError (Ended or Invalidated { from }) |
DecodeOutcome | struct | One decode() call’s result: decoded events plus an undecodable payload count |
Explanation, ExplanationNode | enum/struct | The dry-run “why did or didn’t this match” tree a Matcher::explain returns |
BatchEntry | struct | One create in Storage::put_batch’s create-only batch |
FakeSource, FakeDecoder, FakeMatcher, PassthroughGate, FakeSink, MemoryStorage, FlakyStorage | struct | (fakes feature) In-memory, minimal implementations of every port |
Object safety
Every port trait is consumed as a trait object, never through its concrete
type: each *Factory alias resolves to Arc<dyn Source>, Arc<dyn Decoder>, Arc<dyn Matcher>, Arc<dyn Gate>, Arc<dyn Sink>, or Arc<dyn Storage>
(source.rs, decoder.rs, matcher.rs, gate.rs,
sink.rs, storage.rs), and every module’s registered
factory constructs and returns exactly one of those. Staying
dyn-compatible is load-bearing for that reason, and every trait keeps it
this way:
- No generic methods. None of them declares a method with its own
type parameter: every method’s arguments and return type are concrete
port types (
&RawEvent,&CompiledSpec,&SchemaSet, and so on). The generic constructors this crate does define,CompiledSpec::new::<T>andCompiledSelector::new::<T>(decoder.rs) andCompiledPredicate::new::<T>(matcher.rs) andCompiledGate::new::<T>(gate.rs), are inherent methods on plain structs, not methods on a port trait, so they never have to satisfy object safety at all. async fnonly behind the macro that makes it dyn-compatible.Source,Sink, andStorageeach declareasync fnmethods and are annotated#[async_trait](source.rs,sink.rs,storage.rs), which rewrites everyasync fninto a plainfnreturning a boxed, pinned future (the shape a trait object can actually hold), since a bareasync fnin a trait is not itself object-safe.DecoderandMatcherhave no async methods at all:compile_spec,compile,decode,interest, andmerge_interestonDecoder, andcompile,matches,explain, andreferenced_fieldsonMatcher, all run synchronously, so both stay a plainpub trait X: Send + Syncwith no macro needed (decoder.rs,matcher.rs).Gateis the same shape:compile,on_hit, and defaultedon_invalidateare synchronous (gate.rs).
The Send + Sync bound itself sits once, on the trait declaration, for the
same reason: every one is declared pub trait X: Send + Sync as a
supertrait bound (source.rs, decoder.rs, matcher.rs, gate.rs,
sink.rs, storage.rs), so every dyn X is already Send + Sync by
construction. None of the *Factory aliases repeats the bound on its
own Arc<dyn X>: there is no Arc<dyn X + Send + Sync> anywhere in this
crate, because the supertrait already settled it once, at the trait
itself.
How data flows through it
The crate’s defining shape is the split between write time, when a monitor
or spec is compiled into an opaque artifact, and the hot path, which only
ever reads that artifact back through a typed downcast:
flowchart TD
subgraph wt["Write time: once per monitor/spec"]
Spec -->|"Decoder::compile_spec"| CS["CompiledSpec"]
CS -->|"Decoder::compile(selectors, specs)"| CSel["CompiledSelector"]
Text["predicate source"] -->|"Matcher::compile(text, schemas)"| CP["CompiledPredicate"]
end
subgraph hp["Hot path: once per event"]
Raw["RawEvent"] -->|"Decoder::decode(raw, selector)"| DE["DecodedEvent"]
DE -->|"Matcher::matches(predicate, event)"| Bool["bool"]
Bool -->|"Sink::deliver(event)"| Result["Ok / SinkError"]
end
CSel -.->|"read via downcast"| DE
CP -.->|"read via downcast"| Bool
Every port besides Storage follows this same compile-once,
evaluate-many shape; Storage is the exception, since every one of its
operations is already a runtime call with no separate write-time
compilation step.
Neighbours
blockwatcher-ports depends on the following in production:
blockwatcher-types: vocabulary cratetokio: async plumbingtokio-util: async utilitiesfutures: future combinatorsasync-trait: trait async supportserde: (de)serialization deriveserde_json: JSON values for module configuration and factoriesthiserror: error derive
Optionally in production (behind testing and fakes features):
mockall: mockall-generated trait mocks (testingfeature)num-bigint: arbitrary-precision integers (fakesfeature)
In [dev-dependencies] only:
tokio(test-utilfeature): forfakesmodule async tests
The following crates depend on it directly (per the dependency table):
blockwatcher-core: engine pipelineblockwatcher-api: HTTP APIblockwatcher-expr: predicate language and matcher moduleblockwatcher-rpc: RPC chain moduleblockwatcher-evm: EVM chain moduleblockwatcher-testkit: workspace test fixturesblockwatcher-storage: storage moduleblockwatcher-sinks: sink modulesblockwatcher-gates: gate modulesblockwatcher(binary): process binary
blockwatcher-evm-testkit reaches it only transitively, through blockwatcher-evm and blockwatcher-rpc.
Reading the source
- Start at
lib.rs: the module list, the full set of re-exports, and which of them are feature-gated. error.rs:ErrorClass,Classify, and the seven port error enums; read this before any port trait, since every fallible method returns one of these.registry.rs:ModuleRegistryandBoxFuture, the shared contract every module family’s own registration builds on.source.rs,decoder.rs,matcher.rs,gate.rs,sink.rs,storage.rs: the port traits, each beside the associated types and factory alias it needs; read in this order, sinceSource’sInterestSetandDecoder’sinterest/merge_interestdefaults are easiest to follow beforeMatcher,Gate, andSink, which are simpler traits.fakes/mod.rs, thenfakes/source.rs,fakes/decoder.rs,fakes/matcher.rs,fakes/gate.rs,fakes/sink.rs,fakes/storage.rs, andfakes/flaky_storage.rs: one minimal implementation per port, each registered throughModuleRegistryexactly as a real module would be.