spice-couchbase 2.0.61 → 2.0.63

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.
package/build/index.js CHANGED
@@ -24,6 +24,16 @@ var {
24
24
  QueryScanConsistency
25
25
  } = require("couchbase");
26
26
 
27
+ var {
28
+ parseResourceList,
29
+ extractStatementResources
30
+ } = require("./utility");
31
+
32
+ var {
33
+ createHealthMonitor,
34
+ openHandles
35
+ } = require("./connectionHealth");
36
+
27
37
  if (!Promise.allSettled) {
28
38
  Promise.allSettled = promises => Promise.all(promises.map((promise, i) => promise.then(value => ({
29
39
  status: "fulfilled",
@@ -52,8 +62,55 @@ var optimizeState = {
52
62
  inFlight: 0,
53
63
  seen: new Set(),
54
64
  cooldownUntil: 0
65
+ }; // Auto cover indexing is opt-in per resource. A resource is the collection a
66
+ // Database instance serves (scoped deployments) or the `type = '...'` literal
67
+ // legacy bucket mode adds to every statement; raw statements also count their
68
+ // FROM / JOIN keyspaces. Names are case-insensitive. A slow statement is
69
+ // optimised when, in order of precedence:
70
+ // 1. the call says so: query_options.auto_optimize (true / false)
71
+ // 2. the instance says so: constructor option or connection config
72
+ // `auto_optimize` (true / false)
73
+ // 3. the application policy: Database.setAutoOptimizePolicy(fn), where
74
+ // fn(resource, context) returns true / false, or
75
+ // undefined to defer to the static lists
76
+ // 4. a static list names a resource it touches: comma-separated
77
+ // SPICE_COUCHBASE_OPTIMIZE_RESOURCES (`*` = every resource, which is the
78
+ // pre-2.0.62 behaviour), or connection config / constructor option
79
+ // `auto_optimize_resources`.
80
+
81
+ var OPTIMIZE_ENABLED_RESOURCES = parseResourceList(process.env.SPICE_COUCHBASE_OPTIMIZE_RESOURCES);
82
+ var autoOptimizePolicy = null; // One health monitor per process, shared by every copy of this module that
83
+ // ends up loaded. See connectionHealth.js.
84
+
85
+ var HEALTH_MONITOR_KEY = Symbol.for("spice-couchbase.health-monitor");
86
+ var health = globalThis[HEALTH_MONITOR_KEY] || (globalThis[HEALTH_MONITOR_KEY] = createHealthMonitor()); // Cluster handles live on the `spice.database` globals, which a reconnect
87
+ // replaces. Instances read them at call time instead of copying them.
88
+
89
+ var HANDLE_GLOBALS = {
90
+ cluster: "clusters",
91
+ connection: "connections",
92
+ scope: "scopes",
93
+ bucket_handle: "buckets"
55
94
  };
56
95
 
96
+ function globalHandle(kind, connection_name) {
97
+ var _spice, _spice$database, _spice$database$HANDL;
98
+
99
+ if (typeof spice === "undefined") return undefined;
100
+ return (_spice = spice) === null || _spice === void 0 ? void 0 : (_spice$database = _spice.database) === null || _spice$database === void 0 ? void 0 : (_spice$database$HANDL = _spice$database[HANDLE_GLOBALS[kind]]) === null || _spice$database$HANDL === void 0 ? void 0 : _spice$database$HANDL[connection_name];
101
+ }
102
+
103
+ function requestedAutoOptimize(query_options) {
104
+ var _query_options$auto_o;
105
+
106
+ if (!query_options || typeof query_options !== "object" || Array.isArray(query_options)) {
107
+ return undefined;
108
+ }
109
+
110
+ var value = (_query_options$auto_o = query_options.auto_optimize) !== null && _query_options$auto_o !== void 0 ? _query_options$auto_o : query_options.optimize;
111
+ return typeof value === "boolean" ? value : undefined;
112
+ }
113
+
57
114
  function shouldOptimize(query) {
58
115
  if (!AUTO_OPTIMIZE_ENABLED) return false;
59
116
  if (optimizeState.inFlight >= OPTIMIZE_MAX_INFLIGHT) return false;
@@ -124,19 +181,18 @@ module.exports = class Database {
124
181
  collection,
125
182
  scope,
126
183
  query_scope = "cluster",
127
- auto_create_collection
184
+ auto_create_collection,
185
+ auto_optimize,
186
+ auto_optimize_resources
128
187
  } = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
129
188
 
130
189
  if (connection) {
131
- var _spice, _spice$db;
190
+ var _spice2, _spice2$db;
132
191
 
133
192
  this.connection_name = connection;
134
- this.connection = spice.database.connections[connection];
135
193
  this.query_scope = query_scope;
136
- this.cluster = spice.database.clusters[connection];
137
194
  this.bucket = this.connection._scope._bucket._name;
138
195
  this.db_config = spice.config.database.connections[connection];
139
- this.collection_manager = spice.database.buckets[connection].collections();
140
196
  this.auto_create_collection = auto_create_collection || (collection || spice.config.database.connections[connection].collection) != undefined && (collection || spice.config.database.connections[connection].collection) != "default" && (scope || spice.config.database.connections[connection].scope) != "_default";
141
197
  this.collection_name = collection || spice.config.database.connections[connection].collection || "_default";
142
198
  /* console.log(
@@ -147,14 +203,11 @@ module.exports = class Database {
147
203
  );*/
148
204
 
149
205
  this.scope_name = scope || spice.config.database.connections[connection].scope || "_default";
150
- this.scope = spice.database.scopes[connection];
151
206
  this.full_text_index = spice.config.database.connections[connection].full_text_index || full_text_index;
152
207
  this.N1qlQuery = couchbase.N1qlQuery;
153
208
  this.SearchQuery = couchbase.SearchQuery;
154
- this.collection = this.scope.collection(this.collection_name);
155
- this.userManager = this.getUserManager(connection);
156
209
 
157
- if (!((_spice = spice) === null || _spice === void 0 ? void 0 : (_spice$db = _spice.db) === null || _spice$db === void 0 ? void 0 : _spice$db.collections)) {
210
+ if (!((_spice2 = spice) === null || _spice2 === void 0 ? void 0 : (_spice2$db = _spice2.db) === null || _spice2$db === void 0 ? void 0 : _spice2$db.collections)) {
158
211
  spice.db = spice.db || {};
159
212
  spice.db.collections = new Set();
160
213
  }
@@ -163,8 +216,255 @@ module.exports = class Database {
163
216
  this.upsertCollection();
164
217
  }
165
218
 
166
- this.use_bucket_name = spice.config.database.connections[this.connection_name].scope == undefined;
219
+ this.use_bucket_name = spice.config.database.connections[this.connection_name].scope == undefined; // Auto cover indexing. See OPTIMIZE_ENABLED_RESOURCES for precedence.
220
+
221
+ var configured_auto_optimize = auto_optimize !== null && auto_optimize !== void 0 ? auto_optimize : this.db_config.auto_optimize;
222
+ this.auto_optimize = typeof configured_auto_optimize === "boolean" ? configured_auto_optimize : undefined;
223
+ this.enabled_resources = parseResourceList([...parseResourceList(this.db_config.auto_optimize_resources), ...parseResourceList(auto_optimize_resources)]);
224
+ }
225
+ } // Handles resolve from the globals on every access, so an instance created
226
+ // before a reconnect uses the new cluster afterwards. Assigning one pins it
227
+ // on that instance instead (tests, custom wiring).
228
+
229
+
230
+ pinHandle(kind, value) {
231
+ if (!Object.prototype.hasOwnProperty.call(this, "_pinned_handles")) {
232
+ Object.defineProperty(this, "_pinned_handles", {
233
+ value: {},
234
+ enumerable: false,
235
+ writable: true
236
+ });
237
+ }
238
+
239
+ this._pinned_handles[kind] = value;
240
+ }
241
+
242
+ pinnedHandle(kind) {
243
+ var pinned = this._pinned_handles;
244
+ return pinned && kind in pinned ? {
245
+ value: pinned[kind]
246
+ } : null;
247
+ }
248
+
249
+ handle(kind) {
250
+ var pinned = this.pinnedHandle(kind);
251
+ return pinned ? pinned.value : globalHandle(kind, this.connection_name);
252
+ }
253
+
254
+ get cluster() {
255
+ return this.handle("cluster");
256
+ }
257
+
258
+ set cluster(value) {
259
+ this.pinHandle("cluster", value);
260
+ }
261
+
262
+ get connection() {
263
+ return this.handle("connection");
264
+ }
265
+
266
+ set connection(value) {
267
+ this.pinHandle("connection", value);
268
+ }
269
+
270
+ get scope() {
271
+ return this.handle("scope");
272
+ }
273
+
274
+ set scope(value) {
275
+ this.pinHandle("scope", value);
276
+ }
277
+
278
+ get collection() {
279
+ var _this$_collection_cac;
280
+
281
+ var pinned = this.pinnedHandle("collection");
282
+ if (pinned) return pinned.value;
283
+ var scope = this.scope;
284
+ if (!scope) return undefined; // Collection handles are cheap but not free; cache one per scope object,
285
+ // which changes exactly when the connection is replaced.
286
+
287
+ if (((_this$_collection_cac = this._collection_cache) === null || _this$_collection_cac === void 0 ? void 0 : _this$_collection_cac.scope) !== scope) {
288
+ Object.defineProperty(this, "_collection_cache", {
289
+ value: {
290
+ scope,
291
+ collection: scope.collection(this.collection_name)
292
+ },
293
+ enumerable: false,
294
+ writable: true,
295
+ configurable: true
296
+ });
297
+ }
298
+
299
+ return this._collection_cache.collection;
300
+ }
301
+
302
+ set collection(value) {
303
+ this.pinHandle("collection", value);
304
+ }
305
+
306
+ get collection_manager() {
307
+ var _this$handle;
308
+
309
+ var pinned = this.pinnedHandle("collection_manager");
310
+ if (pinned) return pinned.value;
311
+ return (_this$handle = this.handle("bucket_handle")) === null || _this$handle === void 0 ? void 0 : _this$handle.collections();
312
+ }
313
+
314
+ set collection_manager(value) {
315
+ this.pinHandle("collection_manager", value);
316
+ }
317
+
318
+ get userManager() {
319
+ var _this$cluster;
320
+
321
+ var pinned = this.pinnedHandle("userManager");
322
+ if (pinned) return pinned.value;
323
+ return (_this$cluster = this.cluster) === null || _this$cluster === void 0 ? void 0 : _this$cluster.users();
324
+ }
325
+
326
+ set userManager(value) {
327
+ this.pinHandle("userManager", value);
328
+ }
329
+ /**
330
+ * Reports a finished operation to the health monitor. Only connectivity
331
+ * errors (timeouts, cancelled requests, service not available) count.
332
+ */
333
+
334
+
335
+ recordOutcome(error) {
336
+ health.recordOutcome(this.connection_name, error);
337
+ }
338
+
339
+ recordSettled(results) {
340
+ for (var result of results) {
341
+ if ((result === null || result === void 0 ? void 0 : result.status) === "rejected") this.recordOutcome(result.reason);
342
+ }
343
+ }
344
+
345
+ observe(operation) {
346
+ var _this = this;
347
+
348
+ return _asyncToGenerator(function* () {
349
+ try {
350
+ return yield operation;
351
+ } catch (error) {
352
+ _this.recordOutcome(error);
353
+
354
+ throw error;
355
+ }
356
+ })();
357
+ }
358
+ /** Per-connection health: healthy, reconnecting, generation, last error. */
359
+
360
+
361
+ static healthStatus() {
362
+ return health.status();
363
+ }
364
+ /** Probes a connection now and reconnects it if the probe fails. */
365
+
366
+
367
+ static checkHealth() {
368
+ var connection_name = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : "default";
369
+ return health.check(connection_name);
370
+ }
371
+ /** Opens a fresh cluster for a connection and swaps it in. */
372
+
373
+
374
+ static forceReconnect() {
375
+ var connection_name = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : "default";
376
+ return health.reconnect(connection_name);
377
+ }
378
+ /**
379
+ * Stops probing and reconnecting and closes replaced clusters still in
380
+ * their grace period. Call before closing `spice.database.clusters` on
381
+ * shutdown so a reconnect cannot race it.
382
+ */
383
+
384
+
385
+ static stopHealthMonitor() {
386
+ return health.stop();
387
+ }
388
+ /**
389
+ * Lets the application decide per resource whether slow statements may
390
+ * create covering indexes, e.g. from a per-resource setting an operator
391
+ * toggles at runtime. `policy(resource, { query, connection, collection })`
392
+ * returns true or false, or undefined to defer to the static lists. It is
393
+ * synchronous because it runs on the query path. Pass null to clear.
394
+ */
395
+
396
+
397
+ static setAutoOptimizePolicy(policy) {
398
+ if (policy !== null && policy !== undefined && typeof policy !== "function") {
399
+ throw new TypeError("Auto-optimize policy must be a function or null");
400
+ }
401
+
402
+ autoOptimizePolicy = policy || null;
403
+ }
404
+
405
+ static getAutoOptimizePolicy() {
406
+ return autoOptimizePolicy;
407
+ }
408
+ /**
409
+ * Resources a statement touches, plus this instance's collection: that is
410
+ * the keyspace `optimize()` would create the index on.
411
+ */
412
+
413
+
414
+ statementResources(query) {
415
+ var resources = extractStatementResources(query);
416
+
417
+ if (this.collection_name && this.collection_name !== "_default") {
418
+ resources.add(String(this.collection_name).toLowerCase());
419
+ }
420
+
421
+ return resources;
422
+ }
423
+ /**
424
+ * True when a slow statement may create a covering index. See
425
+ * OPTIMIZE_ENABLED_RESOURCES for the order of precedence.
426
+ */
427
+
428
+
429
+ shouldAutoOptimize(query) {
430
+ var query_options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
431
+ var requested = requestedAutoOptimize(query_options);
432
+ if (requested !== undefined) return requested;
433
+ if (typeof this.auto_optimize === "boolean") return this.auto_optimize;
434
+ var resources = this.statementResources(query);
435
+
436
+ if (typeof autoOptimizePolicy === "function" && resources.size > 0) {
437
+ var deferred = false;
438
+
439
+ for (var resource of resources) {
440
+ var verdict = void 0;
441
+
442
+ try {
443
+ verdict = autoOptimizePolicy(resource, {
444
+ query,
445
+ connection: this.connection_name,
446
+ collection: this.collection_name
447
+ });
448
+ } catch (e) {
449
+ // A broken policy must not create indexes nobody asked for.
450
+ verdict = false;
451
+ }
452
+
453
+ if (verdict === true) return true;
454
+ if (verdict === undefined || verdict === null) deferred = true;
455
+ }
456
+
457
+ if (!deferred) return false;
167
458
  }
459
+
460
+ var enabled = new Set([...OPTIMIZE_ENABLED_RESOURCES, ...(this.enabled_resources || [])]);
461
+ if (enabled.has("*")) return true;
462
+
463
+ for (var _resource of resources) {
464
+ if (enabled.has(_resource)) return true;
465
+ }
466
+
467
+ return false;
168
468
  }
169
469
 
170
470
  setGlobalConnectionObject(collection) {
@@ -189,25 +489,15 @@ module.exports = class Database {
189
489
  }
190
490
 
191
491
  connect(connection_name, config) {
192
- var _this = this;
193
-
194
492
  return _asyncToGenerator(function* () {
195
- var connectOptions = {
196
- username: config.username,
197
- password: config.password
198
- };
199
-
200
- if (config.configProfile) {
201
- connectOptions.configProfile = config.configProfile;
202
- }
493
+ var {
494
+ cluster,
495
+ bucket,
496
+ scope,
497
+ collection
498
+ } = yield openHandles(config); // Watch the connection from here on and replace it if it wedges.
203
499
 
204
- var cluster = yield couchbase.connect(config.nodes, connectOptions);
205
- var bucket = cluster.bucket(config.bucket);
206
- var collection;
207
- var scope_name = _this.scope_name || config.scope || "_default";
208
- var collection_name = _this.collection_name || config.collection || "_default";
209
- var scope = bucket.scope(scope_name);
210
- collection = bucket.scope(scope_name).collection(collection_name || "_default");
500
+ health.register(connection_name, config);
211
501
  return {
212
502
  connection: connection_name,
213
503
  bucket,
@@ -219,7 +509,7 @@ module.exports = class Database {
219
509
  }
220
510
 
221
511
  getUserManager(connection) {
222
- return spice.database.clusters[connection].users();
512
+ return globalHandle("cluster", connection).users();
223
513
  }
224
514
 
225
515
  assignUserPermission() {
@@ -292,9 +582,9 @@ module.exports = class Database {
292
582
 
293
583
  return _asyncToGenerator(function* () {
294
584
  try {
295
- var _spice2, _spice2$db, _spice2$db$collection;
585
+ var _spice3, _spice3$db, _spice3$db$collection;
296
586
 
297
- if ((_spice2 = spice) === null || _spice2 === void 0 ? void 0 : (_spice2$db = _spice2.db) === null || _spice2$db === void 0 ? void 0 : (_spice2$db$collection = _spice2$db.collections) === null || _spice2$db$collection === void 0 ? void 0 : _spice2$db$collection.has(collection_name)) {
587
+ if ((_spice3 = spice) === null || _spice3 === void 0 ? void 0 : (_spice3$db = _spice3.db) === null || _spice3$db === void 0 ? void 0 : (_spice3$db$collection = _spice3$db.collections) === null || _spice3$db$collection === void 0 ? void 0 : _spice3$db$collection.has(collection_name)) {
298
588
  return true;
299
589
  } else {
300
590
  var response = yield _this4.cluster.query("SELECT RAW keyspace_id\n FROM system:keyspaces\n WHERE bucket_id = ?\n AND scope_id = ?\n AND keyspace_id = ?\n LIMIT 1", {
@@ -405,7 +695,7 @@ module.exports = class Database {
405
695
  var collection = _this9.use_bucket_name ? _this9.connection : _this9.collection;
406
696
 
407
697
  if (!useLookupIn) {
408
- var response = yield collection.get(doc_id);
698
+ var response = yield _this9.observe(collection.get(doc_id));
409
699
  return _objectSpread({
410
700
  id: doc_id
411
701
  }, response.content);
@@ -423,7 +713,10 @@ module.exports = class Database {
423
713
  var specs = batch.map(col => couchbase.LookupInSpec.get(col));
424
714
  return collection.lookupIn(doc_id, specs);
425
715
  });
426
- var batchResults = yield Promise.allSettled(batchPromises); // Merge results from all batches
716
+ var batchResults = yield Promise.allSettled(batchPromises);
717
+
718
+ _this9.recordSettled(batchResults); // Merge results from all batches
719
+
427
720
 
428
721
  batchResults.forEach((result, batchIndex) => {
429
722
  if (result.status === "fulfilled" && result.value) {
@@ -447,9 +740,9 @@ module.exports = class Database {
447
740
 
448
741
  return _asyncToGenerator(function* () {
449
742
  try {
450
- yield (_this10.use_bucket_name ? _this10.connection : _this10.collection).insert(doc_id, document, {
743
+ yield _this10.observe((_this10.use_bucket_name ? _this10.connection : _this10.collection).insert(doc_id, document, {
451
744
  expiry: expiry
452
- });
745
+ }));
453
746
  } catch (e) {
454
747
  console.log(e.message, e, doc_id);
455
748
  }
@@ -463,7 +756,7 @@ module.exports = class Database {
463
756
 
464
757
  var expiry = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 0;
465
758
  return _asyncToGenerator(function* () {
466
- yield (_this11.use_bucket_name ? _this11.connection : _this11.collection).touch(doc_id, expiry);
759
+ yield _this11.observe((_this11.use_bucket_name ? _this11.connection : _this11.collection).touch(doc_id, expiry));
467
760
  return yield _this11.get(doc_id);
468
761
  })();
469
762
  }
@@ -472,9 +765,9 @@ module.exports = class Database {
472
765
  var _this12 = this;
473
766
 
474
767
  return _asyncToGenerator(function* () {
475
- var res = yield (_this12.use_bucket_name ? _this12.connection : _this12.collection).counter(doc_id, adjustment, {
768
+ var res = yield _this12.observe((_this12.use_bucket_name ? _this12.connection : _this12.collection).counter(doc_id, adjustment, {
476
769
  initial: initial_value
477
- });
770
+ }));
478
771
  return res.value;
479
772
  })();
480
773
  }
@@ -483,9 +776,9 @@ module.exports = class Database {
483
776
  var _this13 = this;
484
777
 
485
778
  return _asyncToGenerator(function* () {
486
- var res = yield (_this13.use_bucket_name ? _this13.connection : _this13.collection).replace(doc_id, document, {
779
+ var res = yield _this13.observe((_this13.use_bucket_name ? _this13.connection : _this13.collection).replace(doc_id, document, {
487
780
  expiry: expiry
488
- });
781
+ }));
489
782
  return res.value;
490
783
  })();
491
784
  }
@@ -494,7 +787,7 @@ module.exports = class Database {
494
787
  var _this14 = this;
495
788
 
496
789
  return _asyncToGenerator(function* () {
497
- var res = yield (_this14.use_bucket_name ? _this14.connection : _this14.collection).remove(doc_id);
790
+ var res = yield _this14.observe((_this14.use_bucket_name ? _this14.connection : _this14.collection).remove(doc_id));
498
791
  return res.value;
499
792
  })();
500
793
  }
@@ -521,6 +814,8 @@ module.exports = class Database {
521
814
 
522
815
  var returned_all = yield Promise.allSettled(functions);
523
816
 
817
+ _this15.recordSettled(returned_all);
818
+
524
819
  var responses = _.flatten(_.map(returned_all, returned_obj => returned_obj === null || returned_obj === void 0 ? void 0 : returned_obj.value));
525
820
 
526
821
  var _final_responses = _.compact(_.map(responses, (res, index) => {
@@ -559,7 +854,10 @@ module.exports = class Database {
559
854
  var specs = batch.map(col => couchbase.LookupInSpec.get(col));
560
855
  return collection.lookupIn(key, specs);
561
856
  });
562
- var batchResults = yield Promise.allSettled(batchPromises); // Merge results from all batches
857
+ var batchResults = yield Promise.allSettled(batchPromises);
858
+
859
+ _this15.recordSettled(batchResults); // Merge results from all batches
860
+
563
861
 
564
862
  batchResults.forEach((result, batchIndex) => {
565
863
  if (result.status === "fulfilled" && result.value) {
@@ -986,14 +1284,14 @@ module.exports = class Database {
986
1284
 
987
1285
  var startTime = process.hrtime.bigint();
988
1286
  var options = buildQueryOptions(query_options);
989
- var response = yield exec_scope.query(sanitizedQuery, options); // End time
1287
+ var response = yield _this20.observe(exec_scope.query(sanitizedQuery, options)); // End time
990
1288
 
991
1289
  var endTime = process.hrtime.bigint(); // Calculate duration in microseconds
992
1290
 
993
1291
  var durationInMilliseconds = (endTime - startTime) / BigInt(1000000); // Optimise slow statements in the background, subject to the guard rails
994
1292
  // declared at the top of this file. Never awaited, never allowed to reject.
995
1293
 
996
- if (durationInMilliseconds > SLOW_QUERY_MS && shouldOptimize(sanitizedQuery)) {
1294
+ if (durationInMilliseconds > SLOW_QUERY_MS && _this20.shouldAutoOptimize(sanitizedQuery, query_options) && shouldOptimize(sanitizedQuery)) {
997
1295
  _this20.optimize(sanitizedQuery, exec_scope).catch(() => {});
998
1296
  }
999
1297
 
package/build/utility.js CHANGED
@@ -435,11 +435,76 @@ function indexSatisfiesCoveringDefinition(index, candidate) {
435
435
  var existingFields = new Set(existingKeys.map(key => key.field));
436
436
  return requiredKeys.slice(candidate.accessKeyCount).every(key => existingFields.has(key.field));
437
437
  }
438
+ /**
439
+ * Normalizes an exemption list into a Set of lower-case resource names.
440
+ * Accepts an array, a Set, or a comma-separated string (the env var form).
441
+ * Surrounding whitespace and backticks are ignored; empty entries are dropped.
442
+ * @param {string|string[]|Set<string>} value
443
+ * @returns {Set<string>}
444
+ */
445
+
446
+
447
+ function parseResourceList(value) {
448
+ var names = new Set();
449
+ var items = [];
450
+
451
+ if (Array.isArray(value) || value instanceof Set) {
452
+ items = [...value];
453
+ } else if (typeof value === "string") {
454
+ items = value.split(",");
455
+ }
456
+
457
+ for (var item of items) {
458
+ if (typeof item !== "string") continue;
459
+ var name = item.trim().replace(/^`+|`+$/g, "").trim().toLowerCase();
460
+ if (name) names.add(name);
461
+ }
462
+
463
+ return names;
464
+ }
465
+ /**
466
+ * Extracts the resource names a statement touches, lower-cased:
467
+ * - the last path segment of every FROM / JOIN keyspace, unquoted, so
468
+ * `default`:`bucket`.`scope`.`users` and users both yield "users";
469
+ * - every `type = '...'` literal, which is how legacy bucket-mode
470
+ * deployments distinguish resources that share one keyspace.
471
+ * Only simple identifiers are recognised; subqueries and expressions are skipped.
472
+ * @param {string} query
473
+ * @returns {Set<string>}
474
+ */
475
+
476
+
477
+ function extractStatementResources(query) {
478
+ var names = new Set();
479
+ if (typeof query !== "string") return names;
480
+ var identifier = "(?:`(?:[^`]|``)+`|[A-Za-z_][\\w$-]*)";
481
+ var keyspacePattern = new RegExp("\\b(?:FROM|JOIN)\\s+(".concat(identifier, "(?:\\s*[.:]\\s*").concat(identifier, ")*)"), "gi");
482
+ var segmentPattern = new RegExp(identifier, "g");
483
+
484
+ for (var match of query.matchAll(keyspacePattern)) {
485
+ var segments = match[1].match(segmentPattern) || [];
486
+ var last = (segments[segments.length - 1] || "").trim();
487
+ var name = last.startsWith("`") ? last.slice(1, -1).replace(/``/g, "`") : last;
488
+ if (name) names.add(name.toLowerCase());
489
+ }
490
+
491
+ var typePattern = /(?:`type`|\btype)\s*=\s*'((?:[^']|'')*)'/gi;
492
+
493
+ for (var _match of query.matchAll(typePattern)) {
494
+ var _name = _match[1].replace(/''/g, "'").trim();
495
+
496
+ if (_name) names.add(_name.toLowerCase());
497
+ }
498
+
499
+ return names;
500
+ }
438
501
 
439
502
  module.exports = {
440
503
  buildCoveringIndex,
441
504
  buildCoveringIndexDefinition,
442
505
  indexSatisfiesCoveringDefinition,
506
+ parseResourceList,
507
+ extractStatementResources,
443
508
  extractWhereFields,
444
509
  extractSelectFields,
445
510
  extractGroupByFields,
@@ -8,7 +8,9 @@ var {
8
8
  extractSelectFields,
9
9
  extractGroupByFields,
10
10
  extractOrderByFields,
11
- generateHash
11
+ generateHash,
12
+ parseResourceList,
13
+ extractStatementResources
12
14
  } = require("./utility"); // Test utilities
13
15
 
14
16
 
@@ -825,6 +827,52 @@ test("field order: WHERE -> GROUP BY -> ORDER BY -> SELECT", () => {
825
827
 
826
828
  assert.ok(createdAtIndex < emailIndex); // ORDER BY before SELECT
827
829
  }); // ============================================
830
+ // Tests for parseResourceList / extractStatementResources
831
+ // ============================================
832
+
833
+ console.log("\n--- parseResourceList tests ---");
834
+ test("parses a comma-separated string, trimming, unquoting and lower-casing", () => {
835
+ var result = parseResourceList(" Users, `Audit_Log` ,, sessions ");
836
+ assert.deepStrictEqual([...result].sort(), ["audit_log", "sessions", "users"]);
837
+ });
838
+ test("accepts arrays and Sets and ignores non-strings", () => {
839
+ assert.deepStrictEqual([...parseResourceList(["Users", 42, null, " logs "])].sort(), ["logs", "users"]);
840
+ assert.deepStrictEqual([...parseResourceList(new Set(["A", "b"]))].sort(), ["a", "b"]);
841
+ });
842
+ test("returns an empty set for undefined, empty or unsupported input", () => {
843
+ assert.strictEqual(parseResourceList(undefined).size, 0);
844
+ assert.strictEqual(parseResourceList("").size, 0);
845
+ assert.strictEqual(parseResourceList(", ,").size, 0);
846
+ assert.strictEqual(parseResourceList({
847
+ users: true
848
+ }).size, 0);
849
+ });
850
+ console.log("\n--- extractStatementResources tests ---");
851
+ test("extracts a bare FROM keyspace", () => {
852
+ assert.deepStrictEqual([...extractStatementResources("SELECT name FROM users WHERE x = 1")], ["users"]);
853
+ });
854
+ test("extracts the last segment of a qualified or quoted keyspace", () => {
855
+ assert.deepStrictEqual([...extractStatementResources("SELECT name FROM `default`:`saturn`.`app`.`Users` AS u")], ["users"]);
856
+ assert.deepStrictEqual([...extractStatementResources("SELECT name FROM saturn.app.logs")], ["logs"]);
857
+ });
858
+ test("extracts JOIN keyspaces alongside FROM", () => {
859
+ var result = extractStatementResources("SELECT u.name FROM users AS u JOIN `orders` AS o ON o.user_id = META(u).id");
860
+ assert.deepStrictEqual([...result].sort(), ["orders", "users"]);
861
+ });
862
+ test("extracts type literals used by legacy bucket mode", () => {
863
+ var result = extractStatementResources("SELECT actor FROM `saturn` WHERE type = 'Audit_Log' AND deleted = false");
864
+ assert.deepStrictEqual([...result].sort(), ["audit_log", "saturn"]);
865
+ assert.ok(extractStatementResources("SELECT a FROM b WHERE `type`='x'").has("x"));
866
+ assert.ok(extractStatementResources("SELECT a FROM b WHERE type = 'it''s'").has("it's"));
867
+ });
868
+ test("does not treat META() arguments, subqueries or column names as keyspaces", () => {
869
+ var result = extractStatementResources("SELECT META(`users`).id AS id, from_date FROM (SELECT * FROM users) AS sub WHERE status = 'from'");
870
+ assert.deepStrictEqual([...result], ["users"]);
871
+ });
872
+ test("handles non-string input", () => {
873
+ assert.strictEqual(extractStatementResources(undefined).size, 0);
874
+ assert.strictEqual(extractStatementResources(null).size, 0);
875
+ }); // ============================================
828
876
  // Summary
829
877
  // ============================================
830
878
 
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "spice-couchbase",
3
- "version": "2.0.61",
3
+ "version": "2.0.63",
4
4
  "description": "",
5
5
  "main": "build/index.js",
6
6
  "scripts": {
7
7
  "test": "node src/utility.test.js",
8
8
  "test:auto-optimize": "node --test src/index.auto-optimize.test.js",
9
9
  "test:query-consistency": "node src/index.query-consistency.test.js",
10
- "test:count-query": "node src/index.count-query.test.js"
10
+ "test:count-query": "node src/index.count-query.test.js",
11
+ "test:connection-health": "node --test src/connectionHealth.test.js"
11
12
  },
12
13
  "author": "Chad Fraser <chad@sonover.com> (http://sonover.com)",
13
14
  "license": "ISC",