@mountainpass/addressr 2.4.3 → 2.5.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.
@@ -32,6 +32,7 @@ var _streamDown = _interopRequireDefault(require("../utils/stream-down"));
32
32
  var _setLinkOptions = require("./set-link-options");
33
33
  var _rangeExpansion = require("./range-expansion");
34
34
  var _gnafPackageFetch = require("./gnaf-package-fetch");
35
+ var _readShadow = require("../src/read-shadow");
35
36
  var _nodeCrypto = _interopRequireDefault(require("node:crypto"));
36
37
  var _glob = require("glob");
37
38
  function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
@@ -806,7 +807,9 @@ async function loadAddressDetails(file, expectedCount, context, {
806
807
  }
807
808
  async function searchForAddress(searchString, p, pageSize = PAGE_SIZE) {
808
809
  // const searchString = '657 The Entrance Road'; //'2/25 TOTTERDE'; // 'UNT 2, BELCONNEN';
809
- const searchResp = await globalThis.esClient.search({
810
+ // ADR 031: hoist params so the same body object is shared by the primary
811
+ // and the fire-and-forget shadow mirror — no double-build cost.
812
+ const searchParameters = {
810
813
  index: ES_INDEX_NAME,
811
814
  body: {
812
815
  from: (p - 1 || 0) * pageSize,
@@ -871,6 +874,15 @@ async function searchForAddress(searchString, p, pageSize = PAGE_SIZE) {
871
874
  }
872
875
  }
873
876
  }
877
+ };
878
+ const searchResp = await globalThis.esClient.search(searchParameters);
879
+ // ADR 031 read-shadow: fire-and-forget mirror to a configurable secondary
880
+ // OpenSearch backend so v2 caches warm with realistic production query
881
+ // distribution before cutover. Returns synchronously; failures swallowed.
882
+ // No-op when ADDRESSR_SHADOW_HOST is unset (default).
883
+ (0, _readShadow.mirrorRequest)({
884
+ method: 'search',
885
+ params: searchParameters
874
886
  });
875
887
  logger('hits', JSON.stringify(searchResp.body.hits, undefined, 2));
876
888
  return searchResp;
@@ -0,0 +1,192 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports._resetShadowClientForTesting = _resetShadowClientForTesting;
7
+ exports.mirrorRequest = mirrorRequest;
8
+ exports.validateReadShadowConfig = validateReadShadowConfig;
9
+ var _debug = _interopRequireDefault(require("debug"));
10
+ var _opensearch = require("@opensearch-project/opensearch");
11
+ function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
12
+ /* eslint-disable @eslint-community/eslint-comments/disable-enable-pair */
13
+ /* eslint-disable security/detect-object-injection -- env var names are compile-time constants, not user input */
14
+
15
+ // @jtbd JTBD-201 (Validate a New Search Backend With Realistic Production Traffic Before Cutover)
16
+ //
17
+ // ADR 031: Read-shadow for search-backend migrations.
18
+ //
19
+ // Mirrors `/addresses?q=...` and `/addresses/{id}` requests to a configurable
20
+ // secondary OpenSearch backend in fire-and-forget mode. Goal: warm the
21
+ // candidate cluster's filesystem and field-data caches with realistic
22
+ // production query distribution before cutover.
23
+ //
24
+ // Default off: when `ADDRESSR_SHADOW_HOST` is unset, `mirrorRequest` is a
25
+ // no-op. Self-hosted users (npm/Docker) are unaffected.
26
+ //
27
+ // Failure isolation: every code path through `mirrorRequest` is wrapped to
28
+ // guarantee the primary `/addresses` response cannot be impacted. Synchronous
29
+ // throws from client construction or method invocation are caught; async
30
+ // rejections are swallowed via `.catch(swallowError)`; per-request
31
+ // `AbortController` timeout (default 3000ms) bounds in-flight resources under
32
+ // shadow target outage.
33
+ //
34
+ // `ADDRESSR_SHADOW_*` is the application-level shadow target (read by the
35
+ // running addressr server). It is distinct from `TF_VAR_ELASTIC_V2_*`, which
36
+ // is the Terraform input that populates EB env at deploy time. They may carry
37
+ // the same values during a migration window, but their lifecycles differ
38
+ // (shadow vars persist across migrations; V2 vars are clobbered into
39
+ // canonical names at decommission per ADR 029 step 9).
40
+
41
+ const logger = (0, _debug.default)('api');
42
+ const error = (0, _debug.default)('error');
43
+ error.log = console.error.bind(console);
44
+ const HOST_VAR = 'ADDRESSR_SHADOW_HOST';
45
+ const PORT_VAR = 'ADDRESSR_SHADOW_PORT';
46
+ const USERNAME_VAR = 'ADDRESSR_SHADOW_USERNAME';
47
+ const PASSWORD_VAR = 'ADDRESSR_SHADOW_PASSWORD';
48
+ const PROTOCOL_VAR = 'ADDRESSR_SHADOW_PROTOCOL';
49
+ const TIMEOUT_VAR = 'ADDRESSR_SHADOW_TIMEOUT_MS';
50
+ const SUPPORTED_METHODS = new Set(['search', 'get']);
51
+ const DEFAULT_TIMEOUT_MS = 3000;
52
+
53
+ // Module-scoped lazy singleton. The shadow client is built on first call
54
+ // and reused for the process lifetime so connection setup amortises across
55
+ // requests (the @opensearch-project/opensearch client uses keepalive HTTP
56
+ // agents internally).
57
+ let cachedClient;
58
+ let cachedClientFingerprint;
59
+ function isNonEmpty(value) {
60
+ return typeof value === 'string' && value.length > 0;
61
+ }
62
+ function validateReadShadowConfig(environment = process.env) {
63
+ const hostSet = isNonEmpty(environment[HOST_VAR]);
64
+ const usernameSet = isNonEmpty(environment[USERNAME_VAR]);
65
+ const passwordSet = isNonEmpty(environment[PASSWORD_VAR]);
66
+ if (!hostSet) {
67
+ return; // feature disabled, nothing to validate
68
+ }
69
+ if (usernameSet && !passwordSet) {
70
+ throw new Error(`Read-shadow misconfigured: ${USERNAME_VAR} is set but ${PASSWORD_VAR} is missing. Set both to enable basic auth, or unset both to use the shadow target without auth.`);
71
+ }
72
+ if (passwordSet && !usernameSet) {
73
+ throw new Error(`Read-shadow misconfigured: ${PASSWORD_VAR} is set but ${USERNAME_VAR} is missing. Set both to enable basic auth, or unset both to use the shadow target without auth.`);
74
+ }
75
+ }
76
+ function buildClientOptions(environment) {
77
+ const host = environment[HOST_VAR];
78
+ const port = environment[PORT_VAR] || '443';
79
+ const protocol = environment[PROTOCOL_VAR] || 'https';
80
+ const username = environment[USERNAME_VAR];
81
+ const password = environment[PASSWORD_VAR];
82
+ const node = isNonEmpty(username) ? `${protocol}://${username}:${password}@${host}:${port}` : `${protocol}://${host}:${port}`;
83
+ return {
84
+ node
85
+ };
86
+ }
87
+
88
+ // Stable fingerprint so the cache resets when env changes between calls
89
+ // (e.g. between unit tests that snapshot/restore env). Excludes credentials.
90
+ function clientFingerprint(environment) {
91
+ return [environment[HOST_VAR] || '', environment[PORT_VAR] || '', environment[PROTOCOL_VAR] || '', isNonEmpty(environment[USERNAME_VAR]) ? '+auth' : '-auth'].join('|');
92
+ }
93
+ function getShadowClient({
94
+ environment = process.env,
95
+ ClientClass = _opensearch.Client
96
+ } = {}) {
97
+ if (!isNonEmpty(environment[HOST_VAR])) {
98
+ return;
99
+ }
100
+ const fingerprint = clientFingerprint(environment);
101
+ if (cachedClient && cachedClientFingerprint === fingerprint) {
102
+ return cachedClient;
103
+ }
104
+ const options = buildClientOptions(environment);
105
+ cachedClient = new ClientClass(options);
106
+ cachedClientFingerprint = fingerprint;
107
+ return cachedClient;
108
+ }
109
+ function getTimeoutMs(environment) {
110
+ const raw = environment[TIMEOUT_VAR];
111
+ if (!isNonEmpty(raw)) {
112
+ return DEFAULT_TIMEOUT_MS;
113
+ }
114
+ const parsed = Number.parseInt(raw, 10);
115
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_TIMEOUT_MS;
116
+ }
117
+ function swallowError(reason) {
118
+ if (reason && reason.name === 'AbortError') {
119
+ error('read-shadow: request aborted by timeout');
120
+ return;
121
+ }
122
+ if (reason instanceof Error) {
123
+ error('read-shadow: %s', reason.message);
124
+ return;
125
+ }
126
+ error('read-shadow: non-error rejection %o', reason);
127
+ }
128
+
129
+ // Fire-and-forget mirror to a configurable shadow OpenSearch backend.
130
+ // Returns synchronously; the kicked-off promise is detached.
131
+ //
132
+ // Programmer-error throws (unsupported method) are synchronous so callers
133
+ // notice during dev. All operational errors (network, target down, abort,
134
+ // synchronous client throws) are swallowed via swallowError.
135
+ function mirrorRequest({
136
+ method,
137
+ params,
138
+ environment = process.env,
139
+ ClientClass = _opensearch.Client
140
+ } = {}) {
141
+ if (!SUPPORTED_METHODS.has(method)) {
142
+ throw new Error(`read-shadow: unsupported method ${JSON.stringify(method)}; expected one of ${[...SUPPORTED_METHODS].join(', ')}`);
143
+ }
144
+ let client;
145
+ try {
146
+ client = getShadowClient({
147
+ environment,
148
+ ClientClass
149
+ });
150
+ } catch (constructError) {
151
+ swallowError(constructError);
152
+ return;
153
+ }
154
+ if (!client) {
155
+ return; // feature disabled (HOST unset)
156
+ }
157
+ const timeoutMs = getTimeoutMs(environment);
158
+ const controller = new AbortController();
159
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
160
+ if (typeof timer.unref === 'function') {
161
+ timer.unref();
162
+ }
163
+ let promise;
164
+ try {
165
+ promise = client[method](params, {
166
+ signal: controller.signal
167
+ });
168
+ } catch (syncError) {
169
+ clearTimeout(timer);
170
+ swallowError(syncError);
171
+ return;
172
+ }
173
+ if (!promise || typeof promise.then !== 'function') {
174
+ clearTimeout(timer);
175
+ return;
176
+ }
177
+ promise.then(() => {
178
+ clearTimeout(timer);
179
+ logger('read-shadow: %s ok', method);
180
+ return;
181
+ }).catch(error_ => {
182
+ clearTimeout(timer);
183
+ swallowError(error_);
184
+ });
185
+ }
186
+
187
+ // Internal: lets unit tests reset the singleton between cases. Not part of
188
+ // the public contract; flagged with a leading underscore by convention.
189
+ function _resetShadowClientForTesting() {
190
+ cachedClient = undefined;
191
+ cachedClientFingerprint = undefined;
192
+ }
@@ -13,6 +13,7 @@ var _addressService = require("../service/address-service");
13
13
  var _version = require("../version");
14
14
  var _nodeCrypto = _interopRequireDefault(require("node:crypto"));
15
15
  var _proxyAuth = require("./proxy-auth");
16
+ var _readShadow = require("./read-shadow");
16
17
  function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
17
18
  //import connect from 'connect';
18
19
 
@@ -659,6 +660,7 @@ let server;
659
660
  const PAGE_SIZE = process.env.PAGE_SIZE || 8;
660
661
  function startRest2Server() {
661
662
  (0, _proxyAuth.validateProxyAuthConfig)();
663
+ (0, _readShadow.validateReadShadowConfig)();
662
664
  app.use((_request, response, next) => {
663
665
  if (process.env.ADDRESSR_ACCESS_CONTROL_ALLOW_ORIGIN !== undefined) {
664
666
  response.append('Access-Control-Allow-Origin', process.env.ADDRESSR_ACCESS_CONTROL_ALLOW_ORIGIN);
package/lib/swagger.js CHANGED
@@ -14,6 +14,7 @@ var _nodeHttp = require("node:http");
14
14
  var _jsYaml = require("js-yaml");
15
15
  var _nodePath = _interopRequireDefault(require("node:path"));
16
16
  var _swaggerTools = require("swagger-tools");
17
+ var _readShadow = require("./src/read-shadow");
17
18
  function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
18
19
  //import connect from 'connect';
19
20
 
@@ -86,6 +87,8 @@ function swaggerInit() {
86
87
  }
87
88
  let server;
88
89
  function startServer() {
90
+ // ADR 031: fail loudly at startup if read-shadow env vars are misconfigured.
91
+ (0, _readShadow.validateReadShadowConfig)();
89
92
  app.use((request, response, next) => {
90
93
  if (process.env.ADDRESSR_ACCESS_CONTROL_ALLOW_ORIGIN !== undefined) {
91
94
  response.append('Access-Control-Allow-Origin', process.env.ADDRESSR_ACCESS_CONTROL_ALLOW_ORIGIN);
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.4.3';
8
+ const version = exports.version = '2.5.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mountainpass/addressr",
3
- "version": "2.4.3",
3
+ "version": "2.5.0",
4
4
  "description": "Australian Address Validation, Search and Autocomplete",
5
5
  "author": {
6
6
  "name": "Mountain Pass",