@learncard/network-brain-service 4.0.2 → 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.
Files changed (32) hide show
  1. package/CHANGELOG.md +14 -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/profile/create.d.ts.map +1 -1
  5. package/dist/accesslayer/profile/read.d.ts.map +1 -1
  6. package/dist/accesslayer/profile/search-fields.d.ts +16 -0
  7. package/dist/accesslayer/profile/search-fields.d.ts.map +1 -0
  8. package/dist/accesslayer/profile/update.d.ts.map +1 -1
  9. package/dist/index.js +2 -2
  10. package/dist/models/credential-refresh-constraints.d.ts.map +1 -1
  11. package/dist/models/index.d.ts.map +1 -1
  12. package/dist/publish-metafile.json +1 -1
  13. package/dist/seed/seedSkillFrameworks.d.ts.map +1 -1
  14. package/package.json +4 -1
  15. package/project.json +18 -0
  16. package/scripts/backfill-profile-search.ts +79 -0
  17. package/scripts/seed-demo-persona.ts +6 -1
  18. package/src/accesslayer/boost/read.ts +171 -34
  19. package/src/accesslayer/boost/relationships/read.ts +163 -126
  20. package/src/accesslayer/profile/create.ts +2 -1
  21. package/src/accesslayer/profile/read.ts +23 -3
  22. package/src/accesslayer/profile/search-fields.ts +69 -0
  23. package/src/accesslayer/profile/update.ts +7 -1
  24. package/src/models/credential-refresh-constraints.test.ts +44 -0
  25. package/src/models/credential-refresh-constraints.ts +29 -3
  26. package/src/models/index.ts +15 -4
  27. package/src/seed/seedSkillFrameworks.ts +4 -1
  28. package/test/boost.familial-equivalence.spec.ts +482 -0
  29. package/test/boost.recipientsJoin.spec.ts +508 -0
  30. package/test/profile-search.spec.ts +307 -0
  31. package/vitest.boost-recipients-neo4j.config.ts +21 -0
  32. 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
  };
@@ -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();
@@ -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
- inputRegex: `(?i).*${searchInput}.*`,
115
+ // normalized in Cypher below with the same expression displayNameLower is written with
116
+ displayNameInput: searchInput,
105
117
  blacklist,
106
118
  })
107
119
  )
108
- .match({ identifier: 'profile', model: Profile })
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('profile += $params');
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
- const createCredentialRefreshConstraints = async (): Promise<void> => {
23
- for (const query of CREDENTIAL_REFRESH_CONSTRAINT_QUERIES) {
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 (!isEquivalentSchemaRuleRace(error)) throw error;
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
  /**