@learncard/network-brain-service 4.0.2 → 4.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/accesslayer/boost/read.d.ts.map +1 -1
  3. package/dist/accesslayer/boost/relationships/read.d.ts.map +1 -1
  4. package/dist/accesslayer/consentflowcontract/relationships/create.d.ts.map +1 -1
  5. package/dist/accesslayer/consentflowcontract/relationships/update.d.ts.map +1 -1
  6. package/dist/accesslayer/inbox-batch/schema.d.ts +4 -0
  7. package/dist/accesslayer/inbox-batch/schema.d.ts.map +1 -0
  8. package/dist/accesslayer/inbox-batch/schema.test.d.ts +2 -0
  9. package/dist/accesslayer/inbox-batch/schema.test.d.ts.map +1 -0
  10. package/dist/accesslayer/inbox-batch/store.d.ts +1 -1
  11. package/dist/accesslayer/inbox-batch/store.d.ts.map +1 -1
  12. package/dist/accesslayer/profile/create.d.ts.map +1 -1
  13. package/dist/accesslayer/profile/read.d.ts.map +1 -1
  14. package/dist/accesslayer/profile/search-fields.d.ts +16 -0
  15. package/dist/accesslayer/profile/search-fields.d.ts.map +1 -0
  16. package/dist/accesslayer/profile/update.d.ts.map +1 -1
  17. package/dist/helpers/boost.helpers.d.ts +1 -0
  18. package/dist/helpers/boost.helpers.d.ts.map +1 -1
  19. package/dist/index.js +2 -2
  20. package/dist/models/credential-refresh-constraints.d.ts.map +1 -1
  21. package/dist/models/index.d.ts.map +1 -1
  22. package/dist/publish-metafile.json +1 -1
  23. package/dist/routes/workflows.d.ts.map +1 -1
  24. package/dist/seed/seedSkillFrameworks.d.ts.map +1 -1
  25. package/esbuildPlugins.cjs +9 -6
  26. package/package.json +19 -16
  27. package/project.json +18 -0
  28. package/scripts/backfill-profile-search.ts +79 -0
  29. package/scripts/seed-demo-persona.ts +6 -1
  30. package/src/accesslayer/boost/read.ts +171 -34
  31. package/src/accesslayer/boost/relationships/read.ts +163 -126
  32. package/src/accesslayer/consentflowcontract/relationships/create.ts +3 -1
  33. package/src/accesslayer/consentflowcontract/relationships/update.ts +5 -1
  34. package/src/accesslayer/inbox-batch/schema.test.ts +91 -0
  35. package/src/accesslayer/inbox-batch/schema.ts +54 -0
  36. package/src/accesslayer/inbox-batch/store.ts +2 -38
  37. package/src/accesslayer/profile/create.ts +2 -1
  38. package/src/accesslayer/profile/read.ts +23 -3
  39. package/src/accesslayer/profile/search-fields.ts +69 -0
  40. package/src/accesslayer/profile/update.ts +7 -1
  41. package/src/helpers/boost-storage.helpers.test.ts +155 -1
  42. package/src/helpers/boost.helpers.ts +52 -10
  43. package/src/models/credential-refresh-constraints.test.ts +44 -0
  44. package/src/models/credential-refresh-constraints.ts +29 -3
  45. package/src/models/index.ts +15 -4
  46. package/src/routes/workflows.ts +8 -1
  47. package/src/seed/seedSkillFrameworks.ts +4 -1
  48. package/test/boost.familial-equivalence.spec.ts +482 -0
  49. package/test/boost.recipientsJoin.spec.ts +508 -0
  50. package/test/consentflow.spec.ts +101 -0
  51. package/test/inbox-batch.spec.ts +189 -1
  52. package/test/profile-search.spec.ts +307 -0
  53. package/vitest.boost-recipients-neo4j.config.ts +21 -0
  54. package/vitest.config.ts +1 -0
  55. package/vitest.profile-search-neo4j.config.ts +20 -0
@@ -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
 
@@ -65,7 +65,12 @@ vi.mock('@tracing', () => ({
65
65
  traceInternal: async (_name: string, action: () => unknown) => action(),
66
66
  }));
67
67
 
68
- import { issueClaimLinkBoost, sendBoost } from './boost.helpers';
68
+ import {
69
+ issueClaimLinkBoost,
70
+ prepareCredentialFromBoost,
71
+ sendBoost,
72
+ stampIssuanceDate,
73
+ } from './boost.helpers';
69
74
 
