Skip to main content

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

FieldTypeNotes
idnumberServer-assigned. Crosses the wire as a JSON string — see the note below.
externalIdstring, 3–256Required. Unique per tenant, stored verbatim, matched case-insensitively.
namestring, 3–512Required. What a human calls it. This is the field search reads.
labelsstring[]Required, at least one. The type tags (Pump, Plant). Upper-cased by the server.
descriptionstringProse.
metadatamap<string, string>Flat key/value, filterable by exact match.
sourcestring, 2–128The upstream system of record this came from (SAP, a historian, a file drop).
dataSetIdnumberThe data set the resource belongs to.
geoLocationGeoJSON geometryPoint, Polygon, … Validated on write; stored verbatim.
isRootbooleanWhether the resource is a navigation root. Deletes are checked against reachability from a root — see Delete.
relatedResourcesobject[]Read-only view of the graph: { id, externalId, relationshipType, direction } per connected node. Populated where the graph is loaded, empty otherwise.
createdTime, lastUpdatedTimeepoch millisServer-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.

Numeric ids cross the wire as JSON strings

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.

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)));

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.

Edges into datasets and time-series are validated

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_TO relationship type — that is the relation the dataset hierarchy and membership are built from, and anything else is rejected with a 400.
  • 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.
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");

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. fromto 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.

FieldMatching
namePattern, case-insensitive. * and % are wildcards, _ is literal.
sourcePattern, on the same rules.
externalIdPattern, on the same rules.
idExact numeric id.
nodeTypeRestrict to these node types. Omit for every type.
isRoottrue or false.
labelsResources carrying all of these labels.
dataSetIdResources in any of these data sets.
metadataEvery 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.

A pattern-less value matches exactly, not as a substring

"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.

Omitting dataSetId and sending [] are opposites

Omit 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.

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);

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.

The filter block on this endpoint is accepted and ignored

A 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.

ResourceSearch search = new ResourceSearch();
search.setLimit(10);
search.getSearch().setQuery("pump");
DataWrapper<Resource> matches = client.resources().search(search);

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":

VerbApplies toEffect
setevery fieldReplace the value.
setNull: truenullable fieldsClear the value.
addmetadata, labelsMerge entries in, keeping the rest.
removemetadata, labelsTake 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.

A 409 means someone else got there first

Updates 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:

The graph must stay connected

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.

client.resources().delete(List.of(IdCollection.createFromExternalId("pump_1")));

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.

FieldDefaultMeaning
id / externalIdWhere to start. Supply exactly one.
depth-1Hops to follow. -1 loads the entire connected component.
relationshipTypesallWhich edge types the walk may follow.
excludedLabelsnoneLabels the walk neither passes through nor returns — e.g. ["POLICY"] to keep governance nodes out of an asset view.
limit5000Safety 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.

// 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()));

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.

FieldDefaultMeaning
idWhere to start. Numeric id only — see below.
endLabelsLabels that qualify as a match, e.g. ["TIMESERIES"]. The walk continues past them.
limit10How many matching end-nodes to return.
relationshipTypesallWhich edge types the walk may follow.
excludedLabelsnoneLabels 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 read

The 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.

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);

What each client covers

OperationJavaPythonRust
Get by numeric idresources().getByIdresources.get_by_idresources.get_by_id
Look up by id / external idresources().byIdsresources.by_idsresources.by_ids
Createresources().createresources.createresources.create
Updateresources().updateresources.updateresources.update
Deleteresources().deleteresources.deleteresources.delete
Searchresources().searchresources.searchresources.search
Filterresources().filterresources.filterresources.filter
Traverse (fetch-related)resources().fetchRelatedresources.fetch_relatedresources.fetch_related
Nearest N (fetch-nearest)resources().fetchNearestresources.fetch_nearestresources.fetch_nearest

Relations have their own client surface in all three clients — edges() in Java, edges in Python and Rust. Edges → client coverage