@mountainpass/addressr 2.1.5 → 2.3.0

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.
@@ -111,6 +111,14 @@ async function initIndex(esClient, clear, synonyms) {
111
111
  }
112
112
  }
113
113
  },
114
+ // ADR 026: range-number address expansion. Multi-valued text field
115
+ // populated with one expanded address per in-range number for range-
116
+ // numbered G-NAF docs (span cap 20). Absent on non-range docs
117
+ // (asymmetric population). No .raw sub-field — never sorted on.
118
+ sla_range_expanded: {
119
+ type: 'text',
120
+ analyzer: 'my_analyzer'
121
+ },
114
122
  confidence: {
115
123
  type: 'integer'
116
124
  },
@@ -30,6 +30,7 @@ var _unzipStream = _interopRequireDefault(require("unzip-stream"));
30
30
  var _elasticsearch = require("../client/elasticsearch");
31
31
  var _streamDown = _interopRequireDefault(require("../utils/stream-down"));
32
32
  var _setLinkOptions = require("./set-link-options");
33
+ var _rangeExpansion = require("./range-expansion");
33
34
  var _keyv = require("keyv");
34
35
  var _keyvFile = require("keyv-file");
35
36
  var _nodeCrypto = _interopRequireDefault(require("node:crypto"));
@@ -675,18 +676,34 @@ function mapAddressDetails(d, context, index, count) {
675
676
  };
676
677
  rval.mla = mapToMla(rval.structured);
677
678
  rval.sla = mapToSla(rval.mla);
