@learncard/network-brain-service 4.0.2 → 4.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/accesslayer/boost/read.d.ts.map +1 -1
  3. package/dist/accesslayer/boost/relationships/read.d.ts.map +1 -1
  4. package/dist/accesslayer/consentflowcontract/relationships/create.d.ts.map +1 -1
  5. package/dist/accesslayer/consentflowcontract/relationships/update.d.ts.map +1 -1
  6. package/dist/accesslayer/inbox-batch/schema.d.ts +4 -0
  7. package/dist/accesslayer/inbox-batch/schema.d.ts.map +1 -0
  8. package/dist/accesslayer/inbox-batch/schema.test.d.ts +2 -0
  9. package/dist/accesslayer/inbox-batch/schema.test.d.ts.map +1 -0
  10. package/dist/accesslayer/inbox-batch/store.d.ts +1 -1
  11. package/dist/accesslayer/inbox-batch/store.d.ts.map +1 -1
  12. package/dist/accesslayer/profile/create.d.ts.map +1 -1
  13. package/dist/accesslayer/profile/read.d.ts.map +1 -1
  14. package/dist/accesslayer/profile/search-fields.d.ts +16 -0
  15. package/dist/accesslayer/profile/search-fields.d.ts.map +1 -0
  16. package/dist/accesslayer/profile/update.d.ts.map +1 -1
  17. package/dist/helpers/boost.helpers.d.ts +1 -0
  18. package/dist/helpers/boost.helpers.d.ts.map +1 -1
  19. package/dist/index.js +2 -2
  20. package/dist/models/credential-refresh-constraints.d.ts.map +1 -1
  21. package/dist/models/index.d.ts.map +1 -1
  22. package/dist/publish-metafile.json +1 -1
  23. package/dist/routes/workflows.d.ts.map +1 -1
  24. package/dist/seed/seedSkillFrameworks.d.ts.map +1 -1
  25. package/esbuildPlugins.cjs +9 -6
  26. package/package.json +19 -16
  27. package/project.json +18 -0
  28. package/scripts/backfill-profile-search.ts +79 -0
  29. package/scripts/seed-demo-persona.ts +6 -1
  30. package/src/accesslayer/boost/read.ts +171 -34
  31. package/src/accesslayer/boost/relationships/read.ts +163 -126
  32. package/src/accesslayer/consentflowcontract/relationships/create.ts +3 -1
  33. package/src/accesslayer/consentflowcontract/relationships/update.ts +5 -1
  34. package/src/accesslayer/inbox-batch/schema.test.ts +91 -0
  35. package/src/accesslayer/inbox-batch/schema.ts +54 -0
  36. package/src/accesslayer/inbox-batch/store.ts +2 -38
  37. package/src/accesslayer/profile/create.ts +2 -1
  38. package/src/accesslayer/profile/read.ts +23 -3
  39. package/src/accesslayer/profile/search-fields.ts +69 -0
  40. package/src/accesslayer/profile/update.ts +7 -1
  41. package/src/helpers/boost-storage.helpers.test.ts +155 -1
  42. package/src/helpers/boost.helpers.ts +52 -10
  43. package/src/models/credential-refresh-constraints.test.ts +44 -0
  44. package/src/models/credential-refresh-constraints.ts +29 -3
  45. package/src/models/index.ts +15 -4
  46. package/src/routes/workflows.ts +8 -1
  47. package/src/seed/seedSkillFrameworks.ts +4 -1
  48. package/test/boost.familial-equivalence.spec.ts +482 -0
  49. package/test/boost.recipientsJoin.spec.ts +508 -0
  50. package/test/consentflow.spec.ts +101 -0
  51. package/test/inbox-batch.spec.ts +189 -1
  52. package/test/profile-search.spec.ts +307 -0
  53. package/vitest.boost-recipients-neo4j.config.ts +21 -0
  54. package/vitest.config.ts +1 -0
  55. package/vitest.profile-search-neo4j.config.ts +20 -0
@@ -324,6 +324,59 @@ export const getBoostByUriWithDefaultClaimPermissions = async (
324
324
  };
325
325
  };
326
326
 
