@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.
- package/CHANGELOG.md +49 -0
- package/dist/accesslayer/boost/read.d.ts.map +1 -1
- package/dist/accesslayer/boost/relationships/read.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/relationships/create.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/relationships/update.d.ts.map +1 -1
- package/dist/accesslayer/inbox-batch/schema.d.ts +4 -0
- package/dist/accesslayer/inbox-batch/schema.d.ts.map +1 -0
- package/dist/accesslayer/inbox-batch/schema.test.d.ts +2 -0
- package/dist/accesslayer/inbox-batch/schema.test.d.ts.map +1 -0
- package/dist/accesslayer/inbox-batch/store.d.ts +1 -1
- package/dist/accesslayer/inbox-batch/store.d.ts.map +1 -1
- package/dist/accesslayer/profile/create.d.ts.map +1 -1
- package/dist/accesslayer/profile/read.d.ts.map +1 -1
- package/dist/accesslayer/profile/search-fields.d.ts +16 -0
- package/dist/accesslayer/profile/search-fields.d.ts.map +1 -0
- package/dist/accesslayer/profile/update.d.ts.map +1 -1
- package/dist/helpers/boost.helpers.d.ts +1 -0
- package/dist/helpers/boost.helpers.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/models/credential-refresh-constraints.d.ts.map +1 -1
- package/dist/models/index.d.ts.map +1 -1
- package/dist/publish-metafile.json +1 -1
- package/dist/routes/workflows.d.ts.map +1 -1
- package/dist/seed/seedSkillFrameworks.d.ts.map +1 -1
- package/esbuildPlugins.cjs +9 -6
- package/package.json +19 -16
- package/project.json +18 -0
- package/scripts/backfill-profile-search.ts +79 -0
- package/scripts/seed-demo-persona.ts +6 -1
- package/src/accesslayer/boost/read.ts +171 -34
- package/src/accesslayer/boost/relationships/read.ts +163 -126
- package/src/accesslayer/consentflowcontract/relationships/create.ts +3 -1
- package/src/accesslayer/consentflowcontract/relationships/update.ts +5 -1
- package/src/accesslayer/inbox-batch/schema.test.ts +91 -0
- package/src/accesslayer/inbox-batch/schema.ts +54 -0
- package/src/accesslayer/inbox-batch/store.ts +2 -38
- package/src/accesslayer/profile/create.ts +2 -1
- package/src/accesslayer/profile/read.ts +23 -3
- package/src/accesslayer/profile/search-fields.ts +69 -0
- package/src/accesslayer/profile/update.ts +7 -1
- package/src/helpers/boost-storage.helpers.test.ts +155 -1
- package/src/helpers/boost.helpers.ts +52 -10
- package/src/models/credential-refresh-constraints.test.ts +44 -0
- package/src/models/credential-refresh-constraints.ts +29 -3
- package/src/models/index.ts +15 -4
- package/src/routes/workflows.ts +8 -1
- package/src/seed/seedSkillFrameworks.ts +4 -1
- package/test/boost.familial-equivalence.spec.ts +482 -0
- package/test/boost.recipientsJoin.spec.ts +508 -0
- package/test/consentflow.spec.ts +101 -0
- package/test/inbox-batch.spec.ts +189 -1
- package/test/profile-search.spec.ts +307 -0
- package/vitest.boost-recipients-neo4j.config.ts +21 -0
- package/vitest.config.ts +1 -0
- 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
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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(
|
|
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
|
-
| '
|
|
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
|
-
| '
|
|
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
|
|
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
|
-
.
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
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
|
|
1175
|
-
.where(
|
|
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
|
-
.
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
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(
|
|
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
|
-
|
|
1497
|
-
.
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
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
|
-
`
|
|
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('
|
|
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
|
-
| '
|
|
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
|
-
.
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
17
|
+
.set(`profile += $params, ${syncProfileDisplayNameLower('profile')}`)
|
|
17
18
|
.return('profile')
|
|
18
19
|
.limit(1)
|
|
19
20
|
.run();
|