@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
@@ -1 +1 @@
1
- {"version":3,"file":"workflows.d.ts","sourceRoot":"","sources":["../../src/routes/workflows.ts"],"names":[],"mappings":"AAgBA,OAAO,EAAgB,OAAO,EAAE,MAAM,SAAS,CAAC;AAuFhD,eAAO,MAAM,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqF1B,CAAC;AA8uBH,MAAM,MAAM,eAAe,GAAG,OAAO,eAAe,CAAC"}
1
+ {"version":3,"file":"workflows.d.ts","sourceRoot":"","sources":["../../src/routes/workflows.ts"],"names":[],"mappings":"AAgBA,OAAO,EAAgB,OAAO,EAAE,MAAM,SAAS,CAAC;AA4FhD,eAAO,MAAM,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqF1B,CAAC;AAgvBH,MAAM,MAAM,eAAe,GAAG,OAAO,eAAe,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"seedSkillFrameworks.d.ts","sourceRoot":"","sources":["../../src/seed/seedSkillFrameworks.ts"],"names":[],"mappings":"AAKA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAehC,KAAK,eAAe,GAAG;IACnB,GAAG,EAAE,CACD,KAAK,EAAE,MAAM,EACb,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAC/B,OAAO,CAAC;QAAE,OAAO,EAAE,KAAK,CAAC;YAAE,GAAG,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAA;SAAE,CAAC,CAAA;KAAE,CAAC,CAAC;CACvE,CAAC;AAEF,KAAK,UAAU,GAAG;IACd,GAAG,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,CAAC;IAC9B,IAAI,CAAC,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,CAAC;CACnC,CAAC;AAEF,KAAK,0BAA0B,GAAG;IAC9B,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,eAAe,CAAC,EAAE,MAAM,EAAE,CAAC;IAC3B,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,GAAG,CAAC,EAAE,UAAU,CAAC;CACpB,CAAC;AAEF,KAAK,wBAAwB,GAAG;IAC5B,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC;CAChB,CAAC;AAgCF,eAAO,MAAM,gCAAgC,QAAO,MAGI,CAAC;AAoLzD,eAAO,MAAM,oCAAoC,GAC7C,QAAO,OAAO,GAAG,SAAmB,KACrC,OAAO,CAAC;IACP,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,wBAAwB,CAAC;CACvC,CA2DA,CAAC;AAEF,eAAO,MAAM,oBAAoB,GAAU,KAAK,eAAe,CAAC,KAAK,CAAC,KAAG,OAAO,CAAC,MAAM,CAGtF,CAAC;AAEF,eAAO,MAAM,eAAe,GAAU,KAAK,eAAe,CAAC,KAAK,CAAC,KAAG,OAAO,CAAC,OAAO,CAElF,CAAC;AAEF,eAAO,MAAM,sBAAsB,GAC/B,KAAK,eAAe,CAAC,KAAK,CAAC,EAC3B,WAAW,MAAM,KAClB,OAAO,CAAC,MAAM,CAwBhB,CAAC;AA6IF,eAAO,MAAM,iBAAiB,GAC1B,KAAK,eAAe,CAAC,KAAK,CAAC,EAC3B,YAAW,MAAiC,KAC7C,OAAO,CAAC,IAAI,CA0Bd,CAAC;AAEF,eAAO,MAAM,0BAA0B,GACnC,KAAK,eAAe,CAAC,KAAK,CAAC,EAC3B,UAAS,0BAA+B,KACzC,OAAO,CAAC;IAAE,kBAAkB,EAAE,MAAM,EAAE,CAAA;CAAE,CAgG1C,CAAC;AAEF,eAAO,MAAM,4BAA4B,GACrC,KAAK,eAAe,CAAC,KAAK,CAAC,EAC3B,UAAS,0BAA+B,KACzC,OAAO,CAAC;IAAE,MAAM,EAAE,OAAO,CAAC;IAAC,kBAAkB,EAAE,MAAM,EAAE,CAAA;CAAE,CAoC3D,CAAC;AAEF,eAAO,MAAM,sBAAsB,GAAI,QAAQ,MAAM,KAAG,MAAM,EAMzD,CAAC"}
1
+ {"version":3,"file":"seedSkillFrameworks.d.ts","sourceRoot":"","sources":["../../src/seed/seedSkillFrameworks.ts"],"names":[],"mappings":"AAKA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAiBhC,KAAK,eAAe,GAAG;IACnB,GAAG,EAAE,CACD,KAAK,EAAE,MAAM,EACb,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAC/B,OAAO,CAAC;QAAE,OAAO,EAAE,KAAK,CAAC;YAAE,GAAG,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAA;SAAE,CAAC,CAAA;KAAE,CAAC,CAAC;CACvE,CAAC;AAEF,KAAK,UAAU,GAAG;IACd,GAAG,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,CAAC;IAC9B,IAAI,CAAC,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,CAAC;CACnC,CAAC;AAEF,KAAK,0BAA0B,GAAG;IAC9B,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,eAAe,CAAC,EAAE,MAAM,EAAE,CAAC;IAC3B,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,GAAG,CAAC,EAAE,UAAU,CAAC;CACpB,CAAC;AAEF,KAAK,wBAAwB,GAAG;IAC5B,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC;CAChB,CAAC;AAgCF,eAAO,MAAM,gCAAgC,QAAO,MAGI,CAAC;AAoLzD,eAAO,MAAM,oCAAoC,GAC7C,QAAO,OAAO,GAAG,SAAmB,KACrC,OAAO,CAAC;IACP,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,wBAAwB,CAAC;CACvC,CA2DA,CAAC;AAEF,eAAO,MAAM,oBAAoB,GAAU,KAAK,eAAe,CAAC,KAAK,CAAC,KAAG,OAAO,CAAC,MAAM,CAGtF,CAAC;AAEF,eAAO,MAAM,eAAe,GAAU,KAAK,eAAe,CAAC,KAAK,CAAC,KAAG,OAAO,CAAC,OAAO,CAElF,CAAC;AAEF,eAAO,MAAM,sBAAsB,GAC/B,KAAK,eAAe,CAAC,KAAK,CAAC,EAC3B,WAAW,MAAM,KAClB,OAAO,CAAC,MAAM,CAwBhB,CAAC;AA6IF,eAAO,MAAM,iBAAiB,GAC1B,KAAK,eAAe,CAAC,KAAK,CAAC,EAC3B,YAAW,MAAiC,KAC7C,OAAO,CAAC,IAAI,CA2Bd,CAAC;AAEF,eAAO,MAAM,0BAA0B,GACnC,KAAK,eAAe,CAAC,KAAK,CAAC,EAC3B,UAAS,0BAA+B,KACzC,OAAO,CAAC;IAAE,kBAAkB,EAAE,MAAM,EAAE,CAAA;CAAE,CAgG1C,CAAC;AAEF,eAAO,MAAM,4BAA4B,GACrC,KAAK,eAAe,CAAC,KAAK,CAAC,EAC3B,UAAS,0BAA+B,KACzC,OAAO,CAAC;IAAE,MAAM,EAAE,OAAO,CAAC;IAAC,kBAAkB,EAAE,MAAM,EAAE,CAAA;CAAE,CAoC3D,CAAC;AAEF,eAAO,MAAM,sBAAsB,GAAI,QAAQ,MAAM,KAAG,MAAM,EAMzD,CAAC"}
@@ -2,6 +2,7 @@ const path = require('node:path');
2
2
  const fs = require('node:fs');
3
3
 
4
4
  const { sentryEsbuildPlugin } = require('@sentry/esbuild-plugin');
5
+ const { withSentryRateLimitRetry } = require('../../../scripts/sentry-rate-limit-retry.cjs');
5
6
 
6
7
  const forceCjsSodiumWrapperPlugin = {
7
8
  name: 'force-cjs-sodium-wrapper',
@@ -40,10 +41,12 @@ const copyDidkitWasmPlugin = {
40
41
  module.exports = [
41
42
  forceCjsSodiumWrapperPlugin,
42
43
  copyDidkitWasmPlugin,
43
- sentryEsbuildPlugin({
44
- telemetry: process.env.SENTRY_BUILD_TELEMETRY !== 'false',
45
- authToken: process.env.SENTRY_AUTH_TOKEN,
46
- org: process.env.SENTRY_ORG,
47
- project: process.env.SENTRY_PROJECT,
48
- }),
44
+ withSentryRateLimitRetry(
45
+ sentryEsbuildPlugin({
46
+ telemetry: process.env.SENTRY_BUILD_TELEMETRY !== 'false',
47
+ authToken: process.env.SENTRY_AUTH_TOKEN,
48
+ org: process.env.SENTRY_ORG,
49
+ project: process.env.SENTRY_PROJECT,
50
+ })
51
+ ),
49
52
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@learncard/network-brain-service",
3
- "version": "4.0.2",
3
+ "version": "4.0.4",
4
4
  "description": "",
5
5
  "engines": {
6
6
  "node": ">=24.12 <25"
@@ -16,11 +16,14 @@
16
16
  "test:coverage": "vitest run --coverage --config vitest.config.ts",
17
17
  "test:integration": "vitest run --config vitest.integration.config.ts",
18
18
  "test:inbox:e2e": "vitest run --config vitest.inbox-e2e.config.ts",
19
+ "test:profile-search": "vitest run --config vitest.profile-search-neo4j.config.ts",
19
20
  "test:integration:watch": "vitest --config vitest.integration.config.ts",
20
21
  "test:docker": "vitest run --config vitest.integration.config.ts",
22
+ "test:boost-recipients": "vitest run --config vitest.boost-recipients-neo4j.config.ts",
21
23
  "seed:dev-app": "bun scripts/seed-dev-app.ts",
22
24
  "seed:demo-persona": "bun --conditions=development scripts/seed-demo-persona.ts",
23
25
  "skill-frameworks": "bun scripts/skill-frameworks.ts",
26
+ "backfill:profile-search": "bun scripts/backfill-profile-search.ts",
24
27
  "build": "shx rm -rf dist && node bundle.mjs && tsc --p tsconfig.build.json && tsc --showConfig --p tsconfig.build.json > tsconfig.temp.json && tsc-alias --replacer ./tsc-alias-replacer.cjs -p tsconfig.temp.json && shx rm tsconfig.temp.json",
25
28
  "typecheck": "tsc --noEmit -p tsconfig.build.json && tsc --noEmit -p tsconfig.entrypoints.json",
26
29
  "build:docker": "shx rm -rf build && node esbuild-docker.mjs",
@@ -62,23 +65,23 @@
62
65
  "@digitalcredentials/issuer-registry-client": "^4.1.0",
63
66
  "@fastify/cors": "^11.2.0",
64
67
  "@google/genai": "^1.27.0",
65
- "@learncard/credential-library": "2.1.2",
66
- "@learncard/core": "9.4.39",
67
- "@learncard/crypto-plugin": "1.1.39",
68
- "@learncard/did-web-plugin": "1.1.39",
69
- "@learncard/didkey-plugin": "1.1.39",
70
- "@learncard/didkit-plugin": "1.10.4",
71
- "@learncard/didkit-plugin-node": "0.3.4",
72
- "@learncard/dynamic-loader-plugin": "1.1.39",
73
- "@learncard/encryption-plugin": "1.1.39",
74
- "@learncard/expiration-plugin": "1.2.39",
68
+ "@learncard/credential-library": "2.1.3",
69
+ "@learncard/core": "9.4.40",
70
+ "@learncard/crypto-plugin": "1.1.40",
71
+ "@learncard/did-web-plugin": "1.1.40",
72
+ "@learncard/didkey-plugin": "1.1.40",
73
+ "@learncard/didkit-plugin": "1.10.5",
74
+ "@learncard/didkit-plugin-node": "0.3.5",
75
+ "@learncard/dynamic-loader-plugin": "1.1.40",
76
+ "@learncard/encryption-plugin": "1.1.40",
77
+ "@learncard/expiration-plugin": "1.2.40",
75
78
  "@learncard/email-templates": "1.2.1",
76
- "@learncard/helpers": "1.6.3",
77
- "@learncard/learn-card-plugin": "1.2.39",
79
+ "@learncard/helpers": "1.7.0",
80
+ "@learncard/learn-card-plugin": "1.2.40",
78
81
  "@learncard/partner-connect-core": "0.2.0",
79
- "@learncard/types": "5.23.1",
80
- "@learncard/vc-plugin": "1.6.5",
81
- "@learncard/vc-templates-plugin": "1.1.39",
82
+ "@learncard/types": "5.23.2",
83
+ "@learncard/vc-plugin": "1.6.6",
84
+ "@learncard/vc-templates-plugin": "1.1.40",
82
85
  "@sentry/serverless": "7.61.0",
83
86
  "@trpc/server": "11.8.0",
84
87
  "@types/lodash": "^4.14.191",
package/project.json CHANGED
@@ -111,12 +111,30 @@
111
111
  "script": "test:inbox:e2e"
112
112
  }
113
113
  },
114
+ "test:profile-search": {
115
+ "executor": "nx:run-script",
116
+ "cache": false,
117
+ "dependsOn": ["^build"],
118
+ "inputs": ["source", "tests", "{projectRoot}/vitest.profile-search-neo4j.config.ts"],
119
+ "options": {
120
+ "script": "test:profile-search"
121
+ }
122
+ },
114
123
  "test:integration": {
115
124
  "executor": "nx:run-script",
116
125
  "inputs": ["source", "tests"],
117
126
  "options": {
118
127
  "script": "test:integration"
119
128
  }
129
+ },
130
+ "test:boost-recipients": {
131
+ "executor": "nx:run-script",
132
+ "cache": false,
133
+ "dependsOn": ["^build"],
134
+ "inputs": ["source", "tests", "{projectRoot}/vitest.boost-recipients-neo4j.config.ts"],
135
+ "options": {
136
+ "script": "test:boost-recipients"
137
+ }
120
138
  }
121
139
  }
122
140
  }
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Backfills `Profile.displayNameLower`, the normalized copy of `displayName` (see
4
+ * `normalizeProfileSearchText`) that case-insensitive profile search reads through the
5
+ * `profile_displayname_lower_text_idx` TEXT index.
6
+ *
7
+ * New and updated profiles get the field on write. Run this once per environment after the
8
+ * release that adds or changes it, and only once no instance of the previous build is still
9
+ * writing profiles, so profiles written before then are findable by display name again.
10
+ * It's idempotent and only touches profiles whose copy is missing or out of date.
11
+ *
12
+ * Usage (from services/learn-card-network/brain-service):
13
+ * NEO4J_URI=... NEO4J_USERNAME=... NEO4J_PASSWORD=... \
14
+ * bun run backfill:profile-search -- --yes [--batch-size 1000]
15
+ *
16
+ * Without --yes it only counts the profiles that need the field and changes nothing.
17
+ */
18
+
19
+ import neo4j from 'neo4j-driver';
20
+
21
+ import {
22
+ PROFILE_DISPLAY_NAME_LOWER_FIELD,
23
+ backfillProfileDisplayNameLower,
24
+ buildBackfillProfileDisplayNameLowerQuery,
25
+ profileDisplayNameLowerIsStale,
26
+ } from '../src/accesslayer/profile/search-fields';
27
+
28
+ const getArg = (name: string): string | undefined => {
29
+ const index = process.argv.indexOf(name);
30
+ return index === -1 ? undefined : process.argv[index + 1];
31
+ };
32
+
33
+ const main = async (): Promise<void> => {
34
+ const uri = process.env.NEO4J_URI;
35
+ const username = process.env.NEO4J_USERNAME;
36
+ const password = process.env.NEO4J_PASSWORD;
37
+
38
+ if (!uri || !username || !password) {
39
+ throw new Error('NEO4J_URI, NEO4J_USERNAME and NEO4J_PASSWORD must all be set explicitly.');
40
+ }
41
+
42
+ const write = process.argv.includes('--yes');
43
+ const batchSize = Number(getArg('--batch-size') ?? 1000);
44
+
45
+ console.log(`Target: ${uri} (${write ? 'write' : 'dry run, pass --yes to write'})`);
46
+
47
+ const driver = neo4j.driver(uri, neo4j.auth.basic(username, password));
48
+ const session = driver.session();
49
+
50
+ try {
51
+ if (!write) {
52
+ const result = await session.run(
53
+ `MATCH (profile:Profile)
54
+ WHERE ${profileDisplayNameLowerIsStale('profile')}
55
+ RETURN count(profile) AS pending`
56
+ );
57
+ console.log(
58
+ `Profiles needing ${PROFILE_DISPLAY_NAME_LOWER_FIELD}:`,
59
+ result.records[0]?.get('pending').toString()
60
+ );
61
+ console.log(`Would run:\n${buildBackfillProfileDisplayNameLowerQuery(batchSize)}`);
62
+ return;
63
+ }
64
+
65
+ const updated = await backfillProfileDisplayNameLower(query => session.run(query), {
66
+ batchSize,
67
+ });
68
+
69
+ console.log(`Set ${PROFILE_DISPLAY_NAME_LOWER_FIELD} on ${updated} profiles.`);
70
+ } finally {
71
+ await session.close();
72
+ await driver.close();
73
+ }
74
+ };
75
+
76
+ main().catch(error => {
77
+ console.error(error);
78
+ process.exit(1);
79
+ });
@@ -33,6 +33,10 @@ import {
33
33
  } from '@learncard/credential-library';
34
34
  import type { UnsignedVC } from '@learncard/types';
35
35
  import { flattenObject } from '../src/helpers/objects.helpers';
36
+ import {
37
+ PROFILE_DISPLAY_NAME_LOWER_FIELD,
38
+ normalizeProfileSearchText,
39
+ } from '../src/accesslayer/profile/search-fields';
36
40
 
37
41
  dotenv.config({ path: fileURLToPath(new URL('../.env', import.meta.url)) });
38
42
 
@@ -237,7 +241,8 @@ const main = async (): Promise<void> => {
237
241
  SET p.displayName = $displayName,
238
242
  p.shortBio = $shortBio,
239
243
  p.image = $image,
240
- p.did = $did
244
+ p.did = $did,
245
+ p.${PROFILE_DISPLAY_NAME_LOWER_FIELD} = ${normalizeProfileSearchText('$displayName')}
241
246
  RETURN p.did AS did`,
242
247
  {
243
248
  profileId: issuer.profileId,
@@ -259,6 +259,159 @@ export const countBoostSiblings = async (
259
259
  return Number(result.records[0]?.get('count') ?? 0);
260
260
  };
261
261
 
262
+ /**
263
+ * Builds the Cypher (to follow `MATCH (start:Boost { id })`) that binds `boost` to every distinct
264
+ * member of start's family: ancestors (up to parentGenerations), descendants (up to
265
+ * childGenerations) and siblings (or, with includeExtendedFamily, cousins), never start itself
266
+ * via the sibling path.
267
+ *
268
+ * Each branch is expanded outward from the already-bound `start` node, so the cost is proportional
269
+ * to the size of the family. The previous form (`MATCH (boost:Boost) WHERE <pattern> OR <pattern>`)
270
+ * label-scanned every Boost node and evaluated the pattern predicates per node.
271
+ *
272
+ * Generations are interpolated (Cypher cannot parameterise variable-length bounds); callers must
273
+ * validate they are numbers. Non-finite values produce an unbounded range, matching prior behaviour.
274
+ */
275
+ const getFamilialBoostsCypher = ({
276
+ parentGenerations,
277
+ childGenerations,
278
+ includeExtendedFamily,
279
+ }: {
280
+ parentGenerations: number;
281
+ childGenerations: number;
282
+ includeExtendedFamily: boolean;
283
+ }): string => {
284
+ const parentRange = Number.isFinite(parentGenerations) ? parentGenerations : '';
285
+ const childRange = Number.isFinite(childGenerations) ? childGenerations : '';
286
+ const siblingParentHops = includeExtendedFamily ? `*1..${parentRange}` : '';
287
+ const siblingChildHops = includeExtendedFamily ? `*1..${childRange}` : '';
288
+
289
+ return `
290
+ OPTIONAL MATCH (start)<-[:PARENT_OF*1..${parentRange}]-(ancestor:Boost)
291
+ WITH start, COLLECT(DISTINCT ancestor) AS ancestors
292
+ OPTIONAL MATCH (start)-[:PARENT_OF*1..${childRange}]->(descendant:Boost)
293
+ WITH start, ancestors, COLLECT(DISTINCT descendant) AS descendants
294
+ OPTIONAL MATCH (start)<-[:PARENT_OF${siblingParentHops}]-(:Boost)-[:PARENT_OF${siblingChildHops}]->(sibling:Boost)
295
+ WHERE sibling <> start
296
+ WITH ancestors, descendants, COLLECT(DISTINCT sibling) AS siblings
297
+ UNWIND ancestors + descendants + siblings AS boost
298
+ WITH DISTINCT boost`;
299
+ };
300
+
301
+ /**
302
+ * Above this many index candidates, `getFamilialBoostsByCandidatesCypher` falls back to expanding
303
+ * the family. At or below it, checking each candidate is cheaper than walking a wide family.
304
+ */
305
+ const FAMILIAL_CANDIDATE_LIMIT = 16;
306
+
307
+ /** Boost properties with a range index (see models/index.ts) */
308
+ const INDEXED_BOOST_FIELDS = ['id', 'category', 'autoConnectRecipients'] as const;
309
+
310
+ /**
311
+ * Finds one top-level condition in a converted BoostQuery that Neo4j can answer from an index:
312
+ * plain equality or `$in` on an indexed Boost property. buildWhereClause ANDs top-level fields
313
+ * together, so every boost the full filter accepts also passes this condition.
314
+ *
315
+ * The value is bound as `$familialProbe`, which can't clash with buildWhereClause's `param_N`.
316
+ */
317
+ const getIndexedFamilialProbe = (
318
+ query: Record<string, unknown>
319
+ ): { predicate: string; value: unknown } | undefined => {
320
+ // A document-level $or makes buildWhereClause ignore the other top-level keys
321
+ if ('$or' in query) return undefined;
322
+
323
+ for (const field of INDEXED_BOOST_FIELDS) {
324
+ const value = query[field];
325
+
326
+ if (value === undefined) continue;
327
+
328
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
329
+ return { predicate: `c.${field} = $familialProbe`, value };
330
+ }
331
+
332
+ // Mirrors buildFieldCondition: a field-level $or wins over $in, then $in wins over the rest
333
+ if (!('$or' in value) && '$in' in value && Array.isArray(value.$in)) {
334
+ return { predicate: `c.${field} IN $familialProbe`, value: value.$in };
335
+ }
336
+ }
337
+
338
+ return undefined;
339
+ };
340
+
341
+ /**
342
+ * Same contract as `getFamilialBoostsCypher`, for queries with an indexed filter.
343
+ *
344
+ * It first index-seeks up to FAMILIAL_CANDIDATE_LIMIT + 1 boosts that pass the indexed condition.
345
+ * - More than the limit: the family is expanded exactly as in `getFamilialBoostsCypher`.
346
+ * - Otherwise: only those candidates are checked. A candidate is an ancestor if it is in start's
347
+ * ancestors, a descendant if start is among its own ancestors (walked up from the candidate,
348
+ * which stays small even under a wide parent), and a sibling via the original pattern.
349
+ *
350
+ * With includeExtendedFamily, the cousin check intersects the candidate's ancestors with start's.
351
+ * That set check can also reach nodes the single-pattern form can't (it ignores relationship
352
+ * uniqueness across the up and down legs), but any such node is already an ancestor or
353
+ * descendant of start as long as every node above start is a Boost. When a non-Boost node sits
354
+ * above start, the original cousin pattern is used instead.
355
+ */
356
+ const getFamilialBoostsByCandidatesCypher = ({
357
+ parentGenerations,
358
+ childGenerations,
359
+ includeExtendedFamily,
360
+ probe,
361
+ }: {
362
+ parentGenerations: number;
363
+ childGenerations: number;
364
+ includeExtendedFamily: boolean;
365
+ probe: string;
366
+ }): string => {
367
+ const p = Number.isFinite(parentGenerations) ? parentGenerations : '';
368
+ const c = Number.isFinite(childGenerations) ? childGenerations : '';
369
+ const up = includeExtendedFamily ? `*1..${p}` : '';
370
+ const down = includeExtendedFamily ? `*1..${c}` : '';
371
+ const k = FAMILIAL_CANDIDATE_LIMIT;
372
+ const sibling = includeExtendedFamily
373
+ ? `(n=size(a) AND any(x IN bu WHERE x IN a) OR n<>size(a) AND EXISTS{(start)<-[:PARENT_OF${up}]-(:Boost)-[:PARENT_OF${down}]->(boost)})`
374
+ : 'EXISTS{(start)<-[:PARENT_OF]-(:Boost)-[:PARENT_OF]->(boost)}';
375
+
376
+ return `
377
+ CALL{MATCH(c:Boost)WHERE ${probe} WITH c LIMIT ${k + 1} RETURN collect(c)AS cs}
378
+ OPTIONAL MATCH(start)<-[:PARENT_OF*1..${p}]-(u)
379
+ WITH start,cs,collect(DISTINCT u)AS us
380
+ WITH start,cs,size(us)AS n,[x IN us WHERE x:Boost]AS a
381
+ CALL{WITH start,cs WITH start WHERE size(cs)>${k}
382
+ OPTIONAL MATCH(start)-[:PARENT_OF*1..${c}]->(d:Boost)RETURN collect(DISTINCT d)AS ds}
383
+ CALL{WITH start,cs WITH start WHERE size(cs)>${k}
384
+ OPTIONAL MATCH(start)<-[:PARENT_OF${up}]-(:Boost)-[:PARENT_OF${down}]->(s:Boost)WHERE s<>start
385
+ RETURN collect(DISTINCT s)AS ss}
386
+ CALL{WITH start,cs,n,a WITH * WHERE size(cs)<=${k}
387
+ UNWIND cs AS boost
388
+ OPTIONAL MATCH(boost)<-[:PARENT_OF*1..${c}]-(v)
389
+ WITH start,n,a,boost,collect(DISTINCT v)AS bu
390
+ WHERE boost IN a OR start IN bu OR boost<>start AND ${sibling}
391
+ RETURN collect(boost)AS hits}
392
+ UNWIND CASE WHEN size(cs)>${k} THEN a+ds+ss ELSE hits END AS boost
393
+ WITH DISTINCT boost`;
394
+ };
395
+
396
+ /** Picks the familial Cypher for a query and returns the extra params it needs */
397
+ const getFamilialCypherForQuery = (
398
+ query: Record<string, unknown>,
399
+ generations: {
400
+ parentGenerations: number;
401
+ childGenerations: number;
402
+ includeExtendedFamily: boolean;
403
+ }
404
+ ): { cypher: string; params: Record<string, unknown> } => {
405
+ const probe = getIndexedFamilialProbe(query);
406
+
407
+ if (!probe) return { cypher: getFamilialBoostsCypher(generations), params: {} };
408
+
409
+ return {
410
+ cypher: getFamilialBoostsByCandidatesCypher({ ...generations, probe: probe.predicate }),
411
+ params: { familialProbe: probe.value },
412
+ };
413
+ };
414
+
262
415
  export const getFamilialBoosts = async (
263
416
  boost: BoostInstance,
264
417
  {
@@ -291,24 +444,16 @@ export const getFamilialBoosts = async (
291
444
  convertedQuery as any
292
445
  );
293
446
 
294
- const _query = new QueryBuilder(new BindParam({ cursor, ...queryParams }))
447
+ const familial = getFamilialCypherForQuery(convertedQuery, {
448
+ parentGenerations,
449
+ childGenerations,
450
+ includeExtendedFamily,
451
+ });
452
+
453
+ const _query = new QueryBuilder(new BindParam({ cursor, ...queryParams, ...familial.params }))
295
454
  .match({ model: Boost, where: { id: boost.id }, identifier: 'start' })
296
- .match({ model: Boost, identifier: 'boost' })
297
- .where(
298
- `
299
- ((start)<-[:PARENT_OF*1..${Number.isFinite(parentGenerations) ? parentGenerations : ''}]-(boost) OR
300
- (start)-[:PARENT_OF*1..${Number.isFinite(childGenerations) ? childGenerations : ''}]->(boost) OR
301
- (start)<-[:PARENT_OF${
302
- includeExtendedFamily
303
- ? `*1..${Number.isFinite(parentGenerations) ? parentGenerations : ''}`
304
- : ''
305
- }]-(:Boost)-[:PARENT_OF${
306
- includeExtendedFamily
307
- ? `*1..${Number.isFinite(childGenerations) ? childGenerations : ''}`
308
- : ''
309
- }]->(boost) AND boost <> start)
310
- AND ${whereClause}`
311
- )
455
+ .raw(familial.cypher)
456
+ .where(whereClause)
312
457
  .raw(`MATCH (boost)-[createdBy:${Boost.getRelationshipByAlias('createdBy').name}]-()`);
313
458
 
314
459
  const query = cursor ? _query.where('createdBy.date < $cursor') : _query;
@@ -358,24 +503,16 @@ export const countFamilialBoosts = async (
358
503
  convertedQuery as any
359
504
  );
360
505
 
361
- const query = new QueryBuilder(new BindParam({ ...queryParams }))
506
+ const familial = getFamilialCypherForQuery(convertedQuery, {
507
+ parentGenerations,
508
+ childGenerations,
509
+ includeExtendedFamily,
510
+ });
511
+
512
+ const query = new QueryBuilder(new BindParam({ ...queryParams, ...familial.params }))
362
513
  .match({ model: Boost, where: { id: boost.id }, identifier: 'start' })
363
- .match({ model: Boost, identifier: 'boost' })
364
- .where(
365
- `
366
- ((start)<-[:PARENT_OF*1..${Number.isFinite(parentGenerations) ? parentGenerations : ''}]-(boost) OR
367
- (start)-[:PARENT_OF*1..${Number.isFinite(childGenerations) ? childGenerations : ''}]->(boost) OR
368
- (start)<-[:PARENT_OF${
369
- includeExtendedFamily
370
- ? `*1..${Number.isFinite(parentGenerations) ? parentGenerations : ''}`
371
- : ''
372
- }]-(:Boost)-[:PARENT_OF${
373
- includeExtendedFamily
374
- ? `*1..${Number.isFinite(childGenerations) ? childGenerations : ''}`
375
- : ''
376
- }]->(boost) AND boost <> start)
377
- AND ${whereClause}`
378
- );
514
+ .raw(familial.cypher)
515
+ .where(whereClause);
379
516
 
380
517
  const result = await query.return('COUNT(DISTINCT boost) AS count').run();
381
518