327
+ /** Every send of the boost bound as `$boostId`: binds `source`, `credential`, `sent`, `sender`. */
328
+ const MATCH_BOOST_SENDS = `MATCH (source:Boost {id: $boostId})<-[:INSTANCE_OF]-(credential:Credential)<-[sent:CREDENTIAL_SENT]-(sender:Profile)`;
329
+
330
+ /**
331
+ * Same rows as MATCH_BOOST_SENDS, but the boost's credentials are collected first and the sends
332
+ * are then matched from each credential.
333
+ *
334
+ * The aim is to keep the cost tied to this boost's own sends. With a range or IN predicate on
335
+ * `sent.date` / `sent.to`, the planner can otherwise start from credential_sent_date_idx or
336
+ * credential_sent_to_idx across every boost and filter down to this one afterwards, which for a
337
+ * small boost reads far more sends than the boost has.
338
+ *
339
+ * This nudges the planner toward expanding from each credential; it doesn't guarantee that plan.
340
+ * With unsampled or stale index statistics, Neo4j has still been seen to run a global
341
+ * credential_sent_date_idx scan once per credential. If a change here matters, check the real plan
342
+ * with PROFILE, and refresh index statistics if it shows that scan. `carry` lists variables from
343
+ * earlier clauses that later clauses still need.
344
+ */
345
+ const matchBoostSendsFromBoost = (carry: string[] = []): string =>
346
+ `MATCH (source:Boost {id: $boostId})<-[:INSTANCE_OF]-(boostCredential:Credential)
347
+ WITH ${[...carry, 'collect(boostCredential) AS boostCredentials'].join(', ')}
348
+ UNWIND boostCredentials AS credential
349
+ MATCH (credential)<-[sent:CREDENTIAL_SENT]-(sender:Profile)`;
350
+
351
+ /**
352
+ * When a recipient profile query pins `profileId` to a value (or an `$in` list), the join
353
+ * `recipient.profileId = sent.to` means the same condition holds for `sent.to`. Applying it to
354
+ * the send first lets the query skip the Profile lookup for every other send.
355
+ *
356
+ * Mirrors buildWhereClause: a document-level `$or` ignores every other key, so no prefilter then.
357
+ */
358
+ const getSentToPrefilter = (
359
+ convertedQuery: Record<string, unknown>
360
+ ): { clause: string; params: Record<string, unknown> } | undefined => {
361
+ if (!convertedQuery || '$or' in convertedQuery) return undefined;
362
+
363
+ const value = convertedQuery.profileId;
364
+
365
+ if (typeof value === 'string') {
366
+ return { clause: 'sent.to = $sentToPrefilter', params: { sentToPrefilter: value } };
367
+ }
368
+
369
+ if (value && typeof value === 'object' && Object.keys(value).length === 1) {
370
+ const inList = (value as { $in?: unknown }).$in;
371
+
372
+ if (Array.isArray(inList)) {
373
+ return { clause: 'sent.to IN $sentToPrefilter', params: { sentToPrefilter: inList } };
374
+ }
375
+ }
376
+
377
+ return undefined;
378
+ };
379
+
327
380
  /**
328
381
  * Query to get the recipients of a boost. Cipher Code is:
329
382
  * MATCH (source:`Boost` { id: $id })<-[instanceOf:INSTANCE_OF]-(credential:`Credential`)-[received:CREDENTIAL_RECEIVED]->(recipient:`Profile`)
@@ -352,26 +405,35 @@ export const getBoostRecipients = async (
352
405
  convertedQuery as any
353
406
  );
354
407
 
355
- const _query = new QueryBuilder(new BindParam({ cursor, from, ...queryParams }))
356
- .match({
357
- related: [
358
- { identifier: 'source', model: Boost, where: { id: boost.id } },
359
- {
360
- ...Credential.getRelationshipByAlias('instanceOf'),
361
- identifier: 'instanceOf',
362
- direction: 'in',
363
- },
364
- { identifier: 'credential', model: Credential },
365
- {
366
- ...Profile.getRelationshipByAlias('credentialSent'),
367
- identifier: 'sent',
368
- direction: 'in',
369
- },
370
- { identifier: 'sender', model: Profile },
371
- ],
408
+ const sentToPrefilter = getSentToPrefilter(convertedQuery);
409
+
410
+ // Filter and sort the sends before touching recipient Profiles. The Profile lookup and the
411
+ // CREDENTIAL_RECEIVED expand then only run until LIMIT is satisfied, instead of once per send.
412
+ const sendFilters = [
413
+ ...(from ? ['sender.profileId = $from'] : []),
414
+ ...(cursor ? ['sent.date > $cursor'] : []),
415
+ ...(sentToPrefilter ? [sentToPrefilter.clause] : []),
416
+ ];
417
+
418
+ const query = new QueryBuilder(
419
+ new BindParam({
420
+ boostId: boost.id,
421
+ cursor,
422
+ from,
423
+ ...queryParams,
424
+ ...sentToPrefilter?.params,
372
425
  })
426
+ )
427
+ .raw(
428
+ // An equality on sent.to or the sender's profileId is selective enough to start from;
429
+ // otherwise try to read the sends from the boost (see matchBoostSendsFromBoost)
430
+ sentToPrefilter || from ? MATCH_BOOST_SENDS : matchBoostSendsFromBoost()
431
+ )
432
+ .raw(sendFilters.length > 0 ? `WHERE ${sendFilters.join(' AND ')}` : '')
433
+ .with('sender, sent, credential')
434
+ .orderBy('sent.date')
373
435
  .match({ model: Profile, identifier: 'recipient' })
374
- .where(`recipient.profileId = sent.to${from ? ' AND sender.profileId = $from' : ''}`)
436
+ .where('recipient.profileId = sent.to')
375
437
  .match({
376
438
  optional: includeUnacceptedBoosts,
377
439
  related: [
@@ -391,8 +453,6 @@ export const getBoostRecipients = async (
391
453
  }`
392
454
  );
393
455
 
394
- const query = cursor ? _query.raw('AND sent.date > $cursor') : _query;
395
-
396
456
  const results = convertQueryResultToPropertiesObjectArray<{
397
457
  sender: FlatProfileType;
398
458
  sent: ProfileRelationships['credentialSent']['RelationshipProperties'];
@@ -414,10 +474,7 @@ export const getBoostRecipients = async (
414
474
  received: received?.date,
415
475
  ...(credential && { uri: getCredentialUri(credential.id, domain) }),
416
476
  status: coalesceStatus(sent.status, received?.status) as
417
- | 'active'
418
- | 'revoked'
419
- | 'suspended'
420
- | undefined,
477
+ 'active' | 'revoked' | 'suspended' | undefined,
421
478
  }));
422
479
 
423
480
  const recipients = await getProfilesByProfileIds(resultsWithIds.map(result => result.to));
@@ -512,10 +569,7 @@ export const getBoostRecipientsSkipLimit = async (
512
569
  received: received?.date,
513
570
  ...(credential && { uri: getCredentialUri(credential.id, domain) }),
514
571
  status: coalesceStatus(sent.status, received?.status) as
515
- | 'active'
516
- | 'revoked'
517
- | 'suspended'
518
- | undefined,
572
+ 'active' | 'revoked' | 'suspended' | undefined,
519
573
  }));
520
574
 
521
575
  const recipients = await getProfilesByProfileIds(resultsWithIds.map(result => result.to));
@@ -990,7 +1044,7 @@ export const getConnectedBoostRecipients = async (
990
1044
  convertedQuery as any
991
1045
  );
992
1046
 
993
- const _query = new QueryBuilder(new BindParam({ cursor, ...queryParams }))
1047
+ const query = new QueryBuilder(new BindParam({ boostId: boost.id, cursor, ...queryParams }))
994
1048
  // Build connection list
995
1049
  .match({
996
1050
  model: Profile,
@@ -1141,23 +1195,11 @@ export const getConnectedBoostRecipients = async (
1141
1195
  .unwind('allConnectedProfiles AS conn')
1142
1196
  .with('COLLECT(DISTINCT conn.profileId) AS connectionIds')
1143
1197
  // --- Boost recipient matching ---
1144
- .match({
1145
- related: [
1146
- { identifier: 'source', model: Boost, where: { id: boost.id } },
1147
- {
1148
- ...Credential.getRelationshipByAlias('instanceOf'),
1149
- identifier: 'instanceOf',
1150
- direction: 'in',
1151
- },
1152
- { identifier: 'credential', model: Credential },
1153
- {
1154
- ...Profile.getRelationshipByAlias('credentialSent'),
1155
- identifier: 'sent',
1156
- direction: 'in',
1157
- },
1158
- { identifier: 'sender', model: Profile },
1159
- ],
1160
- })
1198
+ .raw(matchBoostSendsFromBoost(['connectionIds']))
1199
+ // Narrow and sort the sends first so the Profile lookup only runs until LIMIT is met
1200
+ .raw(`WHERE sent.to IN connectionIds${cursor ? ' AND sent.date > $cursor' : ''}`)
1201
+ .with('sender, sent, credential')
1202
+ .orderBy('sent.date')
1161
1203
  .match({ model: Profile, identifier: 'recipient' })
1162
1204
  .where('recipient.profileId = sent.to')
1163
1205
  .match({
@@ -1171,10 +1213,8 @@ export const getConnectedBoostRecipients = async (
1171
1213
  { identifier: 'recipient' },
1172
1214
  ],
1173
1215
  })
1174
- .with('sender, sent, received, recipient, credential, connectionIds')
1175
- .where(`sent.to IN connectionIds AND ${recipientWhereClause}`);
1176
-
1177
- const query = cursor ? _query.raw('AND sent.date > $cursor') : _query;
1216
+ .with('sender, sent, received, recipient, credential')
1217
+ .where(recipientWhereClause);
1178
1218
 
1179
1219
  const results = convertQueryResultToPropertiesObjectArray<{
1180
1220
  sender: FlatProfileType;
@@ -1208,6 +1248,44 @@ export const getConnectedBoostRecipients = async (
1208
1248
  .filter(result => Boolean(result.to)) as Array<BoostRecipientInfo & { sent: string }>;
1209
1249
  };
1210
1250
 
1251
+ /**
1252
+ * Tail of a recipient count, continuing from a MATCH that bound `credential` and `sent`.
1253
+ * Produces one `to` row per distinct recipient profileId, ready for `RETURN COUNT(to)`.
1254
+ *
1255
+ * Same answer as joining `(recipient:Profile {profileId: sent.to})` (plus the CREDENTIAL_RECEIVED
1256
+ * edge into it when unaccepted sends are excluded) on every send and counting distinct ids, but it
1257
+ * looks each profile up once per distinct id and stops at the first match.
1258
+ */
1259
+ const buildDistinctRecipientCount = ({
1260
+ includeUnacceptedBoosts,
1261
+ recipientWhereClause,
1262
+ profileKnownToExist,
1263
+ }: {
1264
+ includeUnacceptedBoosts: boolean;
1265
+ recipientWhereClause: string;
1266
+ profileKnownToExist: boolean;
1267
+ }): string => {
1268
+ const hasProfileFilter = Boolean(recipientWhereClause) && recipientWhereClause !== 'true';
1269
+ const profileFilter = hasProfileFilter ? ` AND ${recipientWhereClause}` : '';
1270
+
1271
+ if (!includeUnacceptedBoosts) {
1272
+ return `WITH DISTINCT credential, sent.to AS to
1273
+ WHERE EXISTS {
1274
+ MATCH (credential)-[:CREDENTIAL_RECEIVED]->(recipient:Profile)
1275
+ WHERE recipient.profileId = to${profileFilter}
1276
+ }
1277
+ WITH DISTINCT to`;
1278
+ }
1279
+
1280
+ if (profileKnownToExist && !hasProfileFilter) return 'WITH DISTINCT sent.to AS to';
1281
+
1282
+ return `WITH DISTINCT sent.to AS to
1283
+ WHERE EXISTS {
1284
+ MATCH (recipient:Profile)
1285
+ WHERE recipient.profileId = to${profileFilter}
1286
+ }`;
1287
+ };
1288
+
1211
1289
  /**
1212
1290
  * Count boost recipients that are connected with the requesting profile
1213
1291
  */
@@ -1223,7 +1301,7 @@ export const countConnectedBoostRecipients = async (
1223
1301
  const { whereClause: countRecipientWhereClause, params: countQueryParams } =
1224
1302
  buildWhereForQueryBuilder('recipient', convertedQuery as any);
1225
1303
 
1226
- const countQuery = new QueryBuilder(new BindParam({ ...countQueryParams }))
1304
+ const countQuery = new QueryBuilder(new BindParam({ boostId: boost.id, ...countQueryParams }))
1227
1305
  // Build connection list
1228
1306
  .match({
1229
1307
  model: Profile,
@@ -1374,40 +1452,18 @@ export const countConnectedBoostRecipients = async (
1374
1452
  .unwind('allConnectedProfiles AS conn')
1375
1453
  .with('COLLECT(DISTINCT conn.profileId) AS connectionIds')
1376
1454
  // --- Boost recipient matching ---
1377
- .match({
1378
- related: [
1379
- { identifier: 'source', model: Boost, where: { id: boost.id } },
1380
- {
1381
- ...Credential.getRelationshipByAlias('instanceOf'),
1382
- identifier: 'instanceOf',
1383
- direction: 'in',
1384
- },
1385
- { identifier: 'credential', model: Credential },
1386
- {
1387
- ...Profile.getRelationshipByAlias('credentialSent'),
1388
- identifier: 'sent',
1389
- direction: 'in',
1390
- },
1391
- { identifier: 'sender', model: Profile },
1392
- ],
1393
- })
1394
- .match({ model: Profile, identifier: 'recipient' })
1395
- .where('recipient.profileId = sent.to')
1396
- .match({
1397
- optional: includeUnacceptedBoosts,
1398
- related: [
1399
- { identifier: 'credential', model: Credential },
1400
- {
1401
- ...Credential.getRelationshipByAlias('credentialReceived'),
1402
- identifier: 'received',
1403
- },
1404
- { identifier: 'recipient' },
1405
- ],
1406
- })
1407
- .with('sender, sent, received, recipient, credential, connectionIds')
1408
- .where(`sent.to IN connectionIds AND ${countRecipientWhereClause}`);
1455
+ .raw(matchBoostSendsFromBoost(['connectionIds']))
1456
+ .raw('WHERE sent.to IN connectionIds')
1457
+ .raw(
1458
+ buildDistinctRecipientCount({
1459
+ includeUnacceptedBoosts,
1460
+ recipientWhereClause: countRecipientWhereClause,
1461
+ // Every id in connectionIds was read off a Profile node, so the Profile always exists
1462
+ profileKnownToExist: true,
1463
+ })
1464
+ );
1409
1465
 
1410
- const result = await countQuery.return('COUNT(DISTINCT sent.to) AS count').run();
1466
+ const result = await countQuery.return('COUNT(to) AS count').run();
1411
1467
 
1412
1468
  return Number(result.records[0]?.get('count') ?? 0);
1413
1469
  };
@@ -1493,31 +1549,26 @@ export const getBoostRecipientsWithChildren = async (
1493
1549
  { identifier: 'sender', model: Profile },
1494
1550
  ],
1495
1551
  })
1496
- .match({ model: Profile, identifier: 'recipient' })
1497
- .where('recipient.profileId = sent.to')
1498
- // Optional match for received credentials
1499
- .match({
1500
- optional: includeUnacceptedBoosts,
1501
- related: [
1502
- { identifier: 'credential', model: Credential },
1503
- {
1504
- ...Credential.getRelationshipByAlias('credentialReceived'),
1505
- identifier: 'received',
1506
- },
1507
- { identifier: 'recipient' },
1508
- ],
1509
- })
1510
- // Group by recipient and collect boost data first
1552
+ // Read the received edge off the credential (one hop) rather than looking up a Profile
1553
+ // for every send. The recipient Profile is only looked up per page row further down.
1554
+ .raw(
1555
+ `${includeUnacceptedBoosts ? 'OPTIONAL ' : ''}MATCH (credential)-[received:CREDENTIAL_RECEIVED]->(receivedBy:Profile)
1556
+ WHERE receivedBy.profileId = sent.to`
1557
+ )
1558
+ // Group by recipient profileId and collect boost data first
1511
1559
  .with(
1512
- `recipient,
1560
+ `sent.to AS to,
1513
1561
  COLLECT(DISTINCT {relevantBoost: relevantBoost, sender: sender, sent: sent, received: received, credential: credential}) AS boostData`
1514
1562
  )
1563
+ .raw(cursor ? 'WHERE NOT to <= $cursor' : '')
1564
+ .orderBy('to ASC')
1565
+ .match({ model: Profile, identifier: 'recipient' })
1566
+ .where('recipient.profileId = to')
1567
+ .with('to, recipient, boostData')
1515
1568
  // Apply profile query filtering after grouping
1516
1569
  .where(profileWhereClause)
1517
- // Add cursor filtering if provided
1518
- .raw(cursor ? 'AND recipient.profileId > $cursor' : '')
1519
1570
  // Order by profileId and limit profiles (not individual boost relationships)
1520
- .orderBy('recipient.profileId ASC')
1571
+ .orderBy('to ASC')
1521
1572
  .limit(limit)
1522
1573
  .unwind('boostData AS data')
1523
1574
  .return(
@@ -1547,10 +1598,7 @@ export const getBoostRecipientsWithChildren = async (
1547
1598
  boostUri: getBoostUri(boostId, domain),
1548
1599
  ...(credential && { uri: getCredentialUri(credential.id, domain) }),
1549
1600
  status: coalesceStatus(sent.status, received?.status) as
1550
- | 'active'
1551
- | 'revoked'
1552
- | 'suspended'
1553
- | undefined,
1601
+ 'active' | 'revoked' | 'suspended' | undefined,
1554
1602
  };
1555
1603
  });
1556
1604
 
@@ -1663,26 +1711,15 @@ export const countBoostRecipientsWithChildren = async (
1663
1711
  { identifier: 'sender', model: Profile },
1664
1712
  ],
1665
1713
  })
1666
- .match({ model: Profile, identifier: 'recipient' })
1667
- .where('recipient.profileId = sent.to')
1668
- // Optional match for received credentials
1669
- .match({
1670
- optional: includeUnacceptedBoosts,
1671
- related: [
1672
- { identifier: 'credential', model: Credential },
1673
- {
1674
- ...Credential.getRelationshipByAlias('credentialReceived'),
1675
- identifier: 'received',
1676
- },
1677
- { identifier: 'recipient' },
1678
- ],
1679
- })
1680
- // Reduce to recipient before applying filter to ensure it doesn't attach to OPTIONAL MATCH
1681
- .with('recipient')
1682
- // Apply profile query filtering after WITH so it filters rows, not optional pattern
1683
- .where(countProfileWhereClause);
1714
+ .raw(
1715
+ buildDistinctRecipientCount({
1716
+ includeUnacceptedBoosts,
1717
+ recipientWhereClause: countProfileWhereClause,
1718
+ profileKnownToExist: false,
1719
+ })
1720
+ );
1684
1721
 
1685
- const result = await _query.return('COUNT(DISTINCT recipient.profileId) AS count').run();
1722
+ const result = await _query.return('COUNT(to) AS count').run();
1686
1723
 
1687
1724
  return Number(result.records[0]?.get('count') ?? 0);
1688
1725
  };
@@ -21,7 +21,7 @@ import { BoostType } from 'types/boost';
21
21
  import { flattenObject, inflateObject } from '@helpers/objects.helpers';
22
22
  import { convertQueryResultToPropertiesObjectArray } from '@helpers/neo4j.helpers';
23
23
  import { reconsentTerms } from './update';
24
- import { getBoostUri, sendBoost } from '@helpers/boost.helpers';
24
+ import { getBoostUri, sendBoost, stampIssuanceDate } from '@helpers/boost.helpers';
25
25
  import { setCredentialSubjectIds } from '@helpers/credentialSubject.helpers';
26
26
  import { getDidWeb } from '@helpers/did.helpers';
27
27
  import { getSigningAuthorityForUserByName } from '@accesslayer/signing-authority/relationships/read';
@@ -296,6 +296,8 @@ export const consentToContract = async (
296
296
 
297
297
  boostCredential.issuer = { id: contractOwnerSigningAuthority.relationship.did };
298
298
 
299
+ stampIssuanceDate(boostCredential);
300
+
299
301
  if (boostCredential.type.includes('BoostCredential')) {
300
302
  boostCredential.boostId = getBoostUri(boost.target.id, domain);
301
303
  }
@@ -15,7 +15,7 @@ import { ConsentFlowTerms, ConsentFlowTransaction, ConsentFlowContract } from '@
15
15
  import { neogma } from '@instance';
16
16
  import { flattenObject } from '@helpers/objects.helpers';
17
17
  import { DbContractType, DbTermsType } from 'types/consentflowcontract';
18
- import { getBoostUri, sendBoost } from '@helpers/boost.helpers';
18
+ import { getBoostUri, sendBoost, stampIssuanceDate } from '@helpers/boost.helpers';
19
19
  import { setCredentialSubjectIds } from '@helpers/credentialSubject.helpers';
20
20
  import { getDidWeb } from '@helpers/did.helpers';
21
21
  import { getSigningAuthorityForUserByName } from '@accesslayer/signing-authority/relationships/read';
@@ -201,6 +201,8 @@ export const reconsentTerms = async (
201
201
  // Set the issuer and subject
202
202
  boostCredential.issuer = { id: contractOwnerSigningAuthority.relationship.did };
203
203
 
204
+ stampIssuanceDate(boostCredential);
205
+
204
206
  if (boostCredential.type.includes('BoostCredential')) {
205
207
  boostCredential.boostId = getBoostUri(boost.dataValues.id, domain);
206
208
  }
@@ -439,6 +441,8 @@ export const updateTerms = async (
439
441
  // Set the issuer and subject
440
442
  boostCredential.issuer = { id: contractOwnerSigningAuthority.relationship.did };
441
443
 
444
+ stampIssuanceDate(boostCredential);
445
+
442
446
  if (boostCredential.type.includes('BoostCredential')) {
443
447
  boostCredential.boostId = getBoostUri(boost.target.id, domain);
444
448
  }
@@ -0,0 +1,91 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import type { Driver, ManagedTransaction } from 'neo4j-driver';
3
+
4
+ vi.mock('@instance', () => ({ neogma: { driver: { session: vi.fn() } } }));
5
+ import { createInboxBatchSchemaInitializer } from './schema';
6
+
7
+ const harness = () => {
8
+ const run = vi.fn().mockResolvedValue({});
9
+ const close = vi.fn().mockResolvedValue(undefined);
10
+ const executeWrite = vi.fn(async (callback: (tx: ManagedTransaction) => unknown) =>
11
+ callback({ run } as unknown as ManagedTransaction)
12
+ );
13
+ const driver = { session: vi.fn(() => ({ run: vi.fn(), executeWrite, close })) };
14
+ const ensure = createInboxBatchSchemaInitializer(driver as unknown as Driver);
15
+ return { run, close, executeWrite, driver, ensure };
16
+ };
17
+
18
+ beforeEach(() => vi.clearAllMocks());
19
+
20
+ describe('inbox batch schema readiness', () => {
21
+ it('shares in-flight readiness and runs every constraint AND index in its own managed write', async () => {
22
+ const { ensure, driver, executeWrite, run, close } = harness();
23
+ const first = ensure();
24
+ expect(ensure()).toBe(first);
25
+ await first;
26
+ await ensure();
27
+ expect(driver.session).toHaveBeenCalledTimes(1);
28
+ expect(executeWrite).toHaveBeenCalledTimes(10);
29
+ expect(run).toHaveBeenCalledTimes(10);
30
+ expect(
31
+ run.mock.calls.filter(([query]) => query.startsWith('CREATE CONSTRAINT'))
32
+ ).toHaveLength(4);
33
+ expect(run.mock.calls.filter(([query]) => query.startsWith('CREATE INDEX'))).toHaveLength(
34
+ 6
35
+ );
36
+ expect(close).toHaveBeenCalledTimes(1);
37
+ });
38
+
39
+ it('lets independent API/worker initializers complete through a transient managed-transaction retry', async () => {
40
+ const api = harness();
41
+ const worker = harness();
42
+ // Model the driver re-entering the transaction callback after a DDL deadlock.
43
+ // The real-driver concurrent cold-start regression lives in inbox-batch.spec.ts.
44
+ const deadlock = { code: 'Neo.TransientError.Transaction.DeadlockDetected' };
45
+ api.run.mockRejectedValueOnce(deadlock);
46
+ api.executeWrite.mockImplementationOnce(async callback => {
47
+ await expect(callback({ run: api.run } as unknown as ManagedTransaction)).rejects.toBe(
48
+ deadlock
49
+ );
50
+ return callback({ run: api.run } as unknown as ManagedTransaction);
51
+ });
52
+ await expect(Promise.all([api.ensure(), worker.ensure()])).resolves.toEqual([
53
+ undefined,
54
+ undefined,
55
+ ]);
56
+ const firstQuery =
57
+ 'CREATE CONSTRAINT inboxbatch_id IF NOT EXISTS FOR (n:InboxBatch) REQUIRE n.id IS UNIQUE';
58
+ expect(api.run.mock.calls.slice(0, 2)).toEqual([[firstQuery], [firstQuery]]);
59
+ expect(api.run).toHaveBeenCalledTimes(11);
60
+ expect(worker.run).toHaveBeenCalledTimes(10);
61
+ });
62
+
63
+ it.each([0, 4])('accepts equivalent-schema races for statement %i', async position => {
64
+ const { ensure, run } = harness();
65
+ for (let index = 0; index < position; index++) run.mockResolvedValueOnce({});
66
+ run.mockRejectedValueOnce({
67
+ code: 'Neo.ClientError.Schema.EquivalentSchemaRuleAlreadyExists',
68
+ });
69
+ await expect(ensure()).resolves.toBeUndefined();
70
+ expect(run).toHaveBeenCalledTimes(10);
71
+ });
72
+
73
+ it.each([
74
+ 'Neo.ClientError.Security.Forbidden',
75
+ 'Neo.TransientError.Transaction.DeadlockDetected',
76
+ ])(
77
+ 'propagates %s after the managed write fails and allows a later readiness attempt',
78
+ async code => {
79
+ const { ensure, executeWrite, run, close, driver } = harness();
80
+ const failure = { code };
81
+ executeWrite.mockRejectedValueOnce(failure);
82
+ await expect(ensure()).rejects.toBe(failure);
83
+ expect(run).not.toHaveBeenCalled();
84
+ expect(close).toHaveBeenCalledTimes(1);
85
+ await expect(ensure()).resolves.toBeUndefined();
86
+ expect(driver.session).toHaveBeenCalledTimes(2);
87
+ expect(run).toHaveBeenCalledTimes(10);
88
+ expect(close).toHaveBeenCalledTimes(2);
89
+ }
90
+ );
91
+ });
@@ -0,0 +1,54 @@
1
+ import type { Driver } from 'neo4j-driver';
2
+ import { neogma } from '@instance';
3
+
4
+ const SCHEMA_QUERIES = [
5
+ ...['InboxBatch', 'InboxBatchItem', 'InboxBatchIssuer', 'InboxBatchReplay'].map(
6
+ label =>
7
+ `CREATE CONSTRAINT ${label.toLowerCase()}_id IF NOT EXISTS FOR (n:${label}) REQUIRE n.id IS UNIQUE`
8
+ ),
9
+ 'CREATE INDEX inbox_batch_request IF NOT EXISTS FOR (b:InboxBatch) ON (b.issuer, b.requestId)',
10
+ 'CREATE INDEX inbox_batch_dispatch IF NOT EXISTS FOR (i:InboxBatchItem) ON (i.state, i.dispatchAt)',
11
+ 'CREATE INDEX inbox_batch_recovery IF NOT EXISTS FOR (i:InboxBatchItem) ON (i.state, i.leaseUntil)',
12
+ 'CREATE INDEX inbox_batch_replay_owner IF NOT EXISTS FOR (r:InboxBatchReplay) ON (r.itemId)',
13
+ 'CREATE INDEX inbox_batch_completed IF NOT EXISTS FOR (b:InboxBatch) ON (b.completedAt)',
14
+ 'CREATE INDEX inbox_batch_replay_expiry IF NOT EXISTS FOR (r:InboxBatchReplay) ON (r.expiresAt)',
15
+ ];
16
+
17
+ /** Each API/worker process shares readiness locally; the driver retries cross-process DDL deadlocks. */
18
+ export const createInboxBatchSchemaInitializer = (
19
+ driver: Pick<Driver, 'session'>
20
+ ): (() => Promise<void>) => {
21
+ let readiness: Promise<void> | undefined;
22
+ return (): Promise<void> => {
23
+ if (!readiness) {
24
+ const pending = (async (): Promise<void> => {
25
+ const session = driver.session();
26
+ try {
27
+ for (const query of SCHEMA_QUERIES) {
28
+ try {
29
+ // One schema statement per managed transaction. IF NOT EXISTS is
30
+ // idempotent, but competing cold processes can still deadlock while
31
+ // acquiring label locks. Auto-commit queryRunner.run does not retry.
32
+ await session.executeWrite(tx => tx.run(query));
33
+ } catch (error) {
34
+ if (
35
+ (error as { code?: string } | null)?.code !==
36
+ 'Neo.ClientError.Schema.EquivalentSchemaRuleAlreadyExists'
37
+ )
38
+ throw error;
39
+ }
40
+ }
41
+ } finally {
42
+ await session.close();
43
+ }
44
+ })();
45
+ readiness = pending;
46
+ void pending.catch(() => {
47
+ if (readiness === pending) readiness = undefined;
48
+ });
49
+ }
50
+ return readiness;
51
+ };
52
+ };
53
+
54
+ export const ensureInboxBatchConstraints = createInboxBatchSchemaInitializer(neogma.driver);
@@ -6,49 +6,13 @@ import { neogma } from '@instance';
6
6
  import { encryptInboxCredential, decryptInboxCredential } from '@helpers/inbox-encryption.helpers';
7
7
  import type { BatchItem, BatchJob, BatchReplayStore, InboxDispatchLease } from 'types/inbox-batch';
8
8
  import { getInboxBatchState } from '@helpers/inbox-batch-status.helpers';
9
+ import { ensureInboxBatchConstraints } from './schema';
9
10
 
10
11
  export const DAY = 86_400_000;
11
12
  export const LEASE_MS = 360_000;
12
13
  const CLEANUP_MARGIN_MS = 5_000;
13
14
 
14
- let readiness: Promise<void> | undefined;
15
- export const ensureInboxBatchConstraints = (): Promise<void> => {
16
- if (!readiness) {
17
- readiness = (async () => {
18
- for (const label of [
19
- 'InboxBatch',
20
- 'InboxBatchItem',
21
- 'InboxBatchIssuer',
22
- 'InboxBatchReplay',
23
- ]) {
24
- try {
25
- await neogma.queryRunner.run(
26
- `CREATE CONSTRAINT ${label.toLowerCase()}_id IF NOT EXISTS FOR (n:${label}) REQUIRE n.id IS UNIQUE`
27
- );
28
- } catch (error) {
29
- if (
30
- (error as { code?: string }).code !==
31
- 'Neo.ClientError.Schema.EquivalentSchemaRuleAlreadyExists'
32
- )
33
- throw error;
34
- }
35
- }
36
- for (const query of [
37
- 'CREATE INDEX inbox_batch_request IF NOT EXISTS FOR (b:InboxBatch) ON (b.issuer, b.requestId)',
38
- 'CREATE INDEX inbox_batch_dispatch IF NOT EXISTS FOR (i:InboxBatchItem) ON (i.state, i.dispatchAt)',
39
- 'CREATE INDEX inbox_batch_recovery IF NOT EXISTS FOR (i:InboxBatchItem) ON (i.state, i.leaseUntil)',
40
- 'CREATE INDEX inbox_batch_replay_owner IF NOT EXISTS FOR (r:InboxBatchReplay) ON (r.itemId)',
41
- 'CREATE INDEX inbox_batch_completed IF NOT EXISTS FOR (b:InboxBatch) ON (b.completedAt)',
42
- 'CREATE INDEX inbox_batch_replay_expiry IF NOT EXISTS FOR (r:InboxBatchReplay) ON (r.expiresAt)',
43
- ])
44
- await neogma.queryRunner.run(query);
45
- })();
46
- void readiness.catch(() => {
47
- readiness = undefined;
48
- });
49
- }
50
- return readiness;
51
- };
15
+ export { ensureInboxBatchConstraints } from './schema';
52
16
 
53
17
  const transaction = async <T>(fn: (tx: ManagedTransaction) => Promise<T>): Promise<T> => {
54
18
  await ensureInboxBatchConstraints();
@@ -5,6 +5,7 @@ import { LCNProfile } from '@learncard/types';
5
5
  import { Profile } from '@models';
6
6
  import { BindParam, QueryBuilder } from 'neogma';
7
7
  import { ProfileType } from 'types/profile';
8
+ import { syncProfileDisplayNameLower } from './search-fields';
8
9
 
9
10
  export const createProfile = async (input: LCNProfile): Promise<ProfileType> => {
10
11
  const result = await new QueryBuilder(
@@ -13,7 +14,7 @@ export const createProfile = async (input: LCNProfile): Promise<ProfileType> =>
13
14
  })
14
15
  )
15
16
  .create({ model: Profile, identifier: 'profile' })
16
- .set('profile += $params')
17
+ .set(`profile += $params, ${syncProfileDisplayNameLower('profile')}`)
17
18
  .return('profile')
18
19
  .limit(1)
19
20
  .run();