678
- if (rval.structured.flat != undefined) {
679
+ if (rval.structured.flat == undefined) {
680
+ // Symmetric ssla so BM25 summation across sla+ssla does not favour
681
+ // sub-unit docs over exact street-level matches. See ADR 025 / P007.
682
+ rval.ssla = rval.sla;
683
+ } else {
679
684
  rval.smla = mapToShortMla(rval.structured);
680
685
  rval.ssla = mapToSla(rval.smla);
681
686
  }
687
+ // ADR 026: asymmetric population of sla_range_expanded — one expanded
688
+ // alias per in-range number for range-numbered records up to SPAN_CAP.
689
+ // Non-range docs leave the field absent. expandRangeAliases returns []
690
+ // for span>cap or invalid inputs; in that case we keep the field absent
691
+ // rather than storing an empty array so OpenSearch _source stays clean.
692
+ if (rval.structured.number?.last?.number !== undefined) {
693
+ const s = rval.structured;
694
+ const streetType = s.street.type ? ` ${s.street.type.name}` : '';
695
+ const streetSuffix = s.street.suffix ? ` ${s.street.suffix.name}` : '';
696
+ rval.sla_range_expanded = (0, _rangeExpansion.expandRangeAliases)(Number(s.number.number), Number(s.number.last.number), `${s.street.name}${streetType}${streetSuffix}`, `${s.locality.name} ${s.state.abbreviation} ${s.postcode}`);
697
+ if (rval.sla_range_expanded.length === 0) {
698
+ delete rval.sla_range_expanded;
699
+ }
700
+ }
682
701
  if (count) {
683
702
  if (index % Math.ceil(count / 100) === 0) {
684
- logger('addr', JSON.stringify(rval, undefined, 2));
685
703
  logger(`${index / count * 100}%`);
686
704
  }
687
705
  } else {
688
706
  if (index % 10_000 === 0) {
689
- logger('addr', JSON.stringify(rval, undefined, 2));
690
707
  logger(`${index} rows`);
691
708
  }
692
709
  }
@@ -853,7 +870,14 @@ async function searchForAddress(searchString, p, pageSize = PAGE_SIZE) {
853
870
  }
854
871
  }, {
855
872
  multi_match: {
856
- fields: ['sla', 'ssla'],
873
+ // ADR 026: sla_range_expanded added HERE ONLY (not in the
874
+ // bool_prefix clause above). phrase_prefix uses best_fields
875
+ // max with tie_breaker default 0.0, so an absent field on
876
+ // non-range docs contributes 0 to the max — no P007-shape
877
+ // asymmetry. Adding sla_range_expanded to the bool_prefix
878
+ // fields would reintroduce the summation asymmetry ADR 025
879
+ // resolved. DO NOT move this field into the clause above.
880
+ fields: ['sla', 'ssla', 'sla_range_expanded'],
857
881
  query: searchString,
858
882
  // fuzziness: 'AUTO',
859
883
  type: 'phrase_prefix',
@@ -1590,20 +1614,27 @@ async function getAddress(addressId) {
1590
1614
  };
1591
1615
  } catch (error_) {
1592
1616
  error('error getting record from elastic search', error_);
1593
- if (error_.body.found === false) {
1617
+ if (error_.body && error_.body.found === false) {
1594
1618
  return {
1595
1619
  statusCode: 404,
1596
1620
  json: {
1597
1621
  error: 'not found'
1598
1622
  }
1599
1623
  };
1600
- } else if (error_.body.error.type === 'index_not_found_exception') {
1624
+ } else if (error_.body && error_.body.error && error_.body.error.type === 'index_not_found_exception') {
1601
1625
  return {
1602
1626
  statusCode: 503,
1603
1627
  json: {
1604
1628
  error: 'service unavailable'
1605
1629
  }
1606
1630
  };
1631
+ } else if (error_.displayName === 'RequestTimeout') {
1632
+ return {
1633
+ statusCode: 504,
1634
+ json: {
1635
+ error: 'gateway timeout'
1636
+ }
1637
+ };
1607
1638
  } else {
1608
1639
  return {
1609
1640
  statusCode: 500,
@@ -0,0 +1,131 @@
1
+ "use strict";
2
+
3
+ var _nodeTest = require("node:test");
4
+ var _strict = _interopRequireDefault(require("node:assert/strict"));
5
+ var _addressService = require("./address-service.js");
6
+ function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
7
+ // Regression test for P007 / issue #375 — see ADR 025.
8
+ // Sub-unit documents populate both `sla` and `ssla`; street-level documents
9
+ // historically populated only `sla`, which caused BM25 per-field score
10
+ // summation to favour sub-unit docs over exact street-level matches.
11
+ // The fix: always populate `ssla` (= `sla` when no flat) so scoring is
12
+ // symmetric across all documents.
13
+
14
+ const baseAuthorityContext = {
15
+ Authority_Code_LOCALITY_CLASS_AUT_psv: [{
16
+ CODE: 'G',
17
+ NAME: 'GAZETTED LOCALITY'
18
+ }],
19
+ Authority_Code_STREET_CLASS_AUT_psv: [{
20
+ CODE: 'C',
21
+ NAME: 'CONFIRMED'
22
+ }],
23
+ Authority_Code_STREET_TYPE_AUT_psv: [{
24
+ CODE: 'ROAD',
25
+ NAME: 'RD',
26
+ DESCRIPTION: 'RD'
27
+ }],
28
+ Authority_Code_STREET_SUFFIX_AUT_psv: [],
29
+ Authority_Code_FLAT_TYPE_AUT_psv: [{
30
+ CODE: 'UNIT',
31
+ NAME: 'UNIT'
32
+ }],
33
+ Authority_Code_LEVEL_TYPE_AUT_psv: [],
34
+ Authority_Code_GEOCODED_LEVEL_TYPE_AUT_psv: [],
35
+ Authority_Code_GEOCODE_TYPE_AUT_psv: [],
36
+ Authority_Code_GEOCODE_RELIABILITY_AUT_psv: [],
37
+ state: 'QLD',
38
+ stateName: 'QUEENSLAND'
39
+ };
40
+ function buildContext(streetLocalityPid, streetLocality, localityPid, locality) {
41
+ const context = {
42
+ ...baseAuthorityContext,
43
+ streetLocalityIndexed: [],
44
+ localityIndexed: []
45
+ };
46
+ context.streetLocalityIndexed[streetLocalityPid] = streetLocality;
47
+ context.localityIndexed[localityPid] = locality;
48
+ return context;
49
+ }
50
+ const streetLocality = {
51
+ STREET_LOCALITY_PID: 'QLD180101',
52
+ DATE_CREATED: '2017-08-10',
53
+ DATE_RETIRED: '',
54
+ STREET_CLASS_CODE: 'C',
55
+ STREET_NAME: 'AERODROME',
56
+ STREET_TYPE_CODE: 'ROAD',
57
+ STREET_SUFFIX_CODE: '',
58
+ LOCALITY_PID: 'QLD69',
59
+ GNAF_STREET_PID: '3169537',
60
+ GNAF_STREET_CONFIDENCE: '2',
61
+ GNAF_RELIABILITY_CODE: '4'
62
+ };
63
+ const locality = {
64
+ LOCALITY_PID: 'QLD69',
65
+ DATE_CREATED: '2016-08-10',
66
+ DATE_RETIRED: '',
67
+ LOCALITY_NAME: 'APPLETHORPE',
68
+ PRIMARY_POSTCODE: '',
69
+ LOCALITY_CLASS_CODE: 'G',
70
+ STATE_PID: '3',
71
+ GNAF_LOCALITY_PID: '198011',
72
+ GNAF_RELIABILITY_CODE: '5'
73
+ };
74
+ function addressDetail(overrides = {}) {
75
+ return {
76
+ ADDRESS_DETAIL_PID: 'GAQLD163157353',
77
+ DATE_CREATED: '2010-04-21',
78
+ DATE_LAST_MODIFIED: '2018-08-03',
79
+ DATE_RETIRED: '',
80
+ BUILDING_NAME: '',
81
+ LOT_NUMBER_PREFIX: '',
82
+ LOT_NUMBER: '',
83
+ LOT_NUMBER_SUFFIX: '',
84
+ FLAT_TYPE_CODE: '',
85
+ FLAT_NUMBER_PREFIX: '',
86
+ FLAT_NUMBER: '',
87
+ FLAT_NUMBER_SUFFIX: '',
88
+ LEVEL_TYPE_CODE: '',
89
+ LEVEL_NUMBER_PREFIX: '',
90
+ LEVEL_NUMBER: '',
91
+ LEVEL_NUMBER_SUFFIX: '',
92
+ NUMBER_FIRST_PREFIX: '',
93
+ NUMBER_FIRST: '42',
94
+ NUMBER_FIRST_SUFFIX: '',
95
+ NUMBER_LAST_PREFIX: '',
96
+ NUMBER_LAST: '',
97
+ NUMBER_LAST_SUFFIX: '',
98
+ STREET_LOCALITY_PID: 'QLD180101',
99
+ LOCATION_DESCRIPTION: '',
100
+ LOCALITY_PID: 'QLD69',
101
+ ALIAS_PRINCIPAL: 'P',
102
+ POSTCODE: '4378',
103
+ PRIVATE_STREET: '',
104
+ LEGAL_PARCEL_ID: '',
105
+ CONFIDENCE: '0',
106
+ ADDRESS_SITE_PID: '',
107
+ LEVEL_GEOCODED_CODE: '',
108
+ PROPERTY_PID: '',
109
+ GNAF_PROPERTY_PID: '',
110
+ PRIMARY_SECONDARY: '',
111
+ ...overrides
112
+ };
113
+ }
114
+ (0, _nodeTest.describe)('P007 — ssla population (ADR 025)', () => {
115
+ (0, _nodeTest.it)('populates ssla equal to sla for a street-level address with no sub-unit', () => {
116
+ const context = buildContext('QLD180101', streetLocality, 'QLD69', locality);
117
+ const mapped = (0, _addressService.mapAddressDetails)(addressDetail(), context, 1, 1);
118
+ _strict.default.equal(typeof mapped.sla, 'string', 'sla must be present on every indexed address');
119
+ _strict.default.equal(mapped.ssla, mapped.sla, 'ssla must equal sla when the address has no sub-unit, so BM25 per-field ' + 'score summation across [sla, ssla] is symmetric across documents');
120
+ });
121
+ (0, _nodeTest.it)('populates ssla from the short-form address for a sub-unit', () => {
122
+ const context = buildContext('QLD180101', streetLocality, 'QLD69', locality);
123
+ const mapped = (0, _addressService.mapAddressDetails)(addressDetail({
124
+ FLAT_TYPE_CODE: 'UNIT',
125
+ FLAT_NUMBER: '1'
126
+ }), context, 1, 1);
127
+ _strict.default.ok(mapped.ssla && mapped.ssla !== mapped.sla, 'for a sub-unit, ssla should be the short-form address (distinct from sla)');
128
+ _strict.default.match(mapped.sla, /UNIT 1/, 'sub-unit sla includes the unit prefix');
129
+ _strict.default.doesNotMatch(mapped.ssla, /UNIT 1/, 'sub-unit ssla strips the unit prefix (preserving slash-form matching)');
130
+ });
131
+ });
@@ -0,0 +1,25 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.SPAN_CAP = void 0;
7
+ exports.expandRangeAliases = expandRangeAliases;
8
+ // ADR 026: range-number address expansion via multi-valued text alias field.
9
+ // Pure helper — no I/O, no OpenSearch dependency. Invoked by `mapToMla` in
10
+ // `service/address-service.js` when a G-NAF address has NUMBER_LAST set.
11
+ // See docs/problems/015-range-number-addresses-not-searchable-by-base-number.open.md
12
+ // for the range-distribution measurements that justify SPAN_CAP = 20.
13
+
14
+ const SPAN_CAP = exports.SPAN_CAP = 20;
15
+ function expandRangeAliases(first, last, streetPart, localityPart) {
16
+ if (!Number.isInteger(first) || !Number.isInteger(last)) return [];
17
+ if (first <= 0 || last <= 0) return [];
18
+ if (first >= last) return [];
19
+ if (last - first > SPAN_CAP) return [];
20
+ const aliases = [];
21
+ for (let n = first; n <= last; n++) {
22
+ aliases.push(`${n} ${streetPart}, ${localityPart}`);
23
+ }
24
+ return aliases;
25
+ }
@@ -999,6 +999,12 @@ function startRest2Server() {
999
999
  }],
1000
1000
  headers: {
1001
1001
  etag: `"${_version.version}"`,
1002
+ // Long-lived by design (P018 parked). New rels are added
1003
+ // infrequently and every client page load fetches this for
1004
+ // HATEOAS discovery; a short TTL would cost an origin
1005
+ // round-trip per request. When the rel set does change,
1006
+ // request a RapidAPI CF purge (natural expiry up to 7 days
1007
+ // per P017 close notes).
1002
1008
  'cache-control': `public, max-age=${ONE_WEEK}`
1003
1009
  }
1004
1010
  };
package/lib/version.js CHANGED
@@ -5,4 +5,4 @@ Object.defineProperty(exports, "__esModule", {
5
5
  });
6
6
  exports.version = void 0;
7
7
  // Generated by genversion.
8
- const version = exports.version = '2.1.5';
8
+ const version = exports.version = '2.3.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mountainpass/addressr",
3
- "version": "2.1.5",
3
+ "version": "2.3.0",
4
4
  "description": "Australian Address Validation, Search and Autocomplete",
5
5
  "author": {
6
6
  "name": "Mountain Pass",
@@ -107,6 +107,8 @@
107
107
  "dotest:cli2:geo": "ADDRESSR_ENABLE_GEO=1 ES_INDEX_NAME=test COVERED_STATES=OT TEST_PROFILE=cli2 cucumber-js -p cli2 -- --harmony_async_iteration",
108
108
  "cover:cli:geo": "nyc --report-dir coverage/cli --temp-dir coverage/cli/.nyc_output npm run test:cli:nogeo",
109
109
  "test:mcp:smoke": "node --test test/mcp/smoke.test.mjs",
110
+ "test:precommit": "node --test test/precommit/*.test.mjs",
111
+ "test:js": "node --test test/js/__tests__/*.test.mjs",
110
112
  "test:nodejs:QLD:nogeo": "PORT=$npm_package_config_localport ES_INDEX_NAME=test COVERED_STATES=QLD DEBUG=error,api,express:*,swagger-tools*,test,es TEST_PROFILE=default cucumber-js -p default -- --harmony_async_iteration",
111
113
  "test:nodejs:QLD:geo": "PORT=$npm_package_config_localport ADDRESSR_ENABLE_GEO=1 ES_INDEX_NAME=test-geo COVERED_STATES=QLD DEBUG=error,api,express:*,swagger-tools*,test,es TEST_PROFILE=default NODE_OPTIONS=--max_old_space_size=8196 cucumber-js -p default -- --harmony_async_iteration",
112
114
  "prebuildX": "npm run genversion && cat ./templates/LICENSE.md | envsubst '${PRODUCT},${VERSION},${COMPANY},${YEAR}' > ./LICENSE.md",
@@ -122,7 +124,8 @@
122
124
  "docker:push": "docker push \"mountainpass/addressr:${npm_package_version}\"",
123
125
  "postdocker:push": "docker push \"mountainpass/addressr:latest\"",
124
126
  "check-licenses": "license-checker --production --onlyAllow 'MIT;Apache-2.0;ISC;Custom: http://github.com/substack/node-bufferlist;Unlicense;BSD-2-Clause;BSD-3-Clause;WTFPL;0BSD;MIT*;Python-2.0;MPL-2.0;BlueOak-1.0.0' --summary",
125
- "pre-commit": "lint-staged && npm run check-licenses",
127
+ "pre-commit": "lint-staged && npm run check-licenses && npm run check:not-cli2-tags && npm run test:js",
128
+ "check:not-cli2-tags": "node scripts/check-not-cli2-tags.mjs",
126
129
  "check-deps": "dry-aged-deps --check",
127
130
  "test:performance": "k6 run --out csv=target/stress.csv test/k6/script.js",
128
131
  "add-changeset": "changeset add --open",