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

knowledgepixels / nanopub-registry / 30893238389

04 Aug 2026 08:44AM UTC coverage: 83.415% (+51.1%) from 32.285%
30893238389

Pull #123

github

web-flow
Merge 84fad06c1 into e5ed6d462
Pull Request #123: Add unit tests for existing functionality

865 of 1112 branches covered (77.79%)

Branch coverage included in aggregate %.

2706 of 3169 relevant lines covered (85.39%)

12.96 hits per line

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

71.13
src/main/java/com/knowledgepixels/registry/RegistryDB.java
1
package com.knowledgepixels.registry;
2

3
import com.knowledgepixels.registry.db.IndexInitializer;
4
import com.mongodb.*;
5
import com.mongodb.client.ClientSession;
6
import com.mongodb.client.MongoCollection;
7
import com.mongodb.client.MongoCursor;
8
import com.mongodb.client.MongoDatabase;
9
import com.mongodb.client.model.CountOptions;
10
import com.mongodb.client.model.FindOneAndUpdateOptions;
11
import com.mongodb.client.model.ReturnDocument;
12
import com.mongodb.client.model.UpdateOptions;
13
import net.trustyuri.TrustyUriUtils;
14
import org.bson.Document;
15
import org.bson.conversions.Bson;
16
import org.bson.types.Binary;
17
import org.eclipse.rdf4j.common.exception.RDF4JException;
18
import org.eclipse.rdf4j.model.IRI;
19
import org.eclipse.rdf4j.rio.RDFFormat;
20
import org.nanopub.MalformedNanopubException;
21
import org.nanopub.Nanopub;
22
import org.nanopub.NanopubUtils;
23
import org.nanopub.extra.security.MalformedCryptoElementException;
24
import org.nanopub.extra.security.NanopubSignatureElement;
25
import org.nanopub.extra.security.SignatureUtils;
26
import org.nanopub.jelly.JellyUtils;
27
import org.slf4j.Logger;
28
import org.slf4j.LoggerFactory;
29

30
import java.io.IOException;
31
import java.security.GeneralSecurityException;
32
import java.util.ArrayList;
33
import java.util.Calendar;
34
import java.util.HashSet;
35
import java.util.Set;
36

37
import static com.mongodb.client.model.Indexes.ascending;
38

