• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

knowledgepixels / nanopub-query / 24637317682

19 Apr 2026 07:32PM UTC coverage: 62.14% (+1.4%) from 60.755%
24637317682

Pull #71

github

web-flow
Merge 012634e07 into 391841c58
Pull Request #71: feat: extract admin grants and profile fields into the spaces repo (#62)

323 of 576 branches covered (56.08%)

Branch coverage included in aggregate %.

908 of 1405 relevant lines covered (64.63%)

9.54 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

74.31
src/main/java/com/knowledgepixels/query/NanopubLoader.java
1
package com.knowledgepixels.query;
2

3
import net.trustyuri.TrustyUriUtils;
4
import org.apache.http.client.HttpClient;
5
import org.apache.http.impl.client.HttpClientBuilder;
6
import org.eclipse.rdf4j.common.exception.RDF4JException;
7
import org.eclipse.rdf4j.common.transaction.IsolationLevels;
8
import org.eclipse.rdf4j.model.*;
9
import org.eclipse.rdf4j.model.impl.SimpleValueFactory;
10
import org.eclipse.rdf4j.model.vocabulary.DCTERMS;
11
import org.eclipse.rdf4j.model.vocabulary.RDFS;
12
import org.eclipse.rdf4j.query.BindingSet;
13
import org.eclipse.rdf4j.query.QueryLanguage;
14
import org.eclipse.rdf4j.query.TupleQuery;
15
import org.eclipse.rdf4j.query.TupleQueryResult;
16
import org.eclipse.rdf4j.repository.RepositoryConnection;
17
import org.nanopub.Nanopub;
18
import org.nanopub.NanopubUtils;
19
import org.nanopub.SimpleCreatorPattern;
20
import org.nanopub.SimpleTimestampPattern;
21
import org.nanopub.extra.security.KeyDeclaration;
22
import org.nanopub.extra.security.MalformedCryptoElementException;
23
import org.nanopub.extra.security.NanopubSignatureElement;
24
import org.nanopub.extra.security.SignatureUtils;
25
import org.nanopub.extra.server.GetNanopub;
26
import org.nanopub.extra.setting.IntroNanopub;
27
import org.nanopub.vocabulary.NP;
28
import org.nanopub.vocabulary.NPA;
29
import org.nanopub.vocabulary.NPX;
30
import org.nanopub.vocabulary.PAV;
31
import org.slf4j.Logger;
32
import org.slf4j.LoggerFactory;
33

34
import com.knowledgepixels.query.vocabulary.GEN;
35

36
import java.security.GeneralSecurityException;
37
import java.util.*;
38
import java.util.concurrent.ExecutionException;
39
import java.util.concurrent.Executors;
40
import java.util.concurrent.Future;
41
import java.util.concurrent.ThreadPoolExecutor;
42
import java.util.function.Consumer;
43

44
/**
45
 * Utility class for loading nanopublications into the database.
46
 */
47
public class NanopubLoader {
48

49
    private static HttpClient httpClient;
50
    private static final ThreadPoolExecutor loadingPool = (ThreadPoolExecutor) Executors.newFixedThreadPool(4);
12✔
51
    private static final int MAX_RETRIES = 30;
52
    private static final int RETRY_DELAY_MS = 10000;
53
    private Nanopub np;
54
    private NanopubSignatureElement el = null;
9✔
55
    private List<Statement> metaStatements = new ArrayList<>();
15✔
56
    private List<Statement> nanopubStatements = new ArrayList<>();
15✔
57
    private List<Statement> literalStatements = new ArrayList<>();
15✔
58
    private List<Statement> invalidateStatements = new ArrayList<>();
15✔
59
    private List<Statement> textStatements, allStatements;
60
    private Calendar timestamp = null;
9✔
61
    private Statement pubkeyStatement, pubkeyStatementX;
62
    private List<String> notes = new ArrayList<>();
15✔
63
    private boolean aborted = false;
9✔
64
    private static final Logger log = LoggerFactory.getLogger(NanopubLoader.class);
9✔
65

66

67
    NanopubLoader(Nanopub np, long counter) {
6✔
68
        this.np = np;
9✔
69
        if (counter >= 0) {
12✔
70
            log.info("Loading {}: {}", counter, np.getUri());
24✔
71
        } else {
72
            log.info("Loading: {}", np.getUri());
15✔
73
        }
74

75
        // TODO Ensure proper synchronization and DB rollbacks
76

77
        // TODO Check for null characters ("\0"), which can cause problems in Virtuoso.
78

79
        String ac = TrustyUriUtils.getArtifactCode(np.getUri().toString());
15✔
80
        if (!np.getHeadUri().toString().contains(ac) || !np.getAssertionUri().toString().contains(ac) || !np.getProvenanceUri().toString().contains(ac) || !np.getPubinfoUri().toString().contains(ac)) {
72!
81
            notes.add("could not load nanopub as not all graphs contained the artifact code");
×
82
            aborted = true;
×
83
            return;
×
84
        }
85

86
        try {
87
            el = SignatureUtils.getSignatureElement(np);
12✔
88
        } catch (MalformedCryptoElementException ex) {
×
89
            notes.add("Signature error");
×
90
        }
3✔
91
        if (!hasValidSignature(el)) {
12✔
92
            aborted = true;
9✔
93
            return;
3✔
94
        }
95

96
        pubkeyStatement = vf.createStatement(np.getUri(), NPA.HAS_VALID_SIGNATURE_FOR_PUBLIC_KEY, vf.createLiteral(el.getPublicKeyString()), NPA.GRAPH);
39✔
97
        // @ADMIN-TRIPLE-TABLE@ NANOPUB, npa:hasValidSignatureForPublicKey, FULL_PUBKEY, npa:graph, meta, full pubkey if signature is valid
98
        metaStatements.add(pubkeyStatement);
18✔
99
        pubkeyStatementX = vf.createStatement(np.getUri(), NPA.HAS_VALID_SIGNATURE_FOR_PUBLIC_KEY_HASH, vf.createLiteral(Utils.createHash(el.getPublicKeyString())), NPA.GRAPH);
42✔
100
        // @ADMIN-TRIPLE-TABLE@ NANOPUB, npa:hasValidSignatureForPublicKeyHash, PUBKEY_HASH, npa:graph, meta, hex-encoded SHA256 hash if signature is valid
101
        metaStatements.add(pubkeyStatementX);
18✔
102

103
        if (el.getSigners().size() == 1) {  // > 1 is deprecated
18!
104
            metaStatements.add(vf.createStatement(np.getUri(), NPX.SIGNED_BY, el.getSigners().iterator().next(), NPA.GRAPH));
48✔
105
            // @ADMIN-TRIPLE-TABLE@ NANOPUB, npx:signedBy, SIGNER, npa:graph, meta, ID of signer
106
        }
107

108
        Set<IRI> subIris = new HashSet<>();
12✔
109
        Set<IRI> otherNps = new HashSet<>();
12✔
110
        Set<IRI> invalidated = new HashSet<>();
12✔
111
        Set<IRI> retracted = new HashSet<>();
12✔
112
        Set<IRI> superseded = new HashSet<>();
12✔
113
        String combinedLiterals = "";
6✔
114
        for (Statement st : NanopubUtils.getStatements(np)) {
33✔
115
            nanopubStatements.add(st);
15✔
116

117
            if (st.getPredicate().toString().contains(ac)) {
18!
118
                subIris.add(st.getPredicate());
×
119
            } else {
120
                IRI b = getBaseTrustyUri(st.getPredicate());
12✔
121
                if (b != null) otherNps.add(b);
6!
122
            }
123
            if (st.getPredicate().equals(NPX.RETRACTS) && st.getObject() instanceof IRI) {
15!
124
                retracted.add((IRI) st.getObject());
×
125
            }
126
            if (st.getPredicate().equals(NPX.INVALIDATES) && st.getObject() instanceof IRI) {
15!
127
                invalidated.add((IRI) st.getObject());
×
128
            }
129
            if (st.getSubject().equals(np.getUri()) && st.getObject() instanceof IRI) {
30✔
130
                if (st.getPredicate().equals(NPX.SUPERSEDES)) {
15✔
131
                    superseded.add((IRI) st.getObject());
18✔
132
                }
133
                if (st.getObject().toString().matches(".*[^A-Za-z0-9\\-_]RA[A-Za-z0-9\\-_]{43}")) {
18✔
134
                    metaStatements.add(vf.createStatement(np.getUri(), st.getPredicate(), st.getObject(), NPA.NETWORK_GRAPH));
39✔
135
                    // @ADMIN-TRIPLE-TABLE@ NANOPUB1, RELATION, NANOPUB2, npa:networkGraph, meta, any inter-nanopub relation found in NANOPUB1
136
                }
137
                if (st.getContext().equals(np.getPubinfoUri())) {
18✔
138
                    if (st.getPredicate().equals(NPX.INTRODUCES) || st.getPredicate().equals(NPX.DESCRIBES) || st.getPredicate().equals(NPX.EMBEDS)) {
45!
139
                        metaStatements.add(vf.createStatement(np.getUri(), st.getPredicate(), st.getObject(), NPA.GRAPH));
39✔
140
                        // @ADMIN-TRIPLE-TABLE@ NANOPUB, npx:introduces, THING, npa:graph, meta, when such a triple is present in pubinfo of NANOPUB
141
                        // @ADMIN-TRIPLE-TABLE@ NANOPUB, npx:describes, THING, npa:graph, meta, when such a triple is present in pubinfo of NANOPUB
142
                        // @ADMIN-TRIPLE-TABLE@ NANOPUB, npx:embeds, THING, npa:graph, meta, when such a triple is present in pubinfo of NANOPUB
143
                    }
144
                }
145
            }
146
            if (st.getSubject().toString().contains(ac)) {
18✔
147
                subIris.add((IRI) st.getSubject());
21✔
148
            } else {
149
                IRI b = getBaseTrustyUri(st.getSubject());
12✔
150
                if (b != null) otherNps.add(b);
6!
151
            }
152
            if (st.getObject() instanceof IRI) {
12✔
153
                if (st.getObject().toString().contains(ac)) {
18✔
154
                    subIris.add((IRI) st.getObject());
21✔
155
                } else {
156
                    IRI b = getBaseTrustyUri(st.getObject());
12✔
157
                    if (b != null) otherNps.add(b);
18✔
158
                }
3✔
159
            } else {
160
                combinedLiterals += st.getObject().stringValue().replaceAll("\\s+", " ") + "\n";
27✔
161
//                                if (st.getSubject().equals(np.getUri()) && !st.getSubject().equals(HAS_FILTER_LITERAL)) {
162
//                                        literalStatements.add(vf.createStatement(np.getUri(), st.getPredicate(), st.getObject(), LITERAL_GRAPH));
163
//                                } else {
164
//                                        literalStatements.add(vf.createStatement(np.getUri(), HAS_LITERAL, st.getObject(), LITERAL_GRAPH));
165
//                                }
166
            }
167
        }
3✔
168
        subIris.remove(np.getUri());
15✔
169
        subIris.remove(np.getAssertionUri());
15✔
170
        subIris.remove(np.getProvenanceUri());
15✔
171
        subIris.remove(np.getPubinfoUri());
15✔
172
        for (IRI i : subIris) {
30✔
173
            metaStatements.add(vf.createStatement(np.getUri(), NPA.HAS_SUB_IRI, i, NPA.GRAPH));
33✔
174
            // @ADMIN-TRIPLE-TABLE@ NANOPUB, npa:hasSubIri, SUB_IRI, npa:graph, meta, for any IRI minted in the namespace of the NANOPUB
175
        }
3✔
176
        for (IRI i : otherNps) {
30✔
177
            metaStatements.add(vf.createStatement(np.getUri(), NPA.REFERS_TO_NANOPUB, i, NPA.NETWORK_GRAPH));
33✔
178
            // @ADMIN-TRIPLE-TABLE@ NANOPUB1, npa:refersToNanopub, NANOPUB2, npa:networkGraph, meta, generic inter-nanopub relation
179
        }
3✔
180
        for (IRI i : invalidated) {
18!
181
            invalidateStatements.add(vf.createStatement(np.getUri(), NPX.INVALIDATES, i, NPA.GRAPH));
×
182
            // @ADMIN-TRIPLE-TABLE@ NANOPUB, npx:invalidates, INVALIDATED_NANOPUB, npa:graph, meta, if the NANOPUB retracts or supersedes another nanopub
183
        }
×
184
        for (IRI i : retracted) {
18!
185
            invalidateStatements.add(vf.createStatement(np.getUri(), NPX.INVALIDATES, i, NPA.GRAPH));
×
186
            metaStatements.add(vf.createStatement(np.getUri(), NPX.RETRACTS, i, NPA.GRAPH));
×
187
            // @ADMIN-TRIPLE-TABLE@ NANOPUB, npx:retracts, RETRACTED_NANOPUB, npa:graph, meta, if the NANOPUB retracts another nanopub
188
        }
×
189
        for (IRI i : superseded) {
30✔
190
            invalidateStatements.add(vf.createStatement(np.getUri(), NPX.INVALIDATES, i, NPA.GRAPH));
33✔
191
            metaStatements.add(vf.createStatement(np.getUri(), NPX.SUPERSEDES, i, NPA.GRAPH));
33✔
192
            // @ADMIN-TRIPLE-TABLE@ NANOPUB, npx:supersedes, SUPERSEDED_NANOPUB, npa:graph, meta, if the NANOPUB supersedes another nanopub
193
        }
3✔
194

195
        metaStatements.add(vf.createStatement(np.getUri(), NPA.HAS_HEAD_GRAPH, np.getHeadUri(), NPA.GRAPH));
36✔
196
        // @ADMIN-TRIPLE-TABLE@ NANOPUB, npa:hasHeadGraph, HEAD_GRAPH, npa:graph, meta, direct link to the head graph of the NANOPUB
197
        metaStatements.add(vf.createStatement(np.getUri(), NPA.HAS_GRAPH, np.getHeadUri(), NPA.GRAPH));
36✔
198
        // @ADMIN-TRIPLE-TABLE@ NANOPUB, npa:hasGraph, GRAPH, npa:graph, meta, generic link to all four graphs of the given NANOPUB
199
        metaStatements.add(vf.createStatement(np.getUri(), NP.HAS_ASSERTION, np.getAssertionUri(), NPA.GRAPH));
36✔
200
        // @ADMIN-TRIPLE-TABLE@ NANOPUB, np:hasAssertion, ASSERTION_GRAPH, npa:graph, meta, direct link to the assertion graph of the NANOPUB
201
        metaStatements.add(vf.createStatement(np.getUri(), NPA.HAS_GRAPH, np.getAssertionUri(), NPA.GRAPH));
36✔
202
        metaStatements.add(vf.createStatement(np.getUri(), NP.HAS_PROVENANCE, np.getProvenanceUri(), NPA.GRAPH));
36✔
203
        // @ADMIN-TRIPLE-TABLE@ NANOPUB, np:hasProvenance, PROVENANCE_GRAPH, npa:graph, meta, direct link to the provenance graph of the NANOPUB
204
        metaStatements.add(vf.createStatement(np.getUri(), NPA.HAS_GRAPH, np.getProvenanceUri(), NPA.GRAPH));
36✔
205
        metaStatements.add(vf.createStatement(np.getUri(), NP.HAS_PUBINFO, np.getPubinfoUri(), NPA.GRAPH));
36✔
206
        // @ADMIN-TRIPLE-TABLE@ NANOPUB, np:hasPublicationInfo, PUBINFO_GRAPH, npa:graph, meta, direct link to the pubinfo graph of the NANOPUB
207
        metaStatements.add(vf.createStatement(np.getUri(), NPA.HAS_GRAPH, np.getPubinfoUri(), NPA.GRAPH));
36✔
208

209
        String artifactCode = TrustyUriUtils.getArtifactCode(np.getUri().stringValue());
15✔
210
        metaStatements.add(vf.createStatement(np.getUri(), NPA.ARTIFACT_CODE, vf.createLiteral(artifactCode), NPA.GRAPH));
39✔
211
        // @ADMIN-TRIPLE-TABLE@ NANOPUB, npa:artifactCode, ARTIFACT_CODE, npa:graph, meta, artifact code starting with 'RA...'
212

213
        if (isIntroNanopub(np)) {
9✔
214
            IntroNanopub introNp = new IntroNanopub(np);
15✔
215
            metaStatements.add(vf.createStatement(np.getUri(), NPA.IS_INTRODUCTION_OF, introNp.getUser(), NPA.GRAPH));
36✔
216
            // @ADMIN-TRIPLE-TABLE@ NANOPUB, npa:isIntroductionOf, AGENT, npa:graph, meta, linking intro nanopub to the agent it is introducing
217
            for (KeyDeclaration kc : introNp.getKeyDeclarations()) {
33✔
218
                metaStatements.add(vf.createStatement(np.getUri(), NPA.DECLARES_PUBKEY, vf.createLiteral(kc.getPublicKeyString()), NPA.GRAPH));
42✔
219
                // @ADMIN-TRIPLE-TABLE@ NANOPUB, npa:declaresPubkey, FULL_PUBKEY, npa:graph, meta, full pubkey declared by the given intro NANOPUB
220
            }
3✔
221
        }
222

223
        try {
224
            timestamp = SimpleTimestampPattern.getCreationTime(np);
12✔
225
        } catch (IllegalArgumentException ex) {
×
226
            notes.add("Illegal date/time");
×
227
        }
3✔
228
        if (timestamp != null) {
9!
229
            metaStatements.add(vf.createStatement(np.getUri(), DCTERMS.CREATED, vf.createLiteral(timestamp.getTime()), NPA.GRAPH));
45✔
230
            // @ADMIN-TRIPLE-TABLE@ NANOPUB, dct:created, CREATION_DATE, npa:graph, meta, normalized creation timestamp
231
        }
232

233
        String literalFilter = "_pubkey_" + Utils.createHash(el.getPublicKeyString());
18✔
234
        for (IRI typeIri : NanopubUtils.getTypes(np)) {
33✔
235
            metaStatements.add(vf.createStatement(np.getUri(), NPX.HAS_NANOPUB_TYPE, typeIri, NPA.GRAPH));
33✔
236
            // @ADMIN-TRIPLE-TABLE@ NANOPUB, npx:hasNanopubType, NANOPUB_TYPE, npa:graph, meta, type of NANOPUB
237
            literalFilter += " _type_" + Utils.createHash(typeIri);
15✔
238
        }
3✔
239
        // Side-effecting call: populates SpaceRegistry as gen:Space-typed nanopubs flow through.
240
        // Consumers of the registry (extraction, materialization) land in later steps of #62.
241
        detectAndRegisterSpaces(np);
9✔
242
        String label = NanopubUtils.getLabel(np);
9✔
243
        if (label != null) {
6!
244
            metaStatements.add(vf.createStatement(np.getUri(), RDFS.LABEL, vf.createLiteral(label), NPA.GRAPH));
39✔
245
            // @ADMIN-TRIPLE-TABLE@ NANOPUB, rdfs:label, LABEL, npa:graph, meta, label of NANOPUB
246
        }
247
        String description = NanopubUtils.getDescription(np);
9✔
248
        if (description != null) {
6✔
249
            metaStatements.add(vf.createStatement(np.getUri(), DCTERMS.DESCRIPTION, vf.createLiteral(description), NPA.GRAPH));
39✔
250
            // @ADMIN-TRIPLE-TABLE@ NANOPUB, dct:description, LABEL, npa:graph, meta, description of NANOPUB
251
        }
252
        for (IRI creatorIri : SimpleCreatorPattern.getCreators(np)) {
33✔
253
            metaStatements.add(vf.createStatement(np.getUri(), DCTERMS.CREATOR, creatorIri, NPA.GRAPH));
33✔
254
            // @ADMIN-TRIPLE-TABLE@ NANOPUB, dct:creator, CREATOR, npa:graph, meta, creator of NANOPUB (can be several)
255
        }
3✔
256
        for (IRI authorIri : SimpleCreatorPattern.getAuthors(np)) {
21!
257
            metaStatements.add(vf.createStatement(np.getUri(), PAV.AUTHORED_BY, authorIri, NPA.GRAPH));
×
258
            // @ADMIN-TRIPLE-TABLE@ NANOPUB, pav:authoredBy, AUTHOR, npa:graph, meta, author of NANOPUB (can be several)
259
        }
×
260

261
        if (!combinedLiterals.isEmpty()) {
9!
262
            literalStatements.add(vf.createStatement(np.getUri(), NPA.HAS_FILTER_LITERAL, vf.createLiteral(literalFilter + "\n" + combinedLiterals), NPA.GRAPH));
45✔
263
            // @ADMIN-TRIPLE-TABLE@ NANOPUB, npa:hasFilterLiteral, FILTER_LITERAL, npa:graph, literal, auxiliary literal for filtering by type and pubkey in text repo
264
        }
265

266
        // Any statements that express that the currently processed nanopub is already invalidated:
267
        List<Statement> invalidatingStatements = getInvalidatingStatements(np.getUri());
12✔
268

269
        metaStatements.addAll(invalidateStatements);
18✔
270

271
        allStatements = new ArrayList<>(nanopubStatements);
21✔
272
        allStatements.addAll(metaStatements);
18✔
273
        allStatements.addAll(invalidatingStatements);
15✔
274

275
        textStatements = new ArrayList<>(literalStatements);
21✔
276
        textStatements.addAll(metaStatements);
18✔
277
        textStatements.addAll(invalidatingStatements);
15✔
278
    }
3✔
279

280
    /**
281
     * Get the HTTP client used for fetching nanopublications.
282
     *
283
     * @return the HTTP client
284
     */
285
    static HttpClient getHttpClient() {
286
        if (httpClient == null) {
6✔
287
            httpClient = HttpClientBuilder.create().setDefaultRequestConfig(Utils.getHttpRequestConfig()).build();
15✔
288
        }
289
        return httpClient;
6✔
290
    }
291

292
    /**
293
     * Load the given nanopublication into the database.
294
     *
295
     * @param nanopubUri Nanopublication identifier (URI)
296
     */
297
    public static void load(String nanopubUri) {
298
        if (isNanopubLoaded(nanopubUri)) {
9!
299
            log.info("Already loaded: {}", nanopubUri);
×
300
        } else {
301
            Nanopub np = GetNanopub.get(nanopubUri, getHttpClient());
12✔
302
            load(np, -1);
9✔
303
        }
304
    }
3✔
305

306
    /**
307
     * Load a nanopub into the database.
308
     *
309
     * @param np      the nanopub to load
310
     * @param counter the load counter, only used for logging (or -1 if not known)
311
     * @throws RDF4JException if the loading fails
312
     */
313
    public static void load(Nanopub np, long counter) throws RDF4JException {
314
        NanopubLoader loader = new NanopubLoader(np, counter);
18✔
315
        loader.executeLoading();
6✔
316
    }
3✔
317

318
    @GeneratedFlagForDependentElements
319
    private void executeLoading() {
320
        var runningTasks = new ArrayList<Future<?>>();
321
        Consumer<Runnable> runTask = t -> runningTasks.add(loadingPool.submit(t));
×
322

323
        for (String note : notes) {
324
            loadNoteToRepo(np.getUri(), note);
325
        }
326

327
        if (!aborted) {
328
            // Submit all tasks except the "meta" task
329
            if (timestamp != null) {
330
                if (new Date().getTime() - timestamp.getTimeInMillis() < THIRTY_DAYS) {
331
                    runTask.accept(() -> loadNanopubToLatest(allStatements));
×
332
                }
333
            }
334

335
            runTask.accept(() -> loadNanopubToRepo(np.getUri(), textStatements, "text"));
×
336
            runTask.accept(() -> loadNanopubToRepo(np.getUri(), allStatements, "full"));
×
337
            // Note: "meta" task is deferred until all other tasks complete successfully
338

339
            runTask.accept(() -> loadNanopubToRepo(np.getUri(), allStatements, "pubkey_" + Utils.createHash(el.getPublicKeyString())));
×
340
            //                loadNanopubToRepo(np.getUri(), textStatements, "text-pubkey_" + Utils.createHash(el.getPublicKeyString()));
341
            for (IRI typeIri : NanopubUtils.getTypes(np)) {
342
                // Exclude locally minted IRIs:
343
                if (typeIri.stringValue().startsWith(np.getUri().stringValue())) continue;
344
                if (!typeIri.stringValue().matches("https?://.*")) continue;
345
                runTask.accept(() -> loadNanopubToRepo(np.getUri(), allStatements, "type_" + Utils.createHash(typeIri)));
×
346
                //                        loadNanopubToRepo(np.getUri(), textStatements, "text-type_" + Utils.createHash(typeIri));
347
            }
348
            // Project authority and profile contributions into the shared `spaces` repo.
349
            // Computation is cheap (a walk over the assertion) so we do it inline; the
350
            // write itself is parallelized with the other repo writes.
351
            SpacesExtractor.ExtractionResult spaceExtracts =
352
                    SpacesExtractor.extract(np, SpaceRegistry.get());
353
            if (!spaceExtracts.statements().isEmpty()) {
354
                runTask.accept(() -> loadSpaceExtracts(np.getUri(), spaceExtracts));
×
355
            }
356
            //                for (IRI creatorIri : SimpleCreatorPattern.getCreators(np)) {
357
            //                        // Exclude locally minted IRIs:
358
            //                        if (creatorIri.stringValue().startsWith(np.getUri().stringValue())) continue;
359
            //                        if (!creatorIri.stringValue().matches("https?://.*")) continue;
360
            //                        loadNanopubToRepo(np.getUri(), allStatements, "user_" + Utils.createHash(creatorIri));
361
            //                        loadNanopubToRepo(np.getUri(), textStatements, "text-user_" + Utils.createHash(creatorIri));
362
            //                }
363
            //                for (IRI authorIri : SimpleCreatorPattern.getAuthors(np)) {
364
            //                        // Exclude locally minted IRIs:
365
            //                        if (authorIri.stringValue().startsWith(np.getUri().stringValue())) continue;
366
            //                        if (!authorIri.stringValue().matches("https?://.*")) continue;
367
            //                        loadNanopubToRepo(np.getUri(), allStatements, "user_" + Utils.createHash(authorIri));
368
            //                        loadNanopubToRepo(np.getUri(), textStatements, "text-user_" + Utils.createHash(authorIri));
369
            //                }
370

371
            for (Statement st : invalidateStatements) {
372
                runTask.accept(() -> loadInvalidateStatements(np, el.getPublicKeyString(), st, pubkeyStatement, pubkeyStatementX));
×
373
            }
374

375
            // Wait for all non-meta tasks to complete successfully before submitting the meta task
376
            for (var task : runningTasks) {
377
                try {
378
                    task.get();
379
                } catch (ExecutionException | InterruptedException ex) {
380
                    throw new RuntimeException("Error in nanopub loading thread", ex.getCause());
381
                }
382
            }
383

384
            // Now submit and wait for the "meta" task after all other tasks have completed successfully
385
            Future<?> metaTask = loadingPool.submit(() -> loadNanopubToRepo(np.getUri(), metaStatements, "meta"));
×
386
            try {
387
                metaTask.get();
388
            } catch (ExecutionException | InterruptedException ex) {
389
                throw new RuntimeException("Error in nanopub loading thread (meta task)", ex.getCause());
390
            }
391
        }
392
    }
393

394
    private static Long lastUpdateOfLatestRepo = null;
6✔
395
    private static long THIRTY_DAYS = 1000L * 60 * 60 * 24 * 30;
6✔
396
    private static long ONE_HOUR = 1000L * 60 * 60;
6✔
397

398
    @GeneratedFlagForDependentElements
399
    private static void loadNanopubToLatest(List<Statement> statements) {
400
        boolean success = false;
401
        int retries = 0;
402
        while (!success) {
403
            RepositoryConnection conn = TripleStore.get().getRepoConnection("last30d");
404
            try (conn) {
405
                // Read committed, because deleting old nanopubs is idempotent. Inserts do not collide
406
                // with deletes, because we are not inserting old nanopubs.
407
                conn.begin(IsolationLevels.READ_COMMITTED);
408
                conn.add(statements);
409
                if (lastUpdateOfLatestRepo == null || new Date().getTime() - lastUpdateOfLatestRepo > ONE_HOUR) {
410
                    log.trace("Remove old nanopubs...");
411
                    Literal thirtyDaysAgo = vf.createLiteral(new Date(new Date().getTime() - THIRTY_DAYS));
412
                    TupleQuery q = conn.prepareTupleQuery(QueryLanguage.SPARQL, "SELECT * { graph <" + NPA.GRAPH + "> { " + "?np <" + DCTERMS.CREATED + "> ?date . " + "filter ( ?date < ?thirtydaysago ) " + "} }");
413
                    q.setBinding("thirtydaysago", thirtyDaysAgo);
414
                    try (TupleQueryResult r = q.evaluate()) {
415
                        while (r.hasNext()) {
416
                            BindingSet b = r.next();
417
                            IRI oldNpId = (IRI) b.getBinding("np").getValue();
418
                            log.trace("Remove old nanopub: {}", oldNpId);
419
                            for (Value v : Utils.getObjectsForPattern(conn, NPA.GRAPH, oldNpId, NPA.HAS_GRAPH)) {
420
                                // Remove all four nanopub graphs:
421
                                conn.remove((Resource) null, (IRI) null, (Value) null, (IRI) v);
422
                            }
423
                            // Remove nanopubs in admin graphs:
424
                            conn.remove(oldNpId, null, null, NPA.GRAPH);
425
                            conn.remove(oldNpId, null, null, NPA.NETWORK_GRAPH);
426
                        }
427
                    }
428
                    lastUpdateOfLatestRepo = new Date().getTime();
429
                }
430
                conn.commit();
431
                success = true;
432
            } catch (Exception ex) {
433
                log.info("Could not get environment variable", ex);
434
                if (conn.isActive()) conn.rollback();
435
            }
436
            if (!success) {
437
                retries++;
438
                if (retries >= MAX_RETRIES) {
439
                    throw new RuntimeException("Failed to load nanopub to last30d repo after " + MAX_RETRIES + " retries");
440
                }
441
                log.info("Retrying in 10 seconds (attempt {}/{})...", retries, MAX_RETRIES);
442
                try {
443
                    Thread.sleep(RETRY_DELAY_MS);
444
                } catch (InterruptedException x) {
445
                }
446
            }
447
        }
448
    }
449

450
    @GeneratedFlagForDependentElements
451
    private static void loadNanopubToRepo(IRI npId, List<Statement> statements, String repoName) {
452
        boolean success = false;
453
        int retries = 0;
454
        while (!success) {
455
            RepositoryConnection conn = TripleStore.get().getRepoConnection(repoName);
456
            try (conn) {
457
                // Serializable, because write skew would cause the chain of hashes to be broken.
458
                // The inserts must be done serially.
459
                conn.begin(IsolationLevels.SERIALIZABLE);
460
                var repoStatus = fetchRepoStatus(conn, npId);
461
                if (repoStatus.isLoaded) {
462
                    log.info("Already loaded: {}", npId);
463
                } else {
464
                    String newChecksum = NanopubUtils.updateXorChecksum(npId, repoStatus.checksum);
465
                    conn.remove(NPA.THIS_REPO, NPA.HAS_NANOPUB_COUNT, null, NPA.GRAPH);
466
                    conn.remove(NPA.THIS_REPO, NPA.HAS_NANOPUB_CHECKSUM, null, NPA.GRAPH);
467
                    conn.add(NPA.THIS_REPO, NPA.HAS_NANOPUB_COUNT, vf.createLiteral(repoStatus.count + 1), NPA.GRAPH);
468
                    // @ADMIN-TRIPLE-TABLE@ REPO, npa:hasNanopubCount, NANOPUB_COUNT, npa:graph, admin, number of nanopubs loaded
469
                    conn.add(NPA.THIS_REPO, NPA.HAS_NANOPUB_CHECKSUM, vf.createLiteral(newChecksum), NPA.GRAPH);
470
                    // @ADMIN-TRIPLE-TABLE@ REPO, npa:hasNanopubChecksum, NANOPUB_CHECKSUM, npa:graph, admin, checksum of all loaded nanopubs (order-independent XOR checksum on trusty URIs in Base64 notation)
471
                    conn.add(npId, NPA.HAS_LOAD_NUMBER, vf.createLiteral(repoStatus.count), NPA.GRAPH);
472
                    // @ADMIN-TRIPLE-TABLE@ NANOPUB, npa:hasLoadNumber, LOAD_NUMBER, npa:graph, admin, the sequential number at which this NANOPUB was loaded
473
                    conn.add(npId, NPA.HAS_LOAD_CHECKSUM, vf.createLiteral(newChecksum), NPA.GRAPH);
474
                    // @ADMIN-TRIPLE-TABLE@ NANOPUB, npa:hasLoadChecksum, LOAD_CHECKSUM, npa:graph, admin, the checksum of all loaded nanopubs after loading the given NANOPUB
475
                    conn.add(npId, NPA.HAS_LOAD_TIMESTAMP, vf.createLiteral(new Date()), NPA.GRAPH);
476
                    // @ADMIN-TRIPLE-TABLE@ NANOPUB, npa:hasLoadTimestamp, LOAD_TIMESTAMP, npa:graph, admin, the time point at which this NANOPUB was loaded
477
                    conn.add(statements);
478
                }
479
                conn.commit();
480
                success = true;
481
            } catch (Exception ex) {
482
                log.info("Could not load nanopub to repo. ", ex);
483
                if (conn.isActive()) conn.rollback();
484
            }
485
            if (!success) {
486
                retries++;
487
                if (retries >= MAX_RETRIES) {
488
                    throw new RuntimeException("Failed to load nanopub " + npId + " to repo " + repoName + " after " + MAX_RETRIES + " retries");
489
                }
490
                log.info("Retrying in 10 seconds (attempt {}/{})...", retries, MAX_RETRIES);
491
                try {
492
                    Thread.sleep(RETRY_DELAY_MS);
493
                } catch (InterruptedException x) {
494
                }
495
            }
496
        }
497
    }
498

499
    private record RepoStatus(boolean isLoaded, long count, String checksum) {
×
500
    }
501

502
    /**
503
     * To execute before loading a nanopub: check if the nanopub is already loaded and what is the
504
     * current load counter and checksum. This effectively batches three queries into one.
505
     * This method must be called from within a transaction.
506
     *
507
     * @param conn repo connection
508
     * @param npId nanopub ID
509
     * @return the current status
510
     */
511
    @GeneratedFlagForDependentElements
512
    private static RepoStatus fetchRepoStatus(RepositoryConnection conn, IRI npId) {
513
        var result = conn.prepareTupleQuery(QueryLanguage.SPARQL, REPO_STATUS_QUERY_TEMPLATE.formatted(npId)).evaluate();
514
        try (result) {
515
            if (!result.hasNext()) {
516
                // This may happen if the repo was created, but is completely empty.
517
                return new RepoStatus(false, 0, NanopubUtils.INIT_CHECKSUM);
518
            }
519
            var row = result.next();
520
            return new RepoStatus(row.hasBinding("loadNumber"), Long.parseLong(row.getBinding("count").getValue().stringValue()), row.getBinding("checksum").getValue().stringValue());
521
        }
522
    }
523

524
    @GeneratedFlagForDependentElements
525
    private static void loadInvalidateStatements(Nanopub thisNp, String thisPubkey, Statement invalidateStatement, Statement pubkeyStatement, Statement pubkeyStatementX) {
526
        boolean success = false;
527
        int retries = 0;
528
        while (!success) {
529
            List<RepositoryConnection> connections = new ArrayList<>();
530
            RepositoryConnection metaConn = TripleStore.get().getRepoConnection("meta");
531
            try {
532
                IRI invalidatedNpId = (IRI) invalidateStatement.getObject();
533
                // Basic isolation because here we only read append-only data.
534
                metaConn.begin(IsolationLevels.READ_COMMITTED);
535

536
                Value pubkeyValue = Utils.getObjectForPattern(metaConn, NPA.GRAPH, invalidatedNpId, NPA.HAS_VALID_SIGNATURE_FOR_PUBLIC_KEY);
537
                if (pubkeyValue != null) {
538
                    String pubkey = pubkeyValue.stringValue();
539

540
                    if (!pubkey.equals(thisPubkey)) {
541
                        //log.info("Adding invalidation expressed in " + thisNp.getUri() + " also to repo for pubkey " + pubkey);
542
                        connections.add(loadStatements("pubkey_" + Utils.createHash(pubkey), invalidateStatement, pubkeyStatement, pubkeyStatementX));
543
//                                                connections.add(loadStatements("text-pubkey_" + Utils.createHash(pubkey), invalidateStatement, pubkeyStatement));
544
                    }
545

546
                    for (Value v : Utils.getObjectsForPattern(metaConn, NPA.GRAPH, invalidatedNpId, NPX.HAS_NANOPUB_TYPE)) {
547
                        IRI typeIri = (IRI) v;
548
                        // TODO Avoid calling getTypes and getCreators multiple times:
549
                        if (!NanopubUtils.getTypes(thisNp).contains(typeIri)) {
550
                            //log.info("Adding invalidation expressed in " + thisNp.getUri() + " also to repo for type " + typeIri);
551
                            connections.add(loadStatements("type_" + Utils.createHash(typeIri), invalidateStatement, pubkeyStatement, pubkeyStatementX));
552
//                                                        connections.add(loadStatements("text-type_" + Utils.createHash(typeIri), invalidateStatement, pubkeyStatement));
553
                        }
554
                    }
555

556
//                                        for (Value v : Utils.getObjectsForPattern(metaConn, NPA.GRAPH, invalidatedNpId, DCTERMS.CREATOR)) {
557
//                                                IRI creatorIri = (IRI) v;
558
//                                                if (!SimpleCreatorPattern.getCreators(thisNp).contains(creatorIri)) {
559
//                                                        //log.info("Adding invalidation expressed in " + thisNp.getUri() + " also to repo for user " + creatorIri);
560
//                                                        connections.add(loadStatements("user_" + Utils.createHash(creatorIri), invalidateStatement, pubkeyStatement));
561
//                                                        connections.add(loadStatements("text-user_" + Utils.createHash(creatorIri), invalidateStatement, pubkeyStatement));
562
//                                                }
563
//                                        }
564
                }
565

566
                metaConn.commit();
567
                // TODO handle case that some commits succeed and some fail
568
                for (RepositoryConnection c : connections) c.commit();
569
                success = true;
570
            } catch (Exception ex) {
571
                log.info("Could not load invalidate statements. ", ex);
572
                if (metaConn.isActive()) metaConn.rollback();
573
                for (RepositoryConnection c : connections) {
574
                    if (c.isActive()) c.rollback();
575
                }
576
            } finally {
577
                metaConn.close();
578
                for (RepositoryConnection c : connections) c.close();
579
            }
580
            if (!success) {
581
                retries++;
582
                if (retries >= MAX_RETRIES) {
583
                    throw new RuntimeException("Failed to load invalidate statements for " + thisNp.getUri() + " after " + MAX_RETRIES + " retries");
584
                }
585
                log.info("Retrying in 10 seconds (attempt {}/{})...", retries, MAX_RETRIES);
586
                try {
587
                    Thread.sleep(RETRY_DELAY_MS);
588
                } catch (InterruptedException x) {
589
                }
590
            }
591
        }
592
    }
593

594
    @GeneratedFlagForDependentElements
595
    private static RepositoryConnection loadStatements(String repoName, Statement... statements) {
596
        RepositoryConnection conn = TripleStore.get().getRepoConnection(repoName);
597
        // Basic isolation: we only append new statements
598
        conn.begin(IsolationLevels.READ_COMMITTED);
599
        for (Statement st : statements) {
600
            conn.add(st);
601
        }
602
        return conn;
603
    }
604

605
    @GeneratedFlagForDependentElements
606
    static List<Statement> getInvalidatingStatements(IRI npId) {
607
        List<Statement> invalidatingStatements = new ArrayList<>();
608
        boolean success = false;
609
        int retries = 0;
610
        while (!success) {
611
            RepositoryConnection conn = TripleStore.get().getRepoConnection("meta");
612
            try (conn) {
613
                // Basic isolation because here we only read append-only data.
614
                conn.begin(IsolationLevels.READ_COMMITTED);
615

616
                TupleQueryResult r = conn.prepareTupleQuery(QueryLanguage.SPARQL, "SELECT * { graph <" + NPA.GRAPH + "> { " + "?np <" + NPX.INVALIDATES + "> <" + npId + "> ; <" + NPA.HAS_VALID_SIGNATURE_FOR_PUBLIC_KEY + "> ?pubkey . " + "} }").evaluate();
617
                try (r) {
618
                    while (r.hasNext()) {
619
                        BindingSet b = r.next();
620
                        invalidatingStatements.add(vf.createStatement((IRI) b.getBinding("np").getValue(), NPX.INVALIDATES, npId, NPA.GRAPH));
621
                        invalidatingStatements.add(vf.createStatement((IRI) b.getBinding("np").getValue(), NPA.HAS_VALID_SIGNATURE_FOR_PUBLIC_KEY, b.getBinding("pubkey").getValue(), NPA.GRAPH));
622
                    }
623
                }
624
                conn.commit();
625
                success = true;
626
            } catch (Exception ex) {
627
                log.info("Could not load invalidating statements. ", ex);
628
                if (conn.isActive()) conn.rollback();
629
            }
630
            if (!success) {
631
                retries++;
632
                if (retries >= MAX_RETRIES) {
633
                    throw new RuntimeException("Failed to get invalidating statements for " + npId + " after " + MAX_RETRIES + " retries");
634
                }
635
                log.info("Retrying in 10 seconds (attempt {}/{})...", retries, MAX_RETRIES);
636
                try {
637
                    Thread.sleep(RETRY_DELAY_MS);
638
                } catch (InterruptedException x) {
639
                }
640
            }
641
        }
642
        return invalidatingStatements;
643
    }
644

645
    private static void loadSpaceExtracts(IRI npUri, SpacesExtractor.ExtractionResult result) {
646
        boolean success = false;
×
647
        int retries = 0;
×
648
        while (!success) {
×
649
            RepositoryConnection conn = TripleStore.get().getRepoConnection("spaces");
×
650
            try (conn) {
×
651
                conn.begin(IsolationLevels.SERIALIZABLE);
×
652
                conn.add(result.statements());
×
653
                conn.commit();
×
654
                success = true;
×
655
            } catch (Exception ex) {
×
656
                log.info("Could not load space extracts. ", ex);
×
657
                if (conn.isActive()) conn.rollback();
×
658
            }
×
659
            if (!success) {
×
660
                retries++;
×
661
                if (retries >= MAX_RETRIES) {
×
662
                    throw new RuntimeException("Failed to load space extracts after " + MAX_RETRIES + " retries");
×
663
                }
664
                log.info("Retrying in 10 seconds (attempt {}/{})...", retries, MAX_RETRIES);
×
665
                try {
666
                    Thread.sleep(RETRY_DELAY_MS);
×
667
                } catch (InterruptedException x) {
×
668
                }
×
669
            }
670
        }
×
671
        // Reverse index: only after the write commits.
672
        for (String spaceRef : result.spaceRefs()) {
×
673
            SpaceRegistry.get().recordSourceNanopub(npUri, spaceRef);
×
674
        }
×
675
    }
×
676

677
    @GeneratedFlagForDependentElements
678
    private static void loadNoteToRepo(Resource subj, String note) {
679
        boolean success = false;
680
        int retries = 0;
681
        while (!success) {
682
            RepositoryConnection conn = TripleStore.get().getAdminRepoConnection();
683
            try (conn) {
684
                List<Statement> statements = new ArrayList<>();
685
                statements.add(vf.createStatement(subj, NPA.NOTE, vf.createLiteral(note), NPA.GRAPH));
686
                conn.add(statements);
687
                success = true;
688
            } catch (Exception ex) {
689
                log.info("Could not load note to repo. ", ex);
690
            }
691
            if (!success) {
692
                retries++;
693
                if (retries >= MAX_RETRIES) {
694
                    throw new RuntimeException("Failed to load note to repo for " + subj + " after " + MAX_RETRIES + " retries");
695
                }
696
                log.info("Retrying in 10 seconds (attempt {}/{})...", retries, MAX_RETRIES);
697
                try {
698
                    Thread.sleep(RETRY_DELAY_MS);
699
                } catch (InterruptedException x) {
700
                }
701
            }
702
        }
703
    }
704

705
    static boolean hasValidSignature(NanopubSignatureElement el) {
706
        try {
707
            if (el != null && SignatureUtils.hasValidSignature(el) && el.getPublicKeyString() != null) {
24!
708
                return true;
6✔
709
            }
710
        } catch (GeneralSecurityException ex) {
3✔
711
            log.info("Error for signature element {}", el.getUri());
15✔
712
            log.info("Error", ex);
12✔
713
        }
3✔
714
        return false;
6✔
715
    }
716

717
    private static IRI getBaseTrustyUri(Value v) {
718
        if (!(v instanceof IRI)) return null;
9!
719
        String s = v.stringValue();
9✔
720
        if (!s.matches(".*[^A-Za-z0-9\\-_]RA[A-Za-z0-9\\-_]{43}([^A-Za-z0-9\\\\-_].{0,43})?")) {
12✔
721
            return null;
6✔
722
        }
723
        return vf.createIRI(s.replaceFirst("^(.*[^A-Za-z0-9\\-_]RA[A-Za-z0-9\\-_]{43})([^A-Za-z0-9\\\\-_].{0,43})?$", "$1"));
21✔
724
    }
725

726
    // TODO: Move this to nanopub library:
727
    private static boolean isIntroNanopub(Nanopub np) {
728
        for (Statement st : np.getAssertion()) {
33✔
729
            if (st.getPredicate().equals(NPX.DECLARED_BY)) return true;
21✔
730
        }
3✔
731
        return false;
6✔
732
    }
733

734
    /**
735
     * Detects whether the given nanopub is a Space-defining nanopub (typed
736
     * {@code gen:Space}) and, if so, registers each space it declares (one per
737
     * {@code <spaceIri> gen:hasRootDefinition <rootUri>} triple) in
738
     * {@link SpaceRegistry}. Nanopubs missing the {@code gen:hasRootDefinition}
739
     * triple are not recognized as space-defining — there is no transition fallback.
740
     *
741
     * @param np the nanopub to inspect
742
     * @return the set of space refs registered from this nanopub (possibly empty);
743
     *         currently used by tests to assert detection behavior. Production
744
     *         callers invoke this for its side effect on {@link SpaceRegistry};
745
     *         downstream consumers (extraction, materialization) follow in later
746
     *         steps of #62.
747
     */
748
    static Set<String> detectAndRegisterSpaces(Nanopub np) {
749
        boolean isSpaceTyped = false;
6✔
750
        for (IRI typeIri : NanopubUtils.getTypes(np)) {
33✔
751
            if (typeIri.equals(GEN.SPACE)) {
12✔
752
                isSpaceTyped = true;
6✔
753
                break;
3✔
754
            }
755
        }
3✔
756
        if (!isSpaceTyped) return Collections.emptySet();
12✔
757
        Set<String> spaceRefs = new LinkedHashSet<>();
12✔
758
        for (Statement st : np.getAssertion()) {
33✔
759
            if (!st.getPredicate().equals(GEN.HAS_ROOT_DEFINITION)) continue;
18✔
760
            if (!(st.getSubject() instanceof IRI spaceIri)) continue;
27!
761
            if (!(st.getObject() instanceof IRI rootUri)) continue;
27!
762
            String rootNanopubId = TrustyUriUtils.getArtifactCode(rootUri.stringValue());
12✔
763
            if (rootNanopubId == null || rootNanopubId.isEmpty()) {
15!
764
                log.warn("Ignoring space {}: gen:hasRootDefinition target is not a trusty URI: {}", spaceIri, rootUri);
15✔
765
                continue;
3✔
766
            }
767
            SpaceRegistry.Registration registration = SpaceRegistry.get().registerSpace(rootNanopubId, spaceIri);
15✔
768
            spaceRefs.add(registration.spaceRef());
15✔
769
            if (registration.wasNew()) {
9!
770
                SpacesStateStore.persistSpace(rootNanopubId, spaceIri, rootUri);
12✔
771
            }
772
        }
3✔
773
        return spaceRefs;
6✔
774
    }
775

776
    /**
777
     * Check if a nanopub is already loaded in the admin graph.
778
     *
779
     * @param npId the nanopub ID
780
     * @return true if the nanopub is loaded, false otherwise
781
     */
782
    @GeneratedFlagForDependentElements
783
    static boolean isNanopubLoaded(String npId) {
784
        boolean loaded = false;
785
        RepositoryConnection conn = TripleStore.get().getRepoConnection("meta");
786
        try (conn) {
787
            if (Utils.getObjectForPattern(conn, NPA.GRAPH, vf.createIRI(npId), NPA.HAS_LOAD_NUMBER) != null) {
788
                loaded = true;
789
            }
790
        } catch (Exception ex) {
791
            log.info("Could no load nanopub. ", ex);
792
        }
793
        return loaded;
794
    }
795

796
    private static ValueFactory vf = SimpleValueFactory.getInstance();
6✔
797

798
    // TODO remove the constants and use the ones from the nanopub library instead
799

800
    /**
801
     * Template for the query that fetches the status of a repository.
802
     */
803
    // Template for .fetchRepoStatus
804
    private static final String REPO_STATUS_QUERY_TEMPLATE = """
84✔
805
            SELECT * { graph <%s> {
806
              OPTIONAL { <%s> <%s> ?loadNumber . }
807
              <%s> <%s> ?count ;
808
                   <%s> ?checksum .
809
            } }
810
            """.formatted(NPA.GRAPH, "%s", NPA.HAS_LOAD_NUMBER, NPA.THIS_REPO, NPA.HAS_NANOPUB_COUNT, NPA.HAS_NANOPUB_CHECKSUM);
6✔
811
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc