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. Returned only on assets.
isRootbooleanWhether the resource is a navigation root. Deletes are checked against reachability from a root — see Delete. Returned only on resources and assets.
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. A create body may carry them, but they are ignored: the stored values are the server's.

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. The same label types what a read returns: see Reads come back typed.

Two of those type-labels are privileged. A create carrying DATASET or POLICY builds a data set or a policy, and managing those is stricter than writing data: it requires the /datasets/*/write grant or DATAHUB_ADMIN, whichever endpoint the request arrives through. Without it the call is a 403, even when you can write the data set named in dataSetId. The same rule guards updating or deleting such a node via /resources, and edges onto a data set node. See Access control.

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.

Reads come back typed

The read endpoints (/resources/{id}, byids, filter, search, fetch-related, fetch-nearest) return each node in the shape of its kind, and the type-label inside labels is the discriminator. There is deliberately no separate type property on the wire: an element whose labels contain TIMESERIES is the time-series shape.

Type-label presentShape returned
ASSETAn asset: the body above, geoLocation included.
TIMESERIESA time-series: unit, unitExternalId, valueType.
DATASETA data set.
POLICYA policy: type, value, deactivated, templateId.
FUNCTIONA function.
noneA plain resource, the body above.

Three rules govern which fields appear where:

  • A time-series carries its full label set, not only ["TIMESERIES"].
  • isRoot belongs to resources and assets; geoLocation belongs to assets. A flat resource body naming a geoLocation is a 400: a plain resource has nowhere to store one, so it is refused rather than accepted and dropped. Send an ASSET-labelled body instead.
  • A policy carries no nodeType field. The POLICY label is the type.

These calls return DataWrapper<NodeModel>, and the concrete class of each item is the subtype, so pattern-match to reach type-specific fields. fetchRelated/fetchNearest still return a ResourceNetwork; its nodes are NodeModel too.

for (NodeModel node : client.resources().filter(retriever).getItems()) {
if (node instanceof Timeseries ts) {
System.out.println(ts.getExternalId() + " in " + ts.getUnit());
}
}

Create and update echoes are typed the same way, so an asset updated through /resources/update comes back as an asset carrying its geoLocation. One difference on the update echo: relatedResources is left empty on purpose. The request touched only some of the node's edges, and answering with those alone would be indistinguishable from answering with all of them. A delete has no echo at all, being a 204 with no body.

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;

NodeModel pump = client.resources().getById(5677892).getItems().iterator().next();

DataWrapper<NodeModel> 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.

One request carries at most 1 000 nodes and 1 000 relations, and one node at most 10 000 characters of description, 256 metadata entries, 64 labels and 64 KiB of raw GeoJSON in geoLocation. Past any of those is a 400 before anything is written; the same caps apply on update, to set and add alike. See Limits & quotas.

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.

Two more checks run over the whole batch before anything is written, on every node create endpoint: /resources, /assets, /datasets, /functions, /policies and /timeseries alike. Both refuse the whole request, so a rejected batch creates nothing.

RefusedStatusNamed in
An externalId already taken in the tenant, or repeated within the same batch. Compared without case.409error.duplicated, one entry per offending id
A dataSetId that does not exist, or that resolves to a node which is not a data set.400error.fields, one entry per offending id
{
"error": {
"code": 409,
"message": "A node with that externalId already exists.",
"duplicated": [{ "externalId": "pump_1" }]
}
}

Use update to change an existing resource rather than re-creating it. Access is decided before either check, so a caller who may not write the data set is told that (403) instead of being handed a 400 about an id they were never allowed to name.

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<NodeModel> matches = client.resources().filter(retriever);

Free-text search across every node type (assets, timeseries, functions, resources, data sets and policies), the same breadth as POST /resources/filter. The phrase is matched against name, externalId and description. Matching is fuzzy and word-aware: search pipe and you also get pipes, piping, and multi-word names containing the term.

Results are ranked by relevance (ts_rank), strongest match first, with id as a tie-break so equal-scoring rows keep a stable order and repeated identical searches agree. Ranking means the database scores and sorts every match before applying limit, so a very broad phrase costs more than a narrow one.

limit is capped at 1 000 here, lower than the 10 000 of filter, and query must be 3 to 140 characters.

Narrowing with filter

filter is optional and takes the same criteria as POST /resources/filter. It only ever removes matches: the phrase decides what the candidates are. nodeType and dataSetId are applied by the search query itself, everything else is applied to the hits afterwards.

{
"search": { "query": "pump" },
"filter": { "nodeType": ["timeseries"], "dataSetId": [{ "id": "12" }] },
"limit": 50
}
What changed

filter used to be accepted and silently ignored here, as it was on the data set and event searches. All four searches now apply it.

The phrase and the filter are now one query, so the database plans them together. They were briefly two, with the phrase capped at a 10 000-row candidate set that the filter then narrowed, which quietly dropped matches past that cap.

Two other things moved with this. The search originally ran one query per node type and concatenated the results, so limit applied per type (a request for 50 could return 250) and results came back grouped by type. Policies were never searched at all, and now are, so a search with no nodeType can return rows it did not before.

ResourceSearch search = new ResourceSearch();
search.setLimit(10);
search.getSearch().setQuery("pump");
DataWrapper<NodeModel> 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 fields onlyClear the value. name and externalId are not nullable, so asking to clear either is a 400.
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. setNull against name or externalId is also a 400, for the same reason a create cannot omit them: every resource has to have both. Rename with set instead. (This used to return 200 and quietly change nothing, so check the value rather than the status if you are working against an older deployment.) 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.

Nodes from fetchRelated and fetch-nearest come back typed by label but sparsely populated: the graph holds a subset of each node's columns, so a node from these endpoints is not the full record. Fetch by id when you need everything.

A TIMESERIES node from these endpoints carries unit, unitExternalId and valueType. Every node carries its metadata.

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

The /assets endpoints

An asset is the node type that can be a navigation root and the only one that carries a geoLocation. It has its own endpoint family, and every call in it is the pipeline above with the ASSET type pinned: the same ACLs, the same naming policy, the same create checks, the same status codes. Reach for it when a call should only ever see assets, and for /resources when one call carries or returns several node types.

EndpointBehaves asWorth knowing
POST /assets/createcreateNodes only, no relations array. 201, and the echo is asset-shaped. labels may be omitted: ASSET is added for you.
GET /assets/{id}look upOne asset, wrapped in items like every other read.
POST /assets/byidslook upIds that are missing, are not assets, or are not readable are omitted rather than failing the call.
POST /assets/filterfilterThe same criteria, the same paging. A nodeType in the body is replaced, see below.
POST /assets/searchsearchSame replacement, and the filter block is still accepted and ignored.
POST /assets/updateupdateTakes nodes and relations exactly as /resources/update does.
POST or DELETE /assets/deletedelete204, and the same connectivity check.
POST /assets/create
{
"items": [
{
"externalId": "plant_oslo",
"name": "Oslo Plant",
"labels": ["Plant"],
"isRoot": true,
"geoLocation": { "type": "Point", "coordinates": [10.75, 59.91] }
}
]
}
nodeType in the body is replaced, not merged

A nodeType you send to /assets/filter or /assets/search is overwritten with asset. nodeType entries are combined with OR, so honouring a supplied ["timeseries"] would widen a request made to /assets into a mixed query instead of narrowing it. Ask /resources/filter when you want a mixed set.

A 404 here answers three questions at once

GET /assets/{id} replies the same way to an id that does not exist, an id belonging to a node of some other type, and an asset in a data set you may not read. That is deliberate: a distinguishable 403 would confirm that an id exists.

The /functions endpoints

A function is a plain node distinguished by its FUNCTION label, with the same shape as a resource. Its family is POST /functions/create, GET /functions/list, GET /functions/{id}, POST /functions/update and POST or DELETE /functions/delete, on the same shared pipeline. GET /functions/list takes no filter: the inventory is expected to be small.

GET /functions/{id} is new, and completes the read surface: it returns the one function wrapped in items, and reports a function you may not read as missing (404) rather than forbidden, exactly as GET /assets/{id} does.

The Java client has no assets() or functions() service, so reach for the endpoints there. Creating an asset through resources().create with an ASSET label is the same pipeline and gives you the same asset back.

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