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/src/index.js CHANGED
@@ -4,6 +4,8 @@ let _ = require("lodash");
4
4
  let couchbase = require("couchbase");
5
5
  const { ConflictResolutionType } = require("couchbase");
6
6
  const { QueryScanConsistency } = require("couchbase");
7
+ const { parseResourceList, extractStatementResources } = require("./utility");
8
+ const { createHealthMonitor, openHandles } = require("./connectionHealth");
7
9
 
8
10
  if (!Promise.allSettled) {
9
11
  Promise.allSettled = (promises) =>
@@ -50,6 +52,58 @@ const optimizeState = {
50
52
  seen: new Set(),
51
53
  cooldownUntil: 0,
52
54
  };
55
+ // Auto cover indexing is opt-in per resource. A resource is the collection a
56
+ // Database instance serves (scoped deployments) or the `type = '...'` literal
57
+ // legacy bucket mode adds to every statement; raw statements also count their
58
+ // FROM / JOIN keyspaces. Names are case-insensitive. A slow statement is
59
+ // optimised when, in order of precedence:
60
+ // 1. the call says so: query_options.auto_optimize (true / false)
61
+ // 2. the instance says so: constructor option or connection config
62
+ // `auto_optimize` (true / false)
63
+ // 3. the application policy: Database.setAutoOptimizePolicy(fn), where
64
+ // fn(resource, context) returns true / false, or
65
+ // undefined to defer to the static lists
66
+ // 4. a static list names a resource it touches: comma-separated
67
+ // SPICE_COUCHBASE_OPTIMIZE_RESOURCES (`*` = every resource, which is the
68
+ // pre-2.0.62 behaviour), or connection config / constructor option
69
+ // `auto_optimize_resources`.
70
+ const OPTIMIZE_ENABLED_RESOURCES = parseResourceList(
71
+ process.env.SPICE_COUCHBASE_OPTIMIZE_RESOURCES,
72
+ );
73
+ let autoOptimizePolicy = null;
74
+
75
+ // One health monitor per process, shared by every copy of this module that
76
+ // ends up loaded. See connectionHealth.js.
77
+ const HEALTH_MONITOR_KEY = Symbol.for("spice-couchbase.health-monitor");
78
+ const health =
79
+ globalThis[HEALTH_MONITOR_KEY] ||
80
+ (globalThis[HEALTH_MONITOR_KEY] = createHealthMonitor());
81
+
82
+ // Cluster handles live on the `spice.database` globals, which a reconnect
83
+ // replaces. Instances read them at call time instead of copying them.
84
+ const HANDLE_GLOBALS = {
85
+ cluster: "clusters",
86
+ connection: "connections",
87
+ scope: "scopes",
88
+ bucket_handle: "buckets",
89
+ };
90
+
91
+ function globalHandle(kind, connection_name) {
92
+ if (typeof spice === "undefined") return undefined;
93
+ return spice?.database?.[HANDLE_GLOBALS[kind]]?.[connection_name];
94
+ }
95
+
96
+ function requestedAutoOptimize(query_options) {
97
+ if (
98
+ !query_options ||
99
+ typeof query_options !== "object" ||
100
+ Array.isArray(query_options)
101
+ ) {
102
+ return undefined;
103
+ }
104
+ const value = query_options.auto_optimize ?? query_options.optimize;
105
+ return typeof value === "boolean" ? value : undefined;
106
+ }
53
107
 
54
108
  function shouldOptimize(query) {
55
109
  if (!AUTO_OPTIMIZE_ENABLED) return false;
@@ -126,17 +180,15 @@ module.exports = class Database {
126
180
  scope,
127
181
  query_scope = "cluster",
128
182
  auto_create_collection,
183
+ auto_optimize,
184
+ auto_optimize_resources,
129
185
  } = {},
130
186
  ) {
131
187
  if (connection) {
132
188
  this.connection_name = connection;
133
- this.connection = spice.database.connections[connection];
134
189
  this.query_scope = query_scope;
135
- this.cluster = spice.database.clusters[connection];
136
190
  this.bucket = this.connection._scope._bucket._name;
137
191
  this.db_config = spice.config.database.connections[connection];
138
- this.collection_manager =
139
- spice.database.buckets[connection].collections();
140
192
  this.auto_create_collection =
141
193
  auto_create_collection ||
142
194
  ((collection ||
@@ -162,14 +214,11 @@ module.exports = class Database {
162
214
  scope ||
163
215
  spice.config.database.connections[connection].scope ||
164
216
  "_default";
165
- this.scope = spice.database.scopes[connection];
166
217
  this.full_text_index =
167
218
  spice.config.database.connections[connection].full_text_index ||
168
219
  full_text_index;
169
220
  this.N1qlQuery = couchbase.N1qlQuery;
170
221
  this.SearchQuery = couchbase.SearchQuery;
171
- this.collection = this.scope.collection(this.collection_name);
172
- this.userManager = this.getUserManager(connection);
173
222
  if (!spice?.db?.collections) {
174
223
  spice.db = spice.db || {};
175
224
  spice.db.collections = new Set();
@@ -181,7 +230,225 @@ module.exports = class Database {
181
230
  this.use_bucket_name =
182
231
  spice.config.database.connections[this.connection_name].scope ==
183
232
  undefined;
233
+
234
+ // Auto cover indexing. See OPTIMIZE_ENABLED_RESOURCES for precedence.
235
+ const configured_auto_optimize =
236
+ auto_optimize ?? this.db_config.auto_optimize;
237
+ this.auto_optimize =
238
+ typeof configured_auto_optimize === "boolean"
239
+ ? configured_auto_optimize
240
+ : undefined;
241
+ this.enabled_resources = parseResourceList([
242
+ ...parseResourceList(this.db_config.auto_optimize_resources),
243
+ ...parseResourceList(auto_optimize_resources),
244
+ ]);
245
+ }
246
+ }
247
+
248
+ // Handles resolve from the globals on every access, so an instance created
249
+ // before a reconnect uses the new cluster afterwards. Assigning one pins it
250
+ // on that instance instead (tests, custom wiring).
251
+ pinHandle(kind, value) {
252
+ if (!Object.prototype.hasOwnProperty.call(this, "_pinned_handles")) {
253
+ Object.defineProperty(this, "_pinned_handles", {
254
+ value: {},
255
+ enumerable: false,
256
+ writable: true,
257
+ });
258
+ }
259
+ this._pinned_handles[kind] = value;
260
+ }
261
+
262
+ pinnedHandle(kind) {
263
+ const pinned = this._pinned_handles;
264
+ return pinned && kind in pinned ? { value: pinned[kind] } : null;
265
+ }
266
+
267
+ handle(kind) {
268
+ const pinned = this.pinnedHandle(kind);
269
+ return pinned ? pinned.value : globalHandle(kind, this.connection_name);
270
+ }
271
+
272
+ get cluster() {
273
+ return this.handle("cluster");
274
+ }
275
+ set cluster(value) {
276
+ this.pinHandle("cluster", value);
277
+ }
278
+
279
+ get connection() {
280
+ return this.handle("connection");
281
+ }
282
+ set connection(value) {
283
+ this.pinHandle("connection", value);
284
+ }
285
+
286
+ get scope() {
287
+ return this.handle("scope");
288
+ }
289
+ set scope(value) {
290
+ this.pinHandle("scope", value);
291
+ }
292
+
293
+ get collection() {
294
+ const pinned = this.pinnedHandle("collection");
295
+ if (pinned) return pinned.value;
296
+ const scope = this.scope;
297
+ if (!scope) return undefined;
298
+ // Collection handles are cheap but not free; cache one per scope object,
299
+ // which changes exactly when the connection is replaced.
300
+ if (this._collection_cache?.scope !== scope) {
301
+ Object.defineProperty(this, "_collection_cache", {
302
+ value: { scope, collection: scope.collection(this.collection_name) },
303
+ enumerable: false,
304
+ writable: true,
305
+ configurable: true,
306
+ });
307
+ }
308
+ return this._collection_cache.collection;
309
+ }
310
+ set collection(value) {
311
+ this.pinHandle("collection", value);
312
+ }
313
+
314
+ get collection_manager() {
315
+ const pinned = this.pinnedHandle("collection_manager");
316
+ if (pinned) return pinned.value;
317
+ return this.handle("bucket_handle")?.collections();
318
+ }
319
+ set collection_manager(value) {
320
+ this.pinHandle("collection_manager", value);
321
+ }
322
+
323
+ get userManager() {
324
+ const pinned = this.pinnedHandle("userManager");
325
+ if (pinned) return pinned.value;
326
+ return this.cluster?.users();
327
+ }
328
+ set userManager(value) {
329
+ this.pinHandle("userManager", value);
330
+ }
331
+
332
+ /**
333
+ * Reports a finished operation to the health monitor. Only connectivity
334
+ * errors (timeouts, cancelled requests, service not available) count.
335
+ */
336
+ recordOutcome(error) {
337
+ health.recordOutcome(this.connection_name, error);
338
+ }
339
+
340
+ recordSettled(results) {
341
+ for (const result of results) {
342
+ if (result?.status === "rejected") this.recordOutcome(result.reason);
343
+ }
344
+ }
345
+
346
+ async observe(operation) {
347
+ try {
348
+ return await operation;
349
+ } catch (error) {
350
+ this.recordOutcome(error);
351
+ throw error;
352
+ }
353
+ }
354
+
355
+ /** Per-connection health: healthy, reconnecting, generation, last error. */
356
+ static healthStatus() {
357
+ return health.status();
358
+ }
359
+
360
+ /** Probes a connection now and reconnects it if the probe fails. */
361
+ static checkHealth(connection_name = "default") {
362
+ return health.check(connection_name);
363
+ }
364
+
365
+ /** Opens a fresh cluster for a connection and swaps it in. */
366
+ static forceReconnect(connection_name = "default") {
367
+ return health.reconnect(connection_name);
368
+ }
369
+
370
+ /**
371
+ * Stops probing and reconnecting and closes replaced clusters still in
372
+ * their grace period. Call before closing `spice.database.clusters` on
373
+ * shutdown so a reconnect cannot race it.
374
+ */
375
+ static stopHealthMonitor() {
376
+ return health.stop();
377
+ }
378
+
379
+ /**
380
+ * Lets the application decide per resource whether slow statements may
381
+ * create covering indexes, e.g. from a per-resource setting an operator
382
+ * toggles at runtime. `policy(resource, { query, connection, collection })`
383
+ * returns true or false, or undefined to defer to the static lists. It is
384
+ * synchronous because it runs on the query path. Pass null to clear.
385
+ */
386
+ static setAutoOptimizePolicy(policy) {
387
+ if (
388
+ policy !== null &&
389
+ policy !== undefined &&
390
+ typeof policy !== "function"
391
+ ) {
392
+ throw new TypeError("Auto-optimize policy must be a function or null");
393
+ }
394
+ autoOptimizePolicy = policy || null;
395
+ }
396
+
397
+ static getAutoOptimizePolicy() {
398
+ return autoOptimizePolicy;
399
+ }
400
+
401
+ /**
402
+ * Resources a statement touches, plus this instance's collection: that is
403
+ * the keyspace `optimize()` would create the index on.
404
+ */
405
+ statementResources(query) {
406
+ const resources = extractStatementResources(query);
407
+ if (this.collection_name && this.collection_name !== "_default") {
408
+ resources.add(String(this.collection_name).toLowerCase());
184
409
  }
410
+ return resources;
411
+ }
412
+
413
+ /**
414
+ * True when a slow statement may create a covering index. See
415
+ * OPTIMIZE_ENABLED_RESOURCES for the order of precedence.
416
+ */
417
+ shouldAutoOptimize(query, query_options = {}) {
418
+ const requested = requestedAutoOptimize(query_options);
419
+ if (requested !== undefined) return requested;
420
+ if (typeof this.auto_optimize === "boolean") return this.auto_optimize;
421
+
422
+ const resources = this.statementResources(query);
423
+ if (typeof autoOptimizePolicy === "function" && resources.size > 0) {
424
+ let deferred = false;
425
+ for (const resource of resources) {
426
+ let verdict;
427
+ try {
428
+ verdict = autoOptimizePolicy(resource, {
429
+ query,
430
+ connection: this.connection_name,
431
+ collection: this.collection_name,
432
+ });
433
+ } catch (e) {
434
+ // A broken policy must not create indexes nobody asked for.
435
+ verdict = false;
436
+ }
437
+ if (verdict === true) return true;
438
+ if (verdict === undefined || verdict === null) deferred = true;
439
+ }
440
+ if (!deferred) return false;
441
+ }
442
+
443
+ const enabled = new Set([
444
+ ...OPTIMIZE_ENABLED_RESOURCES,
445
+ ...(this.enabled_resources || []),
446
+ ]);
447
+ if (enabled.has("*")) return true;
448
+ for (const resource of resources) {
449
+ if (enabled.has(resource)) return true;
450
+ }
451
+ return false;
185
452
  }
186
453
 
187
454
  setGlobalConnectionObject(collection) {
@@ -203,26 +470,9 @@ module.exports = class Database {
203
470
  }
204
471
 
205
472
  async connect(connection_name, config) {
206
- let connectOptions = {
207
- username: config.username,
208
- password: config.password,
209
- };
210
- if (config.configProfile) {
211
- connectOptions.configProfile = config.configProfile;
212
- }
213
-
214
- let cluster = await couchbase.connect(config.nodes, connectOptions);
215
- let bucket = cluster.bucket(config.bucket);
216
- let collection;
217
- let scope_name = this.scope_name || config.scope || "_default";
218
- let collection_name =
219
- this.collection_name || config.collection || "_default";
220
- let scope = bucket.scope(scope_name);
221
-
222
- collection = bucket
223
- .scope(scope_name)
224
- .collection(collection_name || "_default");
225
-
473
+ const { cluster, bucket, scope, collection } = await openHandles(config);
474
+ // Watch the connection from here on and replace it if it wedges.
475
+ health.register(connection_name, config);
226
476
  return {
227
477
  connection: connection_name,
228
478
  bucket,
@@ -233,7 +483,7 @@ module.exports = class Database {
233
483
  }
234
484
 
235
485
  getUserManager(connection) {
236
- return spice.database.clusters[connection].users();
486
+ return globalHandle("cluster", connection).users();
237
487
  }
238
488
 
239
489
  async assignUserPermission() {
@@ -413,7 +663,7 @@ module.exports = class Database {
413
663
  this.use_bucket_name ? this.connection : this.collection;
414
664
 
415
665
  if (!useLookupIn) {
416
- let response = await collection.get(doc_id);
666
+ let response = await this.observe(collection.get(doc_id));
417
667
  return { id: doc_id, ...response.content };
418
668
  }
419
669
 
@@ -428,6 +678,7 @@ module.exports = class Database {
428
678
  });
429
679
 
430
680
  const batchResults = await Promise.allSettled(batchPromises);
681
+ this.recordSettled(batchResults);
431
682
 
432
683
  // Merge results from all batches
433
684
  batchResults.forEach((result, batchIndex) => {
@@ -449,12 +700,14 @@ module.exports = class Database {
449
700
 
450
701
  async insert(doc_id, document, expiry) {
451
702
  try {
452
- await (this.use_bucket_name ? this.connection : this.collection).insert(
453
- doc_id,
454
- document,
455
- {
456
- expiry: expiry,
457
- },
703
+ await this.observe(
704
+ (this.use_bucket_name ? this.connection : this.collection).insert(
705
+ doc_id,
706
+ document,
707
+ {
708
+ expiry: expiry,
709
+ },
710
+ ),
458
711
  );
459
712
  } catch (e) {
460
713
  console.log(e.message, e, doc_id);
@@ -463,38 +716,45 @@ module.exports = class Database {
463
716
  }
464
717
 
465
718
  async touch(doc_id, expiry = 0) {
466
- await (this.use_bucket_name ? this.connection : this.collection).touch(
467
- doc_id,
468
- expiry,
719
+ await this.observe(
720
+ (this.use_bucket_name ? this.connection : this.collection).touch(
721
+ doc_id,
722
+ expiry,
723
+ ),
469
724
  );
470
725
  return await this.get(doc_id);
471
726
  }
472
727
 
473
728
  async count(doc_id, adjustment, initial_value) {
474
- let res = await (
475
- this.use_bucket_name ?
476
- this.connection
477
- : this.collection).counter(doc_id, adjustment, {
478
- initial: initial_value,
479
- });
729
+ let res = await this.observe(
730
+ (this.use_bucket_name ? this.connection : this.collection).counter(
731
+ doc_id,
732
+ adjustment,
733
+ {
734
+ initial: initial_value,
735
+ },
736
+ ),
737
+ );
480
738
  return res.value;
481
739
  }
482
740
 
483
741
  async update(doc_id, document, expiry) {
484
- let res = await (
485
- this.use_bucket_name ?
486
- this.connection
487
- : this.collection).replace(doc_id, document, {
488
- expiry: expiry,
489
- });
742
+ let res = await this.observe(
743
+ (this.use_bucket_name ? this.connection : this.collection).replace(
744
+ doc_id,
745
+ document,
746
+ {
747
+ expiry: expiry,
748
+ },
749
+ ),
750
+ );
490
751
  return res.value;
491
752
  }
492
753
 
493
754
  async delete(doc_id) {
494
- let res = await (
495
- this.use_bucket_name ?
496
- this.connection
497
- : this.collection).remove(doc_id);
755
+ let res = await this.observe(
756
+ (this.use_bucket_name ? this.connection : this.collection).remove(doc_id),
757
+ );
498
758
  return res.value;
499
759
  }
500
760
 
@@ -511,6 +771,7 @@ module.exports = class Database {
511
771
  // Regular get - no batching needed
512
772
  let functions = _.map(keys, (key) => collection.get(key));
513
773
  let returned_all = await Promise.allSettled(functions);
774
+ this.recordSettled(returned_all);
514
775
 
515
776
  let responses = _.flatten(
516
777
  _.map(returned_all, (returned_obj) => returned_obj?.value),
@@ -549,6 +810,7 @@ module.exports = class Database {
549
810
  });
550
811
 
551
812
  const batchResults = await Promise.allSettled(batchPromises);
813
+ this.recordSettled(batchResults);
552
814
 
553
815
  // Merge results from all batches
554
816
  batchResults.forEach((result, batchIndex) => {
@@ -947,7 +1209,9 @@ module.exports = class Database {
947
1209
  // Start time
948
1210
  let startTime = process.hrtime.bigint();
949
1211
  const options = buildQueryOptions(query_options);
950
- let response = await exec_scope.query(sanitizedQuery, options);
1212
+ let response = await this.observe(
1213
+ exec_scope.query(sanitizedQuery, options),
1214
+ );
951
1215
  // End time
952
1216
  let endTime = process.hrtime.bigint();
953
1217
  // Calculate duration in microseconds
@@ -956,6 +1220,7 @@ module.exports = class Database {
956
1220
  // declared at the top of this file. Never awaited, never allowed to reject.
957
1221
  if (
958
1222
  durationInMilliseconds > SLOW_QUERY_MS &&
1223
+ this.shouldAutoOptimize(sanitizedQuery, query_options) &&
959
1224
  shouldOptimize(sanitizedQuery)
960
1225
  ) {
961
1226
  this.optimize(sanitizedQuery, exec_scope).catch(() => {});
package/src/utility.js CHANGED
@@ -458,10 +458,73 @@ function indexSatisfiesCoveringDefinition(index, candidate) {
458
458
  return requiredKeys.slice(candidate.accessKeyCount).every((key) => existingFields.has(key.field));
459
459
  }
460
460
 
461
+ /**
462
+ * Normalizes an exemption list into a Set of lower-case resource names.
463
+ * Accepts an array, a Set, or a comma-separated string (the env var form).
464
+ * Surrounding whitespace and backticks are ignored; empty entries are dropped.
465
+ * @param {string|string[]|Set<string>} value
466
+ * @returns {Set<string>}
467
+ */
468
+ function parseResourceList(value) {
469
+ const names = new Set();
470
+ let items = [];
471
+ if (Array.isArray(value) || value instanceof Set) {
472
+ items = [...value];
473
+ } else if (typeof value === "string") {
474
+ items = value.split(",");
475
+ }
476
+ for (const item of items) {
477
+ if (typeof item !== "string") continue;
478
+ const name = item.trim().replace(/^`+|`+$/g, "").trim().toLowerCase();
479
+ if (name) names.add(name);
480
+ }
481
+ return names;
482
+ }
483
+
484
+ /**
485
+ * Extracts the resource names a statement touches, lower-cased:
486
+ * - the last path segment of every FROM / JOIN keyspace, unquoted, so
487
+ * `default`:`bucket`.`scope`.`users` and users both yield "users";
488
+ * - every `type = '...'` literal, which is how legacy bucket-mode
489
+ * deployments distinguish resources that share one keyspace.
490
+ * Only simple identifiers are recognised; subqueries and expressions are skipped.
491
+ * @param {string} query
492
+ * @returns {Set<string>}
493
+ */
494
+ function extractStatementResources(query) {
495
+ const names = new Set();
496
+ if (typeof query !== "string") return names;
497
+
498
+ const identifier = "(?:`(?:[^`]|``)+`|[A-Za-z_][\\w$-]*)";
499
+ const keyspacePattern = new RegExp(
500
+ `\\b(?:FROM|JOIN)\\s+(${identifier}(?:\\s*[.:]\\s*${identifier})*)`,
501
+ "gi",
502
+ );
503
+ const segmentPattern = new RegExp(identifier, "g");
504
+ for (const match of query.matchAll(keyspacePattern)) {
505
+ const segments = match[1].match(segmentPattern) || [];
506
+ const last = (segments[segments.length - 1] || "").trim();
507
+ const name = last.startsWith("`")
508
+ ? last.slice(1, -1).replace(/``/g, "`")
509
+ : last;
510
+ if (name) names.add(name.toLowerCase());
511
+ }
512
+
513
+ const typePattern = /(?:`type`|\btype)\s*=\s*'((?:[^']|'')*)'/gi;
514
+ for (const match of query.matchAll(typePattern)) {
515
+ const name = match[1].replace(/''/g, "'").trim();
516
+ if (name) names.add(name.toLowerCase());
517
+ }
518
+
519
+ return names;
520
+ }
521
+
461
522
  module.exports = {
462
523
  buildCoveringIndex,
463
524
  buildCoveringIndexDefinition,
464
525
  indexSatisfiesCoveringDefinition,
526
+ parseResourceList,
527
+ extractStatementResources,
465
528
  extractWhereFields,
466
529
  extractSelectFields,
467
530
  extractGroupByFields,
@@ -8,6 +8,8 @@ const {
8
8
  extractGroupByFields,
9
9
  extractOrderByFields,
10
10
  generateHash,
11
+ parseResourceList,
12
+ extractStatementResources,
11
13
  } = require("./utility");
12
14
 
13
15
  // Test utilities
@@ -1046,6 +1048,73 @@ test("field order: WHERE -> GROUP BY -> ORDER BY -> SELECT", () => {
1046
1048
  assert.ok(createdAtIndex < emailIndex); // ORDER BY before SELECT
1047
1049
  });
1048
1050
 
1051
+ // ============================================
1052
+ // Tests for parseResourceList / extractStatementResources
1053
+ // ============================================
1054
+ console.log("\n--- parseResourceList tests ---");
1055
+
1056
+ test("parses a comma-separated string, trimming, unquoting and lower-casing", () => {
1057
+ const result = parseResourceList(" Users, `Audit_Log` ,, sessions ");
1058
+ assert.deepStrictEqual([...result].sort(), ["audit_log", "sessions", "users"]);
1059
+ });
1060
+
1061
+ test("accepts arrays and Sets and ignores non-strings", () => {
1062
+ assert.deepStrictEqual([...parseResourceList(["Users", 42, null, " logs "])].sort(), ["logs", "users"]);
1063
+ assert.deepStrictEqual([...parseResourceList(new Set(["A", "b"]))].sort(), ["a", "b"]);
1064
+ });
1065
+
1066
+ test("returns an empty set for undefined, empty or unsupported input", () => {
1067
+ assert.strictEqual(parseResourceList(undefined).size, 0);
1068
+ assert.strictEqual(parseResourceList("").size, 0);
1069
+ assert.strictEqual(parseResourceList(", ,").size, 0);
1070
+ assert.strictEqual(parseResourceList({ users: true }).size, 0);
1071
+ });
1072
+
1073
+ console.log("\n--- extractStatementResources tests ---");
1074
+
1075
+ test("extracts a bare FROM keyspace", () => {
1076
+ assert.deepStrictEqual([...extractStatementResources("SELECT name FROM users WHERE x = 1")], ["users"]);
1077
+ });
1078
+
1079
+ test("extracts the last segment of a qualified or quoted keyspace", () => {
1080
+ assert.deepStrictEqual(
1081
+ [...extractStatementResources("SELECT name FROM `default`:`saturn`.`app`.`Users` AS u")],
1082
+ ["users"],
1083
+ );
1084
+ assert.deepStrictEqual(
1085
+ [...extractStatementResources("SELECT name FROM saturn.app.logs")],
1086
+ ["logs"],
1087
+ );
1088
+ });
1089
+
1090
+ test("extracts JOIN keyspaces alongside FROM", () => {
1091
+ const result = extractStatementResources(
1092
+ "SELECT u.name FROM users AS u JOIN `orders` AS o ON o.user_id = META(u).id",
1093
+ );
1094
+ assert.deepStrictEqual([...result].sort(), ["orders", "users"]);
1095
+ });
1096
+
1097
+ test("extracts type literals used by legacy bucket mode", () => {
1098
+ const result = extractStatementResources(
1099
+ "SELECT actor FROM `saturn` WHERE type = 'Audit_Log' AND deleted = false",
1100
+ );
1101
+ assert.deepStrictEqual([...result].sort(), ["audit_log", "saturn"]);
1102
+ assert.ok(extractStatementResources("SELECT a FROM b WHERE `type`='x'").has("x"));
1103
+ assert.ok(extractStatementResources("SELECT a FROM b WHERE type = 'it''s'").has("it's"));
1104
+ });
1105
+
1106
+ test("does not treat META() arguments, subqueries or column names as keyspaces", () => {
1107
+ const result = extractStatementResources(
1108
+ "SELECT META(`users`).id AS id, from_date FROM (SELECT * FROM users) AS sub WHERE status = 'from'",
1109
+ );
1110
+ assert.deepStrictEqual([...result], ["users"]);
1111
+ });
1112
+
1113
+ test("handles non-string input", () => {
1114
+ assert.strictEqual(extractStatementResources(undefined).size, 0);
1115
+ assert.strictEqual(extractStatementResources(null).size, 0);
1116
+ });
1117
+
1049
1118
  // ============================================
1050
1119
  // Summary
1051
1120
  // ============================================