39
public class RegistryDB {
40

41
    private RegistryDB() {
42
    }
43

44
    private static final String REGISTRY_DB_NAME = Utils.getEnv("REGISTRY_DB_NAME", "nanopubRegistry");
12✔
45

46
    private static final Logger logger = LoggerFactory.getLogger(RegistryDB.class);
9✔
47

48
    private static MongoClient mongoClient;
49
    private static MongoDatabase mongoDB;
50

51
    /**
52
     * Returns the MongoDB database instance.
53
     *
54
     * @return the MongoDatabase instance
55
     */
56
    public static MongoDatabase getDB() {
57
        return mongoDB;
6✔
58
    }
59

60
    /**
61
     * Returns the MongoDB client instance.
62
     *
63
     * @return the MongoClient instance
64
     */
65
    public static MongoClient getClient() {
66
        return mongoClient;
6✔
67
    }
68

69
    /**
70
     * Returns the specified collection from the MongoDB database.
71
     *
72
     * @param name the name of the collection
73
     * @return the MongoCollection instance
74
     */
75
    public static MongoCollection<Document> collection(String name) {
76
        return mongoDB.getCollection(name);
12✔
77
    }
78

79
    /**
80
     * Initializes the MongoDB connection and sets up collections and indexes if not already initialized.
81
     */
82
    public static void init() {
83
        if (mongoClient != null) {
6✔
84
            logger.info("RegistryDB already initialized (database: {})", REGISTRY_DB_NAME);
12✔
85
            return;
3✔
86
        }
87
        final String REGISTRY_DB_HOST = Utils.getEnv("REGISTRY_DB_HOST", "mongodb");
12✔
88
        final int REGISTRY_DB_PORT = Integer.parseInt(Utils.getEnv("REGISTRY_DB_PORT", String.valueOf(ServerAddress.defaultPort())));
18✔
89
        logger.info("Initializing RegistryDB connection to database '{}' at {}:{}", REGISTRY_DB_NAME, REGISTRY_DB_HOST, REGISTRY_DB_PORT);
54✔
90
        mongoClient = new MongoClient(REGISTRY_DB_HOST, REGISTRY_DB_PORT);
18✔
91
        mongoDB = mongoClient.getDatabase(REGISTRY_DB_NAME);
12✔
92

93
        try (ClientSession mongoSession = mongoClient.startSession()) {
9✔
94
            logger.debug("MongoDB client session started for initialization");
9✔
95
            if (!isInitialized(mongoSession)) {
9!
96
                logger.info("Database '{}' not initialized; creating collections and indexes", REGISTRY_DB_NAME);
12✔
97
                IndexInitializer.initCollections(mongoSession);
9✔
98
            } else {
99
                logger.debug("Database '{}' already has setupId", REGISTRY_DB_NAME);
×
100
            }
101
            initCounter(mongoSession);
6✔
102
        }
103
    }
3✔
104

105
    /**
106
     * Checks if the database has been initialized.
107
     *
108
     * @param mongoSession the MongoDB client session
109
     * @return true if initialized, false otherwise
110
     */
111
    public static boolean isInitialized(ClientSession mongoSession) {
112
        boolean initialized = getValue(mongoSession, Collection.SERVER_INFO.toString(), "setupId") != null;
30✔
113
        logger.debug("isInitialized check for database '{}': {}", REGISTRY_DB_NAME, initialized);
18✔
114
        return initialized;
6✔
115
    }
116

117
    /**
118
     * Renames a collection in the database. If the new collection name already exists, it will be dropped first.
119
     *
120
     * @param oldCollectionName the current name of the collection
121
     * @param newCollectionName the new name for the collection
122
     */
123
    public static void rename(String oldCollectionName, String newCollectionName) {
124
        // Designed as idempotent operation: calling multiple times has same effect as calling once
125
        if (hasCollection(oldCollectionName)) {
9✔
126
            if (hasCollection(newCollectionName)) {
9✔
127
                collection(newCollectionName).drop();
9✔
128
            }
129
            collection(oldCollectionName).renameCollection(new MongoNamespace(REGISTRY_DB_NAME, newCollectionName));
24✔
130
        }
131
    }
3✔
132

133
    /**
134
     * Checks if a collection with the given name exists in the database.
135
     *
136
     * @param collectionName the name of the collection to check
137
     * @return true if the collection exists, false otherwise
138
     */
139
    public static boolean hasCollection(String collectionName) {
140
        boolean exists = mongoDB.listCollectionNames().into(new ArrayList<>()).contains(collectionName);
30✔
141
        logger.debug("Collection existence check for '{}': {}", collectionName, exists);
18✔
142
        return exists;
6✔
143
    }
144

145
    /**
146
     * Increases the trust state counter in the server info collection.
147
     *
148
     * @param mongoSession the MongoDB client session
149
     */
150
    public static void increaseStateCounter(ClientSession mongoSession) {
151
        try (MongoCursor<Document> cursor = collection(Collection.SERVER_INFO.toString()).find(mongoSession, new Document("_id", "trustStateCounter")).cursor()) {
36✔
152
            if (cursor.hasNext()) {
9✔
153
                long counter = cursor.next().getLong("value");
21✔
154
                collection(Collection.SERVER_INFO.toString()).updateOne(mongoSession, new Document("_id", "trustStateCounter"), new Document("$set", new Document("value", counter + 1)));
69✔
155
                logger.debug("Incremented trustStateCounter from {} to {}", counter, counter + 1);
27✔
156
            } else {
3✔
157
                collection(Collection.SERVER_INFO.toString()).insertOne(mongoSession, new Document("_id", "trustStateCounter").append("value", 0L));
42✔
158
                logger.info("Initialized trustStateCounter to 0 in collection '{}'", Collection.SERVER_INFO);
12✔
159
            }
160
        }
161
    }
3✔
162

163
    /**
164
     * Checks if an element with the given name exists in the specified collection.
165
     *
166
     * @param mongoSession the MongoDB client session
167
     * @param collection   the name of the collection
168
     * @param elementName  the name of the element used as the _id field
169
     * @return true if the element exists, false otherwise
170
     */
171
    public static boolean has(ClientSession mongoSession, String collection, String elementName) {
172
        return has(mongoSession, collection, new Document("_id", elementName));
27✔
173
    }
174

175
    private static final CountOptions hasCountOptions = new CountOptions().limit(1);
21✔
176

177
    /**
178
     * Checks if any document matching the given filter exists in the specified collection.
179
     *
180
     * @param mongoSession the MongoDB client session
181
     * @param collection   the name of the collection
182
     * @param find         the filter to match documents
183
     * @return true if at least one matching document exists, false otherwise
184
     */
185
    public static boolean has(ClientSession mongoSession, String collection, Bson find) {
186
        boolean found = collection(collection).countDocuments(mongoSession, find, hasCountOptions) > 0;
39✔
187
        logger.debug("Existence check in collection '{}' for filter {}: {}", collection, find, found);
54✔
188
        return found;
6✔
189
    }
190

191
    /**
192
     * Retrieves a cursor for documents matching the given filter in the specified collection.
193
     *
194
     * @param mongoSession the MongoDB client session
195
     * @param collection   the name of the collection
196
     * @param find         the filter to match documents
197
     * @return a MongoCursor for the matching documents
198
     */
199
    public static MongoCursor<Document> get(ClientSession mongoSession, String collection, Bson find) {
200
        logger.trace("Querying collection '{}' with filter {}", collection, find);
15✔
201
        return collection(collection).find(mongoSession, find).cursor();
21✔
202
    }
203

204
    /**
205
     * Retrieves the value of an element with the given name from the specified collection.
206
     *
207
     * @param mongoSession the MongoDB client session
208
     * @param collection   the name of the collection
209
     * @param elementName  the name of the element used as the _id field
210
     * @return the value of the element, or null if not found
211
     */
212
    public static Object getValue(ClientSession mongoSession, String collection, String elementName) {
213
        logger.debug("Reading value of element '{}' from collection '{}'", elementName, collection);
15✔
214
        Document d = collection(collection).find(mongoSession, new Document("_id", elementName)).first();
36✔
215
        if (d == null) {
6✔
216
            logger.trace("Element '{}' not found in collection '{}'", elementName, collection);
15✔
217
            return null;
6✔
218
        }
219
        Object value = d.get("value");
12✔
220
        logger.debug("Found element '{}' in collection '{}' with value type {}", elementName, collection, value == null ? "null" : value.getClass().getSimpleName());
63!
221
        return value;
6✔
222
    }
223

224
    /**
225
     * Retrieves the boolean value of an element with the given name from the specified collection.
226
     *
227
     * @param mongoSession the MongoDB client session
228
     * @param collection   the name of the collection
229
     * @param elementName  the name of the element used as the _id field
230
     * @return the value of the element, or null if not found
231
     */
232
    public static boolean isSet(ClientSession mongoSession, String collection, String elementName) {
233
        Document d = collection(collection).find(mongoSession, new Document("_id", elementName)).first();
36✔
234
        if (d == null) {
6✔
235
            logger.trace("isSet: element '{}' not found in collection '{}'", elementName, collection);
15✔
236
            return false;
6✔
237
        }
238
        Boolean val = d.getBoolean("value");
12✔
239
        logger.debug("isSet: element '{}' in collection '{}' has boolean value {}", elementName, collection, val);
51✔
240
        return val;
9✔
241
    }
242

243
    /**
244
     * Retrieves a single document matching the given filter from the specified collection.
245
     *
246
     * @param mongoSession the MongoDB client session
247
     * @param collection   the name of the collection
248
     * @param find         the filter to match the document
249
     * @return the matching document, or null if not found
250
     */
251
    public static Document getOne(ClientSession mongoSession, String collection, Bson find) {
252
        logger.trace("getOne from '{}' with filter {}", collection, find);
15✔
253
        return collection(collection).find(mongoSession, find).first();
24✔
254
    }
255

256
    /**
257
     * Retrieves the maximum value of a specified field from the documents in the given collection.
258
     *
259
     * @param mongoSession the MongoDB client session
260
     * @param collection   the name of the collection
261
     * @param fieldName    the field for which to find the maximum value
262
     * @return the maximum value of the specified field, or null if no documents exist
263
     */
264
    public static Object getMaxValue(ClientSession mongoSession, String collection, String fieldName) {
265
        Document doc = collection(collection).find(mongoSession).projection(new Document(fieldName, 1)).sort(new Document(fieldName, -1)).first();
63✔
266
        if (doc == null) {
6✔
267
            logger.trace("getMaxValue: no documents in collection '{}' for field '{}'", collection, fieldName);
15✔
268
            return null;
6✔
269
        }
270
        Object val = doc.get(fieldName);
12✔
271
        logger.debug("getMaxValue: collection '{}' field '{}' max = {}", collection, fieldName, val);
51✔
272
        return val;
6✔
273
    }
274

275
    /**
276
     * Retrieves the document with the maximum value of a specified field from the documents matching the given filter in the specified collection.
277
     *
278
     * @param mongoSession the MongoDB client session
279
     * @param collection   the name of the collection
280
     * @param find         the filter to match documents
281
     * @param fieldName    the field for which to find the maximum value
282
     * @return the document with the maximum value of the specified field, or null if no matching documents exist
283
     */
284
    public static Document getMaxValueDocument(ClientSession mongoSession, String collection, Bson find, String fieldName) {
285
        logger.trace("getMaxValueDocument in '{}' with filter {} for field '{}'", collection, find, fieldName);
51✔
286
        return collection(collection).find(mongoSession, find).sort(new Document(fieldName, -1)).first();
45✔
287
    }
288

289
    /**
290
     * Sets or updates a document in the specified collection.
291
     *
292
     * @param mongoSession the MongoDB client session
293
     * @param collection   the name of the collection
294
     * @param update       the document to set or update (must contain an _id field)
295
     */
296
    public static void set(ClientSession mongoSession, String collection, Document update) {
297
        Bson find = new Document("_id", update.get("_id"));
24✔
298
        try (MongoCursor<Document> cursor = collection(collection).find(mongoSession, find).cursor()) {
21✔
299
            if (cursor.hasNext()) {
9✔
300
                collection(collection).updateOne(mongoSession, find, new Document("$set", update));
33✔
301
                logger.debug("Updated document with _id={} in collection '{}'", update.get("_id"), collection);
24✔
302
            } else {
303
                logger.debug("set: no existing document with _id={} in collection '{}'; update skipped", update.get("_id"), collection);
21✔
304
            }
305
        }
306
    }
3✔
307

308
    /**
309
     * Inserts a document into the specified collection.
310
     *
311
     * @param mongoSession the MongoDB client session
312
     * @param collection   the name of the collection
313
     * @param doc          the document to insert
314
     */
315
    public static void insert(ClientSession mongoSession, String collection, Document doc) {
316
        collection(collection).insertOne(mongoSession, doc);
15✔
317
        logger.debug("Inserted document into '{}' with _id={}", collection, doc.get("_id"));
21✔
318
    }
3✔
319

320
    /**
321
     * Sets the value of an element with the given name in the specified collection.
322
     * If the element does not exist, it will be created.
323
     *
324
     * @param mongoSession the MongoDB client session
325
     * @param collection   the name of the collection
326
     * @param elementId    the name of the element used as the _id field
327
     * @param value        the value to set
328
     */
329
    public static void setValue(ClientSession mongoSession, String collection, String elementId, Object value) {
330
        logger.debug("Setting value for element '{}' in collection '{}' (upsert)", elementId, collection);
15✔
331
        collection(collection).updateOne(mongoSession, new Document("_id", elementId), new Document("$set", new Document("value", value)), new UpdateOptions().upsert(true));
72✔
332
    }
3✔
333

334
    /**
335
     * Records the hash of a given value in the "hashes" collection.
336
     * Uses upsert to avoid expensive exception-based duplicate handling.
337
     *
338
     * @param mongoSession the MongoDB client session
339
     * @param value        the value to hash and record
340
     */
341
    public static void recordHash(ClientSession mongoSession, String value) {
342
        String hash = Utils.getHash(value);
9✔
343
        try {
344
            collection("hashes").updateOne(mongoSession, new Document("value", value), new Document("$setOnInsert", new Document("value", value).append("hash", hash)), new UpdateOptions().upsert(true));
81✔
345
            logger.debug("Recorded hash for value (hash={})", hash);
12✔
346
        } catch (MongoWriteException e) {
×
347
            // Concurrent upsert race: another thread inserted the same hash — safe to ignore
348
            if (e.getError().getCategory() != ErrorCategory.DUPLICATE_KEY) {
×
349
                logger.error("Failed to record hash for value (hash={}): {}", hash, e.getMessage(), e);
×
350
                throw e;
×
351
            }
352
            logger.debug("Concurrent insertion for hash {} detected; duplicate ignored", hash);
×
353
        }
3✔
354
    }
3✔
355

356
    /**
357
     * Retrieves the original value corresponding to a given hash from the "hashes" collection.
358
     *
359
     * @param hash the hash to look up
360
     * @return the original value, or null if not found
361
     */
362
    public static String unhash(String hash) {
363
        try (var c = collection("hashes").find(new Document("hash", hash)).cursor()) {
30✔
364
            if (c.hasNext()) {
9✔
365
                String value = c.next().getString("value");
18✔
366
                logger.debug("Unhash found value for hash {}", hash);
12✔
367
                return value;
12✔
368
            }
369
            logger.debug("Unhash: no value found for hash {}", hash);
12✔
370
            return null;
12✔
371
        }
12!
372
    }
373

374
    /**
375
     * Initializes the counter document to the current maximum counter value
376
     * in the nanopubs collection.
377
     * Uses $max to ensure the counter is never decreased. Safe to call on every startup.
378
     */
379
    private static void initCounter(ClientSession mongoSession) {
380
        Long maxCounter = (Long) getMaxValue(mongoSession, Collection.NANOPUBS.toString(), "counter");
21✔
381
        long effective = maxCounter != null ? maxCounter : 0L;
21✔
382
        collection("counters").updateOne(mongoSession, new Document("_id", "nanopubs"), new Document("$max", new Document("value", effective)), new UpdateOptions().upsert(true));
75✔
383
        if (maxCounter != null) {
6✔
384
            logger.info("Nanopub counter resumed at {} (max found in DB)", effective);
18✔
385
        } else {
386
            logger.info("Nanopub counter initialized to 0 (no existing nanopubs found)");
9✔
387
        }
388
    }
3✔
389

390
    /**
391
     * Returns the next counter value for a nanopub via atomic increment.
392
     */
393
    private static long getNextCounter(ClientSession mongoSession) {
394
        Document result = collection("counters").findOneAndUpdate(mongoSession, new Document("_id", "nanopubs"), new Document("$inc", new Document("value", 1L)), new FindOneAndUpdateOptions().upsert(true).returnDocument(ReturnDocument.AFTER));
84✔
395
        return result.getLong("value");
15✔
396
    }
397

398
    /**
399
     * Loads a nanopublication into the database.
400
     *
401
     * @param mongoSession the MongoDB client session
402
     * @param nanopub      the nanopublication to load
403
     */
404
    public static boolean loadNanopub(ClientSession mongoSession, Nanopub nanopub) {
405
        return loadNanopub(mongoSession, nanopub, null);
21✔
406
    }
407

408
    /**
409
     * Loads a nanopublication into the database, optionally filtering by public key hash and types.
410
     *
411
     * @param mongoSession the MongoDB client session
412
     * @param nanopub      the nanopublication to load
413
     * @param pubkeyHash   the public key hash to filter by (can be null)
414
     * @param types        the types to filter by (can be empty)
415
     * @return true if the nanopublication was loaded, false otherwise
416
     */
417
    public static boolean loadNanopub(ClientSession mongoSession, Nanopub nanopub, String pubkeyHash, String... types) {
418
        String pubkey = getPubkey(nanopub);
9✔
419
        if (pubkey == null) {
6✔
420
            logger.warn("Ignoring nanopub {}: no valid public key / signature found", nanopub.getUri());
15✔
421
            return false;
6✔
422
        }
423
        return loadNanopubVerified(mongoSession, nanopub, pubkey, pubkeyHash, types);
21✔
424
    }
425

426
    /**
427
     * Loads a nanopublication with a pre-verified public key, skipping signature verification.
428
     * Use this when the caller has already verified the signature via getPubkey().
429
     */
430
    static boolean loadNanopubVerified(ClientSession mongoSession, Nanopub nanopub, String verifiedPubkey, String pubkeyHash, String... types) {
431
        if (nanopub.getTripleCount() > 1200) {
12!
432
            logger.error("Rejecting nanopub {}: triple count {} exceeds limit of 1200", nanopub.getUri(), nanopub.getTripleCount());
×
433
            return false;
×
434
        }
435
        if (nanopub.getByteCount() > 1000000) {
15!
436
            logger.error("Rejecting nanopub {}: size {} bytes exceeds limit of 1000000", nanopub.getUri(), nanopub.getByteCount());
×
437
            return false;
×
438
        }
439
        Calendar creationTime;
440
        try {
441
            creationTime = nanopub.getCreationTime();
9✔
442
        } catch (Exception ex) {
×
443
            logger.warn("Nanopub {} has a malformed timestamp; proceeding without one", nanopub.getUri());
×
444
            creationTime = null;
×
445
        }
3✔
446
        if (creationTime != null && creationTime.getTimeInMillis() > System.currentTimeMillis() + 60000) {
27!
447
            logger.error("Rejecting nanopub {}: timestamp {} is more than 60s in the future", nanopub.getUri(), creationTime.toInstant());
×
448
            return false;
×
449
        }
450
        String nanopubUriStr = nanopub.getUri().stringValue();
12✔
451
        for (IRI graphUri : nanopub.getGraphUris()) {
33✔
452
            if (!graphUri.stringValue().startsWith(nanopubUriStr)) {
15!
453
                logger.error("Rejecting nanopub {}: graph URI {} does not start with the nanopub base URI", nanopub.getUri(), graphUri);
×
454
                return false;
×
455
            }
456
        }
3✔
457
        String ph = Utils.getHash(verifiedPubkey);
9✔
458
        if (pubkeyHash != null && !pubkeyHash.equals(ph)) {
18!
459
            logger.error("Rejecting nanopub {}: provided pubkey hash {} does not match computed hash {}", nanopub.getUri(), pubkeyHash, ph);
×
460
            return false;
×
461
        }
462
        recordHash(mongoSession, verifiedPubkey);
9✔
463

464
        String ac = TrustyUriUtils.getArtifactCode(nanopub.getUri().stringValue());
15✔
465
        if (ac == null) {
6!
466
            // I don't think this ever happens, but checking here to be sure
467
            logger.error("Rejecting nanopub {}: could not extract artifact code from Trusty URI", nanopub.getUri());
×
468
            return false;
×
469
        }
470
        if (has(mongoSession, Collection.NANOPUBS.toString(), ac)) {
18✔
471
            logger.debug("Skipping nanopub {}: already present in the database", nanopub.getUri());
18✔
472
        } else {
473
            String nanopubString;
474
            byte[] jellyContent;
475
            try {
476
                nanopubString = NanopubUtils.writeToString(nanopub, RDFFormat.TRIG);
12✔
477
                // Save the same thing in the Jelly format for faster loading
478
                jellyContent = JellyUtils.writeNanopubForDB(nanopub);
9✔
479
            } catch (IOException ex) {
×
480
                logger.error("Failed to serialize nanopub {}: {}", nanopub.getUri(), ex.getMessage(), ex);
×
481
                throw new RuntimeException(ex);
×
482
            }
3✔
483
            long counter = getNextCounter(mongoSession);
9✔
484
            boolean inserted = false;
6✔
485
            try {
486
                collection(Collection.NANOPUBS.toString()).insertOne(mongoSession, new Document("_id", ac).append("fullId", nanopub.getUri().stringValue()).append("counter", counter).append("pubkey", ph).append("content", nanopubString).append("jelly", new Binary(jellyContent)));
93✔
487
                inserted = true;
6✔
488
                logger.info("Loaded nanopub {} (counter: {}, pubkey hash: {})", nanopub.getUri(), counter, ph);
57✔
489
            } catch (MongoWriteException e) {
×
490
                if (e.getError().getCategory() != ErrorCategory.DUPLICATE_KEY) {
×
491
                    logger.error("Failed to insert nanopub {} (artifact {}): {}", nanopub.getUri(), ac, e.getMessage(), e);
×
492
                    throw e;
×
493
                }
494
                // Another thread inserted this nanopub concurrently — safe to skip
495
                logger.debug("Skipping nanopub {}: inserted concurrently by another thread", nanopub.getUri());
×
496
            }
3✔
497

498
            if (inserted) {
6!
499
                for (IRI invalidatedId : Utils.getInvalidatedNanopubIds(nanopub)) {
33✔
500
                    String invalidatedAc = TrustyUriUtils.getArtifactCode(invalidatedId.stringValue());
12✔
501
                    if (invalidatedAc == null) {
6!
502
                        logger.warn("Nanopub {} references invalidated nanopub {} with an unresolvable artifact code; skipping", nanopub.getUri(), invalidatedId);
×
503
                        continue;  // This should never happen; checking here just to be sure
×
504
                    }
505

506
                    // Add this nanopub also to all lists of invalidated nanopubs:
507
                    logger.debug("Nanopub {} invalidates {}; updating list entries and trust edges", nanopub.getUri(), invalidatedId);
18✔
508
                    collection("invalidations").insertOne(mongoSession, new Document("invalidatingNp", ac).append("invalidatingPubkey", ph).append("invalidatedNp", invalidatedAc));
45✔
509
                    try (MongoCursor<Document> invalidatedEntries = collection("listEntries").find(mongoSession, new Document("np", invalidatedAc).append("pubkey", ph)).cursor()) {
42✔
510
                        while (invalidatedEntries.hasNext()) {
9!
511
                            Document invalidatedEntry = invalidatedEntries.next();
×
512
                            addToList(mongoSession, nanopub, ph, invalidatedEntry.getString("type"));
×
513
                        }
×
514
                    }
515

516
                    collection("listEntries").updateMany(mongoSession, new Document("np", invalidatedAc).append("pubkey", ph), new Document("$set", new Document("invalidated", true)));
69✔
517
                    collection("trustEdges").updateMany(mongoSession, new Document("source", invalidatedAc), new Document("$set", new Document("invalidated", true)));
60✔
518
                    logger.debug("Marked invalidated entries and trust edges for invalidated artifact {}", invalidatedAc);
12✔
519
                }
3✔
520
            }
521
        }
522

523
        if (pubkeyHash != null) {
6✔
524
            for (String type : types) {
48✔
525
                // TODO Check if nanopub really has the type?
526
                addToList(mongoSession, nanopub, pubkeyHash, Utils.getTypeHash(mongoSession, type));
21✔
527
                if (type.equals("$")) {
12!
528
                    for (IRI t : NanopubUtils.getTypes(nanopub)) {
33✔
529
                        String th = Utils.getTypeHash(mongoSession, t);
12✔
530
                        if (CoverageFilter.isCoveredType(th)) {
9!
531
                            addToList(mongoSession, nanopub, pubkeyHash, th);
×
532
                        }
533
                    }
3✔
534
                }
535
            }
536
        }
537

538
        // Add the invalidating nanopubs also to the lists of this nanopub:
539
        try (MongoCursor<Document> invalidations = collection("invalidations").find(mongoSession, new Document("invalidatedNp", ac).append("invalidatingPubkey", ph)).cursor()) {
42✔
540
            if (invalidations.hasNext()) {
9!
541
                collection("listEntries").updateMany(mongoSession, new Document("np", ac).append("pubkey", ph), new Document("$set", new Document("invalidated", true)));
×
542
                collection("trustEdges").updateMany(mongoSession, new Document("source", ac), new Document("$set", new Document("invalidated", true)));
×
543
                logger.debug("Marked existing list entries and trust edges for nanopub {} as invalidated due to invalidations", ac);
×
544
            }
545
            while (invalidations.hasNext()) {
9!
546
                String iac = invalidations.next().getString("invalidatingNp");
×
547
                try {
548
                    Document npDoc = collection(Collection.NANOPUBS.toString()).find(mongoSession, new Document("_id", iac)).projection(new Document("jelly", 1)).first();
×
549
                    Nanopub inp = JellyUtils.readFromDB(npDoc.get("jelly", Binary.class).getData());
×
550
                    for (IRI type : NanopubUtils.getTypes(inp)) {
×
551
                        addToList(mongoSession, inp, ph, Utils.getTypeHash(mongoSession, type));
×
552
                    }
×
553
                } catch (RDF4JException | MalformedNanopubException ex) {
×
554
                    logger.error("Failed to load invalidating nanopub {} for invalidation record; skipping", iac, ex);
×
555
                }
×
556
            }
×
557

558
        }
559

560
        return true;
6✔
561
    }
562

563
    private static void addToList(ClientSession mongoSession, Nanopub nanopub, String pubkeyHash, String typeHash) {
564
        String ac = TrustyUriUtils.getArtifactCode(nanopub.getUri().stringValue());
15✔
565
        try {
566
            insert(mongoSession, "lists", new Document("pubkey", pubkeyHash).append("type", typeHash).append("maxPosition", -1L));
45✔
567
            logger.debug("Ensured list document exists for pubkey={} type={}", pubkeyHash, typeHash);
15✔
568
        } catch (MongoWriteException e) {
×
569
            // Duplicate key error -- ignore it
570
            if (e.getError().getCategory() != ErrorCategory.DUPLICATE_KEY) {
×
571
                logger.error("Failed to create list document for pubkey={} type={}: {}", pubkeyHash, typeHash, e.getMessage(), e);
×
572
                throw e;
×
573
            }
574
            logger.trace("List document already existed for pubkey={} type={}", pubkeyHash, typeHash);
×
575
        }
3✔
576

577
        if (has(mongoSession, "listEntries", new Document("pubkey", pubkeyHash).append("type", typeHash).append("np", ac))) {
45!
578
            logger.debug("Already listed: nanopub {} (artifact {}) for pubkey={} type={}", nanopub.getUri(), ac, pubkeyHash, typeHash);
×
579
        } else {
580
            initListPositionIfNeeded(mongoSession, pubkeyHash, typeHash);
12✔
581

582
            for (int attempt = 0; ; attempt++) {
6✔
583
                // Atomically claim next position
584
                Document updated = collection("lists").findOneAndUpdate(mongoSession, new Document("pubkey", pubkeyHash).append("type", typeHash), new Document("$inc", new Document("maxPosition", 1L)), new FindOneAndUpdateOptions().returnDocument(ReturnDocument.AFTER));
87✔
585
                long position = updated.getLong("maxPosition");
15✔
586

587
                // Get checksum from previous entry by exact position lookup (O(1) index hit)
588
                String checksum;
589
                if (position == 0) {
12!
590
                    checksum = NanopubUtils.updateXorChecksum(nanopub.getUri(), NanopubUtils.INIT_CHECKSUM);
18✔
591
                } else {
592
                    Document prevEntry = collection("listEntries").find(mongoSession, new Document("pubkey", pubkeyHash).append("type", typeHash).append("position", position - 1)).first();
×
593
                    String prevChecksum = (prevEntry != null) ? prevEntry.getString("checksum") : null;
×
594
                    if (prevChecksum == null) {
×
595
                        // Rare: previous entry not yet inserted by concurrent thread; fall back to sorted query
596
                        Document maxDoc = getMaxValueDocument(mongoSession, "listEntries", new Document("pubkey", pubkeyHash).append("type", typeHash), "position");
×
597
                        prevChecksum = (maxDoc != null) ? maxDoc.getString("checksum") : NanopubUtils.INIT_CHECKSUM;
×
598
                    }
599
                    checksum = NanopubUtils.updateXorChecksum(nanopub.getUri(), prevChecksum);
×
600
                }
601

602
                try {
603
                    collection("listEntries").insertOne(mongoSession, new Document("pubkey", pubkeyHash).append("type", typeHash).append("position", position).append("np", ac).append("checksum", checksum).append("invalidated", false));
78✔
604
                    logger.debug("Inserted list entry: pubkey={} type={} np={} position={} checksum={}", pubkeyHash, typeHash, ac, position, checksum);
78✔
605
                    break;
3✔
606
                } catch (MongoWriteException e) {
×
607
                    if (e.getError().getCategory() != ErrorCategory.DUPLICATE_KEY) {
×
608
                        logger.error("Failed to insert list entry for pubkey={} type={} np={}: {}", pubkeyHash, typeHash, ac, e.getMessage(), e);
×
609
                        throw e;
×
610
                    }
611
                    if (has(mongoSession, "listEntries", new Document("pubkey", pubkeyHash).append("type", typeHash).append("np", ac))) {
×
612
                        logger.debug("Concurrent insert detected and entry already exists for pubkey={} type={} np={}", pubkeyHash, typeHash, ac);
×
613
                        break; // Already listed by concurrent thread
×
614
                    }
615
                    if (attempt >= 100) {
×
616
                        logger.error("Failed to insert list entry after {} attempts for pubkey={} type={} np={}", attempt + 1, pubkeyHash, typeHash, ac);
×
617
                        throw new RuntimeException("Failed to insert list entry after " + (attempt + 1) + " attempts");
×
618
                    }
619
                    logger.debug("Retrying list entry insert (attempt {}) for pubkey={} type={} np={}", attempt + 1, pubkeyHash, typeHash, ac);
×
620
                }
621
            }
622
        }
623
    }
3✔
624

625
    /**
626
     * Lazily initializes the maxPosition field on a lists document for lists
627
     * created before this field existed. Uses a one-time sorted query, then
628
     * all subsequent calls use the atomic counter.
629
     */
630
    private static void initListPositionIfNeeded(ClientSession mongoSession, String pubkeyHash, String typeHash) {
631
        Document listDoc = collection("lists").find(mongoSession, new Document("pubkey", pubkeyHash).append("type", typeHash)).first();
45✔
632
        if (listDoc == null || listDoc.get("maxPosition") != null) {
18!
633
            logger.trace("initListPositionIfNeeded: no action needed for pubkey={} type={}", pubkeyHash, typeHash);
15✔
634
            return;
3✔
635
        }
636

637
        Document maxDoc = getMaxValueDocument(mongoSession, "listEntries", new Document("pubkey", pubkeyHash).append("type", typeHash), "position");
×
638
        long maxPos = (maxDoc != null) ? maxDoc.getLong("position") : -1L;
×
639

640
        // Conditional update: only set if maxPosition still doesn't exist (race-safe)
641
        collection("lists").updateOne(mongoSession, new Document("pubkey", pubkeyHash).append("type", typeHash).append("maxPosition", new Document("$exists", false)), new Document("$set", new Document("maxPosition", maxPos)));
×
642
        logger.debug("Initialized maxPosition={} for list pubkey={} type={}", maxPos, pubkeyHash, typeHash);
×
643
    }
×
644

645
    /**
646
     * Builds a comma-separated list of checksums at geometric positions for a given list,
647
     * for use with the afterChecksums parameter during peer sync.
648
     * Returns checksums at positions: max, max-10, max-100, max-1000, max-10000, ...
649
     * Returns null if the list has no entries.
650
     */
651
    public static String buildChecksumFallbacks(ClientSession mongoSession, String pubkeyHash, String typeHash) {
652
        Document maxDoc = getMaxValueDocument(mongoSession, "listEntries", new Document("pubkey", pubkeyHash).append("type", typeHash), "position");
39✔
653
        if (maxDoc == null) {
6✔
654
            logger.debug("buildChecksumFallbacks: no entries for pubkey={} type={}", pubkeyHash, typeHash);
15✔
655
            return null;
6✔
656
        }
657

658
        long maxPosition = maxDoc.getLong("position");
15✔
659
        StringBuilder sb = new StringBuilder();
12✔
660
        sb.append(maxDoc.getString("checksum"));
18✔
661

662
        for (long offset = 10; offset <= maxPosition; offset *= 10) {
33✔
663
            long targetPos = maxPosition - offset;
12✔
664
            Document entry = collection("listEntries").find(mongoSession, new Document("pubkey", pubkeyHash).append("type", typeHash).append("position", targetPos)).first();
57✔
665
            if (entry != null) {
6!
666
                sb.append(",").append(entry.getString("checksum"));
24✔
667
            }
668
        }
669
        String result = sb.toString();
9✔
670
        logger.debug("buildChecksumFallbacks for pubkey={} type={} -> {}", pubkeyHash, typeHash, result);
51✔
671
        return result;
6✔
672
    }
673

674
    /**
675
     * Returns the public key string of the Nanopub's signature, or null if not available or invalid.
676
     *
677
     * @param nanopub the nanopub to extract the public key from
678
     * @return The public key string, or null if not available or invalid.
679
     */
680
    public static String getPubkey(Nanopub nanopub) {
681
        // TODO shouldn't this be moved to a utility class in nanopub-java? there is a similar method in NanopubElement class of nanodash
682
        NanopubSignatureElement el;
683
        try {
684
            el = SignatureUtils.getSignatureElement(nanopub);
9✔
685
            if (el != null && SignatureUtils.hasValidSignature(el) && el.getPublicKeyString() != null) {
24!
686
                logger.trace("Valid signature found for nanopub {}", nanopub.getUri());
15✔
687
                return el.getPublicKeyString();
9✔
688
            }
689
            logger.debug("No valid signature element or public key present for nanopub {}", nanopub.getUri());
15✔
690
        } catch (MalformedCryptoElementException | GeneralSecurityException ex) {
×
691
            logger.error("Failed to verify signature of nanopub {}: {}", nanopub.getUri(), ex.getMessage(), ex);
×
692
        }
3✔
693
        return null;
6✔
694
    }
695

696
    /**
697
     * Calculates a hash representing the current state of the trust paths in the loading collection.
698
     *
699
     * @param mongoSession the MongoDB client session
700
     * @return the calculated trust state hash
701
     */
702
    public static String calculateTrustStateHash(ClientSession mongoSession) {
703
        // Accounts still in the 'toLoad' staging status are not yet servable and must not enter the
704
        // public trust state: a path's head account flips 'toLoad' -> 'loaded' between UPDATE cycles
705
        // (in LOAD_FULL) without changing any trust path, so if those paths were hashed, a leaf
706
        // account's promotion would never change the hash and never trigger a new snapshot, leaving
707
        // it published as 'toLoad' indefinitely (see issue #119). By excluding 'toLoad' paths here,
708
        // crossing out of 'toLoad' is itself a membership change -> hash change -> emission.
709
        Set<String> toLoadAccounts = new HashSet<>();
12✔
710
        try (MongoCursor<Document> ac = collection("accounts_loading")
21✔
711
                .find(mongoSession, new Document("status", EntryStatus.toLoad.getValue()))
21✔
712
                .projection(new Document("agent", 1).append("pubkey", 1))
21✔
713
                .cursor()) {
6✔
714
            while (ac.hasNext()) {
9✔
715
                Document a = ac.next();
12✔
716
                toLoadAccounts.add(a.getString("agent") + "|" + a.getString("pubkey"));
30✔
717
            }
3✔
718
        }
719

720
        String s;
721
        try (MongoCursor<Document> tp = collection("trustPaths_loading").find(mongoSession).sort(ascending("_id")).cursor()) {
42✔
722
            // TODO Improve this so we don't create the full string just for calculating its hash:
723
            s = "";
6✔
724
            while (tp.hasNext()) {
9✔
725
                Document d = tp.next();
12✔
726
                if (toLoadAccounts.contains(d.getString("agent") + "|" + d.getString("pubkey"))) {
30✔
727
                    continue;
3✔
728
                }
729
                s += d.getString("_id") + " (" + d.getString("type") + ")\n";
27✔
730
            }
3✔
731
        }
732
        String hash = Utils.getHash(s);
9✔
733
        logger.debug("Calculated trust state hash: {}", hash);
12✔
734
        return hash;
6✔
735
    }
736

737
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc