@learncard/network-brain-service 4.0.1 → 4.0.3
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 +35 -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/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/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/seed/seedSkillFrameworks.d.ts.map +1 -1
- 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/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/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/seed/seedSkillFrameworks.ts +4 -1
- package/test/boost.familial-equivalence.spec.ts +482 -0
- package/test/boost.recipientsJoin.spec.ts +508 -0
- package/test/profile-search.spec.ts +307 -0
- package/vitest.boost-recipients-neo4j.config.ts +21 -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
|
};
|
|
@@ -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();
|
|
@@ -3,6 +3,7 @@ import { Profile } from '@models';
|
|
|
3
3
|
import { ProfileType, FlatProfileType } from 'types/profile';
|
|
4
4
|
import { transformProfileId } from '@helpers/profile.helpers';
|
|
5
5
|
import { inflateObject } from '@helpers/objects.helpers';
|
|
6
|
+
import { PROFILE_DISPLAY_NAME_LOWER_FIELD, normalizeProfileSearchText } from './search-fields';
|
|
6
7
|
|
|
7
8
|
export const getProfileByProfileId = async (profileId: string): Promise<ProfileType | null> => {
|
|
8
9
|
const result = await new QueryBuilder()
|
|
@@ -86,6 +87,15 @@ export const checkIfProfileExists = async ({
|
|
|
86
87
|
return false;
|
|
87
88
|
};
|
|
88
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Case-insensitive substring search over `profileId` and `displayName`.
|
|
92
|
+
*
|
|
93
|
+
* Each branch of the `UNION` is a single `CONTAINS` predicate so the planner can seek a TEXT
|
|
94
|
+
* index for it (`profileId_text_idx` and `profile_displayname_lower_text_idx`) instead of
|
|
95
|
+
* scanning every `Profile`. `UNION` (not `UNION ALL`) dedupes a profile that matches both.
|
|
96
|
+
* The visibility/private/service/blacklist filters, ordering and limit are applied to the
|
|
97
|
+
* merged set, same as before.
|
|
98
|
+
*/
|
|
89
99
|
export const searchProfiles = async (
|
|
90
100
|
searchInput: string,
|
|
91
101
|
{
|
|
@@ -100,15 +110,25 @@ export const searchProfiles = async (
|
|
|
100
110
|
): Promise<ProfileType[]> => {
|
|
101
111
|
const result = await new QueryBuilder(
|
|
102
112
|
new BindParam({
|
|
113
|
+
// profileId is stored lowercased by transformProfileId
|
|
103
114
|
input: searchInput.toLowerCase(),
|
|
104
|
-
|
|
115
|
+
// normalized in Cypher below with the same expression displayNameLower is written with
|
|
116
|
+
displayNameInput: searchInput,
|
|
105
117
|
blacklist,
|
|
106
118
|
})
|
|
107
119
|
)
|
|
108
|
-
.
|
|
120
|
+
.raw(
|
|
121
|
+
`CALL {
|
|
122
|
+
MATCH (profile:Profile) WHERE profile.profileId CONTAINS $input RETURN profile
|
|
123
|
+
UNION
|
|
124
|
+
MATCH (profile:Profile) WHERE profile.${PROFILE_DISPLAY_NAME_LOWER_FIELD} CONTAINS ${normalizeProfileSearchText(
|
|
125
|
+
'$displayNameInput'
|
|
126
|
+
)} RETURN profile
|
|
127
|
+
}`
|
|
128
|
+
)
|
|
129
|
+
.with('profile')
|
|
109
130
|
.where(
|
|
110
131
|
`
|
|
111
|
-
(profile.profileId CONTAINS $input OR profile.displayName =~ $inputRegex) AND
|
|
112
132
|
(
|
|
113
133
|
profile.profileVisibility IS NULL OR
|
|
114
134
|
profile.profileVisibility = "public"
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Profile search runs `CONTAINS` against TEXT indexes, and TEXT index `CONTAINS` is case-sensitive.
|
|
3
|
+
* `profileId` is already stored lowercased (`transformProfileId`), so it can be searched as-is.
|
|
4
|
+
* `displayName` keeps the casing people typed, so we keep a normalized copy of it in
|
|
5
|
+
* `displayNameLower` (TEXT-indexed by `profile_displayname_lower_text_idx`) and search that.
|
|
6
|
+
*
|
|
7
|
+
* Every write that sets `displayName` must also set `displayNameLower`, and writes, the backfill
|
|
8
|
+
* and the search input all go through the same Cypher expression, `normalizeProfileSearchText`.
|
|
9
|
+
*/
|
|
10
|
+
export const PROFILE_DISPLAY_NAME_LOWER_FIELD = 'displayNameLower';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The one search normalization for display names: Cypher `toLower`, then final sigma (ς, U+03C2)
|
|
14
|
+
* folded to σ (U+03C3).
|
|
15
|
+
*
|
|
16
|
+
* `toLower` alone isn't safe for substring matching because it's context-sensitive for Greek:
|
|
17
|
+
* a capital Σ becomes ς at the end of a word and σ everywhere else. So `ΑΝΑΣΤΑΣΙΑ` is stored as
|
|
18
|
+
* `αναστασια`, but the search input `ΑΝΑΣ` lowercases to `ανας` and no longer matches. Folding ς to
|
|
19
|
+
* σ afterwards makes the result the same for a letter wherever it sits in the string, so a
|
|
20
|
+
* substring of a name always normalizes to a substring of the normalized name.
|
|
21
|
+
*
|
|
22
|
+
* Use this for writes, the backfill and queries alike. Changing it means re-running the backfill.
|
|
23
|
+
*/
|
|
24
|
+
export const normalizeProfileSearchText = (expression: string): string =>
|
|
25
|
+
`replace(toLower(${expression}), '\u03C2', '\u03C3')`;
|
|
26
|
+
|
|
27
|
+
/** Cypher fragment that keeps `displayNameLower` in sync with `displayName` for `identifier`. */
|
|
28
|
+
export const syncProfileDisplayNameLower = (identifier: string): string =>
|
|
29
|
+
`${identifier}.${PROFILE_DISPLAY_NAME_LOWER_FIELD} = ${normalizeProfileSearchText(
|
|
30
|
+
`${identifier}.displayName`
|
|
31
|
+
)}`;
|
|
32
|
+
|
|
33
|
+
/** Cypher predicate: true for a profile whose `displayNameLower` is missing or stale. */
|
|
34
|
+
export const profileDisplayNameLowerIsStale = (identifier: string): string =>
|
|
35
|
+
`${identifier}.displayName IS NOT NULL
|
|
36
|
+
AND (${identifier}.${PROFILE_DISPLAY_NAME_LOWER_FIELD} IS NULL
|
|
37
|
+
OR ${identifier}.${PROFILE_DISPLAY_NAME_LOWER_FIELD} <> ${normalizeProfileSearchText(
|
|
38
|
+
`${identifier}.displayName`
|
|
39
|
+
)})`;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* One-pass, idempotent backfill for profiles written before `displayNameLower` existed (or whose
|
|
43
|
+
* copy has drifted). Uses `CALL { ... } IN TRANSACTIONS`, so it has to run in an auto-commit
|
|
44
|
+
* transaction (e.g. `session.run`), not inside an explicit transaction.
|
|
45
|
+
*/
|
|
46
|
+
export const buildBackfillProfileDisplayNameLowerQuery = (batchSize = 1000): string => `
|
|
47
|
+
MATCH (profile:Profile)
|
|
48
|
+
WHERE ${profileDisplayNameLowerIsStale('profile')}
|
|
49
|
+
CALL {
|
|
50
|
+
WITH profile
|
|
51
|
+
SET ${syncProfileDisplayNameLower('profile')}
|
|
52
|
+
} IN TRANSACTIONS OF ${Number.isFinite(batchSize) ? Math.max(1, Math.floor(batchSize)) : 1000} ROWS
|
|
53
|
+
`;
|
|
54
|
+
|
|
55
|
+
/** Anything that runs a query in an auto-commit transaction, e.g. `query => session.run(query)`. */
|
|
56
|
+
export type AutoCommitRunner = (
|
|
57
|
+
query: string
|
|
58
|
+
) => PromiseLike<{ summary: { counters: { updates: () => Record<string, number> } } }>;
|
|
59
|
+
|
|
60
|
+
/** Runs the backfill and returns how many profiles were updated. */
|
|
61
|
+
export const backfillProfileDisplayNameLower = async (
|
|
62
|
+
run: AutoCommitRunner,
|
|
63
|
+
{ batchSize = 1000 }: { batchSize?: number } = {}
|
|
64
|
+
): Promise<number> => {
|
|
65
|
+
// The batch size is inlined: OF ... ROWS needs an integer and JS numbers bind as floats.
|
|
66
|
+
const result = await run(buildBackfillProfileDisplayNameLowerQuery(batchSize));
|
|
67
|
+
|
|
68
|
+
return result.summary.counters.updates().propertiesSet ?? 0;
|
|
69
|
+
};
|
|
@@ -2,6 +2,7 @@ import { QueryBuilder, BindParam } from 'neogma';
|
|
|
2
2
|
import { flattenObject } from '@helpers/objects.helpers';
|
|
3
3
|
import { Profile } from '@models';
|
|
4
4
|
import { ProfileType } from 'types/profile';
|
|
5
|
+
import { syncProfileDisplayNameLower } from './search-fields';
|
|
5
6
|
|
|
6
7
|
export const updateProfile = async (profile: ProfileType, updates: Partial<ProfileType>) => {
|
|
7
8
|
const currentDisplay = profile.display;
|
|
@@ -14,7 +15,12 @@ export const updateProfile = async (profile: ProfileType, updates: Partial<Profi
|
|
|
14
15
|
|
|
15
16
|
const query = new QueryBuilder(new BindParam({ params: newUpdates }))
|
|
16
17
|
.match({ model: Profile, where: { profileId: profile.profileId }, identifier: 'profile' })
|
|
17
|
-
.set(
|
|
18
|
+
.set(
|
|
19
|
+
// Keep the lowercased search copy in sync whenever displayName changes
|
|
20
|
+
'displayName' in newUpdates
|
|
21
|
+
? `profile += $params, ${syncProfileDisplayNameLower('profile')}`
|
|
22
|
+
: 'profile += $params'
|
|
23
|
+
);
|
|
18
24
|
|
|
19
25
|
const result = await query.run();
|
|
20
26
|
|
|
@@ -77,4 +77,48 @@ describe('credential refresh constraint readiness', () => {
|
|
|
77
77
|
|
|
78
78
|
expect(mocks.run).toHaveBeenCalledTimes(9);
|
|
79
79
|
});
|
|
80
|
+
|
|
81
|
+
it('runs the constraint setup once per process across many calls', async () => {
|
|
82
|
+
mocks.run.mockResolvedValue(undefined);
|
|
83
|
+
|
|
84
|
+
const { ensureCredentialRefreshConstraints } =
|
|
85
|
+
await import('./credential-refresh-constraints');
|
|
86
|
+
|
|
87
|
+
// A burst of concurrent callers (cold start plus the first requests) ...
|
|
88
|
+
await Promise.all(Array.from({ length: 50 }, () => ensureCredentialRefreshConstraints()));
|
|
89
|
+
// ... and every later caller in the same process.
|
|
90
|
+
for (let i = 0; i < 200; i++) await ensureCredentialRefreshConstraints();
|
|
91
|
+
|
|
92
|
+
expect(mocks.run).toHaveBeenCalledTimes(8);
|
|
93
|
+
expect(new Set(mocks.run.mock.calls.map(([query]) => query)).size).toBe(8);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('retries transient errors and deadlocks before giving up on a statement', async () => {
|
|
97
|
+
mocks.run
|
|
98
|
+
.mockRejectedValueOnce({ code: 'Neo.TransientError.Transaction.DeadlockDetected' })
|
|
99
|
+
.mockRejectedValueOnce({
|
|
100
|
+
code: 'Neo.TransientError.Transaction.LockClientStopped',
|
|
101
|
+
retriable: true,
|
|
102
|
+
})
|
|
103
|
+
.mockResolvedValue(undefined);
|
|
104
|
+
|
|
105
|
+
const { ensureCredentialRefreshConstraints } =
|
|
106
|
+
await import('./credential-refresh-constraints');
|
|
107
|
+
|
|
108
|
+
await expect(ensureCredentialRefreshConstraints()).resolves.toBeUndefined();
|
|
109
|
+
// The first statement took three attempts, the other seven one each
|
|
110
|
+
expect(mocks.run).toHaveBeenCalledTimes(10);
|
|
111
|
+
expect(mocks.run.mock.calls[0]![0]).toBe(mocks.run.mock.calls[2]![0]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('does not retry a non-transient failure', async () => {
|
|
115
|
+
const setupFailure = { code: 'Neo.ClientError.Security.Forbidden' };
|
|
116
|
+
mocks.run.mockRejectedValueOnce(setupFailure).mockResolvedValue(undefined);
|
|
117
|
+
|
|
118
|
+
const { ensureCredentialRefreshConstraints } =
|
|
119
|
+
await import('./credential-refresh-constraints');
|
|
120
|
+
|
|
121
|
+
await expect(ensureCredentialRefreshConstraints()).rejects.toBe(setupFailure);
|
|
122
|
+
expect(mocks.run).toHaveBeenCalledTimes(1);
|
|
123
|
+
});
|
|
80
124
|
});
|
|
@@ -13,24 +13,50 @@ const CREDENTIAL_REFRESH_CONSTRAINT_QUERIES = [
|
|
|
13
13
|
|
|
14
14
|
type Neo4jSchemaError = {
|
|
15
15
|
code?: string;
|
|
16
|
+
retriable?: boolean;
|
|
16
17
|
};
|
|
17
18
|
|
|
19
|
+
const MAX_ATTEMPTS = 5;
|
|
20
|
+
|
|
21
|
+
const wait = (ms: number): Promise<void> => new Promise(resolve => setTimeout(resolve, ms));
|
|
22
|
+
|
|
18
23
|
const isEquivalentSchemaRuleRace = (error: unknown): boolean =>
|
|
19
24
|
(error as Neo4jSchemaError | undefined)?.code ===
|
|
20
25
|
'Neo.ClientError.Schema.EquivalentSchemaRuleAlreadyExists';
|
|
21
26
|
|
|
22
|
-
|
|
23
|
-
|
|
27
|
+
// Same retry rule as the index setup in models/index.ts: transient errors and deadlocks
|
|
28
|
+
// (schema locks contended by other cold starts) are retried with a short backoff.
|
|
29
|
+
const isRetriable = (error: unknown): boolean => {
|
|
30
|
+
const neo4jError = error as Neo4jSchemaError | undefined;
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
neo4jError?.retriable === true ||
|
|
34
|
+
neo4jError?.code === 'Neo.TransientError.Transaction.DeadlockDetected'
|
|
35
|
+
);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const runConstraintQuery = async (query: string): Promise<void> => {
|
|
39
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
24
40
|
try {
|
|
25
41
|
await neogma.queryRunner.run(query);
|
|
42
|
+
return;
|
|
26
43
|
} catch (error) {
|
|
27
44
|
// Concurrent service starts can both observe a missing rule before one wins
|
|
28
45
|
// creation. The losing CREATE is equivalent to the desired ready state.
|
|
29
|
-
if (
|
|
46
|
+
if (isEquivalentSchemaRuleRace(error)) return;
|
|
47
|
+
if (!isRetriable(error) || attempt === MAX_ATTEMPTS) throw error;
|
|
48
|
+
|
|
49
|
+
await wait(100 * attempt);
|
|
30
50
|
}
|
|
31
51
|
}
|
|
32
52
|
};
|
|
33
53
|
|
|
54
|
+
const createCredentialRefreshConstraints = async (): Promise<void> => {
|
|
55
|
+
for (const query of CREDENTIAL_REFRESH_CONSTRAINT_QUERIES) {
|
|
56
|
+
await runConstraintQuery(query);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
34
60
|
let constraintReadiness: Promise<void> | undefined;
|
|
35
61
|
|
|
36
62
|
/**
|