Resources
Hierarchical, asset-like entities and the relationships between them. Create resources and the edges between them in one call; the server returns the persisted graph.
A resource's externalId is its identity: unique per tenant, stored exactly as you send
it, and compared without case. Mirror the tag your operation already maintains —
COM-99-PT-1034 is stored as COM-99-PT-1034, not rewritten.
External ids & naming →
The resource body
| Field | Type | Notes |
|---|---|---|
id | number | Server-assigned. Crosses the wire as a JSON string — see the note below. |
externalId | string, 3–256 | Required. Unique per tenant, stored verbatim, matched case-insensitively. |
name | string, 3–512 | Required. What a human calls it. This is the field search reads. |
labels | string[] | Required, at least one. The type tags (Pump, Plant). Upper-cased by the server. |
description | string | Prose. |
metadata | map<string, string> | Flat key/value, filterable by exact match. |
source | string, 2–128 | The upstream system of record this came from (SAP, a historian, a file drop). |
dataSetId | number | The data set the resource belongs to. |
geoLocation | GeoJSON geometry | Point, Polygon, … Validated on write; stored verbatim. |
isRoot | boolean | Whether the resource is a navigation root. Deletes are checked against reachability from a root — see Delete. |
relatedResources | object[] | Read-only view of the graph: { id, externalId, relationshipType, direction } per connected node. Populated where the graph is loaded, empty otherwise. |
createdTime, lastUpdatedTime | epoch millis | Server-set. |
Labels are how the platform types a node. The type-label (ASSET, TIMESERIES, DATASET,
POLICY, FUNCTION) is what the create pipeline reads to decide which kind of entity to
build, and free-form labels ride alongside it. That is also why one /resources/create call
can hold a mix of node types — a time-series next to an asset — rather than needing one
endpoint per type.
id and dataSetId serialize as "5677892", not 5677892 — ids can exceed the 53-bit
integer a JSON number is safe for in JavaScript. The clients parse them back for you. The same
holds for the ids on an edge, start and end included.
Look up
Fetch by numeric id or external id (you can mix them). Lookup ignores case, so pump_1 and
PUMP_1 resolve to the same resource; what comes back keeps the spelling it was created
with. Identifiers that match nothing are silently omitted rather than erroring, so
compare the returned items against what you asked for when a miss matters.
- Java
- Python
- Rust
import ai.intellistream.datahub.models.IdCollection;
Resource pump = client.resources().getById(5677892).getItems().iterator().next();
DataWrapper<Resource> some = client.resources().byIds(List.of(
IdCollection.createFromExternalId("pump_1"),
IdCollection.createFromId(5677892)));
# pass entity objects, external-id strings, or numeric ids
resources = client.resources.by_ids(["pump_1", 5677892])
use intellistream_datahub_sdk::generic::IdAndExtId;
let resources = api.resources.by_ids(&vec![
IdAndExtId::from_external_id("pump_1"),
IdAndExtId::from_id(5677892),
]).await?;
Create resources and relations
Pass the resource forms (nodes) and the relation forms (edges); the call returns the
created graph — nodes plus server-assigned edges. Each resource needs at least one
label (a type tag such as Plant or Pump) — a node with none is rejected with
400 resource.needs.at.least.one.label. Labels and relationship types are both
upper-cased by the server. External ids are not: they are stored verbatim.
The call is all-or-nothing. Every external id in the batch is validated before anything
is written, so one item rejected by the
naming policy means nothing is created and the 400
names every offending item, not just the first. If the policy is set to warn instead, the
response carries a warnings array next to
items.
A relation may reference a node being created in the same request by its externalId, or
point at one that already exists. An edge whose endpoint is neither is a 400 naming the
endpoint it could not resolve. Re-using an externalId that already exists in the tenant is
a 409 whose duplicated list names which ones — use update to change the
existing resource instead.
Two endpoint rules apply to every edge, on create and on update (an update can retarget an edge or change its type):
- A relation to a dataset must use the
BELONGS_TOrelationship type — that is the relation the dataset hierarchy and membership are built from, and anything else is rejected with a400. - A dataset → time-series edge is accepted only when the series has no dataset yet, or
already belongs to that very dataset (creating a series inside a dataset produces exactly
that membership edge). A series in a different dataset is rejected with a
400— a time-series has one dataset.
- Java
- Python
- Rust
ResourceForm plant = new ResourceForm();
plant.setExternalId("plant_oslo");
plant.setName("Oslo Plant");
plant.setLabels(List.of("Plant"));
ResourceForm pump = new ResourceForm();
pump.setExternalId("pump_1");
pump.setName("Pump 1");
pump.setLabels(List.of("Pump"));
RelForm contains = new RelForm();
contains.setName("contains");
contains.setFromExternalId("plant_oslo");
contains.setToExternalId("pump_1");
GraphDataWrapper<Resource, EdgeProxy> created = client.resources()
.create(List.of(plant, pump), List.of(contains));
System.out.println(created.getNodes().size() + " resources, "
+ created.getRelations().size() + " relations");
import intellistream_datahub_sdk
plant = intellistream_datahub_sdk.Resource(external_id="plant_oslo", name="Oslo Plant", labels=["Plant"])
pump = intellistream_datahub_sdk.Resource(external_id="pump_1", name="Pump 1", labels=["Pump"])
contains = intellistream_datahub_sdk.RelForm.by_external_ids("plant_oslo", "pump_1", "contains")
result = client.resources.create([plant, pump], [contains])
print(len(result.nodes), "resources,", len(result.relations), "relations")
use intellistream_datahub_sdk::resources::Resource;
use intellistream_datahub_sdk::relations::RelForm;
let mut plant = Resource::new();
plant.external_id = "plant_oslo".into();
plant.name = "Oslo Plant".into();
plant.labels = Some(vec!["Plant".into()]);
let mut pump = Resource::new();
pump.external_id = "pump_1".into();
pump.name = "Pump 1".into();
pump.labels = Some(vec!["Pump".into()]);
let contains = RelForm::by_external_ids("plant_oslo", "pump_1", "contains");
let created = api.resources.create(vec![plant, pump], vec![contains]).await?;
An edge comes back as a Relation — { id, start, end, type, description, metadata },
where start and end are the ids of the two nodes (as JSON strings, like every other id).
That is why you send fromExternalId/toExternalId but read start/end: the write side
speaks in your identifiers, the read side in the graph's.
Relations are directional. from → to is the direction you will see when you
traverse, so plant contains pump and pump contains plant
describe different graphs.
Relations without the nodes
There are two ways to create a relation and they produce the same edge. The call above sends
nodes and relations together, in one transaction. POST /edges/create sends the relations by
themselves, for when both ends already exist and repeating them would be noise — same fields,
same rules, same edges back.
That endpoint, and the rest of the /edges surface (reading an edge back, deleting one
without touching its endpoints, the relationship-type catalog), has its own page.
Edges →
To disconnect two resources without touching either of them, delete the edge. Deleting a resource is the heavier move: it takes every relation the resource had with it.
Filter
POST /resources/filter finds resources by structured criteria. Everything you supply is
combined with AND.
| Field | Matching |
|---|---|
name | Pattern, case-insensitive. * and % are wildcards, _ is literal. |
source | Pattern, on the same rules. |
externalId | Pattern, on the same rules. |
id | Exact numeric id. |
nodeType | Restrict to these node types. Omit for every type. |
isRoot | true or false. |
labels | Resources carrying all of these labels. |
dataSetId | Resources in any of these data sets. |
metadata | Every key/value given must be present on the resource. |
createdTime, lastUpdatedTime | { "min": …, "max": … }, ISO-8601, both bounds inclusive. |
Each field above except isRoot, labels and metadata takes either a bare value or an
array, and the entries of an array are combined with OR. That is why they are named in the
singular: "name": "pipe%" is the common case, and "name": ["pipe%", "valve%"] asks for either.
labels and metadata are the exceptions, requiring all entries to match, and they keep
plural names because adding an entry there narrows the result where adding a name widens it.
{
"limit": 100,
"filter": {
"name": "pipe%",
"dataSetId": [{ "id": 12 }, { "externalId": "data_set_sap" }],
"metadata": { "work_order": "wo-sap-12344" },
"createdTime": { "min": "2026-01-01T00:00:00Z" }
}
}
limit defaults to 1 000 and is capped at 10 000; a zero, negative or null value
falls back to the default rather than returning nothing. Results come newest created first
unless ordered otherwise, and page with a cursor — the same contract as
timeseries, over the same sortable properties.
"name": "pipe" matches a resource named exactly pipe, not every name containing it. Add a
wildcard for the loose match you probably want: "pipe*" for a prefix, "*pipe*" for a contains
search. The same holds for source and externalId.
dataSetId and sending [] are oppositesOmit the field (or send null) for no data set restriction. An explicit empty list means
narrow to no data sets, which matches nothing. Every other list field treats empty as "no
restriction", so this is the one to watch when you build the filter programmatically.
- Java
- Python
- Rust
ResourceRetreiver retriever = new ResourceRetreiver();
retriever.setLimit(100);
retriever.getFilter().setName(List.of("pipe%"));
retriever.getFilter().setMetadata(Map.of("work_order", "wo-sap-12344"));
retriever.getFilter().setDataSetId(List.of(IdCollection.createFromId(12L)));
DataWrapper<Resource> matches = client.resources().filter(retriever);
matches = client.resources.filter(
name="pipe%",
metadata={"work_order": "wo-sap-12344"},
data_set_id=[12],
limit=100)
use intellistream_datahub_sdk::filters::NodeFilter;
use intellistream_datahub_sdk::generic::IdAndExtId;
use intellistream_datahub_sdk::resources::{ResourceFilter, ResourceRetreiver};
// The criteria every node type shares are a flattened `NodeFilter`, so they nest in Rust even
// though they sit alongside the resource's own fields on the wire.
let retriever = ResourceRetreiver::new(ResourceFilter {
node: NodeFilter {
name: Some(vec!["pipe%".into()]),
metadata: Some([("work_order".into(), Some("wo-sap-12344".into()))].into()),
..Default::default()
},
data_set_id: Some(vec![IdAndExtId::from_id(12)]),
..Default::default()
}).with_limit(100);
let matches = api.resources.filter(&retriever).await?;
Search
Free-text search across resource names. Matching is fuzzy and word-aware: search pipe
and you also get pipes, piping, and multi-word names containing the term. Results are
ordered by relevance, not alphabetically.
limit is capped at 1 000 here, lower than the 10 000 of filter, and query must be
3–140 characters.
filter block on this endpoint is accepted and ignoredA search body may carry the same filter as POST /resources/filter, and the SDKs let you pass
one, but the resource search does not apply it — nor do the dataset and event searches. Only
/timeseries/search reads its filter today. A search you believe is narrowed to a data set is
not, so narrow it afterwards, or use filter and give up the relevance ranking. The gap is
pinned by strict-xfail tests in the SDK, which turn green when it closes.
- Java
- Python
- Rust
ResourceSearch search = new ResourceSearch();
search.setLimit(10);
search.getSearch().setQuery("pump");
DataWrapper<Resource> matches = client.resources().search(search);
form = intellistream_datahub_sdk.SearchAndFilterForm(query="pump", limit=10)
matches = client.resources.search(form)
use intellistream_datahub_sdk::generic::{SearchAndFilterForm, SearchForm};
let form = SearchAndFilterForm {
search: Some(SearchForm { name: None, description: None, query: Some("pump".into()) }),
limit: Some(10),
filter: None,
};
let matches = api.resources.search(&form).await?;
Reach for filter instead whenever the question is structured — an exact external id, a
metadata value, a data set, a time range. It is faster and its results are predictable.
Update
POST /resources/update changes fields on resources and relations that already exist.
Identify each node by id or externalId, each relation by id, and name only what you
want changed — anything you leave out keeps its current value.
Each field is an object carrying a verb rather than a bare value, which is what lets "clear this" be said distinctly from "leave it alone":
| Verb | Applies to | Effect |
|---|---|---|
set | every field | Replace the value. |
setNull: true | nullable fields | Clear the value. |
add | metadata, labels | Merge entries in, keeping the rest. |
remove | metadata, labels | Take entries out, keeping the rest. |
{
"nodes": [
{
"externalId": "klp_pipe_ws_a1212_dl",
"update": {
"name": { "set": "klp pipe ws-a1212-dl (renamed)" },
"metadata": { "add": { "inspected_by": "olav" } },
"labels": { "add": ["CRITICAL"] }
}
}
],
"relations": []
}
Updatable node fields are externalId, name, description, source, dataSetId,
metadata, labels and geoLocation. On a relation they are start, end,
fromExternalId, toExternalId, relationship, relationshipId, description and
metadata — so an edge can be retargeted or retyped in place, subject to the same
endpoint rules as a create.
Sending both set and setNull for one field is a 400: the request is contradictory, so
it is refused rather than resolved by precedence. Changing externalId runs it past the
naming policy, which reports violations per item in an
RFC 9457 problem response. The whole batch is all-or-nothing.
409 means someone else got there firstUpdates are guarded by optimistic locking. If another request changed or deleted the
resource while yours was in flight, you get a 409 with "cause": "concurrency" and
nothing was written — no partial application to unpick. Re-read the resource with
byIds and retry the update against fresh state.
This is worth designing for rather than retrying blindly: two writers doing
metadata: { add: … } can both succeed after a re-read, whereas two doing
metadata: { set: … } will keep clobbering each other however many times you retry.
All three clients wrap this: resources().update(nodes, relations) in Java,
resources.update([...]) in Python, and resources.update(&updates) in Rust, each taking the
per-entry update forms above.
Delete
Delete by id or external id; unknown identifiers are silently skipped. A successful delete
returns 204 with no body, and deleting something already gone is a no-op — so a retried
delete needs no bookkeeping.
Deleting a resource takes all of its relationships with it, inbound and outbound. That is where the one real constraint comes from:
A delete is rejected with 400 if it would leave any surviving resource unreachable from a
root resource — that is, if it would strand part of the graph. The response names the
resources that would be stranded, so the fix is either to include them in the same delete or
to re-attach them through another path first.
Delete a mid-level node in a hierarchy and this is what you will hit: removing a plant that holds twenty pumps takes the edges to those pumps with it, stranding all twenty. The check is what stops a routine cleanup from quietly orphaning half a site.
A single safety-check failure rolls the whole batch back — nothing is deleted unless
everything can be. As with update, a concurrent modification surfaces as a 409 with
nothing removed.
- Java
- Python
- Rust
client.resources().delete(List.of(IdCollection.createFromExternalId("pump_1")));
client.resources.delete(["pump_1"])
api.resources.delete(&vec![IdAndExtId::from_external_id("pump_1")]).await?;
Traverse the graph
fetchRelated walks the graph outward from a starting resource and returns the
connected sub-graph — a ResourceNetwork of nodes, the edges between them, and
their labels. Traversal is undirected and bounded by depth (-1 = the whole
connected component), optionally filtered to specific relationship types. Use it for
relationship reasoning — root-cause correlation, blast radius — that a flat lookup
can't do. See Correlate alarms with the graph.
| Field | Default | Meaning |
|---|---|---|
id / externalId | — | Where to start. Supply exactly one. |
depth | -1 | Hops to follow. -1 loads the entire connected component. |
relationshipTypes | all | Which edge types the walk may follow. |
excludedLabels | none | Labels the walk neither passes through nor returns — e.g. ["POLICY"] to keep governance nodes out of an asset view. |
limit | 5000 | Safety cap on nodes loaded. When the component is bigger, the nearest limit nodes come back. |
That limit is the one to watch: it is a silent truncation, not an error. On a densely
connected site an unbounded depth will hit 5 000 nodes long before it runs out of graph,
and what you get back is a neighbourhood, not the component you asked for. Bound depth
to 1–3 unless you know the graph is sparse.
- Java
- Python
- Rust
// convenience: within `depth` hops of an external id
ResourceNetwork net = client.resources().fetchRelated("sensor_a", 5);
// or the full form, filtering which relationship types to follow
RelatedResourcesForm form = new RelatedResourcesForm();
form.setExternalId("sensor_a");
form.setDepth(5);
form.setRelationshipTypes(List.of("PART_OF"));
ResourceNetwork filtered = client.resources().fetchRelated(form);
net.nodes().forEach(n -> System.out.println(n.getExternalId()));
net = client.resources.fetch_related(
external_id="sensor_a", depth=5, relationship_types=["PART_OF"])
for node in net.nodes:
print(node.external_id)
for edge in net.edges:
print(edge.start, "->", edge.end, edge.relationship_type)
use intellistream_datahub_sdk::resources::RelatedResourcesForm;
let net = api.resources.fetch_related(
&RelatedResourcesForm::from_external_id("sensor_a")
.with_depth(5)
.with_relationship_types(vec!["PART_OF".into()])).await?;
for node in net.nodes() {
println!("{}", node.external_id);
}
The nearest N of a kind
POST /resources/fetch-nearest answers a question fetchRelated cannot: the ten nearest
time-series to this pump. It walks breadth-first and caps on the number of matching
end-nodes, not on hops or total nodes — so "the 10 nearest TIMESERIES" is exactly ten
however many intermediate nodes lie between them. You get those nodes plus the sub-graph
connecting them back to the start.
| Field | Default | Meaning |
|---|---|---|
id | — | Where to start. Numeric id only — see below. |
endLabels | — | Labels that qualify as a match, e.g. ["TIMESERIES"]. The walk continues past them. |
limit | 10 | How many matching end-nodes to return. |
relationshipTypes | all | Which edge types the walk may follow. |
excludedLabels | none | Labels never traversed or returned. |
That is the difference worth internalising: with fetchRelated you pick a radius and find
out what is inside it, which on an unfamiliar graph is a guess. With fetch-nearest you name
what you are looking for and how many you want, and the radius follows.
externalId is accepted but not readThe request form carries an externalId field, but this endpoint starts from id only —
sending an external id alone gets you a 404. Resolve it to a numeric id with byIds first.
fetchRelated takes either.
- Java
- Python
- Rust
FetchNearestResourcesForm form = new FetchNearestResourcesForm();
form.setId(5677892L); // numeric id, not external id
form.setEndLabels(List.of("TIMESERIES"));
form.setLimit(10);
form.setExcludedLabels(List.of("POLICY"));
ResourceNetwork nearest = client.resources().fetchNearest(form);
nearest = client.resources.fetch_nearest(
5677892, # numeric id, not external id
end_labels=["TIMESERIES"],
limit=10,
excluded_labels=["POLICY"])
use intellistream_datahub_sdk::resources::FetchNearestResourcesForm;
let nearest = api.resources.fetch_nearest(
&FetchNearestResourcesForm::from_id(5677892) // numeric id, not external id
.with_end_labels(vec!["TIMESERIES".into()])
.with_limit(10)
.with_excluded_labels(vec!["POLICY".into()])).await?;
What each client covers
| Operation | Java | Python | Rust |
|---|---|---|---|
| Get by numeric id | resources().getById | resources.get_by_id | resources.get_by_id |
| Look up by id / external id | resources().byIds | resources.by_ids | resources.by_ids |
| Create | resources().create | resources.create | resources.create |
| Update | resources().update | resources.update | resources.update |
| Delete | resources().delete | resources.delete | resources.delete |
| Search | resources().search | resources.search | resources.search |
| Filter | resources().filter | resources.filter | resources.filter |
Traverse (fetch-related) | resources().fetchRelated | resources.fetch_related | resources.fetch_related |
Nearest N (fetch-nearest) | resources().fetchNearest | resources.fetch_nearest | resources.fetch_nearest |
Relations have their own client surface in all three clients — edges() in Java, edges in
Python and Rust. Edges → client coverage