70
75
  const vc = {
71
76
  '@context': ['https://www.w3.org/2018/credentials/v1'],
@@ -223,6 +228,155 @@ describe('boost payload storage', () => {
223
228
  }
224
229
  );
225
230
 
231
+ describe('malformed template issuance dates', () => {
232
+ // Seen on a production Boost template; it is not RFC 3339, so DIDKit refuses to sign it.
233
+ const MALFORMED_DATE = '2025-10-19+053008:18:16';
234
+ // The reused JOTA-JOTI "Health and Well-being" Boost that carried it.
235
+ const BOOST_ID = 'e073d183-8304-419f-b4f6-ef91fda6524c';
236
+ const V1 = ['https://www.w3.org/2018/credentials/v1'];
237
+ const V2 = ['https://www.w3.org/ns/credentials/v2'];
238
+ const authority = { relationship: { did: 'did:example:sa' } } as Parameters<
239
+ typeof issueClaimLinkBoost
240
+ >[4];
241
+ const boostWith = (template: Record<string, unknown>) =>
242
+ ({
243
+ ...options.boost,
244
+ dataValues: {
245
+ id: BOOST_ID,
246
+ boost: JSON.stringify({ ...vc, boostId: undefined, ...template }),
247
+ },
248
+ }) as Parameters<typeof issueClaimLinkBoost>[0];
249
+ const expectFreshDate = (value: unknown, before: number) => {
250
+ expect(typeof value).toBe('string');
251
+ expect(value).toBe(new Date(value as string).toISOString());
252
+ expect(Date.parse(value as string)).toBeGreaterThanOrEqual(before);
253
+ };
254
+
255
+ beforeEach(() => {
256
+ mocks.signingAuthority.mockResolvedValue({
257
+ kind: 'issued-credential',
258
+ credential: jwe,
259
+ statusEntries: [],
260
+ });
261
+ });
262
+
263
+ it.each([
264
+ [
265
+ 'VC 1.1 issuanceDate',
266
+ { '@context': V1, issuanceDate: MALFORMED_DATE },
267
+ 'issuanceDate',
268
+ ],
269
+ [
270
+ 'VC 2.0 validFrom',
271
+ { '@context': V2, issuanceDate: undefined, validFrom: MALFORMED_DATE },
272
+ 'validFrom',
273
+ ],
274
+ ] as const)(
275
+ 'restamps a malformed %s when a claim link is claimed',
276
+ async (_kind, template, field) => {
277
+ const before = Date.now();
278
+ await issueClaimLinkBoost(
279
+ boostWith(template),
280
+ options.domain,
281
+ options.from,
282
+ options.to,
283
+ authority
284
+ );
285
+ const unsigned = mocks.signingAuthority.mock.calls[0]![1];
286
+ expectFreshDate(unsigned[field], before);
287
+ }
288
+ );
289
+
290
+ it('drops a stale issuanceDate left on a VC 2.0 template', async () => {
291
+ const before = Date.now();
292
+ await issueClaimLinkBoost(
293
+ boostWith({ '@context': V2, issuanceDate: MALFORMED_DATE }),
294
+ options.domain,
295
+ options.from,
296
+ options.to,
297
+ authority
298
+ );
299
+ const unsigned = mocks.signingAuthority.mock.calls[0]![1];
300
+ expectFreshDate(unsigned.validFrom, before);
301
+ expect(unsigned).not.toHaveProperty('issuanceDate');
302
+ });
303
+
304
+ it('drops a stale issuanceDate left on a VC 2.0 template for direct sends', async () => {
305
+ const before = Date.now();
306
+ const credential = await prepareCredentialFromBoost(
307
+ boostWith({ '@context': V2, issuanceDate: MALFORMED_DATE }),
308
+ `lc:network:network.example/boost:${BOOST_ID}`,
309
+ options.domain
310
+ );
311
+ expectFreshDate(credential.validFrom, before);
312
+ expect(credential).not.toHaveProperty('issuanceDate');
313
+ });
314
+
315
+ it.each([
316
+ { credentialSubject: { creditsEarned: 'not a number' } },
317
+ { awardedDate: 'bad-date' },
318
+ ])('reports invalid standard fields as BAD_REQUEST', async fields => {
319
+ await expect(
320
+ prepareCredentialFromBoost(
321
+ boostWith({
322
+ '@context': V2,
323
+ type: ['VerifiableCredential', 'OpenBadgeCredential'],
324
+ proof: undefined,
325
+ ...fields,
326
+ }),
327
+ `lc:network:network.example/boost:${BOOST_ID}`,
328
+ options.domain
329
+ )
330
+ ).rejects.toMatchObject({ code: 'BAD_REQUEST' });
331
+ });
332
+
333
+ it.each(['', ' '])(
334
+ 'omits blank awardedDate after template substitution %j',
335
+ async completed => {
336
+ const credential = await prepareCredentialFromBoost(
337
+ boostWith({
338
+ '@context': V2,
339
+ type: ['VerifiableCredential', 'OpenBadgeCredential'],
340
+ proof: undefined,
341
+ awardedDate: '{{completed}}',
342
+ }),
343
+ `lc:network:network.example/boost:${BOOST_ID}`,
344
+ options.domain,
345
+ { templateData: { completed } }
346
+ );
347
+ expect(credential).not.toHaveProperty('awardedDate');
348
+ }
349
+ );
350
+
351
+ describe('stampIssuanceDate', () => {
352
+ const NOW = '2026-10-18T05:00:00.000Z';
353
+
354
+ it('sets validFrom and removes issuanceDate on VC 2.0', () => {
355
+ const stamped = stampIssuanceDate(
356
+ {
357
+ ...vc,
358
+ '@context': V2,
359
+ validFrom: MALFORMED_DATE,
360
+ issuanceDate: MALFORMED_DATE,
361
+ },
362
+ NOW
363
+ );
364
+
365
+ expect(stamped.validFrom).toBe(NOW);
366
+ expect(stamped).not.toHaveProperty('issuanceDate');
367
+ });
368
+
369
+ it('only sets issuanceDate on VC 1.1', () => {
370
+ const template = { ...vc, '@context': V1, issuanceDate: MALFORMED_DATE };
371
+
372
+ expect(stampIssuanceDate({ ...template }, NOW)).toEqual({
373
+ ...template,
374
+ issuanceDate: NOW,
375
+ });
376
+ });
377
+ });
378
+ });
379
+
226
380
  it('preserves explicit notification and acceptance opt-outs', async () => {
227
381
  await sendBoost({
228
382
  ...options,
@@ -1,5 +1,10 @@
1
1
  import cloneDeep from 'lodash/cloneDeep';
2
- import { isEncrypted, isVC2Format } from '@learncard/helpers';
2
+ import {
3
+ CredentialFieldError,
4
+ isEncrypted,
5
+ isVC2Format,
6
+ materializeCredentialFields,
7
+ } from '@learncard/helpers';
3
8
  import { TRPCError } from '@trpc/server';
4
9
  import {
5
10
  VC,
@@ -87,6 +92,29 @@ export const isBoostViewableByClaimLink = async (boost: BoostInstance): Promise<
87
92
  return true;
88
93
  };
89
94
 
95
+ /**
96
+ * Stamps the issuance time onto a credential built from a Boost template.
97
+ *
98
+ * A template's own issuanceDate/validFrom is when the template was written, not when this
99
+ * credential is issued, and templates created through the API are not date-validated: a
100
+ * malformed value (e.g. "2025-10-19+053008:18:16") makes DIDKit refuse to sign. VC 2.0
101
+ * templates get validFrom and lose any leftover issuanceDate, which the VC 2.0 context doesn't
102
+ * define (DIDKit rejects it as an undefined JSON-LD term). VC 1.1 templates get issuanceDate.
103
+ */
104
+ export const stampIssuanceDate = <T extends UnsignedVC | VC>(
105
+ credential: T,
106
+ now: string = new Date().toISOString()
107
+ ): T => {
108
+ if (isVC2Format(credential)) {
109
+ credential.validFrom = now;
110
+ delete credential.issuanceDate;
111
+ } else {
112
+ credential.issuanceDate = now;
113
+ }
114
+
115
+ return credential;
116
+ };
117
+
90
118
  export const convertCredentialToBoostTemplateJSON = (
91
119
  credential: VC | UnsignedVC,
92
120
  defaultIssuerDid?: string
@@ -255,6 +283,8 @@ export const issueClaimLinkBoost = async (
255
283
 
256
284
  boostCredential.issuer = signingAuthorityForUser.relationship.did;
257
285
 
286
+ stampIssuanceDate(boostCredential);
287
+
258
288
  setCredentialSubjectIds(boostCredential, getDidWeb(domain, to.profileId));
259
289
 
260
290
  // Embed the boostURI into the boost credential for verification purposes.
@@ -331,17 +361,20 @@ export const prepareCredentialFromBoost = async (
331
361
  boostJsonString = renderBoostTemplate(boostJsonString, mergedTemplateData);
332
362
  }
333
363
 
334
- const credential = parseRenderedTemplate<UnsignedVC>(boostJsonString);
364
+ let credential: UnsignedVC;
365
+ try {
366
+ credential = materializeCredentialFields(
367
+ parseRenderedTemplate<UnsignedVC>(boostJsonString)
368
+ ) as UnsignedVC;
369
+ } catch (error) {
370
+ if (error instanceof CredentialFieldError)
371
+ throw new TRPCError({ code: 'BAD_REQUEST', message: error.message, cause: error });
372
+ throw error;
373
+ }
335
374
 
336
375
  appendTemplateEvidenceToCredential(credential, mergedTemplateData, allowAutoAppendEvidence);
337
376
 
338
- // Set issuance date based on VC version
339
- const now = new Date().toISOString();
340
- if (isVC2Format(credential)) {
341
- credential.validFrom = now;
342
- } else {
343
- credential.issuanceDate = now;
344
- }
377
+ stampIssuanceDate(credential);
345
378
 
346
379
  // Set issuer if provided
347
380
  if (issuerDid) {
@@ -432,7 +465,16 @@ export const appendTemplateEvidenceToCredential = (
432
465
  // EvidenceFile subterms — otherwise the signer fails later with an opaque
433
466
  // JSON-LD key-expansion error. Surface a clear message instead so the
434
467
  // caller can prompt the user to republish the boost.
435
- if (!credentialContextSupportsDynamicEvidence(credential)) {
468
+ const usesEvidenceExtensions = normalizedTemplateEvidence.some(item => {
469
+ if (!item || typeof item !== 'object') return false;
470
+ const evidence = item as Record<string, unknown>;
471
+ const types = Array.isArray(evidence.type) ? evidence.type : [evidence.type];
472
+ return (
473
+ types.includes('EvidenceFile') ||
474
+ ['fileName', 'fileType', 'fileSize'].some(key => key in evidence)
475
+ );
476
+ });
477
+ if (usesEvidenceExtensions && !credentialContextSupportsDynamicEvidence(credential)) {
436
478
  throw new TRPCError({
437
479
  code: 'PRECONDITION_FAILED',
438
480
  message:
@@ -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
  /**
@@ -150,6 +150,8 @@ const indexQueries = [
150
150
  'CREATE INDEX profile_did_idx IF NOT EXISTS FOR (p:Profile) ON (p.did)',
151
151
  'CREATE TEXT INDEX profileId_text_idx IF NOT EXISTS FOR (n:Profile) ON (n.profileId)',
152
152
  'CREATE TEXT INDEX profile_displayname_text_idx IF NOT EXISTS FOR (n:Profile) ON (n.displayName)',
153
+ // Backs case-insensitive profile search (see accesslayer/profile/search-fields.ts)
154
+ 'CREATE TEXT INDEX profile_displayname_lower_text_idx IF NOT EXISTS FOR (n:Profile) ON (n.displayNameLower)',
153
155
  'CREATE INDEX boost_id_idx IF NOT EXISTS FOR (b:Boost) ON (b.id)',
154
156
  'CREATE INDEX boost_category_idx IF NOT EXISTS FOR (b:Boost) ON (b.category)',
155
157
  'CREATE INDEX boost_autoconnect_idx IF NOT EXISTS FOR (b:Boost) ON (b.autoConnectRecipients)',
@@ -210,6 +212,10 @@ const indexQueries = [
210
212
  'CREATE INDEX contact_method_primary_idx IF NOT EXISTS FOR (c:ContactMethod) ON (c.isPrimary)',
211
213
  'CREATE INDEX credential_refresh_credential_id_idx IF NOT EXISTS FOR (r:CredentialRefresh) ON (r.credentialId)',
212
214
  'CREATE INDEX credential_refresh_version_refresh_id_idx IF NOT EXISTS FOR (c:Credential) ON (c.refreshId)',
215
+ // Credential activity reads join each CredentialActivity to its credential through
216
+ // (sender)-[:CREDENTIAL_SENT { activityId }]->(credential). Without this index every
217
+ // activity row scans all CREDENTIAL_SENT relationships.
218
+ 'CREATE INDEX credential_sent_activity_id_idx IF NOT EXISTS FOR ()-[r:CREDENTIAL_SENT]-() ON (r.activityId)',
213
219
  ];
214
220
 
215
221
  const wait = (ms: number): Promise<void> => new Promise(resolve => setTimeout(resolve, ms));
@@ -240,11 +246,16 @@ const runIndexQuery = async (query: string): Promise<void> => {
240
246
  };
241
247
 
242
248
  if (shouldCreateIndices) {
243
- void ensureCredentialRefreshConstraints().catch(err => {
244
- console.error('Error creating credential refresh constraints:', err);
245
- });
246
-
247
249
  (async function createIndices() {
250
+ // Constraints first, then indexes, one schema statement at a time. Running the two
251
+ // lists concurrently made every cold start contend with itself for schema locks.
252
+ // The readiness promise is memoized, so refresh routes awaiting it later reuse this run.
253
+ try {
254
+ await ensureCredentialRefreshConstraints();
255
+ } catch (err) {
256
+ console.error('Error creating credential refresh constraints:', err);
257
+ }
258
+
248
259
  try {
249
260
  for (const query of indexQueries) {
250
261
  await runIndexQuery(query);
@@ -37,7 +37,12 @@ import { createClaimedRelationship } from '@accesslayer/inbox-credential/relatio
37
37
  import { getContactMethodById, getProfileByContactMethod } from '@accesslayer/contact-method/read';
38
38
  import { getProfileByDid, getProfileByProfileId } from '@accesslayer/profile/read';
39
39
 
40
- import { getBoostUri, isBoostViewableByClaimLink, isDraftBoost } from '@helpers/boost.helpers';
40
+ import {
41
+ getBoostUri,
42
+ isBoostViewableByClaimLink,
43
+ isDraftBoost,
44
+ stampIssuanceDate,
45
+ } from '@helpers/boost.helpers';
41
46
  import { getBoostOwnerProfile } from 'types/boost';
42
47
  import { getEmptyLearnCard, getLearnCard } from '@helpers/learnCard.helpers';
43
48
  import { issueCredentialWithSigningAuthority } from '@helpers/signingAuthority.helpers';
@@ -392,6 +397,8 @@ async function handlePresentationForClaim(
392
397
 
393
398
  boostCredential.issuer = signingAuthorityForUser.relationship.did;
394
399
 
400
+ stampIssuanceDate(boostCredential);
401
+
395
402
  if (Array.isArray(boostCredential.credentialSubject)) {
396
403
  boostCredential.credentialSubject = boostCredential.credentialSubject.map(subject => ({
397
404
  ...subject,
@@ -13,6 +13,8 @@ import {
13
13
  upsertSkillEmbeddings,
14
14
  type UpdateSkillEmbeddingFn,
15
15
  } from '@helpers/skill-embedding.helpers';
16
+ import { syncProfileDisplayNameLower } from '../accesslayer/profile/search-fields';
17
+
16
18
  // Inlined from @helpers/profile.helpers to avoid importing the access/model layer
17
19
  // (it transitively pulls in @models, which hits a circular-dependency TDZ under tsx).
18
20
  const transformProfileId = (rawInput: string): string =>
@@ -519,7 +521,8 @@ export const ensureSeedProfile = async (
519
521
  p.shortBio = coalesce(p.shortBio, $shortBio),
520
522
  p.profileVisibility = coalesce(p.profileVisibility, $profileVisibility),
521
523
  p.approved = coalesce(p.approved, $approved),
522
- p.isServiceProfile = coalesce(p.isServiceProfile, $isServiceProfile)`,
524
+ p.isServiceProfile = coalesce(p.isServiceProfile, $isServiceProfile)
525
+ SET ${syncProfileDisplayNameLower('p')}`,
523
526
  {
524
527
  profileId: normalizedProfileId,
525
528
  displayName: DEFAULT_OWNER_DISPLAY_NAME,