From 118de3f402ab0907ac87edc95f43e19edf47d111 Mon Sep 17 00:00:00 2001 From: Ben Morrow Date: Wed, 6 May 2026 10:57:17 +0100 Subject: [PATCH 1/6] Replace v1 search with v2 find endpoint Note that this endpoint is not implemented by either ConfigDB or MetaDB yet. Maintain compatibility within the `resolve` method, which is the only current use of the search interface. The compatibility is not exact but is good enough to cover existing use cases where jsonpath is not used and exact matches are what we want. --- lib/js-service-client/lib/service/configdb.js | 81 +++++++++++++++---- 1 file changed, 67 insertions(+), 14 deletions(-) diff --git a/lib/js-service-client/lib/service/configdb.js b/lib/js-service-client/lib/service/configdb.js index f7ecb5ce2..d83c3944d 100644 --- a/lib/js-service-client/lib/service/configdb.js +++ b/lib/js-service-client/lib/service/configdb.js @@ -315,6 +315,8 @@ export class ConfigDB extends ServiceInterface { * * The arguments are either `(app, query, results)` or an object * containing these properties. + * + * @deprecated The MetaDB will not implement this. * @arg app The application UUID * @arg klass (optional) Restrict to objects in this class * @arg query The query object @@ -344,27 +346,78 @@ export class ConfigDB extends ServiceInterface { return await res.json(); } - /** Use `search` to locate a single object. - * This calls `search` with the provided options. The `results` key - * must be omitted. If we get more than one result this throws an - * exception. - * @returns The UUID of the single object which matches the query. + /** List objects by config entry. + * + * Finds all objects with a config entry exactly matching the one + * supplied. Results may be restricted by class. + * + * @arg app The App to search + * @arg config The config entry to match against + * @arg klass (optional) Restrict results to this class + * @returns An array of UUIDs */ - async resolve (opts) { - if ("results" in opts) - throw new RangeError("resolve doesn't return results"); + async find (app, config, klass) { + const kpath = klass ? `/class/${klass}` : ""; - const uuids = await this.search(opts); + const [st, uuids] = await this.fetch({ + method: "POST", + url: `v2/app/${app}${kpath}/find`, + body: config, + }); - if (!Array.isArray(uuids)) - this.throw("search didn't return an array"); - if (uuids.length > 1) - this.throw(format("More than one result resolving %s with %o", - opts.app, opts.query)); + if (st != 200) { + const kmsg = klass ? ` in ${klass}` : ""; + this.throw(`Find for ${app}${kmsg} failed`, st); + } + return uuids; + } + /** Find a single object by config entry. + * + * Calls `find` and expects the result to contain no more than one + * object. Throws if we get multiple results. + * + * @arg app The App to search + * @arg config The config entry to match against + * @arg klass (optional) Restrict results to this class + * @returns A single UUID, or undefined + */ + async find_unique (app, config, klass) { + const uuids = await this.find(app, config, klass); + if (uuids.length > 1) { + const kmsg = klass ? ` in ${klass}` : ""; + this.throw(`Find for ${app}${kmsg} returned multiple results`); + } return uuids[0]; } + /** Find a single object by config entry. + * + * This is a compatibility method that calls `find_unique`. + * + * This used to call `search` but that endpoint is deprecated and + * not implemented by the MetaDB. The `query` parameter will be used + * directly as the config entry to match. This is only compatible + * with `search` as long as only top-level properties are used; the + * JSONpath feature of `search` is no longer supported. Properties + * explicitly mapped to `undefined` will not be included in the + * request; this matches the `search` behaviour of requiring these + * to not be present. Properties omitted from the query must also be + * not present in the config; this is a change from the `search` + * behaviour, which would ignore additional properties. + * + * Arguments are in an object. + * + * @deprecated Use find_unique for new code + * @arg app The App to search + * @arg query The config entry to search for + * @arg klass Restrict search to objects of this class + * @returns The UUID of the single object which matches the query. + */ + resolve (opts) { + return this.find_unique(opts.app, opts.query, opts.klass); + } + /** Create a ConfigDBWatcher object. * This returns a Promise to a ConfigDBWatcher object for this * ConfigDB. This allows watching config entry changes over From 2c08de44547180029dc9ed9fb8b397afad490a26 Mon Sep 17 00:00:00 2001 From: Ben Morrow Date: Wed, 6 May 2026 12:42:19 +0100 Subject: [PATCH 2/6] Start app-level endpoints So far we have only implemented config entry endpoints. The app-level endpoints such as listing objects are not implemented yet. --- .../co/amrc/factoryplus/metadb/api/V2App.java | 61 +++++++++++++++++++ .../amrc/factoryplus/metadb/db/Dataflow.java | 2 + 2 files changed, 63 insertions(+) create mode 100644 acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/api/V2App.java diff --git a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/api/V2App.java b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/api/V2App.java new file mode 100644 index 000000000..db71fe164 --- /dev/null +++ b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/api/V2App.java @@ -0,0 +1,61 @@ +/* + * Factory+ RDF store + * App endpoints + * Copyright 2026 University of Sheffield AMRC + */ + +package uk.co.amrc.factoryplus.metadb.api; + +import java.util.Date; +import java.util.UUID; +import java.util.function.Supplier; + +import jakarta.inject.*; +import jakarta.json.*; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.*; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.apache.jena.rdf.model.*; +import org.apache.jena.vocabulary.*; + +import io.vavr.control.*; + +import uk.co.amrc.factoryplus.metadb.db.*; + +@Path("v2/app/{app}") +@Consumes("application/json") +public class V2App { + private static final Logger log = LoggerFactory.getLogger(V2App.class); + + @Inject private RdfStore db; + @Inject private SecurityContext auth; + + @PathParam("app") private UUID app; + + /* Because we are pulling from dataflow we do not need our own txns. + * Dataflow will open a txn if it needs to do an initial fetch and + * doesn't have cached results already. This means we don't want to + * use requestCalculate and need to build our own RequestHandler. */ + private void checkACL (UUID perm, UUID targ) + { + new RequestHandler(db, auth) + .fetchACL() + .checkACL(perm, targ); + } + + @GET @Path("object") + public JsonArray list () + { + checkACL(Vocab.Perm.ReadApp, app); + var objs = db.dataflow() + .appValues(app) + .blockingFirst() + .keySet() + .map(UUID::toString) + .toJavaSet(); + return Json.createArrayBuilder(objs).build(); + } +} diff --git a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/Dataflow.java b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/Dataflow.java index 3eaca82ad..738fc4030 100644 --- a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/Dataflow.java +++ b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/Dataflow.java @@ -181,6 +181,8 @@ private Map appState (UUID app) .withReplay() .build(); + /* XXX This returns an empty Map for UUIDs which aren't Apps. This + * is incorrect, we should probably have an Option return type. */ private Observable> _buildAppValues (UUID app) { return appUpdates(app) From 0c2f2707e2485f15b356d81e76bff2624c2c8063 Mon Sep 17 00:00:00 2001 From: Ben Morrow Date: Wed, 6 May 2026 13:48:02 +0100 Subject: [PATCH 3/6] Add an app find endpoint This does a simpleminded search over the current app state. This will be significantly less efficient than a dedicated query for a single use, but may be more efficient in situations where only a few Apps are being queried, especially if those Apps are also the subjects of notify sequences. The simpleminded approach also avoids issues of JSON canonicalisation. --- .../co/amrc/factoryplus/metadb/api/V2App.java | 37 +++++++++++++++---- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/api/V2App.java b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/api/V2App.java index db71fe164..e2280cb10 100644 --- a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/api/V2App.java +++ b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/api/V2App.java @@ -21,6 +21,7 @@ import org.apache.jena.rdf.model.*; import org.apache.jena.vocabulary.*; +import io.vavr.collection.*; import io.vavr.control.*; import uk.co.amrc.factoryplus.metadb.db.*; @@ -46,16 +47,36 @@ private void checkACL (UUID perm, UUID targ) .checkACL(perm, targ); } + private JsonValue uuidSet (Set uuids) + { + var strs = uuids.map(UUID::toString) + .toJavaSet(); + return Json.createArrayBuilder(strs) + .build(); + } + + private Map appValues () + { + return db.dataflow() + .appValues(app) + .blockingFirst(); + } + @GET @Path("object") - public JsonArray list () + public JsonValue list () { checkACL(Vocab.Perm.ReadApp, app); - var objs = db.dataflow() - .appValues(app) - .blockingFirst() - .keySet() - .map(UUID::toString) - .toJavaSet(); - return Json.createArrayBuilder(objs).build(); + var objs = appValues().keySet(); + return uuidSet(objs); + } + + @POST @Path("find") + public JsonValue find (JsonValue config) + { + checkACL(Vocab.Perm.ReadApp, app); + var objs = appValues() + .filterValues(e -> e.value().equals(config)) + .keySet(); + return uuidSet(objs); } } From 666dea73b4d08fead2c8f32ffd30c7d3f2c1b518 Mon Sep 17 00:00:00 2001 From: Ben Morrow Date: Wed, 6 May 2026 14:14:58 +0100 Subject: [PATCH 4/6] Implement find-within-class --- .../co/amrc/factoryplus/metadb/api/V2App.java | 41 ++++++++++++++----- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/api/V2App.java b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/api/V2App.java index e2280cb10..49a2499dc 100644 --- a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/api/V2App.java +++ b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/api/V2App.java @@ -21,6 +21,7 @@ import org.apache.jena.rdf.model.*; import org.apache.jena.vocabulary.*; +import io.vavr.Lazy; import io.vavr.collection.*; import io.vavr.control.*; @@ -34,17 +35,19 @@ public class V2App { @Inject private RdfStore db; @Inject private SecurityContext auth; - @PathParam("app") private UUID app; - /* Because we are pulling from dataflow we do not need our own txns. * Dataflow will open a txn if it needs to do an initial fetch and * doesn't have cached results already. This means we don't want to - * use requestCalculate and need to build our own RequestHandler. */ + * use requestRead and need to build our own RequestHandler. */ + private Lazy req = Lazy.of(() -> + new RequestHandler(db, auth) + .fetchACL()); + + @PathParam("app") private UUID app; + private void checkACL (UUID perm, UUID targ) { - new RequestHandler(db, auth) - .fetchACL() - .checkACL(perm, targ); + req.get().checkACL(perm, targ); } private JsonValue uuidSet (Set uuids) @@ -70,13 +73,31 @@ public JsonValue list () return uuidSet(objs); } + private Set findAll (JsonValue config) + { + return appValues() + .filterValues(e -> e.value().equals(config)) + .keySet(); + } + @POST @Path("find") public JsonValue find (JsonValue config) { checkACL(Vocab.Perm.ReadApp, app); - var objs = appValues() - .filterValues(e -> e.value().equals(config)) - .keySet(); - return uuidSet(objs); + return uuidSet(findAll(config)); + } + + @POST @Path("class/{class}/find") + public JsonValue findInClass (@PathParam("class") UUID klass, JsonValue config) + { + checkACL(Vocab.Perm.ReadApp, app); + var rel = Relation.of("member").bind(klass, true, false); + checkACL(rel.perm(), klass); + + var objs = findAll(config); + var mems = db.dataflow() + .relation(rel) + .blockingFirst(); + return uuidSet(objs.intersect(mems)); } } From f6791a9327fd8e40f43ff15e2e230c940604aa34 Mon Sep 17 00:00:00 2001 From: Ben Morrow Date: Wed, 6 May 2026 14:24:16 +0100 Subject: [PATCH 5/6] Make cluster/node subclasses of SparkplugEntity In order for the MetaDB to recognise that objects of these may have Sparkplug addresses it is necessary for them to be subclasses of SparkplugEntity. --- acs-service-setup/dumps/clusters.yaml | 7 ++++++- acs-service-setup/dumps/sparkplug.yaml | 7 ++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/acs-service-setup/dumps/clusters.yaml b/acs-service-setup/dumps/clusters.yaml index b3863637f..b56648f79 100644 --- a/acs-service-setup/dumps/clusters.yaml +++ b/acs-service-setup/dumps/clusters.yaml @@ -1,4 +1,4 @@ -#-REQUIRE: auth git helm +#-REQUIRE: auth git helm metadb --- service: !u UUIDs.Service.ConfigDB version: 2 @@ -59,6 +59,11 @@ objects: !u Clusters.Class.Cluster: name: "Edge cluster" iri: edge/Cluster + subclassOf: + # Edge cluster is not the only possibility for a Sparkplug + # Group, so probably we should have a SparkplugGroup + # intermediate class here. + - !u MetaDB.Class.SparkplugEntity !u UUIDs.Class.App: !u Clusters.App.Bootstrap: diff --git a/acs-service-setup/dumps/sparkplug.yaml b/acs-service-setup/dumps/sparkplug.yaml index fb296ce53..242e1e070 100644 --- a/acs-service-setup/dumps/sparkplug.yaml +++ b/acs-service-setup/dumps/sparkplug.yaml @@ -1,5 +1,5 @@ # ACS-specific deployment decisions -#-REQUIRE: auth +#-REQUIRE: auth metadb --- service: !u UUIDs.Service.ConfigDB version: 2 @@ -19,10 +19,15 @@ objects: - !u UUIDs.Permission.CmdEsc.Rebirth !u Auth.Class.ClientRole: + # XXX There is some confusion here between 'an object with a + # Sparkplug Node address' and 'an object which is authorised to use + # its Node address to publish data'. The former is ⊂ Principal and ⊂ + # SparkplugEntity but only the latter is ∈ ClientRole. !u ACS.Group.SparkplugNode: name: "Sparkplug Node" subclassOf: - !u Auth.Class.Principal + - !u MetaDB.Class.SparkplugEntity # _Active Edge Agent_ is created by helm.js. !u ACS.Group.SparkplugReader: name: "Sparkplug global reader" From e23af2cd03ab2274f7c03333a453dee7dc50929d Mon Sep 17 00:00:00 2001 From: Ben Morrow Date: Wed, 6 May 2026 14:34:43 +0100 Subject: [PATCH 6/6] Log config used for find --- .../src/main/java/uk/co/amrc/factoryplus/metadb/api/V2App.java | 1 + 1 file changed, 1 insertion(+) diff --git a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/api/V2App.java b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/api/V2App.java index 49a2499dc..5659fb016 100644 --- a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/api/V2App.java +++ b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/api/V2App.java @@ -75,6 +75,7 @@ public JsonValue list () private Set findAll (JsonValue config) { + log.info("Searching for config {}", config); return appValues() .filterValues(e -> e.value().equals(config)) .keySet();